feat(clients): cap how many times a client may auto-renew (#6238)

* feat(clients): cap how many times a client may auto-renew

Auto-renew today runs forever: a prepaid or fixed-term client keeps being
handed new periods until an operator remembers to switch it off. There is no
way to say "renew this three times, then let it lapse".

Add a per-client maximum. Zero keeps today's behaviour, so nothing changes for
anyone who does not set one. When the count is reached the client is simply
left to expire, like any client without auto-renew.

Catching up several missed periods spends one allowance per period. A client
that was away for three cycles must not receive three of them free of the cap,
and the catch-up stops at the last period the cap paid for rather than jumping
to the present.

* fix(clients): persist the auto-renew cap and stop the capped churn

resetMax lived only in the inbound settings JSON and client_traffics, so
every path that rebuilds a client from the clients table wrote it back as
zero. The edit dialog showed 0 for a capped client, and saving an
unrelated comment change lifted the cap; an attach or a traffic reset did
the same with no operator action at all.

Adds reset_max to ClientRecord and threads it through ToRecord, ToClient,
applyClientRecordMerge, the record update map and ClientSlim, so the cap
survives the round trip.

When the cap truncates a catch-up the client is still expired, but the
renewal side effects fired anyway: counters were zeroed for periods it
can never use, and it was enabled and pushed to xray only for
disableInvalidClients to undo both in the same transaction. Those are now
skipped when the new expiry has not reached the present.

Also makes any non-positive resetMax mean unlimited instead of silently
meaning "never renew again", rejects a negative one at the service layer,
surfaces renewals used against allowed in the client info modal so the
operator can see what to raise, adds the field to the bulk-add modal,
translates the labels in all 13 locales, and drops the stray
internal/web/dist/.gitkeep build stub.

* fix(clients): let the renewal cap be changed after creation

ClientService.Update writes the record columns directly only for a client
with no inbounds. The normal path goes through SyncInbound and
applyClientRecordMerge, which this change had not extended, so raising a
cap from 3 to 6 — the natural action when a customer buys another block
of periods — updated the inbound settings JSON while clients.reset_max
kept the old value and the renewal query kept enforcing it.

The existing test did not catch it: it asserted the cap survived an
unrelated edit, and it survived precisely because nothing on that path
ever wrote it. TestClientEditChangesTheRenewalCap raises the cap and then
lifts it entirely; removing the record write turns it red.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-18 14:53:11 +05:00
committed by GitHub
parent 6a674c7f0c
commit e940f30bb8
29 changed files with 508 additions and 27 deletions
+25
View File
@@ -1110,6 +1110,10 @@
"description": "Reset period in days", "description": "Reset period in days",
"type": "integer" "type": "integer"
}, },
"resetMax": {
"description": "Max auto-renew count, 0 = unlimited",
"type": "integer"
},
"reverse": { "reverse": {
"allOf": [ "allOf": [
{ {
@@ -1154,6 +1158,7 @@
"expiryTime", "expiryTime",
"limitIp", "limitIp",
"reset", "reset",
"resetMax",
"security", "security",
"subId", "subId",
"tgId", "tgId",
@@ -1246,6 +1251,9 @@
"reset": { "reset": {
"type": "integer" "type": "integer"
}, },
"resetMax": {
"type": "integer"
},
"reverse": {}, "reverse": {},
"secret": { "secret": {
"type": "string" "type": "string"
@@ -1292,6 +1300,7 @@
"privateKey", "privateKey",
"publicKey", "publicKey",
"reset", "reset",
"resetMax",
"reverse", "reverse",
"secret", "secret",
"security", "security",
@@ -1357,6 +1366,16 @@
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetCount": {
"description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
"example": 0,
"type": "integer"
},
"resetMax": {
"description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "i7tvdpeffi0hvvf1", "example": "i7tvdpeffi0hvvf1",
"type": "string" "type": "string"
@@ -1386,6 +1405,8 @@
"lastOnline", "lastOnline",
"lastSubFetch", "lastSubFetch",
"reset", "reset",
"resetCount",
"resetMax",
"subId", "subId",
"total", "total",
"up", "up",
@@ -3327,6 +3348,8 @@
"lastOnline": 1735680000000, "lastOnline": 1735680000000,
"lastSubFetch": 1735680000000, "lastSubFetch": 1735680000000,
"reset": 0, "reset": 0,
"resetCount": 0,
"resetMax": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -8130,6 +8153,8 @@
"lastOnline": 1735680000000, "lastOnline": 1735680000000,
"lastSubFetch": 1735680000000, "lastSubFetch": 1735680000000,
"reset": 0, "reset": 0,
"resetCount": 0,
"resetMax": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
+6
View File
@@ -256,6 +256,7 @@ export const EXAMPLES: Record<string, unknown> = {
"privateKey": "", "privateKey": "",
"publicKey": "", "publicKey": "",
"reset": 0, "reset": 0,
"resetMax": 0,
"reverse": null, "reverse": null,
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
"security": "", "security": "",
@@ -290,6 +291,7 @@ export const EXAMPLES: Record<string, unknown> = {
"privateKey": "", "privateKey": "",
"publicKey": "", "publicKey": "",
"reset": 0, "reset": 0,
"resetMax": 0,
"reverse": null, "reverse": null,
"secret": "", "secret": "",
"security": "", "security": "",
@@ -312,6 +314,8 @@ export const EXAMPLES: Record<string, unknown> = {
"lastOnline": 1735680000000, "lastOnline": 1735680000000,
"lastSubFetch": 1735680000000, "lastSubFetch": 1735680000000,
"reset": 0, "reset": 0,
"resetCount": 0,
"resetMax": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
@@ -478,6 +482,8 @@ export const EXAMPLES: Record<string, unknown> = {
"lastOnline": 1735680000000, "lastOnline": 1735680000000,
"lastSubFetch": 1735680000000, "lastSubFetch": 1735680000000,
"reset": 0, "reset": 0,
"resetCount": 0,
"resetMax": 0,
"subId": "i7tvdpeffi0hvvf1", "subId": "i7tvdpeffi0hvvf1",
"total": 10737418240, "total": 10737418240,
"up": 1048576, "up": 1048576,
+21
View File
@@ -1084,6 +1084,10 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Reset period in days", "description": "Reset period in days",
"type": "integer" "type": "integer"
}, },
"resetMax": {
"description": "Max auto-renew count, 0 = unlimited",
"type": "integer"
},
"reverse": { "reverse": {
"allOf": [ "allOf": [
{ {
@@ -1128,6 +1132,7 @@ export const SCHEMAS: Record<string, unknown> = {
"expiryTime", "expiryTime",
"limitIp", "limitIp",
"reset", "reset",
"resetMax",
"security", "security",
"subId", "subId",
"tgId", "tgId",
@@ -1220,6 +1225,9 @@ export const SCHEMAS: Record<string, unknown> = {
"reset": { "reset": {
"type": "integer" "type": "integer"
}, },
"resetMax": {
"type": "integer"
},
"reverse": {}, "reverse": {},
"secret": { "secret": {
"type": "string" "type": "string"
@@ -1266,6 +1274,7 @@ export const SCHEMAS: Record<string, unknown> = {
"privateKey", "privateKey",
"publicKey", "publicKey",
"reset", "reset",
"resetMax",
"reverse", "reverse",
"secret", "secret",
"security", "security",
@@ -1331,6 +1340,16 @@ export const SCHEMAS: Record<string, unknown> = {
"example": 0, "example": 0,
"type": "integer" "type": "integer"
}, },
"resetCount": {
"description": "ResetCount is how many have fired, so a prepaid plan stops on its own.",
"example": 0,
"type": "integer"
},
"resetMax": {
"description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.",
"example": 0,
"type": "integer"
},
"subId": { "subId": {
"example": "i7tvdpeffi0hvvf1", "example": "i7tvdpeffi0hvvf1",
"type": "string" "type": "string"
@@ -1360,6 +1379,8 @@ export const SCHEMAS: Record<string, unknown> = {
"lastOnline", "lastOnline",
"lastSubFetch", "lastSubFetch",
"reset", "reset",
"resetCount",
"resetMax",
"subId", "subId",
"total", "total",
"up", "up",
+4
View File
@@ -266,6 +266,7 @@ export interface Client {
privateKey?: string; privateKey?: string;
publicKey?: string; publicKey?: string;
reset: number; reset: number;
resetMax: number;
reverse?: ClientReverse | null; reverse?: ClientReverse | null;
secret?: string; secret?: string;
security: string; security: string;
@@ -302,6 +303,7 @@ export interface ClientRecord {
privateKey: string; privateKey: string;
publicKey: string; publicKey: string;
reset: number; reset: number;
resetMax: number;
reverse: unknown; reverse: unknown;
secret: string; secret: string;
security: string; security: string;
@@ -326,6 +328,8 @@ export interface ClientTraffic {
lastOnline: number; lastOnline: number;
lastSubFetch: number; lastSubFetch: number;
reset: number; reset: number;
resetCount: number;
resetMax: number;
subId: string; subId: string;
total: number; total: number;
up: number; up: number;
+4
View File
@@ -286,6 +286,7 @@ export const ClientSchema = z.object({
privateKey: z.string().optional(), privateKey: z.string().optional(),
publicKey: z.string().optional(), publicKey: z.string().optional(),
reset: z.number().int(), reset: z.number().int(),
resetMax: z.number().int(),
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
secret: z.string().optional(), secret: z.string().optional(),
security: z.string(), security: z.string(),
@@ -324,6 +325,7 @@ export const ClientRecordSchema = z.object({
privateKey: z.string(), privateKey: z.string(),
publicKey: z.string(), publicKey: z.string(),
reset: z.number().int(), reset: z.number().int(),
resetMax: z.number().int(),
reverse: z.unknown(), reverse: z.unknown(),
secret: z.string(), secret: z.string(),
security: z.string(), security: z.string(),
@@ -350,6 +352,8 @@ export const ClientTrafficSchema = z.object({
lastOnline: z.number().int(), lastOnline: z.number().int(),
lastSubFetch: z.number().int(), lastSubFetch: z.number().int(),
reset: z.number().int(), reset: z.number().int(),
resetCount: z.number().int(),
resetMax: z.number().int(),
subId: z.string(), subId: z.string(),
total: z.number().int(), total: z.number().int(),
up: z.number().int(), up: z.number().int(),
@@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = {
totalGB: 0, totalGB: 0,
expiryTime: 0, expiryTime: 0,
reset: 0, reset: 0,
resetMax: 0,
inboundIds: [], inboundIds: [],
}; };
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB), totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
expiryTime: current.expiryTime, expiryTime: current.expiryTime,
reset: Number(current.reset) || 0, reset: Number(current.reset) || 0,
resetMax: Number(current.resetMax) || 0,
limitIp: Number(current.limitIp) || 0, limitIp: Number(current.limitIp) || 0,
limitHwid: Number(current.limitHwid) || 0, limitHwid: Number(current.limitHwid) || 0,
group: current.group, group: current.group,
@@ -374,6 +376,15 @@ export default function ClientBulkAddModal({
> >
<InputNumber min={0} /> <InputNumber min={0} />
</FormField> </FormField>
<FormField
name="resetMax"
label={t('pages.clients.renewMax')}
tooltip={t('pages.clients.renewMaxDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} />
</FormField>
</Form> </Form>
</FormProvider> </FormProvider>
</Modal> </Modal>
@@ -131,6 +131,7 @@ const EMPTY: Values = {
delayedStart: false, delayedStart: false,
delayedDays: 0, delayedDays: 0,
reset: 0, reset: 0,
resetMax: 0,
limitIp: 0, limitIp: 0,
limitHwid: 0, limitHwid: 0,
tgId: 0, tgId: 0,
@@ -250,6 +251,7 @@ export default function ClientFormModal({
reverseTag: client.reverse?.tag || '', reverseTag: client.reverse?.tag || '',
totalGB: bytesToGB(client.totalGB || 0), totalGB: bytesToGB(client.totalGB || 0),
reset: Number(client.reset) || 0, reset: Number(client.reset) || 0,
resetMax: Number(client.resetMax) || 0,
limitIp: client.limitIp || 0, limitIp: client.limitIp || 0,
limitHwid: client.limitHwid || 0, limitHwid: client.limitHwid || 0,
tgId: Number(client.tgId) || 0, tgId: Number(client.tgId) || 0,
@@ -538,6 +540,7 @@ email: values.email,
delayedStart: values.delayedStart, delayedStart: values.delayedStart,
delayedDays: values.delayedDays, delayedDays: values.delayedDays,
reset: values.reset, reset: values.reset,
resetMax: values.resetMax,
limitIp: values.limitIp, limitIp: values.limitIp,
limitHwid: values.limitHwid, limitHwid: values.limitHwid,
tgId: values.tgId, tgId: values.tgId,
@@ -566,6 +569,7 @@ email: values.email,
totalGB: totalBytes, totalGB: totalBytes,
expiryTime, expiryTime,
reset: Number(values.reset) || 0, reset: Number(values.reset) || 0,
resetMax: Number(values.resetMax) || 0,
limitIp: Number(values.limitIp) || 0, limitIp: Number(values.limitIp) || 0,
limitHwid: Number(values.limitHwid) || 0, limitHwid: Number(values.limitHwid) || 0,
tgId: Number(values.tgId) || 0, tgId: Number(values.tgId) || 0,
@@ -785,6 +789,16 @@ reset: Number(values.reset) || 0,
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</FormField> </FormField>
</Col> </Col>
<Col xs={12} md={6}>
<FormField
name="resetMax"
label={t('pages.clients.renewMax')}
tooltip={t('pages.clients.renewMaxDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} style={{ width: '100%' }} />
</FormField>
</Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
@@ -325,6 +325,16 @@ export default function ClientInfoModal({
</Button> </Button>
</td> </td>
</tr> </tr>
{(traffic?.resetMax ?? 0) > 0 && (
<tr>
<td>{t('pages.clients.renewsUsed')}</td>
<td>
<Tag color={(traffic?.resetCount ?? 0) >= (traffic?.resetMax ?? 0) ? 'red' : 'blue'}>
{traffic?.resetCount ?? 0} / {traffic?.resetMax}
</Tag>
</td>
</tr>
)}
<tr> <tr>
<td>{t('pages.inbounds.createdAt')}</td> <td>{t('pages.inbounds.createdAt')}</td>
<td><Tag>{dateLabel(client.createdAt)}</Tag></td> <td><Tag>{dateLabel(client.createdAt)}</Tag></td>
+5
View File
@@ -11,6 +11,8 @@ export const ClientTrafficSchema = z.object({
enable: z.boolean().optional(), enable: z.boolean().optional(),
lastOnline: z.number().optional(), lastOnline: z.number().optional(),
lastSubFetch: z.number().optional(), lastSubFetch: z.number().optional(),
resetMax: z.number().optional(),
resetCount: z.number().optional(),
}); });
export const ClientRecordSchema = z.object({ export const ClientRecordSchema = z.object({
@@ -31,6 +33,7 @@ export const ClientRecordSchema = z.object({
comment: z.string().optional(), comment: z.string().optional(),
enable: z.boolean().optional(), enable: z.boolean().optional(),
reset: z.number().optional(), reset: z.number().optional(),
resetMax: z.number().optional(),
inboundIds: nullableNumberArray.optional(), inboundIds: nullableNumberArray.optional(),
traffic: ClientTrafficSchema.nullable().optional(), traffic: ClientTrafficSchema.nullable().optional(),
reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(), reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(),
@@ -205,6 +208,7 @@ export const ClientFormSchema = z.object({
delayedStart: z.boolean(), delayedStart: z.boolean(),
delayedDays: z.number().int().min(0), delayedDays: z.number().int().min(0),
reset: z.number().int().min(0), reset: z.number().int().min(0),
resetMax: z.number().int().min(0),
limitIp: z.number().int().min(0), limitIp: z.number().int().min(0),
limitHwid: z.number().int().min(0), limitHwid: z.number().int().min(0),
tgId: z.number().int().min(0), tgId: z.number().int().min(0),
@@ -244,6 +248,7 @@ export const ClientBulkAddFormSchema = z.object({
totalGB: z.number().min(0), totalGB: z.number().min(0),
expiryTime: z.number(), expiryTime: z.number(),
reset: z.number().int().min(0), reset: z.number().int().min(0),
resetMax: z.number().int().min(0),
inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'), inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'),
}); });
+10
View File
@@ -894,6 +894,7 @@ type Client struct {
Group string `json:"group,omitempty" form:"group"` // Logical grouping label Group string `json:"group,omitempty" form:"group"` // Logical grouping label
Comment string `json:"comment" form:"comment"` // Client comment Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days Reset int `json:"reset" form:"reset"` // Reset period in days
ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
} }
@@ -924,6 +925,7 @@ type ClientRecord struct {
Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
Comment string `json:"comment"` Comment string `json:"comment"`
Reset int `json:"reset" gorm:"default:0"` Reset int `json:"reset" gorm:"default:0"`
ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// Owned solely by the node-snapshot sweep, which soft-orphans instead of // Owned solely by the node-snapshot sweep, which soft-orphans instead of
@@ -1105,6 +1107,7 @@ func (c *Client) ToRecord() *ClientRecord {
Group: c.Group, Group: c.Group,
Comment: c.Comment, Comment: c.Comment,
Reset: c.Reset, Reset: c.Reset,
ResetMax: c.ResetMax,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt, UpdatedAt: c.UpdatedAt,
@@ -1158,6 +1161,7 @@ func (r *ClientRecord) ToClient() *Client {
Group: r.Group, Group: r.Group,
Comment: r.Comment, Comment: r.Comment,
Reset: r.Reset, Reset: r.Reset,
ResetMax: r.ResetMax,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt, UpdatedAt: r.UpdatedAt,
@@ -1306,6 +1310,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
existing.Reset = incoming.Reset existing.Reset = incoming.Reset
} }
} }
if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 {
if incomingNewer || existing.ResetMax == 0 {
keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax)
existing.ResetMax = incoming.ResetMax
}
}
if existing.Reverse != incoming.Reverse && incoming.Reverse != "" { if existing.Reverse != incoming.Reverse && incoming.Reverse != "" {
if incomingNewer || existing.Reverse == "" { if incomingNewer || existing.Reverse == "" {
keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse) keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse)
+16
View File
@@ -43,6 +43,15 @@ func validateClientSubID(subID string) error {
return nil 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 {
if resetMax < 0 {
return common.NewError("client resetMax must not be negative, got:", resetMax)
}
return nil
}
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) { func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
if payload == nil { if payload == nil {
return false, common.NewError("empty payload") return false, common.NewError("empty payload")
@@ -57,6 +66,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
if err := validateClientSubID(client.SubID); err != nil { if err := validateClientSubID(client.SubID); err != nil {
return false, err return false, err
} }
if err := validateClientResetMax(client.ResetMax); err != nil {
return false, err
}
if len(payload.InboundIds) == 0 { if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required") return false, common.NewError("at least one inbound is required")
} }
@@ -344,6 +356,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
if err := validateClientSubID(updated.SubID); err != nil { if err := validateClientSubID(updated.SubID); err != nil {
return false, err return false, err
} }
if err := validateClientResetMax(updated.ResetMax); err != nil {
return false, err
}
if updated.SubID == "" { if updated.SubID == "" {
updated.SubID = existing.SubID updated.SubID = existing.SubID
} }
@@ -466,6 +481,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
"tg_id": merged.TgID, "tg_id": merged.TgID,
"comment": merged.Comment, "comment": merged.Comment,
"reset": merged.Reset, "reset": merged.Reset,
"reset_max": merged.ResetMax,
}).Error; err != nil { }).Error; err != nil {
return needRestart, err return needRestart, err
} }
+1
View File
@@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor
} }
row.Comment = incoming.Comment row.Comment = incoming.Comment
row.Reset = incoming.Reset row.Reset = incoming.Reset
row.ResetMax = incoming.ResetMax
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) { if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt row.CreatedAt = incoming.CreatedAt
} }
+2
View File
@@ -26,6 +26,7 @@ type ClientSlim struct {
LimitIP int `json:"limitIp"` LimitIP int `json:"limitIp"`
LimitHwid int `json:"limitHwid"` LimitHwid int `json:"limitHwid"`
Reset int `json:"reset"` Reset int `json:"reset"`
ResetMax int `json:"resetMax"`
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
InboundIds []int `json:"inboundIds"` InboundIds []int `json:"inboundIds"`
@@ -605,6 +606,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim {
LimitIP: c.LimitIP, LimitIP: c.LimitIP,
LimitHwid: c.LimitHwid, LimitHwid: c.LimitHwid,
Reset: c.Reset, Reset: c.Reset,
ResetMax: c.ResetMax,
Group: c.Group, Group: c.Group,
Comment: c.Comment, Comment: c.Comment,
InboundIds: c.InboundIds, InboundIds: c.InboundIds,
@@ -0,0 +1,287 @@
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"
)
// A prepaid plan must stop itself: once as many renewals have fired as the
// operator allowed, the client expires like any other (#5804).
func TestAutoRenewClients_StopsAtMaxCount(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "spent@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
{Email: "left@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
}
ib := mkInbound(t, 30101, model.VLESS, clientsSettings(t, clients))
if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
rows := []xray.ClientTraffic{
{InboundId: ib.Id, Email: "spent@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 2, ExpiryTime: past},
{InboundId: ib.Id, Email: "left@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 1, ExpiryTime: past},
}
if err := db.Create(&rows).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: only the client with an allowance left", count)
}
var spent xray.ClientTraffic
if err := db.Where("email = ?", "spent@x").First(&spent).Error; err != nil {
t.Fatal(err)
}
if spent.ExpiryTime != past {
t.Fatalf("a client that used its allowance was renewed anyway: expiry %d", spent.ExpiryTime)
}
var left xray.ClientTraffic
if err := db.Where("email = ?", "left@x").First(&left).Error; err != nil {
t.Fatal(err)
}
if left.ExpiryTime <= past {
t.Fatal("a client with an allowance left was not renewed")
}
if left.ResetCount != 2 {
t.Fatalf("reset count = %d after one renewal, want 2", left.ResetCount)
}
}
// Catching up several missed periods spends one allowance per period: a client
// that was away for three cycles must not receive three of them for free.
func TestAutoRenewClients_CatchUpSpendsOneAllowancePerPeriod(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
// Three whole 30-day periods behind.
past := time.Now().Add(-95 * 24 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "away@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past},
}
ib := mkInbound(t, 30102, 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: "away@x", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past,
}).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 = ?", "away@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ResetCount != 2 {
t.Fatalf("reset count = %d, want the 2 the cap allowed", row.ResetCount)
}
// Two periods granted, three needed: the client stays expired rather than
// silently receiving the third.
want := past + 2*30*86400000
if row.ExpiryTime != want {
t.Fatalf("expiry = %d, want %d: exactly the periods the cap paid for", row.ExpiryTime, want)
}
if row.ExpiryTime > time.Now().UnixMilli() {
t.Fatal("the capped catch-up handed out a future expiry it had not paid for")
}
}
// No cap set is the existing behaviour: renew for as long as the client keeps
// expiring.
func TestAutoRenewClients_NoCapRenewsAsBefore(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
past := time.Now().Add(-48 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "forever@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, Reset: 30, ExpiryTime: past},
}
ib := mkInbound(t, 30103, 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: "forever@x", Enable: false, Reset: 30, ResetCount: 99, 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: a client without a cap keeps renewing", count)
}
}
// The cap has to survive the clients table, not just the settings JSON: an
// ordinary edit rebuilds the client from the record and writes it back (#5804).
func TestClientEditKeepsTheRenewalCap(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
clients := []model.Client{
{Email: "cap@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Reset: 30, ResetMax: 3, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()},
}
ib := mkInbound(t, 30104, 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, "cap@x", 10, 20, 0, 0, true)
rec, err := svc.clientService.GetRecordByEmail(nil, "cap@x")
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.ResetMax != 3 {
t.Fatalf("clients.reset_max = %d, want the 3 the client was created with", rec.ResetMax)
}
// 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)
}
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].ResetMax != 3 {
t.Fatalf("inbound settings resetMax = %d after an unrelated edit, want 3: the cap was silently lifted", settings.Clients[0].ResetMax)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "cap@x")
if err != nil {
t.Fatalf("GetRecordByEmail after edit: %v", err)
}
if rec.ResetMax != 3 {
t.Fatalf("clients.reset_max = %d after an unrelated edit, want 3", rec.ResetMax)
}
}
// A cap that runs out mid-catch-up leaves the client expired, so the renewal
// side effects must not fire: disableInvalidClients would undo them at once.
func TestAutoRenewClients_TruncatedCatchUpLeavesTheClientDisabled(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
db := database.GetDB()
// Five periods behind with one allowance left: one 30-day step cannot reach
// the present, so the client stays expired.
past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli()
clients := []model.Client{
{Email: "short@x", ID: "55555555-5555-5555-5555-555555555555", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past},
}
ib := mkInbound(t, 30105, 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: "short@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2,
Up: 111, Down: 222, ExpiryTime: past,
}).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 = ?", "short@x").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.ExpiryTime >= time.Now().UnixMilli() {
t.Fatalf("expiry %d reached the present: the cap did not truncate the catch-up", row.ExpiryTime)
}
if row.Enable {
t.Fatal("a client still expired after a truncated catch-up was enabled: xray gains a user only to lose it again")
}
if row.Up != 111 || row.Down != 222 {
t.Fatalf("counters zeroed for periods the client can never use: up=%d down=%d", row.Up, row.Down)
}
}
// The cap 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.
func TestClientEditChangesTheRenewalCap(t *testing.T) {
setupBulkDB(t)
svc := &InboundService{}
clients := []model.Client{
{
Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, Reset: 30, ResetMax: 3,
ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(),
},
}
ib := mkInbound(t, 30106, 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)
}
// The customer buys another block of periods, which is the whole point of
// the field being editable.
edited := rec.ToClient()
edited.ResetMax = 6
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.ResetMax != 6 {
t.Fatalf("clients.reset_max = %d after the operator raised the cap to 6", rec.ResetMax)
}
// Lifting the cap entirely has to work too.
edited = rec.ToClient()
edited.ResetMax = 0
if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil {
t.Fatalf("Update to uncapped: %v", err)
}
rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x")
if err != nil {
t.Fatalf("GetRecordByEmail after lifting the cap: %v", err)
}
if rec.ResetMax != 0 {
t.Fatalf("clients.reset_max = %d after the operator lifted the cap", rec.ResetMax)
}
}
+23 -1
View File
@@ -334,6 +334,9 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
// local inbounds. The email-based join through client_inbounds is authoritative. // local inbounds. The email-based join through client_inbounds is authoritative.
err = tx.Model(xray.ClientTraffic{}). err = tx.Model(xray.ClientTraffic{}).
Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now). Where("reset > 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").
Where("email IN (?)", tx.Table("client_inbounds ci"). Where("email IN (?)", tx.Table("client_inbounds ci").
Select("c.email"). Select("c.email").
Joins("JOIN clients c ON c.id = ci.client_id"). Joins("JOIN clients c ON c.id = ci.client_id").
@@ -411,12 +414,29 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut
if !ok { if !ok {
continue continue
} }
// 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 newExpiryTime := traffic.ExpiryTime
renewals := 0
for newExpiryTime < now { for newExpiryTime < now {
if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
break
}
newExpiryTime += (int64(traffic.Reset) * 86400000) newExpiryTime += (int64(traffic.Reset) * 86400000)
renewals++
}
if renewals == 0 {
continue
} }
c["expiryTime"] = newExpiryTime c["expiryTime"] = newExpiryTime
traffic.ExpiryTime = newExpiryTime traffic.ExpiryTime = newExpiryTime
traffic.ResetCount += renewals
if newExpiryTime <= now {
// Cap ran out mid-catch-up and the client is still expired: enabling it
// for disableInvalidClients to undo adds and removes an xray user for nothing.
clients[client_index] = any(c)
continue
}
traffic.Down = 0 traffic.Down = 0
traffic.Up = 0 traffic.Up = 0
if !traffic.Enable { if !traffic.Enable {
@@ -508,10 +528,11 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model
ExpiryTime: client.ExpiryTime, ExpiryTime: client.ExpiryTime,
Enable: client.Enable, Enable: client.Enable,
Reset: client.Reset, Reset: client.Reset,
ResetMax: client.ResetMax,
} }
return tx.Clauses(clause.OnConflict{ return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}}, Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}), DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}),
}).Create(&clientTraffic).Error }).Create(&clientTraffic).Error
} }
@@ -524,6 +545,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
"total": client.TotalGB, "total": client.TotalGB,
"expiry_time": client.ExpiryTime, "expiry_time": client.ExpiryTime,
"reset": client.Reset, "reset": client.Reset,
"reset_max": client.ResetMax,
}) })
err := result.Error err := result.Error
return err return err
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "تم حذف {count} عميل غير مرتبط", "delOrphans": "تم حذف {count} عميل غير مرتبط",
"imported": "تم استيراد {count} عميل", "imported": "تم استيراد {count} عميل",
"importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم" "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
} },
"renewMax": "الحد الأقصى للتجديدات",
"renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
"renewsUsed": "التجديدات المستخدمة"
}, },
"groups": { "groups": {
"name": "الاسم", "name": "الاسم",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} unattached clients deleted", "delOrphans": "{count} unattached clients deleted",
"imported": "{count} clients imported", "imported": "{count} clients imported",
"importedMixed": "{ok} imported, {failed} skipped" "importedMixed": "{ok} imported, {failed} skipped"
} },
"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.",
"renewsUsed": "Renewals used"
}, },
"groups": { "groups": {
"name": "Name", "name": "Name",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} clientes sin entrante eliminados", "delOrphans": "{count} clientes sin entrante eliminados",
"imported": "{count} clientes importados", "imported": "{count} clientes importados",
"importedMixed": "{ok} importados, {failed} omitidos" "importedMixed": "{ok} importados, {failed} omitidos"
} },
"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.",
"renewsUsed": "Renovaciones usadas"
}, },
"groups": { "groups": {
"name": "Nombre", "name": "Nombre",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} کلاینت بدون اینباند حذف شد", "delOrphans": "{count} کلاینت بدون اینباند حذف شد",
"imported": "{count} کلاینت وارد شد", "imported": "{count} کلاینت وارد شد",
"importedMixed": "{ok} وارد شد، {failed} رد شد" "importedMixed": "{ok} وارد شد، {failed} رد شد"
} },
"renewMax": "حداکثر تعداد تمدید",
"renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
"renewsUsed": "تمدیدهای استفاده‌شده"
}, },
"groups": { "groups": {
"name": "نام", "name": "نام",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} klien tanpa inbound dihapus", "delOrphans": "{count} klien tanpa inbound dihapus",
"imported": "{count} klien diimpor", "imported": "{count} klien diimpor",
"importedMixed": "{ok} diimpor, {failed} dilewati" "importedMixed": "{ok} diimpor, {failed} dilewati"
} },
"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.",
"renewsUsed": "Perpanjangan terpakai"
}, },
"groups": { "groups": {
"name": "Nama", "name": "Nama",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "未アタッチの {count} 件のクライアントを削除しました", "delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
"imported": "{count} 件のクライアントをインポートしました", "imported": "{count} 件のクライアントをインポートしました",
"importedMixed": "{ok} 件インポート、{failed} 件スキップ" "importedMixed": "{ok} 件インポート、{failed} 件スキップ"
} },
"renewMax": "最大更新回数",
"renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
"renewsUsed": "使用済み更新回数"
}, },
"groups": { "groups": {
"name": "名前", "name": "名前",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} clientes sem inbound excluídos", "delOrphans": "{count} clientes sem inbound excluídos",
"imported": "{count} clientes importados", "imported": "{count} clientes importados",
"importedMixed": "{ok} importados, {failed} ignorados" "importedMixed": "{ok} importados, {failed} ignorados"
} },
"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.",
"renewsUsed": "Renovações usadas"
}, },
"groups": { "groups": {
"name": "Nome", "name": "Nome",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "Удалено клиентов без входящего: {count}", "delOrphans": "Удалено клиентов без входящего: {count}",
"imported": "Импортировано клиентов: {count}", "imported": "Импортировано клиентов: {count}",
"importedMixed": "Импортировано: {ok}, пропущено: {failed}" "importedMixed": "Импортировано: {ok}, пропущено: {failed}"
} },
"renewMax": "Лимит продлений",
"renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
"renewsUsed": "Продлений израсходовано"
}, },
"groups": { "groups": {
"name": "Имя", "name": "Имя",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "{count} bağsız kullanıcı silindi", "delOrphans": "{count} bağsız kullanıcı silindi",
"imported": "{count} kullanıcı içe aktarıldı", "imported": "{count} kullanıcı içe aktarıldı",
"importedMixed": "{ok} içe aktarıldı, {failed} atlandı" "importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
} },
"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.",
"renewsUsed": "Kullanılan yenileme"
}, },
"groups": { "groups": {
"name": "İsim", "name": "İsim",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "Видалено клієнтів без вхідного: {count}", "delOrphans": "Видалено клієнтів без вхідного: {count}",
"imported": "Імпортовано клієнтів: {count}", "imported": "Імпортовано клієнтів: {count}",
"importedMixed": "Імпортовано: {ok}, пропущено: {failed}" "importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
} },
"renewMax": "Ліміт подовжень",
"renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
"renewsUsed": "Подовжень витрачено"
}, },
"groups": { "groups": {
"name": "Назва", "name": "Назва",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "Đã xóa {count} khách hàng không gắn inbound", "delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
"imported": "Đã nhập {count} khách hàng", "imported": "Đã nhập {count} khách hàng",
"importedMixed": "Đã nhập {ok}, bỏ qua {failed}" "importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
} },
"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ỳ.",
"renewsUsed": "Số lần gia hạn đã dùng"
}, },
"groups": { "groups": {
"name": "Tên", "name": "Tên",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "已删除 {count} 个未关联的客户端", "delOrphans": "已删除 {count} 个未关联的客户端",
"imported": "已导入 {count} 个客户端", "imported": "已导入 {count} 个客户端",
"importedMixed": "已导入 {ok} 个,跳过 {failed} 个" "importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
} },
"renewMax": "最大续期次数",
"renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
"renewsUsed": "已用续期次数"
}, },
"groups": { "groups": {
"name": "名称", "name": "名称",
+4 -1
View File
@@ -869,7 +869,10 @@
"delOrphans": "已刪除 {count} 個未關聯的客戶端", "delOrphans": "已刪除 {count} 個未關聯的客戶端",
"imported": "已匯入 {count} 個客戶端", "imported": "已匯入 {count} 個客戶端",
"importedMixed": "已匯入 {ok} 個,跳過 {failed} 個" "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
} },
"renewMax": "最大續期次數",
"renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。",
"renewsUsed": "已用續期次數"
}, },
"groups": { "groups": {
"name": "名稱", "name": "名稱",
+17 -13
View File
@@ -3,17 +3,21 @@ package xray
// ClientTraffic represents traffic statistics and limits for a specific client. // ClientTraffic represents traffic statistics and limits for a specific client.
// It tracks upload/download usage, expiry times, and online status for inbound clients. // It tracks upload/download usage, expiry times, and online status for inbound clients.
type ClientTraffic struct { type ClientTraffic struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"` Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
InboundId int `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"` InboundId int `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
Enable bool `json:"enable" form:"enable" example:"true"` Enable bool `json:"enable" form:"enable" example:"true"`
Email string `json:"email" form:"email" gorm:"unique" example:"user1"` Email string `json:"email" form:"email" gorm:"unique" example:"user1"`
UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"` UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"` SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
Up int64 `json:"up" form:"up" example:"1048576"` Up int64 `json:"up" form:"up" example:"1048576"`
Down int64 `json:"down" form:"down" example:"2097152"` Down int64 `json:"down" form:"down" example:"2097152"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"` ExpiryTime int64 `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"`
Total int64 `json:"total" form:"total" example:"10737418240"` 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"` Reset int `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"` // ResetMax caps how many times auto-renew may fire; 0 means no cap.
LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"` 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.
ResetCount int `json:"resetCount" form:"resetCount" gorm:"default:0" example:"0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"`
} }