Kaynağa Gözat

feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust (#6399)

* feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust

Add HWID device limit and Telegram MTProto sponsor channel (ad-tag)
support to the bulk client adjustment flow in both the panel API
and frontend ClientBulkAdjustModal.

Co-Authored-By: Claude Code <[email protected]>

* fix(clients): gate adTag to MTProto inbounds and avoid inbound rewrite for limitHwid

Co-Authored-By: Claude Code <[email protected]>

* fix(clients): stamp updated_at only on the clients a bulk adjust changed

The updated_at write was gated on hasInboundChanges, which accumulates
over the whole inbound instead of describing the client in hand. Once any
client in the settings array changed, every client after it was re-stamped
as well, so whether an untouched client kept its own updated_at depended on
its position in the array. That field feeds node-snapshot conflict
resolution, where a spurious bump lets a stale snapshot value win over the
stored record.

Track the change per client and fold it into the inbound-level flag where
the stamp is written, so the early return still skips a save whose settings
JSON would be unchanged.

Also condenses the BulkAdjust doc comment back to the two-line maximum.

* docs(api): regenerate the bulkAdjust reference for limitHwid and adTag

frontend/public/openapi.json was copied to docs/public/, but pnpm gen:api
was never re-run, so the API reference page's heading, anchor id and search
index still described bulkAdjust without limitHwid or adTag. docs-ci.yml
fires only on docs/**, and that path had been touched, so nothing flagged
the stale MDX.

The externalLinks hunks are the generator rewrapping lines main had left
stale, not a content change.

* fix(i18n): stop enumerating fields in the bulk-adjust empty-form message

bulkAdjustNothing listed the fields the form accepts, so it went stale
every time one was added: only en-US ever gained "flow", leaving the other
twelve locales describing days and traffic alone, and limitHwid and adTag
would have repeated that. Say that one field is required instead of naming
which, so the message cannot drift again.
Rouzbeh† 9 saat önce
ebeveyn
işleme
ed5465d0f2

+ 17 - 13
docs/content/docs/en/reference/api/clients.mdx

@@ -104,9 +104,11 @@ _openapi:
         still-depleted client is left disabled. The optional flow directive sets
         the XTLS flow on every client: "none" clears it,
         "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound
-        supports it (omit or "" to leave it unchanged). Returns the adjusted
+        supports it (omit or "" to leave it unchanged). The optional limitHwid
+        sets maximum registered devices (0 = unlimited). The optional adTag sets
+        MTProto Telegram sponsor channel ("none" clears). Returns the adjusted
         count and per-email skip reasons.'
-      url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons'
+      url: '#shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-the-optional-limithwid-sets-maximum-registered-devices-0--unlimited-the-optional-adtag-sets-mtproto-telegram-sponsor-channel-none-clears-returns-the-adjusted-count-and-per-email-skip-reasons'
     - depth: 2
       title: Enable many clients in one call. Emails are grouped by inbound and
         applied with a single read-modify-write per inbound; the running Xray
@@ -276,9 +278,9 @@ _openapi:
       title: 'Return every URL for one client across all attached inbounds, one per
         advertised endpoint: the managed hosts of the inbound, else its
         streamSettings.externalProxy entries, else its own address. Supported
-        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
-        without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
-        contribute nothing.'
+        protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto.
+        Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
+        tunnel) contribute nothing.'
       url: '#return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing'
   structuredData:
     headings:
@@ -364,9 +366,11 @@ _openapi:
           manually-disabled or still-depleted client is left disabled. The
           optional flow directive sets the XTLS flow on every client: "none"
           clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where
-          the inbound supports it (omit or "" to leave it unchanged). Returns
-          the adjusted count and per-email skip reasons.'
-        id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-returns-the-adjusted-count-and-per-email-skip-reasons
+          the inbound supports it (omit or "" to leave it unchanged). The
+          optional limitHwid sets maximum registered devices (0 = unlimited).
+          The optional adTag sets MTProto Telegram sponsor channel ("none"
+          clears). Returns the adjusted count and per-email skip reasons.'
+        id: shift-expiry-andor-traffic-quota-for-many-clients-in-one-call-adddaysaddbytes-may-be-negative-clients-with-unlimited-expiry-expirytime0-or-unlimited-traffic-totalgb0-are-skipped-for-the-corresponding-field--bulk-extend-never-converts-unlimited-to-limited-a-client-that-was-auto-disabled-solely-because-it-was-depleted-expired-or-over-quota-is-automatically-re-enabled--locally-and-on-its-node--when-the-adjustment-lifts-it-out-of-depletion-a-manually-disabled-or-still-depleted-client-is-left-disabled-the-optional-flow-directive-sets-the-xtls-flow-on-every-client-none-clears-it-xtls-rprx-visionxtls-rprx-vision-udp443-set-it-where-the-inbound-supports-it-omit-or--to-leave-it-unchanged-the-optional-limithwid-sets-maximum-registered-devices-0--unlimited-the-optional-adtag-sets-mtproto-telegram-sponsor-channel-none-clears-returns-the-adjusted-count-and-per-email-skip-reasons
       - content: Enable many clients in one call. Emails are grouped by inbound and
           applied with a single read-modify-write per inbound; the running Xray
           (local or remote node) is updated to add each user. Note that enabling
@@ -508,12 +512,12 @@ _openapi:
           URL is emitted per external proxy. Empty array when the subId has no
           enabled clients.
         id: return-every-protocol-url-vless-vmess-trojan-ss-hysteria-hy2-for-clients-matching-the-subscription-id-same-result-set-as-the-configured-subpath-endpoint-but-as-a-json-array--no-base64-when-an-inbound-has-streamsettingsexternalproxy-set-one-url-is-emitted-per-external-proxy-empty-array-when-the-subid-has-no-enabled-clients
-      - content: 'Return every URL for one client across all attached inbounds, one
-          per advertised endpoint: the managed hosts of the inbound, else its
+      - content: 'Return every URL for one client across all attached inbounds, one per
+          advertised endpoint: the managed hosts of the inbound, else its
           streamSettings.externalProxy entries, else its own address. Supported
-          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto. Protocols
-          without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel)
-          contribute nothing.'
+          protocols: vmess, vless, trojan, shadowsocks, hysteria, mtproto.
+          Protocols without a URL form (socks, http, mixed, wireguard, dokodemo,
+          tunnel) contribute nothing.'
         id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
     contents:
       - content: >-

+ 4 - 2
docs/public/openapi.json

@@ -8366,7 +8366,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel (\"none\" clears). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
           "required": true,
@@ -8382,7 +8382,9 @@
                 ],
                 "addDays": 30,
                 "addBytes": 53687091200,
-                "flow": "xtls-rprx-vision"
+                "flow": "xtls-rprx-vision",
+                "limitHwid": 2,
+                "adTag": "0123456789abcdef0123456789abcdef"
               }
             }
           }

+ 4 - 2
frontend/public/openapi.json

@@ -8366,7 +8366,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel (\"none\" clears). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
           "required": true,
@@ -8382,7 +8382,9 @@
                 ],
                 "addDays": 30,
                 "addBytes": 53687091200,
-                "flow": "xtls-rprx-vision"
+                "flow": "xtls-rprx-vision",
+                "limitHwid": 2,
+                "adTag": "0123456789abcdef0123456789abcdef"
               }
             }
           }

+ 12 - 3
frontend/src/hooks/useClients.ts

@@ -392,7 +392,9 @@ export function useClients(options: UseClientsOptions = {}) {
       emails: string[];
       addDays: number;
       addBytes: number;
-      flow: string;
+      flow?: string;
+      limitHwid?: number | null;
+      adTag?: string;
     }): Promise<Msg<BulkAdjustResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
@@ -561,9 +563,16 @@ export function useClients(options: UseClientsOptions = {}) {
     [bulkCreateMut],
   );
   const bulkAdjust = useCallback(
-    (emails: string[], addDays: number, addBytes: number, flow = '') => {
+    (
+      emails: string[],
+      addDays: number,
+      addBytes: number,
+      flow = '',
+      limitHwid?: number | null,
+      adTag?: string,
+    ) => {
       if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-      return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
+      return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow, limitHwid, adTag });
     },
     [bulkAdjustMut],
   );

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -1280,8 +1280,8 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
         summary:
-          'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
-        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision"\n}',
+          'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). The optional limitHwid sets maximum registered devices (0 = unlimited). The optional adTag sets MTProto Telegram sponsor channel ("none" clears). Returns the adjusted count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision",\n  "limitHwid": 2,\n  "adTag": "0123456789abcdef0123456789abcdef"\n}',
         response:
           '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },

+ 39 - 4
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
+import { Alert, Form, Input, InputNumber, Modal, Select, message } from 'antd';
 import { FormProvider, useForm } from 'react-hook-form';
 
 import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
@@ -11,7 +11,13 @@ const GB = 1024 * 1024 * 1024;
 
 const FLOW_CLEAR = 'none';
 
-const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
+const EMPTY: ClientBulkAdjustFormValues = {
+  addDays: 0,
+  addGB: 0,
+  flow: '',
+  limitHwid: null,
+  adTag: '',
+};
 
 interface ClientBulkAdjustModalProps {
   open: boolean;
@@ -21,6 +27,8 @@ interface ClientBulkAdjustModalProps {
     addDays: number,
     addBytes: number,
     flow: string,
+    limitHwid?: number | null,
+    adTag?: string,
   ) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
 }
 
@@ -45,16 +53,23 @@ export default function ClientBulkAdjustModal({
       addDays: Math.trunc(Number(values.addDays) || 0),
       addGB: Number(values.addGB) || 0,
       flow: values.flow,
+      limitHwid:
+        values.limitHwid !== null &&
+        values.limitHwid !== undefined &&
+        (values.limitHwid as unknown) !== ''
+          ? Math.trunc(Number(values.limitHwid))
+          : null,
+      adTag: values.adTag?.trim() ?? '',
     });
     if (!validated.success) {
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
     }
-    const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
+    const { addDays: days, addGB: gb, flow: flowValue, limitHwid, adTag } = validated.data;
     setSubmitting(true);
     try {
       const bytes = Math.trunc(gb * GB);
-      const result = await onSubmit(days, bytes, flowValue);
+      const result = await onSubmit(days, bytes, flowValue, limitHwid, adTag);
       if (!result) return;
       const ok = result.adjusted ?? 0;
       const skipped = result.skipped?.length ?? 0;
@@ -111,6 +126,26 @@ export default function ClientBulkAdjustModal({
                 ]}
               />
             </FormField>
+            <FormField
+              name="limitHwid"
+              label={t('pages.clients.limitHwid')}
+              tooltip={t('pages.clients.limitHwidDesc')}
+            >
+              <InputNumber
+                style={{ width: '100%' }}
+                min={0}
+                step={1}
+                precision={0}
+                placeholder={t('pages.clients.bulkFlowNoChange')}
+              />
+            </FormField>
+            <FormField
+              name="adTag"
+              label={t('pages.clients.mtprotoAdTag')}
+              extra={t('pages.clients.bulkAdTagHint')}
+            >
+              <Input placeholder={t('pages.clients.bulkFlowNoChange')} allowClear />
+            </FormField>
           </Form>
         </FormProvider>
       </Modal>

+ 9 - 2
frontend/src/pages/clients/ClientsPage.tsx

@@ -1893,8 +1893,15 @@ export default function ClientsPage() {
             open={bulkAdjustOpen}
             count={selectedRowKeys.length}
             onOpenChange={setBulkAdjustOpen}
-            onSubmit={async (addDays, addBytes, flow) => {
-              const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
+            onSubmit={async (addDays, addBytes, flow, limitHwid, adTag) => {
+              const msg = await bulkAdjust(
+                [...selectedRowKeys],
+                addDays,
+                addBytes,
+                flow,
+                limitHwid,
+                adTag,
+              );
               if (msg?.success) {
                 setSelectedRowKeys([]);
                 return msg.obj ?? { adjusted: 0 };

+ 2 - 2
frontend/src/pages/groups/GroupsPage.tsx

@@ -654,8 +654,8 @@ export default function GroupsPage() {
             open={adjustOpen}
             count={groupEmails.length}
             onOpenChange={setAdjustOpen}
-            onSubmit={async (addDays, addBytes) => {
-              const msg = await bulkAdjust(groupEmails, addDays, addBytes);
+            onSubmit={async (addDays, addBytes, flow, limitHwid, adTag) => {
+              const msg = await bulkAdjust(groupEmails, addDays, addBytes, flow, limitHwid, adTag);
               if (msg?.success) {
                 const obj = msg.obj ?? { adjusted: 0 };
                 messageApi.success(

+ 24 - 3
frontend/src/schemas/client.ts

@@ -329,10 +329,31 @@ export const ClientBulkAdjustFormSchema = z
     addDays: z.number().int(),
     addGB: z.number(),
     flow: z.string().optional().default(''),
+    limitHwid: z.number().int().min(0).nullable().optional(),
+    adTag: z.string().optional().default(''),
   })
-  .refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
-    message: 'pages.clients.bulkAdjustNothing',
-  });
+  .refine(
+    (v) =>
+      v.addDays !== 0 ||
+      v.addGB !== 0 ||
+      v.flow !== '' ||
+      (v.limitHwid !== undefined && v.limitHwid !== null) ||
+      (v.adTag !== undefined && v.adTag.trim() !== ''),
+    {
+      message: 'pages.clients.bulkAdjustNothing',
+    },
+  )
+  .refine(
+    (v) => {
+      const tag = v.adTag?.trim();
+      if (!tag || tag === 'none') return true;
+      return /^[0-9a-fA-F]{32}$/.test(tag);
+    },
+    {
+      message: 'pages.inbounds.form.mtgAdTagInvalid',
+      path: ['adTag'],
+    },
+  );
 
 export const ClientBulkAddFormSchema = z.object({
   emailMethod: z.number().int().min(0).max(4),

+ 7 - 5
internal/web/controller/client.go

@@ -313,10 +313,12 @@ func (a *ClientController) resetAllTraffics(c *gin.Context) {
 }
 
 type bulkAdjustRequest struct {
-	Emails   []string `json:"emails"`
-	AddDays  int      `json:"addDays"`
-	AddBytes int64    `json:"addBytes"`
-	Flow     string   `json:"flow"`
+	Emails    []string `json:"emails"`
+	AddDays   int      `json:"addDays"`
+	AddBytes  int64    `json:"addBytes"`
+	Flow      string   `json:"flow"`
+	LimitHwid *int     `json:"limitHwid"`
+	AdTag     string   `json:"adTag"`
 }
 
 func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -325,7 +327,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
-	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow)
+	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow, req.LimitHwid, req.AdTag)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return

+ 130 - 36
internal/web/service/client_bulk.go

@@ -312,11 +312,13 @@ var bulkFlowAllowed = map[string]struct{}{
 // for every email in the list. Clients whose corresponding field is
 // unlimited (0) are skipped — bulk extend should not accidentally
 // limit an unlimited client. addDays and addBytes may be negative.
+// flow sets the XTLS flow, limitHwid the max registered devices (0 = unlimited)
+// and adTag the MTProto sponsor channel; "none" clears flow or adTag.
 //
 // Like BulkDelete, the work is grouped by inbound so each inbound's
 // settings JSON is parsed and written exactly once regardless of how
 // many target emails it contains.
-func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string) (BulkAdjustResult, bool, error) {
+func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string, limitHwid *int, adTag string) (BulkAdjustResult, bool, error) {
 	result := BulkAdjustResult{}
 	if len(emails) == 0 {
 		return result, false, nil
@@ -325,8 +327,18 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	if _, ok := bulkFlowAllowed[flow]; !ok {
 		flow = "" // ignore unknown directives — "" means "leave flow untouched"
 	}
+	adTag = strings.TrimSpace(adTag)
+	if adTag != "" && adTag != bulkFlowClear && !model.ValidMtprotoAdTag(adTag) {
+		return result, false, common.NewError("mtproto client ad tag must be 32 hex characters")
+	}
+	if limitHwid != nil && *limitHwid < 0 {
+		zero := 0
+		limitHwid = &zero
+	}
 	adjustFlow := flow != ""
-	if addDays == 0 && addBytes == 0 && !adjustFlow {
+	adjustHwid := limitHwid != nil
+	adjustAdTag := adTag != ""
+	if addDays == 0 && addBytes == 0 && !adjustFlow && !adjustHwid && !adjustAdTag {
 		return result, false, common.NewError("no adjustment specified")
 	}
 
@@ -419,7 +431,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				}
 			}
 		}
-		if entry.applyExpiry || entry.applyTotal || adjustFlow {
+		if entry.applyExpiry || entry.applyTotal || adjustFlow || adjustHwid || adjustAdTag {
 			plan[email] = entry
 		}
 	}
@@ -434,8 +446,10 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	plannedIds := make([]int, 0, len(plan))
 	recordIdToEmail := make(map[int]string, len(plan))
 	for email, entry := range plan {
-		plannedIds = append(plannedIds, entry.record.Id)
-		recordIdToEmail[entry.record.Id] = email
+		if entry.applyExpiry || entry.applyTotal || adjustFlow || adjustAdTag {
+			plannedIds = append(plannedIds, entry.record.Id)
+			recordIdToEmail[entry.record.Id] = email
+		}
 	}
 
 	var mappings []model.ClientInbound
@@ -458,10 +472,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	needRestart := false
 	flowHonored := map[string]bool{}
 	flowIneligible := map[string]bool{}
+	adTagHonored := map[string]bool{}
+	adTagIneligible := map[string]bool{}
 	execFailed := map[string]bool{}
 	adjustIds := sortedInboundIds(emailsByInbound)
 	adjustResults, adjustPanics := fanoutInboundResults(adjustIds, inboundFanoutConcurrency, func(i int) bulkInboundAdjustResult {
-		return s.bulkAdjustInboundClients(inboundSvc, adjustIds[i], emailsByInbound[adjustIds[i]], plan, flow)
+		return s.bulkAdjustInboundClients(inboundSvc, adjustIds[i], emailsByInbound[adjustIds[i]], plan, flow, adTag)
 	})
 	for i, ibRes := range adjustResults {
 		if adjustPanics[i] != nil {
@@ -483,6 +499,12 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		for email := range ibRes.flowIneligible {
 			flowIneligible[email] = true
 		}
+		for email := range ibRes.adTagHonored {
+			adTagHonored[email] = true
+		}
+		for email := range ibRes.adTagIneligible {
+			adTagIneligible[email] = true
+		}
 		for email, reason := range ibRes.perEmailSkipped {
 			execFailed[email] = true
 			if _, already := skippedReasons[email]; !already {
@@ -511,6 +533,11 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		}
 	}
 
+	wantAdTag := ""
+	if adjustAdTag && adTag != bulkFlowClear {
+		wantAdTag = strings.ToLower(adTag)
+	}
+
 	adjusted := map[string]struct{}{}
 	for email, entry := range plan {
 		if execFailed[email] {
@@ -531,9 +558,24 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				continue
 			}
 		}
-		// Counted when expiry/total changed, or a flow directive was honored
-		// for this client (flow lives in the inbound JSON, not ClientTraffic).
-		if len(updates) > 0 || flowHonored[email] {
+		if adjustHwid {
+			if err := s.setClientLimitHwidByEmail(db, email, *limitHwid); err != nil {
+				if _, already := skippedReasons[email]; !already {
+					skippedReasons[email] = err.Error()
+				}
+				continue
+			}
+		}
+		if adjustAdTag && adTagHonored[email] {
+			if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).UpdateColumn("ad_tag", wantAdTag).Error; err != nil {
+				if _, already := skippedReasons[email]; !already {
+					skippedReasons[email] = err.Error()
+				}
+				continue
+			}
+		}
+		// Counted when expiry/total changed, flow was honored, adTag was honored, or limitHwid was adjusted.
+		if len(updates) > 0 || flowHonored[email] || adTagHonored[email] || adjustHwid {
 			adjusted[email] = struct{}{}
 		}
 	}
@@ -554,6 +596,15 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		}
 		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "flow not supported on inbound"})
 	}
+	for email := range adTagIneligible {
+		if adTagHonored[email] {
+			continue
+		}
+		if _, already := skippedReasons[email]; already {
+			continue
+		}
+		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "adTag not supported on inbound"})
+	}
 
 	if len(wasDisabledDepleted) > 0 {
 		stillDepleted := map[string]struct{}{}
@@ -599,8 +650,10 @@ type bulkInboundAdjustResult struct {
 	// that an inbound cannot carry must not suppress the expiry/total write for
 	// the same client (which would diverge the inbound JSON / ClientRecord from
 	// ClientTraffic). It only feeds the final Skipped report.
-	flowIneligible map[string]bool
-	needRestart    bool
+	flowIneligible  map[string]bool
+	adTagHonored    map[string]bool
+	adTagIneligible map[string]bool
+	needRestart     bool
 }
 
 // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
@@ -615,8 +668,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 	emails []string,
 	plan map[string]*bulkAdjustEntry,
 	flow string,
+	adTag string,
 ) bulkInboundAdjustResult {
-	res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}, flowHonored: map[string]bool{}, flowIneligible: map[string]bool{}}
+	res := bulkInboundAdjustResult{
+		perEmailSkipped: map[string]string{},
+		flowHonored:     map[string]bool{},
+		flowIneligible:  map[string]bool{},
+		adTagHonored:    map[string]bool{},
+		adTagIneligible: map[string]bool{},
+	}
 
 	defer lockInbound(inboundId).Unlock()
 
@@ -655,9 +715,16 @@ func (s *ClientService) bulkAdjustInboundClients(
 		(!oldInbound.DisableFlow &&
 			inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings))
 
+	wantAdTag := ""
+	if adTag != "" && adTag != bulkFlowClear {
+		wantAdTag = strings.ToLower(adTag)
+	}
+
 	interfaceClients, _ := settings["clients"].([]any)
 	foundEmails := map[string]bool{}
 	flowChanged := false
+	adTagChanged := false
+	hasInboundChanges := false
 	nowMs := time.Now().Unix() * 1000
 	for i, client := range interfaceClients {
 		c, ok := client.(map[string]any)
@@ -668,12 +735,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 		if _, want := wantedEmails[targetEmail]; !want || targetEmail == "" {
 			continue
 		}
+		clientChanged := false
 		entry := plan[targetEmail]
 		if entry.applyExpiry {
 			c["expiryTime"] = entry.newExpiry
+			clientChanged = true
 		}
 		if entry.applyTotal {
 			c["totalGB"] = entry.newTotal
+			clientChanged = true
 		}
 		if flow != "" {
 			if flowEligible {
@@ -686,13 +756,29 @@ func (s *ClientService) bulkAdjustInboundClients(
 					flowChanged = true
 				}
 				res.flowHonored[targetEmail] = true
+				clientChanged = true
 			} else {
 				// Record separately so this never suppresses the expiry/total
 				// write for the same client (see flowIneligible doc).
 				res.flowIneligible[targetEmail] = true
 			}
 		}
-		c["updated_at"] = nowMs
+		if adTag != "" {
+			if oldInbound.Protocol == model.MTProto {
+				if cur, _ := c["adTag"].(string); cur != wantAdTag {
+					c["adTag"] = wantAdTag
+					adTagChanged = true
+				}
+				res.adTagHonored[targetEmail] = true
+				clientChanged = true
+			} else {
+				res.adTagIneligible[targetEmail] = true
+			}
+		}
+		if clientChanged {
+			c["updated_at"] = nowMs
+			hasInboundChanges = true
+		}
 		interfaceClients[i] = c
 		foundEmails[targetEmail] = true
 	}
@@ -703,7 +789,7 @@ func (s *ClientService) bulkAdjustInboundClients(
 		}
 	}
 
-	if len(foundEmails) == 0 {
+	if len(foundEmails) == 0 || !hasInboundChanges {
 		return res
 	}
 
@@ -748,28 +834,36 @@ func (s *ClientService) bulkAdjustInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 		}
-	} else if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
-		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
-		if perr != nil {
-			logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
-		} else if push {
-			for email := range foundEmails {
-				entry := plan[email]
-				updated := *entry.record.ToClient()
-				if entry.applyExpiry {
-					updated.ExpiryTime = entry.newExpiry
-				}
-				if entry.applyTotal {
-					updated.TotalGB = entry.newTotal
-				}
-				updated.UpdatedAt = nowMs
-				ctx, cancel := nodePushContext()
-				err1 := rt.UpdateUser(ctx, oldInbound, email, updated)
-				cancel()
-				if err1 != nil {
-					logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-					// First failure ends the batch push; the reconcile converges the rest.
-					break
+	} else {
+		if adTagChanged && oldInbound.Protocol == model.MTProto && oldInbound.NodeID == nil {
+			inboundSvc.applyLocalMtproto(oldInbound.Id)
+		}
+		if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
+			rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
+			if perr != nil {
+				logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
+			} else if push {
+				for email := range foundEmails {
+					entry := plan[email]
+					updated := *entry.record.ToClient()
+					if entry.applyExpiry {
+						updated.ExpiryTime = entry.newExpiry
+					}
+					if entry.applyTotal {
+						updated.TotalGB = entry.newTotal
+					}
+					if adTag != "" && oldInbound.Protocol == model.MTProto {
+						updated.AdTag = wantAdTag
+					}
+					updated.UpdatedAt = nowMs
+					ctx, cancel := nodePushContext()
+					err1 := rt.UpdateUser(ctx, oldInbound, email, updated)
+					cancel()
+					if err1 != nil {
+						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+						// First failure ends the batch push; the reconcile converges the rest.
+						break
+					}
 				}
 			}
 		}

+ 1 - 1
internal/web/service/client_bulk_fanout_test.go

@@ -77,7 +77,7 @@ func TestBulkAdjustAcrossNodesPushesConcurrently(t *testing.T) {
 	}
 
 	bar.arm()
-	if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{email}, 1, 0, ""); err != nil {
+	if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{email}, 1, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust across %d node inbounds: %v", nodes, err)
 	}
 	if got := bar.updateUser.Load(); got == 0 {

+ 323 - 7
internal/web/service/client_bulk_flow_test.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"encoding/json"
 	"testing"
 	"time"
 
@@ -64,7 +65,7 @@ func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
 	emails := emailsOf(clients)
 
 	// Set vision flow.
-	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443")
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust set: %v", err)
 	}
@@ -81,14 +82,14 @@ func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
 	}
 
 	// Setting the same flow again is a no-op: honored (counted) but no restart.
-	if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
+	if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust idempotent: %v", err)
 	} else if restart2 {
 		t.Fatalf("re-setting identical flow should not request a restart")
 	}
 
 	// Clear flow.
-	cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none")
+	cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust clear: %v", err)
 	}
@@ -121,7 +122,7 @@ func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
 		t.Fatalf("seed: %v", err)
 	}
 
-	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision")
+	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -146,11 +147,11 @@ func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
 	svc := &ClientService{}
 	inboundSvc := &InboundService{}
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, ""); err == nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "", nil, ""); err == nil {
 		t.Fatalf("expected error when no adjustment is specified")
 	}
 	// An unknown flow directive is ignored (treated as ""), so it also errors.
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow"); err == nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow", nil, ""); err == nil {
 		t.Fatalf("unknown flow should be ignored and error like an empty directive")
 	}
 }
@@ -182,7 +183,7 @@ func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
 		t.Fatalf("seed traffic: %v", err)
 	}
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -217,3 +218,318 @@ func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
 		t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
 	}
 }
+
+// TestBulkAdjust_HwidLimit verifies setting and clearing HWID limit in bulk.
+func TestBulkAdjust_HwidLimit(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "h1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sub-h1", Enable: true},
+		{Email: "h2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sub-h2", Enable: true},
+	}
+	ib := mkInbound(t, 30301, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	emails := emailsOf(clients)
+
+	limit2 := 2
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit2, "")
+	if err != nil {
+		t.Fatalf("BulkAdjust hwid: %v", err)
+	}
+	if res.Adjusted != 2 {
+		t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("hwid adjustment should not request xray restart")
+	}
+	for _, e := range emails {
+		rec, rErr := svc.GetRecordByEmail(nil, e)
+		if rErr != nil || rec.LimitHwid != 2 {
+			t.Fatalf("%s limitHwid = %d (err=%v), want 2", e, rec.LimitHwid, rErr)
+		}
+	}
+
+	// Reset to 0 (unlimited)
+	limit0 := 0
+	res0, _, err0 := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", &limit0, "")
+	if err0 != nil || res0.Adjusted != 2 {
+		t.Fatalf("BulkAdjust hwid 0: err=%v, res=%+v", err0, res0)
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.LimitHwid != 0 {
+			t.Fatalf("%s limitHwid = %d, want 0", e, rec.LimitHwid)
+		}
+	}
+}
+
+// TestBulkAdjust_MtprotoAdTagSetAndClear verifies ad-tag bulk update and clearing.
+func TestBulkAdjust_MtprotoAdTagSetAndClear(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	clients := []model.Client{
+		{Email: "tg1@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
+		{Email: "tg2@x", Secret: "ee101112131415161718191a1b1c1d1e1f6578616d706c652e636f6d", Enable: true},
+	}
+	ib := &model.Inbound{
+		Tag:      "mtproto-bulk-test",
+		Enable:   true,
+		Port:     30401,
+		Protocol: model.MTProto,
+		Settings: clientsSettings(t, clients),
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create mtproto inbound: %v", err)
+	}
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed mtproto: %v", err)
+	}
+	emails := emailsOf(clients)
+
+	// Set ad-tag
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust adTag: %v", err)
+	}
+	if res.Adjusted != 2 {
+		t.Fatalf("expected 2 adjusted, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("mtproto adTag update should not request xray restart")
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.AdTag != tag1 {
+			t.Fatalf("%s adTag = %q, want %q", e, rec.AdTag, tag1)
+		}
+	}
+
+	// Clear ad-tag with "none"
+	cres, _, cerr := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "none")
+	if cerr != nil || cres.Adjusted != 2 {
+		t.Fatalf("BulkAdjust clear adTag: err=%v, res=%+v", cerr, cres)
+	}
+	for _, e := range emails {
+		rec, _ := svc.GetRecordByEmail(nil, e)
+		if rec.AdTag != "" {
+			t.Fatalf("%s adTag = %q, want empty after clear", e, rec.AdTag)
+		}
+	}
+
+	// Invalid ad-tag errors
+	if _, _, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, "invalid-hex"); err == nil {
+		t.Fatalf("expected error for invalid hex ad tag")
+	}
+}
+
+// TestBulkAdjust_AdTagIneligibleSkipped verifies that non-MTProto clients are
+// refused adTag adjustment, reported as skipped, and their ClientRecord is untouched.
+func TestBulkAdjust_AdTagIneligibleSkipped(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "vless-notg@x", ID: "55555555-5555-5555-5555-555555555555", SubID: "vless-notg", Enable: true},
+	}
+	ib := mkInbound(t, 30501, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"vless-notg@x"}, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 0 {
+		t.Fatalf("ineligible protocol should adjust nothing, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("no change should not request restart")
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-notg@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-notg@x in skipped with 'adTag not supported on inbound', got %+v", res.Skipped)
+	}
+	rec, err := svc.GetRecordByEmail(nil, "vless-notg@x")
+	if err != nil {
+		t.Fatalf("GetRecordByEmail: %v", err)
+	}
+	if rec.AdTag != "" {
+		t.Fatalf("adTag on non-MTProto record should stay empty, got %q", rec.AdTag)
+	}
+}
+
+// TestBulkAdjust_DaysApplyDespiteIneligibleAdTag verifies that when a non-MTProto
+// client is adjusted with both days and adTag, days are applied but adTag is not
+// written to ClientRecord and is reported as skipped.
+func TestBulkAdjust_DaysApplyDespiteIneligibleAdTag(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const day = int64(24 * 60 * 60 * 1000)
+	baseExpiry := time.Now().UnixMilli() + 30*day
+
+	clients := []model.Client{
+		{Email: "vless-days@x", ID: "66666666-6666-6666-6666-666666666666", SubID: "vless-days", Enable: true, ExpiryTime: baseExpiry},
+	}
+	ib := mkInbound(t, 30601, model.VLESS, clientsSettings(t, clients))
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{Email: "vless-days@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{"vless-days@x"}, 7, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 1 {
+		t.Fatalf("days should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-days@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-days@x reported for unhonored adTag, got %v", res.Skipped)
+	}
+
+	rec, err := svc.GetRecordByEmail(nil, "vless-days@x")
+	if err != nil {
+		t.Fatalf("record: %v", err)
+	}
+	if rec.ExpiryTime != baseExpiry+7*day {
+		t.Fatalf("expiry time not advanced: got %d, want %d", rec.ExpiryTime, baseExpiry+7*day)
+	}
+	if rec.AdTag != "" {
+		t.Fatalf("adTag should remain empty on ClientRecord for non-MTProto, got %q", rec.AdTag)
+	}
+}
+
+// TestBulkAdjust_MixedMtprotoAndVless_AdTag verifies bulk adjust over a mixed
+// MTProto and VLESS selection.
+func TestBulkAdjust_MixedMtprotoAndVless_AdTag(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const tag1 = "0123456789abcdef0123456789abcdef"
+	tgClients := []model.Client{
+		{Email: "tg-mix@x", Secret: "ee00112233445566778899aabbccddeeff6578616d706c652e636f6d", Enable: true},
+	}
+	tgIb := &model.Inbound{
+		Tag:      "mtproto-mix",
+		Enable:   true,
+		Port:     30701,
+		Protocol: model.MTProto,
+		Settings: clientsSettings(t, tgClients),
+	}
+	if err := database.GetDB().Create(tgIb).Error; err != nil {
+		t.Fatalf("create mtproto: %v", err)
+	}
+	if err := svc.SyncInbound(nil, tgIb.Id, tgClients); err != nil {
+		t.Fatalf("sync mtproto: %v", err)
+	}
+
+	vlessClients := []model.Client{
+		{Email: "vless-mix@x", ID: "77777777-7777-7777-7777-777777777777", SubID: "vless-mix", Enable: true},
+	}
+	vlessIb := mkInbound(t, 30702, model.VLESS, clientsSettings(t, vlessClients))
+	if err := svc.SyncInbound(nil, vlessIb.Id, vlessClients); err != nil {
+		t.Fatalf("sync vless: %v", err)
+	}
+
+	emails := []string{"tg-mix@x", "vless-mix@x"}
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "", nil, tag1)
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 1 {
+		t.Fatalf("expected 1 adjusted (MTProto only), got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("adTag should not restart xray")
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "vless-mix@x" || res.Skipped[0].Reason != "adTag not supported on inbound" {
+		t.Fatalf("expected vless-mix@x in skipped, got %+v", res.Skipped)
+	}
+
+	tgRec, _ := svc.GetRecordByEmail(nil, "tg-mix@x")
+	if tgRec.AdTag != tag1 {
+		t.Fatalf("tg-mix@x adTag = %q, want %q", tgRec.AdTag, tag1)
+	}
+	vlessRec, _ := svc.GetRecordByEmail(nil, "vless-mix@x")
+	if vlessRec.AdTag != "" {
+		t.Fatalf("vless-mix@x adTag = %q, want empty", vlessRec.AdTag)
+	}
+}
+
+// TestBulkAdjust_UnchangedClientKeepsUpdatedAt pins the updated_at stamp to the
+// client that actually changed: an untouched client must not be re-stamped only
+// because a client earlier in the same inbound's array was adjusted.
+func TestBulkAdjust_UnchangedClientKeepsUpdatedAt(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const day = int64(24 * 60 * 60 * 1000)
+	const seeded = int64(1600000000000)
+	baseExpiry := time.Now().UnixMilli() + 30*day
+
+	// chg@x is listed first and takes the expiry bump; keep@x has unlimited
+	// expiry on a ws inbound, so the same call changes nothing for it.
+	clients := []model.Client{
+		{Email: "chg@x", ID: "88888888-8888-8888-8888-888888888888", SubID: "chg", Enable: true, ExpiryTime: baseExpiry, UpdatedAt: seeded},
+		{Email: "keep@x", ID: "99999999-9999-9999-9999-999999999999", SubID: "keep", Enable: true, UpdatedAt: seeded},
+	}
+	ib := mkInboundStream(t, 30801, model.VLESS, clientsSettings(t, clients), wsStream)
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	if err := database.GetDB().Create(&xray.ClientTraffic{Email: "chg@x", Enable: true, ExpiryTime: baseExpiry}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	// The flow directive is what keeps keep@x in the plan; the ws inbound cannot
+	// carry it, so the directive is not itself a change for either client.
+	if _, _, err := svc.BulkAdjust(inboundSvc, emailsOf(clients), 7, 0, "xtls-rprx-vision", nil, ""); err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+
+	stamps := settingsUpdatedAt(t, inboundSvc, ib.Id)
+	if stamps["chg@x"] <= seeded {
+		t.Fatalf("adjusted client should be re-stamped, updated_at = %d", stamps["chg@x"])
+	}
+	if stamps["keep@x"] != seeded {
+		t.Fatalf("untouched client updated_at = %d, want %d — a sibling's change must not re-stamp it", stamps["keep@x"], seeded)
+	}
+}
+
+func settingsUpdatedAt(t *testing.T, inboundSvc *InboundService, inboundId int) map[string]int64 {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	var parsed struct {
+		Clients []struct {
+			Email     string `json:"email"`
+			UpdatedAt int64  `json:"updated_at"`
+		} `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(ib.Settings), &parsed); err != nil {
+		t.Fatalf("unmarshal settings: %v", err)
+	}
+	out := make(map[string]int64, len(parsed.Clients))
+	for _, c := range parsed.Clients {
+		out[c.Email] = c.UpdatedAt
+	}
+	return out
+}

+ 11 - 11
internal/web/service/client_bulk_reenable_test.go

@@ -96,7 +96,7 @@ func TestBulkAdjust_ReenablesExpiredThenExtended_AllThreeLocations(t *testing.T)
 	email := "exp@x"
 	ib := seedLocalDisabledClient(t, svc, 52001, "", email, 0, now-reenableDay, 0, 0)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -118,7 +118,7 @@ func TestBulkAdjust_DoesNotReenable_ManuallyDisabledNotDepleted(t *testing.T) {
 	email := "man@x"
 	ib := seedLocalDisabledClient(t, svc, 52002, "", email, 0, now+30*reenableDay, 0, 0)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -137,7 +137,7 @@ func TestBulkAdjust_StaysDisabled_ExtensionTooSmall(t *testing.T) {
 	email := "sml@x"
 	ib := seedLocalDisabledClient(t, svc, 52003, "", email, 0, now-10*reenableDay, 0, 0)
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 5, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 5, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -151,7 +151,7 @@ func TestBulkAdjust_ReenablesOverQuota_WhenAddBytesClearsQuota(t *testing.T) {
 	email := "q@x"
 	ib := seedLocalDisabledClient(t, svc, 52004, "", email, 100, 0, 60, 40)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -177,7 +177,7 @@ func TestBulkAdjust_QuotaReductionBelowZeroSkipsInsteadOfUnlimited(t *testing.T)
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 10, 0, true)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, -20, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -202,7 +202,7 @@ func TestBulkAdjust_AppliedFieldReachesTrafficRowDespiteOtherFieldSkip(t *testin
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 100, 0, true)
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 50, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	if got := trafficOf(t, email).Total; got != 150 {
@@ -219,7 +219,7 @@ func TestBulkAdjust_OverQuota_DaysOnly_StaysDisabled(t *testing.T) {
 	email := "qd@x"
 	ib := seedLocalDisabledClient(t, svc, 52005, "", email, 100, now-reenableDay, 60, 40)
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 60, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 60, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -239,7 +239,7 @@ func TestBulkAdjust_NegativeReduction_DoesNotFlipEnable(t *testing.T) {
 	}
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now+5*reenableDay, true)
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, -10, 0, ""); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, -10, 0, "", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
@@ -254,7 +254,7 @@ func TestBulkAdjust_FlowOnly_NoEnableChange(t *testing.T) {
 	email := "flow@x"
 	ib := seedLocalDisabledClient(t, svc, 52007, realityStream, email, 0, now-reenableDay, 0, 0)
 
-	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 0, "xtls-rprx-vision-udp443", nil, ""); err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
 	assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
@@ -271,7 +271,7 @@ func TestBulkAdjust_UnlimitedExpiry_QuotaCleared_Reenables(t *testing.T) {
 	email := "u@x"
 	ib := seedLocalDisabledClient(t, svc, 52008, "", email, 100, 0, 100, 0)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 0, 200, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}
@@ -314,7 +314,7 @@ func TestBulkAdjust_NodeInbound_ReenablesDBLocations(t *testing.T) {
 	mkTraffic(t, ib.Id, email, 0, 0, 0, now-reenableDay, false)
 	forceRecordDisabled(t, svc, email)
 
-	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "")
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{email}, 30, 0, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}

+ 1 - 1
internal/web/service/node_bulk_dispatch_test.go

@@ -218,7 +218,7 @@ func TestNodeBulkAdjustDoesNotPushBeforeFailedCommit(t *testing.T) {
 	}
 	t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
 
-	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "")
+	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "", nil, "")
 	if err != nil {
 		t.Fatalf("BulkAdjust: %v", err)
 	}

+ 1 - 1
internal/web/service/sync_scale_postgres_test.go

@@ -347,7 +347,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 			}
 
 			t0 := time.Now()
-			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, ""); err != nil {
+			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, "", nil, ""); err != nil {
 				t.Fatalf("BulkAdjust: %v", err)
 			}
 			adjustDur := time.Since(t0)

+ 3 - 2
internal/web/translation/ar-EG.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "سيتم إزالة كل عميل محدد من جميع الاتصالات الواردة المرتبطة وحذف سجل حركة مروره. لا يمكن التراجع.",
       "bulkAdjustTitle": "تعديل {count} عميل",
       "bulkAdjustHint": "القيم الموجبة تزيد، السالبة تنقص. العملاء بصلاحية أو ترافيك غير محدود يُتخطّون لذلك الحقل.",
-      "bulkAdjustNothing": "حدد الأيام أو الترافيك قبل التطبيق.",
+      "bulkAdjustNothing": "حدد حقلاً واحداً على الأقل قبل التطبيق.",
       "addDays": "إضافة أيام",
       "addTrafficGB": "إضافة ترافيك (GB)",
       "bulkFlow": "تعيين التدفق",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.",
       "renewOnDay": "يوم التجديد",
       "renewOnDayDesc": "يتم التجديد في هذا اليوم من كل شهر ميلادي، عند منتصف الليل بتوقيت اللوحة، بدلاً من كل N يوم. إذا كان الشهر أقصر من اليوم المختار، يتم التجديد في آخر يوم منه. القيمة 0 تُبقي وضع الفاصل اليومي.",
-      "renewsUsed": "التجديدات المستخدمة"
+      "renewsUsed": "التجديدات المستخدمة",
+      "bulkAdTagHint": "أدخل علامة من 32 حرفًا ست عشريًا للتعيين، أو 'none' للمسح في منافذ MTProto."
     },
     "groups": {
       "name": "الاسم",

+ 3 - 2
internal/web/translation/en-US.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
       "bulkAdjustTitle": "Adjust {count} clients",
       "bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.",
-      "bulkAdjustNothing": "Set days, traffic, or flow before applying.",
+      "bulkAdjustNothing": "Set at least one field before applying.",
       "addDays": "Add days",
       "addTrafficGB": "Add traffic (GB)",
       "bulkFlow": "Set flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.",
       "renewOnDay": "Renew on day",
       "renewOnDayDesc": "Renew on this day of every calendar month, at midnight in the panel's time zone, instead of every N days. A month too short for the chosen day renews on its last day. 0 keeps the day-interval mode.",
-      "renewsUsed": "Renewals used"
+      "renewsUsed": "Renewals used",
+      "bulkAdTagHint": "Enter a 32-character hex tag to set, or 'none' to clear sponsor channel on MTProto inbounds."
     },
     "groups": {
       "name": "Name",

+ 3 - 2
internal/web/translation/es-ES.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Cada cliente seleccionado se elimina de los inbounds asociados y se descarta su registro de tráfico. No se puede deshacer.",
       "bulkAdjustTitle": "Ajustar {count} clientes",
       "bulkAdjustHint": "Los valores positivos extienden, los negativos reducen. Los clientes con expiración o tráfico ilimitado se omiten para ese campo.",
-      "bulkAdjustNothing": "Establece días o tráfico antes de aplicar.",
+      "bulkAdjustNothing": "Establece al menos un campo antes de aplicar.",
       "addDays": "Añadir días",
       "addTrafficGB": "Añadir tráfico (GB)",
       "bulkFlow": "Establecer flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.",
       "renewOnDay": "Renovar el día",
       "renewOnDayDesc": "Renueva este día de cada mes natural, a medianoche en la zona horaria del panel, en lugar de cada N días. Si el mes es demasiado corto para el día elegido, renueva su último día. 0 mantiene el modo de intervalo en días.",
-      "renewsUsed": "Renovaciones usadas"
+      "renewsUsed": "Renovaciones usadas",
+      "bulkAdTagHint": "Introduce una etiqueta hexadecimal de 32 caracteres para asignar, o 'none' para borrarla en MTProto."
     },
     "groups": {
       "name": "Nombre",

+ 3 - 2
internal/web/translation/fa-IR.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "هر کلاینت انتخاب‌شده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.",
       "bulkAdjustTitle": "تنظیم {count} کلاینت",
       "bulkAdjustHint": "مقادیر مثبت اضافه و منفی کم می‌کنند. کلاینت‌هایی که زمان یا ترافیک نامحدود دارند برای همان فیلد رد می‌شوند.",
-      "bulkAdjustNothing": "قبل از اعمال، روز یا ترافیک را تنظیم کنید.",
+      "bulkAdjustNothing": "قبل از اعمال، حداقل یک فیلد را تنظیم کنید.",
       "addDays": "افزودن روز",
       "addTrafficGB": "افزودن ترافیک (گیگابایت)",
       "bulkFlow": "تنظیم flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.",
       "renewOnDay": "روز تمدید",
       "renewOnDayDesc": "در این روز از هر ماه تقویمی، در نیمه‌شب به وقت پنل تمدید می‌شود، به جای هر N روز. اگر ماه کوتاه‌تر از روز انتخابی باشد، در آخرین روز آن ماه تمدید می‌شود. مقدار ۰ حالت بازهٔ روزانه را حفظ می‌کند.",
-      "renewsUsed": "تمدیدهای استفاده‌شده"
+      "renewsUsed": "تمدیدهای استفاده‌شده",
+      "bulkAdTagHint": "یک برچسب هگز ۳۲ کاراکتری برای تنظیم وارد کنید، یا 'none' برای پاک کردن در MTProto."
     },
     "groups": {
       "name": "نام",

+ 3 - 2
internal/web/translation/id-ID.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Setiap klien yang dipilih dihapus dari semua inbound terlampir dan catatan lalu lintasnya dihapus. Tidak dapat dibatalkan.",
       "bulkAdjustTitle": "Sesuaikan {count} klien",
       "bulkAdjustHint": "Nilai positif menambah, negatif mengurangi. Klien dengan masa berlaku atau trafik tak terbatas dilewati untuk bidang tersebut.",
-      "bulkAdjustNothing": "Setel hari atau trafik sebelum menerapkan.",
+      "bulkAdjustNothing": "Setel setidaknya satu bidang sebelum menerapkan.",
       "addDays": "Tambah hari",
       "addTrafficGB": "Tambah trafik (GB)",
       "bulkFlow": "Atur flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.",
       "renewOnDay": "Perpanjang pada tanggal",
       "renewOnDayDesc": "Perpanjang pada tanggal ini setiap bulan kalender, pada tengah malam menurut zona waktu panel, alih-alih setiap N hari. Bulan yang terlalu pendek untuk tanggal yang dipilih diperpanjang pada hari terakhirnya. 0 mempertahankan mode interval hari.",
-      "renewsUsed": "Perpanjangan terpakai"
+      "renewsUsed": "Perpanjangan terpakai",
+      "bulkAdTagHint": "Masukkan tag heksadesimal 32 karakter untuk mengatur, atau 'none' untuk menghapus pada MTProto."
     },
     "groups": {
       "name": "Nama",

+ 3 - 2
internal/web/translation/ja-JP.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "選択された各クライアントを関連付けされたすべてのインバウンドから削除し、トラフィック記録も破棄します。元に戻せません。",
       "bulkAdjustTitle": "{count} 件のクライアントを調整",
       "bulkAdjustHint": "正の値は延長、負の値は短縮します。無期限の有効期限または無制限のトラフィックを持つクライアントは、その項目についてスキップされます。",
-      "bulkAdjustNothing": "適用する前に日数またはトラフィックを設定してください。",
+      "bulkAdjustNothing": "適用する前に少なくとも1つの項目を設定してください。",
       "addDays": "日数を追加",
       "addTrafficGB": "トラフィックを追加 (GB)",
       "bulkFlow": "Flow を設定",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。",
       "renewOnDay": "更新する日",
       "renewOnDayDesc": "毎月この日の深夜(パネルのタイムゾーン基準)に更新します。N 日ごとの更新の代わりになります。その日が存在しない月は月末に更新されます。0 で日数間隔モードのままになります。",
-      "renewsUsed": "使用済み更新回数"
+      "renewsUsed": "使用済み更新回数",
+      "bulkAdTagHint": "設定するには 32 文字の 16 進数タグを入力し、クリアするには 'none' を入力します (MTProto 用)。"
     },
     "groups": {
       "name": "名前",

+ 3 - 2
internal/web/translation/pt-BR.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Cada cliente selecionado é removido dos inbounds associados e o registro de tráfego é descartado. Não é possível desfazer.",
       "bulkAdjustTitle": "Ajustar {count} clientes",
       "bulkAdjustHint": "Valores positivos estendem, negativos reduzem. Clientes com expiração ou tráfego ilimitado são ignorados para esse campo.",
-      "bulkAdjustNothing": "Defina dias ou tráfego antes de aplicar.",
+      "bulkAdjustNothing": "Defina pelo menos um campo antes de aplicar.",
       "addDays": "Adicionar dias",
       "addTrafficGB": "Adicionar tráfego (GB)",
       "bulkFlow": "Definir flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.",
       "renewOnDay": "Renovar no dia",
       "renewOnDayDesc": "Renova neste dia de cada mês do calendário, à meia-noite no fuso horário do painel, em vez de a cada N dias. Se o mês for curto demais para o dia escolhido, renova no último dia dele. 0 mantém o modo de intervalo em dias.",
-      "renewsUsed": "Renovações usadas"
+      "renewsUsed": "Renovações usadas",
+      "bulkAdTagHint": "Insira uma tag hexadecimal de 32 caracteres para definir ou 'none' para limpar no MTProto."
     },
     "groups": {
       "name": "Nome",

+ 3 - 2
internal/web/translation/ru-RU.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Каждый выбранный клиент удаляется из всех привязанных входящих, его запись трафика уничтожается. Это действие нельзя отменить.",
       "bulkAdjustTitle": "Изменить {count} клиентов",
       "bulkAdjustHint": "Положительные значения добавляют, отрицательные — уменьшают. Клиенты с неограниченным сроком или трафиком пропускаются для соответствующего поля.",
-      "bulkAdjustNothing": "Укажите дни или трафик перед применением.",
+      "bulkAdjustNothing": "Укажите хотя бы одно поле перед применением.",
       "addDays": "Добавить дни",
       "addTrafficGB": "Добавить трафик (ГБ)",
       "bulkFlow": "Задать flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.",
       "renewOnDay": "Продлевать числа",
       "renewOnDayDesc": "Продлевать этого числа каждого месяца, в полночь по часовому поясу панели, вместо интервала в днях. Если в месяце такого числа нет, продление придётся на последний день. 0 — оставить режим интервала.",
-      "renewsUsed": "Продлений израсходовано"
+      "renewsUsed": "Продлений израсходовано",
+      "bulkAdTagHint": "Введите 32-значный hex-тег для установки или 'none' для очистки спонсорского канала на MTProto."
     },
     "groups": {
       "name": "Имя",

+ 3 - 2
internal/web/translation/tr-TR.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Seçili her kullanıcı bağlı tüm gelen bağlantılarndan kaldırılır ve trafik kaydı silinir. Geri alınamaz.",
       "bulkAdjustTitle": "{count} Kullanıcıyı Ayarla",
       "bulkAdjustHint": "Pozitif değerler ekler, negatif değerler azaltır. Sınırsız süreli veya trafikli kullanıcılar ilgili alan için atlanır.",
-      "bulkAdjustNothing": "Uygulamadan önce gün veya trafik belirleyin.",
+      "bulkAdjustNothing": "Uygulamadan önce en az bir alan belirleyin.",
       "addDays": "Gün Ekle",
       "addTrafficGB": "Trafik Ekle (GB)",
       "bulkFlow": "Flow ayarla",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.",
       "renewOnDay": "Yenileme günü",
       "renewOnDayDesc": "Her N günde bir yerine, her takvim ayının bu gününde, panel saat diliminde gece yarısı yeniler. Seçilen gün için kısa olan aylarda ayın son gününde yeniler. 0 gün aralığı modunu korur.",
-      "renewsUsed": "Kullanılan yenileme"
+      "renewsUsed": "Kullanılan yenileme",
+      "bulkAdTagHint": "Aramak için 32 karakterlik onaltılık etiket girin veya MTProto üzerinde temizlemek için 'none' yazın."
     },
     "groups": {
       "name": "İsim",

+ 3 - 2
internal/web/translation/uk-UA.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Кожен вибраний клієнт вилучається з усіх прив'язаних вхідних, його запис трафіку знищується. Цю дію неможливо скасувати.",
       "bulkAdjustTitle": "Змінити {count} клієнтів",
       "bulkAdjustHint": "Додатні значення подовжують, від'ємні зменшують. Клієнти з необмеженим терміном або трафіком пропускаються для відповідного поля.",
-      "bulkAdjustNothing": "Вкажіть дні або трафік перед застосуванням.",
+      "bulkAdjustNothing": "Вкажіть хоча б одне поле перед застосуванням.",
       "addDays": "Додати дні",
       "addTrafficGB": "Додати трафік (ГБ)",
       "bulkFlow": "Задати flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.",
       "renewOnDay": "Подовжувати числа",
       "renewOnDayDesc": "Подовжувати цього числа кожного місяця, опівночі за часовим поясом панелі, замість інтервалу в днях. Якщо в місяці такого числа немає, подовження припаде на останній день. 0 — залишити режим інтервалу.",
-      "renewsUsed": "Подовжень витрачено"
+      "renewsUsed": "Подовжень витрачено",
+      "bulkAdTagHint": "Введіть 32-значний hex-тег для встановлення або 'none' для очищення спонсорського каналу на MTProto."
     },
     "groups": {
       "name": "Назва",

+ 3 - 2
internal/web/translation/vi-VN.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "Mỗi khách hàng được chọn sẽ bị gỡ khỏi tất cả inbound đã gắn và bản ghi lưu lượng cũng bị xóa. Không thể hoàn tác.",
       "bulkAdjustTitle": "Điều chỉnh {count} khách hàng",
       "bulkAdjustHint": "Giá trị dương kéo dài, giá trị âm rút ngắn. Khách hàng có hạn hoặc lưu lượng không giới hạn sẽ bị bỏ qua cho trường đó.",
-      "bulkAdjustNothing": "Đặt số ngày hoặc lưu lượng trước khi áp dụng.",
+      "bulkAdjustNothing": "Đặt ít nhất một trường trước khi áp dụng.",
       "addDays": "Thêm ngày",
       "addTrafficGB": "Thêm lưu lượng (GB)",
       "bulkFlow": "Đặt flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.",
       "renewOnDay": "Gia hạn vào ngày",
       "renewOnDayDesc": "Gia hạn vào ngày này của mỗi tháng dương lịch, lúc nửa đêm theo múi giờ của bảng điều khiển, thay vì mỗi N ngày. Tháng không có ngày đã chọn sẽ gia hạn vào ngày cuối cùng của tháng. 0 giữ nguyên chế độ khoảng cách theo ngày.",
-      "renewsUsed": "Số lần gia hạn đã dùng"
+      "renewsUsed": "Số lần gia hạn đã dùng",
+      "bulkAdTagHint": "Nhập thẻ hex 32 ký tự để đặt hoặc 'none' để xóa kênh tài trợ trên MTProto."
     },
     "groups": {
       "name": "Tên",

+ 3 - 2
internal/web/translation/zh-CN.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "每个所选客户端都会从关联的入站中被移除,其流量记录也会被删除。该操作不可撤销。",
       "bulkAdjustTitle": "调整 {count} 个客户端",
       "bulkAdjustHint": "正值延长,负值减少。具有无限期限或流量的客户端将跳过该字段。",
-      "bulkAdjustNothing": "应用前请设置天数或流量。",
+      "bulkAdjustNothing": "应用前请至少设置一项。",
       "addDays": "添加天数",
       "addTrafficGB": "添加流量 (GB)",
       "bulkFlow": "设置 flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。",
       "renewOnDay": "按日期续期",
       "renewOnDayDesc": "每个自然月的这一天午夜(按面板时区)续期,而不是每 N 天续期一次。若当月没有该日期,则在当月最后一天续期。填 0 保持按天间隔模式。",
-      "renewsUsed": "已用续期次数"
+      "renewsUsed": "已用续期次数",
+      "bulkAdTagHint": "输入 32 位十六进制标签以设置,或输入 'none' 清除 MTProto 赞助频道。"
     },
     "groups": {
       "name": "名称",

+ 3 - 2
internal/web/translation/zh-TW.json

@@ -846,7 +846,7 @@
       "bulkDeleteConfirmContent": "每個所選客戶端都會從關聯的入站中被移除,其流量紀錄也會被刪除。此操作無法復原。",
       "bulkAdjustTitle": "調整 {count} 個客戶端",
       "bulkAdjustHint": "正值延長,負值減少。具有無限期限或流量的客戶端將跳過該欄位。",
-      "bulkAdjustNothing": "套用前請設定天數或流量。",
+      "bulkAdjustNothing": "套用前請至少設定一項。",
       "addDays": "新增天數",
       "addTrafficGB": "新增流量 (GB)",
       "bulkFlow": "設定 flow",
@@ -912,7 +912,8 @@
       "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。",
       "renewOnDay": "按日期續期",
       "renewOnDayDesc": "每個自然月的這一天午夜(依面板時區)續期,而不是每 N 天續期一次。若當月沒有該日期,則在當月最後一天續期。填 0 保持按天間隔模式。",
-      "renewsUsed": "已用續期次數"
+      "renewsUsed": "已用續期次數",
+      "bulkAdTagHint": "輸入 32 位十六進位標籤以設定,或輸入 'none' 清除 MTProto 贊助頻道。"
     },
     "groups": {
       "name": "名稱",