mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 10:00:58 +00:00
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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "الاسم",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "نام",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "名前",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Имя",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Назва",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "名称",
|
||||||
|
|||||||
@@ -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": "名稱",
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user