diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6ec0768f7..c80b1a262 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1118,6 +1118,10 @@ "description": "Reset period in days", "type": "integer" }, + "resetDay": { + "description": "Calendar renewal day 1-31, 0 = interval mode", + "type": "integer" + }, "resetMax": { "description": "Max auto-renew count, 0 = unlimited", "type": "integer" @@ -1166,6 +1170,7 @@ "expiryTime", "limitIp", "reset", + "resetDay", "resetMax", "security", "subId", @@ -1259,6 +1264,9 @@ "reset": { "type": "integer" }, + "resetDay": { + "type": "integer" + }, "resetMax": { "type": "integer" }, @@ -1308,6 +1316,7 @@ "privateKey", "publicKey", "reset", + "resetDay", "resetMax", "reverse", "secret", @@ -1379,6 +1388,11 @@ "example": 0, "type": "integer" }, + "resetDay": { + "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", + "example": 0, + "type": "integer" + }, "resetMax": { "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.", "example": 0, @@ -1414,6 +1428,7 @@ "lastSubFetch", "reset", "resetCount", + "resetDay", "resetMax", "subId", "total", @@ -3357,6 +3372,7 @@ "lastSubFetch": 1735680000000, "reset": 0, "resetCount": 0, + "resetDay": 0, "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, @@ -8162,6 +8178,7 @@ "lastSubFetch": 1735680000000, "reset": 0, "resetCount": 0, + "resetDay": 0, "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 177392f52..5876b7a38 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -258,6 +258,7 @@ export const EXAMPLES: Record = { "privateKey": "", "publicKey": "", "reset": 0, + "resetDay": 0, "resetMax": 0, "reverse": null, "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", @@ -293,6 +294,7 @@ export const EXAMPLES: Record = { "privateKey": "", "publicKey": "", "reset": 0, + "resetDay": 0, "resetMax": 0, "reverse": null, "secret": "", @@ -317,6 +319,7 @@ export const EXAMPLES: Record = { "lastSubFetch": 1735680000000, "reset": 0, "resetCount": 0, + "resetDay": 0, "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, @@ -485,6 +488,7 @@ export const EXAMPLES: Record = { "lastSubFetch": 1735680000000, "reset": 0, "resetCount": 0, + "resetDay": 0, "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index df0763111..00c1f5625 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1092,6 +1092,10 @@ export const SCHEMAS: Record = { "description": "Reset period in days", "type": "integer" }, + "resetDay": { + "description": "Calendar renewal day 1-31, 0 = interval mode", + "type": "integer" + }, "resetMax": { "description": "Max auto-renew count, 0 = unlimited", "type": "integer" @@ -1140,6 +1144,7 @@ export const SCHEMAS: Record = { "expiryTime", "limitIp", "reset", + "resetDay", "resetMax", "security", "subId", @@ -1233,6 +1238,9 @@ export const SCHEMAS: Record = { "reset": { "type": "integer" }, + "resetDay": { + "type": "integer" + }, "resetMax": { "type": "integer" }, @@ -1282,6 +1290,7 @@ export const SCHEMAS: Record = { "privateKey", "publicKey", "reset", + "resetDay", "resetMax", "reverse", "secret", @@ -1353,6 +1362,11 @@ export const SCHEMAS: Record = { "example": 0, "type": "integer" }, + "resetDay": { + "description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.", + "example": 0, + "type": "integer" + }, "resetMax": { "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.", "example": 0, @@ -1388,6 +1402,7 @@ export const SCHEMAS: Record = { "lastSubFetch", "reset", "resetCount", + "resetDay", "resetMax", "subId", "total", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 49b54e9d8..7e814bf6f 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -268,6 +268,7 @@ export interface Client { privateKey?: string; publicKey?: string; reset: number; + resetDay: number; resetMax: number; reverse?: ClientReverse | null; secret?: string; @@ -305,6 +306,7 @@ export interface ClientRecord { privateKey: string; publicKey: string; reset: number; + resetDay: number; resetMax: number; reverse: unknown; secret: string; @@ -331,6 +333,7 @@ export interface ClientTraffic { lastSubFetch: number; reset: number; resetCount: number; + resetDay: number; resetMax: number; subId: string; total: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 4068b1e6a..2da88a1a1 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -288,6 +288,7 @@ export const ClientSchema = z.object({ privateKey: z.string().optional(), publicKey: z.string().optional(), reset: z.number().int(), + resetDay: z.number().int(), resetMax: z.number().int(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), secret: z.string().optional(), @@ -327,6 +328,7 @@ export const ClientRecordSchema = z.object({ privateKey: z.string(), publicKey: z.string(), reset: z.number().int(), + resetDay: z.number().int(), resetMax: z.number().int(), reverse: z.unknown(), secret: z.string(), @@ -355,6 +357,7 @@ export const ClientTrafficSchema = z.object({ lastSubFetch: z.number().int(), reset: z.number().int(), resetCount: z.number().int(), + resetDay: z.number().int(), resetMax: z.number().int(), subId: z.string(), total: z.number().int(), diff --git a/frontend/src/lib/remark/remarkVariables.ts b/frontend/src/lib/remark/remarkVariables.ts index ec30861bb..6a0c3eb4c 100644 --- a/frontend/src/lib/remark/remarkVariables.ts +++ b/frontend/src/lib/remark/remarkVariables.ts @@ -45,6 +45,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [ { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' }, { token: 'CREATED_UNIX', group: 'time', sample: '1700000000' }, { token: 'RESET_DAYS', group: 'time', sample: '30' }, + { token: 'RESET_DAY', group: 'time', sample: '15' }, // Connection (inbound config descriptors) { token: 'PROTOCOL', group: 'connection', sample: 'VLESS' }, { token: 'TRANSPORT', group: 'connection', sample: 'ws' }, diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 679d23d61..eaf35ba04 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = { totalGB: 0, expiryTime: 0, reset: 0, + resetDay: 0, resetMax: 0, inboundIds: [], }; @@ -177,6 +178,7 @@ export default function ClientBulkAddModal({ totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB), expiryTime: current.expiryTime, reset: Number(current.reset) || 0, + resetDay: Number(current.resetDay) || 0, resetMax: Number(current.resetMax) || 0, limitIp: Number(current.limitIp) || 0, limitHwid: Number(current.limitHwid) || 0, @@ -377,6 +379,15 @@ export default function ClientBulkAddModal({ + Number(v) || 0 }} + > + + + + + Number(v) || 0 }} + > + + + 0 { + return strconv.Itoa(c.ResetDay) + } + return "" case "STATUS_EMOJI": return statusEmoji(st) case "USAGE_PERCENTAGE": diff --git a/internal/web/dist/.gitkeep b/internal/web/dist/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/internal/web/service/calendar_renew.go b/internal/web/service/calendar_renew.go new file mode 100644 index 000000000..4f7e9c834 --- /dev/null +++ b/internal/web/service/calendar_renew.go @@ -0,0 +1,42 @@ +package service + +import "time" + +// nextCalendarRenewal returns the next renewal strictly after from, at midnight +// in loc; a missing day clamps to the month's last, so the 31st comes back (#6106). +func nextCalendarRenewal(from time.Time, day int, loc *time.Location) time.Time { + if loc == nil { + loc = time.UTC + } + if day < 1 { + day = 1 + } + if day > 31 { + day = 31 + } + local := from.In(loc) + + candidate := calendarDay(local.Year(), local.Month(), day, loc) + if !candidate.After(local) { + year, month := local.Year(), local.Month()+1 + if month > time.December { + year, month = year+1, time.January + } + candidate = calendarDay(year, month, day, loc) + } + return candidate +} + +// Clamped rather than normalized: time.Date rolls 31 February into March, which +// is the drift this mode exists to avoid. +func calendarDay(year int, month time.Month, day int, loc *time.Location) time.Time { + last := daysInMonth(year, month) + if day > last { + day = last + } + return time.Date(year, month, day, 0, 0, 0, 0, loc) +} + +func daysInMonth(year int, month time.Month) int { + return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() +} diff --git a/internal/web/service/calendar_renew_test.go b/internal/web/service/calendar_renew_test.go new file mode 100644 index 000000000..1b3171946 --- /dev/null +++ b/internal/web/service/calendar_renew_test.go @@ -0,0 +1,116 @@ +package service + +import ( + "testing" + "time" +) + +func TestNextCalendarRenewal_ClampsToShortMonths(t *testing.T) { + utc := time.UTC + cases := []struct { + name string + from time.Time + day int + want time.Time + }{ + { + name: "31st in a 28-day February", + from: time.Date(2026, time.January, 31, 0, 0, 0, 0, utc), + day: 31, + want: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc), + }, + { + name: "31st returns to the 31st after the short month", + from: time.Date(2026, time.February, 28, 0, 0, 0, 0, utc), + day: 31, + want: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc), + }, + { + name: "29th in a leap February", + from: time.Date(2028, time.January, 29, 0, 0, 0, 0, utc), + day: 29, + want: time.Date(2028, time.February, 29, 0, 0, 0, 0, utc), + }, + { + name: "31st in a 30-day month", + from: time.Date(2026, time.March, 31, 0, 0, 0, 0, utc), + day: 31, + want: time.Date(2026, time.April, 30, 0, 0, 0, 0, utc), + }, + { + name: "December rolls into January", + from: time.Date(2026, time.December, 5, 0, 0, 0, 0, utc), + day: 5, + want: time.Date(2027, time.January, 5, 0, 0, 0, 0, utc), + }, + { + name: "later in the same month renews this month", + from: time.Date(2026, time.June, 3, 12, 0, 0, 0, utc), + day: 20, + want: time.Date(2026, time.June, 20, 0, 0, 0, 0, utc), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := nextCalendarRenewal(tc.from, tc.day, utc) + if !got.Equal(tc.want) { + t.Fatalf("next renewal = %s, want %s", got.Format(time.RFC3339), tc.want.Format(time.RFC3339)) + } + }) + } +} + +// The renewal instant is midnight in the panel's zone, not in UTC: an operator +// billing on the 1st expects the period to turn over at their local midnight. +func TestNextCalendarRenewal_UsesPanelZone(t *testing.T) { + loc, err := time.LoadLocation("Asia/Tehran") + if err != nil { + t.Skip("zone database unavailable") + } + from := time.Date(2026, time.June, 15, 12, 0, 0, 0, time.UTC) + got := nextCalendarRenewal(from, 1, loc) + + if got.Location() != loc { + t.Fatalf("renewal computed in %s, want the panel zone", got.Location()) + } + y, m, d := got.Date() + if y != 2026 || m != time.July || d != 1 { + t.Fatalf("renewal date = %04d-%02d-%02d, want 2026-07-01", y, m, d) + } + if h, mi, s := got.Clock(); h != 0 || mi != 0 || s != 0 { + t.Fatalf("renewal at %02d:%02d:%02d, want local midnight", h, mi, s) + } +} + +// Crossing a DST boundary must still land on local midnight rather than +// drifting an hour, which a fixed 24h*N step cannot promise. +func TestNextCalendarRenewal_SurvivesDaylightSaving(t *testing.T) { + loc, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Skip("zone database unavailable") + } + // Berlin moves to summer time on 29 March 2026. + from := time.Date(2026, time.March, 10, 0, 0, 0, 0, loc) + got := nextCalendarRenewal(from, 10, loc) + + if h, mi, _ := got.Clock(); h != 0 || mi != 0 { + t.Fatalf("renewal at %02d:%02d local, want midnight across the DST change", h, mi) + } + if got.Day() != 10 || got.Month() != time.April { + t.Fatalf("renewal = %s, want 10 April", got.Format(time.RFC3339)) + } +} + +func TestNextCalendarRenewal_AlwaysMovesForward(t *testing.T) { + utc := time.UTC + from := time.Date(2026, time.May, 20, 0, 0, 0, 0, utc) + // Same day: the boundary has already passed today, so the next one is a + // month away rather than the instant we started from. + got := nextCalendarRenewal(from, 20, utc) + if !got.After(from) { + t.Fatalf("next renewal %s is not after %s", got, from) + } + if got.Month() != time.June { + t.Fatalf("next renewal = %s, want June", got.Format(time.RFC3339)) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e1d6559df..d7de748ab 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -43,6 +43,15 @@ func validateClientSubID(subID string) error { return nil } +// Rejected rather than clamped: nextCalendarRenewal would silently move an +// out-of-range day, and a negative one drops out of the renewal query entirely. +func validateClientResetDay(day int) error { + if day < 0 || day > 31 { + return common.NewError("client resetDay must be between 0 and 31, got:", day) + } + return nil +} + // Rejected rather than coerced: a negative cap reads as "unlimited" to a caller // but selects nothing, so the client would silently stop renewing. func validateClientResetMax(resetMax int) error { @@ -66,6 +75,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate if err := validateClientSubID(client.SubID); err != nil { return false, err } + if err := validateClientResetDay(client.ResetDay); err != nil { + return false, err + } if err := validateClientResetMax(client.ResetMax); err != nil { return false, err } @@ -356,6 +368,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if err := validateClientSubID(updated.SubID); err != nil { return false, err } + if err := validateClientResetDay(updated.ResetDay); err != nil { + return false, err + } if err := validateClientResetMax(updated.ResetMax); err != nil { return false, err } @@ -481,6 +496,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model "tg_id": merged.TgID, "comment": merged.Comment, "reset": merged.Reset, + "reset_day": merged.ResetDay, "reset_max": merged.ResetMax, }).Error; err != nil { return needRestart, err diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index e336a1d97..ea7e81ccb 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor } row.Comment = incoming.Comment row.Reset = incoming.Reset + row.ResetDay = incoming.ResetDay row.ResetMax = incoming.ResetMax if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) { row.CreatedAt = incoming.CreatedAt diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go index ab9f5300e..c7ff0e0e5 100644 --- a/internal/web/service/client_paging.go +++ b/internal/web/service/client_paging.go @@ -26,6 +26,7 @@ type ClientSlim struct { LimitIP int `json:"limitIp"` LimitHwid int `json:"limitHwid"` Reset int `json:"reset"` + ResetDay int `json:"resetDay"` ResetMax int `json:"resetMax"` Group string `json:"group,omitempty"` Comment string `json:"comment,omitempty"` @@ -246,9 +247,9 @@ func (q clientQuery) applyParams(tx *gorm.DB, params ClientPageParams, onlines [ } switch strings.ToLower(strings.TrimSpace(params.AutoRenew)) { case "on": - where("COALESCE(c.reset, 0) > 0") + where("(COALESCE(c.reset, 0) > 0 OR COALESCE(c.reset_day, 0) > 0)") case "off": - where("COALESCE(c.reset, 0) <= 0") + where("(COALESCE(c.reset, 0) <= 0 AND COALESCE(c.reset_day, 0) <= 0)") } switch strings.ToLower(strings.TrimSpace(params.HasTgID)) { case "yes": @@ -606,6 +607,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim { LimitIP: c.LimitIP, LimitHwid: c.LimitHwid, Reset: c.Reset, + ResetDay: c.ResetDay, ResetMax: c.ResetMax, Group: c.Group, Comment: c.Comment, diff --git a/internal/web/service/inbound_autorenew_calendar_test.go b/internal/web/service/inbound_autorenew_calendar_test.go new file mode 100644 index 000000000..8eaffe61a --- /dev/null +++ b/internal/web/service/inbound_autorenew_calendar_test.go @@ -0,0 +1,391 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// pinPanelZone fixes the panel time zone so the assertions below can talk about +// calendar days without the test machine's own zone shifting them. +func pinPanelZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Skipf("zone database unavailable: %v", err) + } + if err := database.GetDB().Create(&model.Setting{Key: "timeLocation", Value: name}).Error; err != nil { + t.Fatalf("pin panel zone: %v", err) + } + return loc +} + +// Calendar mode renews on the same day each month. The interval mode drifts — +// 30 days from 31 January is 2 March — which is the whole reason for the mode. +func TestAutoRenewClients_CalendarModeLandsOnTheBillingDay(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + // Expired two calendar months ago, billed on the 15th. + past := time.Date(2026, time.April, 15, 0, 0, 0, 0, time.UTC) + clients := []model.Client{ + {Email: "cal@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, ResetDay: 15, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "cal@x", Enable: false, Up: 5, Down: 6, + ResetDay: 15, ExpiryTime: past.UnixMilli(), + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } else if count != 1 { + t.Fatalf("renewed count = %d, want 1", count) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "cal@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + if got.Day() != 15 { + t.Fatalf("renewed to %s, want the 15th: calendar mode must not drift", got.Format(time.RFC3339)) + } + if !got.After(time.Now()) { + t.Fatalf("renewed to %s, which is not in the future", got.Format(time.RFC3339)) + } + if h, m, s := got.Clock(); h != 0 || m != 0 || s != 0 { + t.Fatalf("renewed to %02d:%02d:%02d, want midnight", h, m, s) + } + if row.Up != 0 || row.Down != 0 { + t.Fatalf("counters not reset: up=%d down=%d", row.Up, row.Down) + } + if !row.Enable { + t.Fatal("a renewed client must be re-enabled") + } +} + +// A client billed on the 31st keeps that day, borrowing the last day only in +// months that are too short for it. +func TestAutoRenewClients_CalendarModeClampsShortMonths(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + past := time.Date(2026, time.January, 31, 0, 0, 0, 0, time.UTC) + clients := []model.Client{ + {Email: "eom@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "eom@x", Enable: false, ResetDay: 31, ExpiryTime: past.UnixMilli(), + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "eom@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + want := firstBillingMidnightAfter(t, time.Now().In(zone), 31, zone) + if !got.Equal(want) { + t.Fatalf("renewed to %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } +} + +// Deliberately not built on nextCalendarRenewal: it walks a day at a time and +// derives month length from time.Date's own zero-day trick, so it can disagree. +func firstBillingMidnightAfter(t *testing.T, from time.Time, day int, loc *time.Location) time.Time { + t.Helper() + cur := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, loc) + for i := 0; i < 400; i++ { + cur = cur.AddDate(0, 0, 1) + want := day + if last := time.Date(cur.Year(), cur.Month()+1, 0, 0, 0, 0, 0, loc).Day(); want > last { + want = last + } + if cur.Day() == want { + return cur + } + } + t.Fatalf("no billing midnight for day %d within a year of %s", day, from) + return time.Time{} +} + +// Interval clients must be untouched by the new field. +func TestAutoRenewClients_IntervalModeUnchanged(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-48 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "days@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ExpiryTime: past}, + } + ib := mkInbound(t, 30203, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "days@x", Enable: false, Reset: 30, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } else if count != 1 { + t.Fatalf("renewed count = %d, want 1", count) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "days@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if want := past + 30*86400000; row.ExpiryTime != want { + t.Fatalf("interval renewal moved to %d, want the old fixed step %d", row.ExpiryTime, want) + } +} + +// The selection filter is what keeps a row with neither mode configured out of +// the renewal loop. That matters more than it looks: the interval step is +// reset*24h, so a zero interval reaching that loop would spin forever on the +// single traffic writer. The guard in the loop is a second line of defence and +// is deliberately unreachable while this filter holds. +func TestAutoRenewClients_RowWithNoModeIsNotSelected(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-48 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "none@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, ExpiryTime: past}, + } + ib := mkInbound(t, 30204, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + // Seeded straight into the table with both modes off, the shape the + // selection filter is supposed to exclude. + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "none@x", Enable: false, Reset: 0, ResetDay: 0, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + // Asserted against the query rather than by running the loop: the failure + // this guards is a hang, and a stuck goroutine outlives the test's DB. + var selected int64 + if err := db.Model(&xray.ClientTraffic{}). + Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", time.Now().UnixMilli()). + Where("email = ?", "none@x"). + Count(&selected).Error; err != nil { + t.Fatal(err) + } + if selected != 0 { + t.Fatal("a row with no renewal mode was selected for renewal: it would reach the interval loop and spin forever") + } + + if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } else if count != 0 { + t.Fatalf("renewed count = %d, want 0", count) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "none@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ExpiryTime != past { + t.Fatalf("a client with no renewal mode was renewed to %d", row.ExpiryTime) + } +} + +// The billing day has to survive the clients table, not just the settings JSON: +// an ordinary edit rebuilds the client from the record and writes it back (#6106). +func TestClientEditKeepsTheBillingDay(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + clients := []model.Client{ + {Email: "keep@x", ID: "55555555-5555-5555-5555-555555555555", Enable: true, ResetDay: 20, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()}, + } + ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + mkTraffic(t, ib.Id, "keep@x", 10, 20, 0, 0, true) + + rec, err := svc.clientService.GetRecordByEmail(nil, "keep@x") + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.ResetDay != 20 { + t.Fatalf("clients.reset_day = %d, want the 20 the client was created with", rec.ResetDay) + } + + // What the edit dialog does: hydrate the record, change something else, save. + edited := rec.ToClient() + edited.Comment = "renamed" + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + + // The inbound JSON is what xray and the edit dialog read back, and it is + // rebuilt from the record, so it is where a dropped converter field shows. + var stored model.Inbound + if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil { + t.Fatal(err) + } + var settings struct { + Clients []model.Client `json:"clients"` + } + if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil { + t.Fatalf("parse inbound settings: %v", err) + } + if len(settings.Clients) != 1 { + t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients)) + } + if settings.Clients[0].ResetDay != 20 { + t.Fatalf("inbound settings resetDay = %d after an unrelated edit, want 20: calendar mode was silently turned off", settings.Clients[0].ResetDay) + } + + rec, err = svc.clientService.GetRecordByEmail(nil, "keep@x") + if err != nil { + t.Fatalf("GetRecordByEmail after edit: %v", err) + } + if rec.ResetDay != 20 { + t.Fatalf("clients.reset_day = %d after an unrelated edit, want 20", rec.ResetDay) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "keep@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ResetDay != 20 { + t.Fatalf("client_traffics.reset_day = %d after an unrelated edit, want 20", row.ResetDay) + } +} + +// The billing day is useless if it can only be chosen once. The test above +// passes even without the record write, because nothing overwrites the value +// it checks; this one fails without it. +func TestClientEditChangesTheBillingDay(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + + clients := []model.Client{ + { + Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, ResetDay: 20, + ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(), + }, + } + ib := mkInbound(t, 30206, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true) + + rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + edited := rec.ToClient() + edited.ResetDay = 5 + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + + rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail after edit: %v", err) + } + if rec.ResetDay != 5 { + t.Fatalf("clients.reset_day = %d after the operator moved the billing day to the 5th", rec.ResetDay) + } + + // Turning calendar mode off has to work too. + edited = rec.ToClient() + edited.ResetDay = 0 + edited.Reset = 30 + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update back to interval mode: %v", err) + } + rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail after switching mode: %v", err) + } + if rec.ResetDay != 0 { + t.Fatalf("clients.reset_day = %d after the operator switched back to interval mode", rec.ResetDay) + } +} + +// The two renewal features meet here: a calendar client is capped like an +// interval one, spending one allowance per month rather than per tick. +func TestAutoRenewClients_CalendarModeSpendsOneAllowancePerMonth(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + zone := pinPanelZone(t, "UTC") + + // Three calendar months behind with one allowance left: a single month step + // cannot reach the present, so the client stays expired on its billing day. + past := time.Now().In(zone).AddDate(0, -3, 0) + past = time.Date(past.Year(), past.Month(), 10, 0, 0, 0, 0, zone) + clients := []model.Client{ + {Email: "calcap@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, ResetDay: 10, ResetMax: 3, ExpiryTime: past.UnixMilli()}, + } + ib := mkInbound(t, 30205, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "calcap@x", Enable: false, ResetDay: 10, ResetMax: 3, ResetCount: 2, + Up: 111, Down: 222, ExpiryTime: past.UnixMilli(), + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "calcap@x").First(&row).Error; err != nil { + t.Fatal(err) + } + got := time.UnixMilli(row.ExpiryTime).In(zone) + if want := past.AddDate(0, 1, 0); !got.Equal(want) { + t.Fatalf("renewed to %s, want exactly one month on to %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } + if row.ResetCount != 3 { + t.Fatalf("resetCount = %d, want 3: one allowance per month stepped", row.ResetCount) + } + if row.Enable { + t.Fatal("a client still expired after a truncated catch-up was enabled") + } + if row.Up != 111 || row.Down != 222 { + t.Fatalf("counters zeroed for a month the client can never use: up=%d down=%d", row.Up, row.Down) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 8f3a71d6a..71e5d3910 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -237,7 +237,7 @@ func mergeActivationExpiry(existing, node int64) int64 { // nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved // forward while the node's cumulative counter fell below the stored baseline. func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool { - if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 { + if (cs.Reset <= 0 && cs.ResetDay <= 0) || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 { return false } if cs.ExpiryTime <= existing.ExpiryTime { @@ -844,6 +844,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi Total: cs.Total, ExpiryTime: cs.ExpiryTime, Reset: cs.Reset, + ResetDay: cs.ResetDay, Up: seedUp, Down: seedDown, LastOnline: cs.LastOnline, @@ -879,12 +880,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi fmt.Sprintf( `UPDATE client_traffics SET up = ?, down = ?, enable = ?, total = ?, - expiry_time = ?, reset = ?, last_online = %s + expiry_time = ?, reset = ?, reset_day = ?, last_online = %s WHERE email = ?`, database.GreatestExpr("last_online", "?"), ), canon.Up, canon.Down, cs.Enable, cs.Total, - cs.ExpiryTime, cs.Reset, + cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.LastOnline, cs.Email, ).Error; err != nil { return false, err @@ -906,7 +907,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi `UPDATE client_traffics SET up = %s, down = %s, enable = %s, total = ?, expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END, - reset = ?, last_online = %s + reset = ?, reset_day = ?, last_online = %s WHERE email = ?`, database.ClampedAddExpr("up"), database.ClampedAddExpr("down"), @@ -914,7 +915,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi database.GreatestExpr("last_online", "?"), ), deltaUp, deltaDown, cs.Enable, cs.Total, - cs.ExpiryTime, cs.ExpiryTime, cs.Reset, + cs.ExpiryTime, cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.LastOnline, cs.Email, ).Error; err != nil { return false, err diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index cbea2e647..f79bca6d3 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -333,7 +333,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut // attached to, so it could be a node inbound even when the client also has // local inbounds. The email-based join through client_inbounds is authoritative. err = tx.Model(xray.ClientTraffic{}). - Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now). + Where("(reset > 0 or reset_day > 0) and expiry_time > 0 and expiry_time <= ?", now). // A prepaid plan stops itself: once as many renewals have fired as the // operator allowed, the client is left to expire like any other. Where("reset_max <= 0 or reset_count < reset_max"). @@ -351,6 +351,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut return false, 0, nil } + renewLocation, locErr := (&SettingService{}).GetTimeLocation() + if locErr != nil || renewLocation == nil { + // Falling back to UTC keeps renewals happening; the alternative is + // skipping them entirely because a setting could not be read. + logger.Warning("autoRenewClients: could not read the panel time zone, using UTC:", locErr) + renewLocation = time.UTC + } + var inbound_ids []int var inbounds []*model.Inbound needRestart := false @@ -417,12 +425,25 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut // One allowance per period, not per tick: a client away for three // cycles must not catch up three of them against a prepaid cap. newExpiryTime := traffic.ExpiryTime + if traffic.ResetDay <= 0 && traffic.Reset <= 0 { + // Unreachable while the selection filter holds: a zero step below + // would spin forever on the single traffic writer and hang the panel. + continue + } + at := time.UnixMilli(newExpiryTime) renewals := 0 for newExpiryTime < now { if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax { break } - newExpiryTime += (int64(traffic.Reset) * 86400000) + if traffic.ResetDay > 0 { + // Calendar mode: step whole months in the panel's zone, so the + // renewal date does not drift the way a fixed 30-day step does. + at = nextCalendarRenewal(at, traffic.ResetDay, renewLocation) + newExpiryTime = at.UnixMilli() + } else { + newExpiryTime += (int64(traffic.Reset) * 86400000) + } renewals++ } if renewals == 0 { @@ -528,11 +549,12 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model ExpiryTime: client.ExpiryTime, Enable: client.Enable, Reset: client.Reset, + ResetDay: client.ResetDay, ResetMax: client.ResetMax, } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "email"}}, - DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}), + DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_day", "reset_max"}), }).Create(&clientTraffic).Error } @@ -545,6 +567,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod "total": client.TotalGB, "expiry_time": client.ExpiryTime, "reset": client.Reset, + "reset_day": client.ResetDay, "reset_max": client.ResetMax, }) err := result.Error diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ddacdeaf6..089f133ed 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -874,6 +874,8 @@ }, "renewMax": "الحد الأقصى للتجديدات", "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.", + "renewOnDay": "يوم التجديد", + "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.", "renewsUsed": "التجديدات المستخدمة" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "الانتهاء كطابع زمني Unix (بالثواني)", "descCREATED_UNIX": "وقت الإنشاء كطابع زمني Unix (بالثواني)", "descRESET_DAYS": "فترة إعادة تعيين حركة المرور بالأيام", + "descRESET_DAY": "يوم الشهر الذي يتم فيه التجديد", "descPROTOCOL": "بروتوكول الوارد (VLESS، VMess، Trojan، …)", "descTRANSPORT": "شبكة النقل (tcp، ws، grpc، …)", "descSECURITY": "أمان النقل (TLS، REALITY، NONE)" diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 0d6a5df92..f4c685786 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -874,6 +874,8 @@ }, "renewMax": "Max renewals", "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.", + "renewOnDay": "Renew on day", + "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.", "renewsUsed": "Renewals used" }, "groups": { @@ -1020,6 +1022,7 @@ "descEXPIRE_UNIX": "Expiry as a Unix timestamp (seconds)", "descCREATED_UNIX": "Creation time as a Unix timestamp (seconds)", "descRESET_DAYS": "Traffic reset period in days", + "descRESET_DAY": "Calendar renewal day of the month", "descPROTOCOL": "Inbound protocol (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Transport network (tcp, ws, grpc, …)", "descSECURITY": "Transport security (TLS, REALITY, NONE)" diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 82c7db697..b25723a66 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -874,6 +874,8 @@ }, "renewMax": "Renovaciones máximas", "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.", + "renewOnDay": "Renovar el día", + "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.", "renewsUsed": "Renovaciones usadas" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Expiración como marca de tiempo Unix (segundos)", "descCREATED_UNIX": "Hora de creación como marca de tiempo Unix (segundos)", "descRESET_DAYS": "Periodo de reinicio de tráfico en días", + "descRESET_DAY": "Día del mes en que se renueva", "descPROTOCOL": "Protocolo del inbound (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Red de transporte (tcp, ws, grpc, …)", "descSECURITY": "Seguridad del transporte (TLS, REALITY, NONE)" diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index dc4a375cd..23f486e51 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -874,6 +874,8 @@ }, "renewMax": "حداکثر تعداد تمدید", "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.", + "renewOnDay": "روز تمدید", + "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.", "renewsUsed": "تمدیدهای استفاده‌شده" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "انقضا به‌صورت مهر زمانی Unix (ثانیه)", "descCREATED_UNIX": "زمان ایجاد به‌صورت مهر زمانی Unix (ثانیه)", "descRESET_DAYS": "دورهٔ بازنشانی ترافیک به روز", + "descRESET_DAY": "روز ماه برای تمدید تقویمی", "descPROTOCOL": "پروتکل اینباند (VLESS، VMess، Trojan، …)", "descTRANSPORT": "شبکهٔ انتقال (tcp، ws، grpc، …)", "descSECURITY": "امنیت انتقال (TLS، REALITY، NONE)" diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 59c76a126..a84c143eb 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -874,6 +874,8 @@ }, "renewMax": "Maksimum perpanjangan", "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.", + "renewOnDay": "Perpanjang pada tanggal", + "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.", "renewsUsed": "Perpanjangan terpakai" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Kedaluwarsa sebagai timestamp Unix (detik)", "descCREATED_UNIX": "Waktu pembuatan sebagai timestamp Unix (detik)", "descRESET_DAYS": "Periode reset trafik dalam hari", + "descRESET_DAY": "Tanggal perpanjangan setiap bulan", "descPROTOCOL": "Protokol inbound (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Jaringan transport (tcp, ws, grpc, …)", "descSECURITY": "Keamanan transport (TLS, REALITY, NONE)" diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 56b6eebd6..6ee839ed5 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -874,6 +874,8 @@ }, "renewMax": "最大更新回数", "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。", + "renewOnDay": "更新する日", + "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。", "renewsUsed": "使用済み更新回数" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "有効期限の Unix タイムスタンプ(秒)", "descCREATED_UNIX": "作成時刻の Unix タイムスタンプ(秒)", "descRESET_DAYS": "トラフィックリセット周期(日数)", + "descRESET_DAY": "毎月の更新日", "descPROTOCOL": "インバウンドのプロトコル(VLESS、VMess、Trojan など)", "descTRANSPORT": "トランスポートネットワーク(tcp、ws、grpc など)", "descSECURITY": "トランスポートのセキュリティ(TLS、REALITY、NONE)" diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index d7936f19b..2d987b78f 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -874,6 +874,8 @@ }, "renewMax": "Renovações máximas", "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.", + "renewOnDay": "Renovar no dia", + "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.", "renewsUsed": "Renovações usadas" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Expiração como timestamp Unix (segundos)", "descCREATED_UNIX": "Data de criação como timestamp Unix (segundos)", "descRESET_DAYS": "Período de redefinição de tráfego em dias", + "descRESET_DAY": "Dia do mês em que é renovado", "descPROTOCOL": "Protocolo da entrada (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Rede de transporte (tcp, ws, grpc, …)", "descSECURITY": "Segurança do transporte (TLS, REALITY, NONE)" diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ecac990d3..8eb4c8285 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -874,6 +874,8 @@ }, "renewMax": "Лимит продлений", "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.", + "renewOnDay": "Продлевать числа", + "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.", "renewsUsed": "Продлений израсходовано" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Окончание в виде Unix-метки времени (секунды)", "descCREATED_UNIX": "Время создания в виде Unix-метки времени (секунды)", "descRESET_DAYS": "Период сброса трафика в днях", + "descRESET_DAY": "Число месяца, в которое продлевается доступ", "descPROTOCOL": "Протокол входящего (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Транспортная сеть (tcp, ws, grpc, …)", "descSECURITY": "Безопасность транспорта (TLS, REALITY, NONE)" diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 0e0475c45..689b4f822 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -874,6 +874,8 @@ }, "renewMax": "En fazla yenileme", "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.", + "renewOnDay": "Yenileme günü", + "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.", "renewsUsed": "Kullanılan yenileme" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Son kullanma Unix zaman damgası olarak (saniye)", "descCREATED_UNIX": "Oluşturulma zamanı Unix zaman damgası olarak (saniye)", "descRESET_DAYS": "Trafik sıfırlama periyodu (gün)", + "descRESET_DAY": "Takvime göre yenileme günü", "descPROTOCOL": "Gelen bağlantı protokolü (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Taşıma ağı (tcp, ws, grpc, …)", "descSECURITY": "Taşıma güvenliği (TLS, REALITY, NONE)" diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index e8302085f..7fef39037 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -874,6 +874,8 @@ }, "renewMax": "Ліміт подовжень", "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.", + "renewOnDay": "Подовжувати числа", + "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.", "renewsUsed": "Подовжень витрачено" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Закінчення як мітка часу Unix (секунди)", "descCREATED_UNIX": "Час створення як мітка часу Unix (секунди)", "descRESET_DAYS": "Період скидання трафіку в днях", + "descRESET_DAY": "Число місяця, у яке подовжується доступ", "descPROTOCOL": "Протокол вхідного (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Транспортна мережа (tcp, ws, grpc, …)", "descSECURITY": "Безпека транспорту (TLS, REALITY, NONE)" diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index df7adf5bb..8df066671 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -874,6 +874,8 @@ }, "renewMax": "Số lần gia hạn tối đa", "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.", + "renewOnDay": "Gia hạn vào ngày", + "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.", "renewsUsed": "Số lần gia hạn đã dùng" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "Hết hạn dạng dấu thời gian Unix (giây)", "descCREATED_UNIX": "Thời điểm tạo dạng dấu thời gian Unix (giây)", "descRESET_DAYS": "Chu kỳ đặt lại lưu lượng tính theo ngày", + "descRESET_DAY": "Ngày trong tháng để gia hạn", "descPROTOCOL": "Giao thức inbound (VLESS, VMess, Trojan, …)", "descTRANSPORT": "Mạng truyền tải (tcp, ws, grpc, …)", "descSECURITY": "Bảo mật truyền tải (TLS, REALITY, NONE)" diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a45f3c6c2..c4df21834 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -874,6 +874,8 @@ }, "renewMax": "最大续期次数", "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。", + "renewOnDay": "按日期续期", + "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。", "renewsUsed": "已用续期次数" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "到期时间的 Unix 时间戳(秒)", "descCREATED_UNIX": "创建时间的 Unix 时间戳(秒)", "descRESET_DAYS": "流量重置周期(天)", + "descRESET_DAY": "按月续期的日期", "descPROTOCOL": "入站协议(VLESS、VMess、Trojan……)", "descTRANSPORT": "传输网络(tcp、ws、grpc……)", "descSECURITY": "传输安全(TLS、REALITY、NONE)" diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 9320d8712..d903df164 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -874,6 +874,8 @@ }, "renewMax": "最大續期次數", "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。", + "renewOnDay": "按日期續期", + "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。", "renewsUsed": "已用續期次數" }, "groups": { @@ -1886,6 +1888,7 @@ "descEXPIRE_UNIX": "到期時間(Unix 時間戳記,秒)", "descCREATED_UNIX": "建立時間(Unix 時間戳記,秒)", "descRESET_DAYS": "流量重置週期(天)", + "descRESET_DAY": "按月續期的日期", "descPROTOCOL": "入站協定(VLESS、VMess、Trojan…)", "descTRANSPORT": "傳輸網路(tcp、ws、grpc…)", "descSECURITY": "傳輸安全(TLS、REALITY、NONE)" diff --git a/internal/xray/client_traffic.go b/internal/xray/client_traffic.go index 655567586..7772b40ca 100644 --- a/internal/xray/client_traffic.go +++ b/internal/xray/client_traffic.go @@ -14,6 +14,9 @@ type ClientTraffic struct { ExpiryTime int64 `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"` Total int64 `json:"total" form:"total" example:"10737418240"` Reset int `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"` + // ResetDay renews on that day of each calendar month instead of every + // Reset days; 0 keeps the interval behaviour. + ResetDay int `json:"resetDay" form:"resetDay" gorm:"default:0" example:"0"` // ResetMax caps how many times auto-renew may fire; 0 means no cap. ResetMax int `json:"resetMax" form:"resetMax" gorm:"default:0" example:"0"` // ResetCount is how many have fired, so a prepaid plan stops on its own.