فهرست منبع

Add per-client external link controls (#5650)

* Add enable toggle for external client links

* Document external link enable API fields

* Extend external client link metadata

* Fix external subscription cache status updates

* fix(sub): address the review on per-client external link controls

Blocking: the expiry filter dropped legacy rows. expiry_time was added
without a default, so AutoMigrate makes it nullable and backfills NULL,
and `expiry_time = 0 OR expiry_time > ?` is false for NULL under
three-valued logic — every external link written before the upgrade
vanished from all subscriptions. Add `default:0` on expiry_time and
last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs
a pre-fix build could already have written.

Rework fetch-status recording. It ran inside the singleflight in-flight
window, so every goroutine parked on the shared fetch waited for a DB
write to commit on the public, unauthenticated subscription path — and
because it was keyed on the row id, waiters and cache hits recorded
nothing, leaving rows that lost the race stuck on "Not fetched yet"
forever. fetchSubscriptionLinks now reports whether it did the network
fetch and expandEntry records afterwards, off the serving path, keyed on
kind+value so every row sharing the URL is stamped by the one fetch.
Keying on value also closes the recycled-rowid hazard: saves delete and
re-insert rows, and SQLite reuses rowids, so an in-flight write could
land on an unrelated client's row. The write no longer discards its
error either.

Drop the inert id round-trip. The panel never sent it, and the byId
branch was guarded by the exact kind+value equality that byKindValue
already keys on, so it could not change an outcome. Matching on
kind+value alone is what actually preserves fetch status across saves.

Reject a negative expiryTime instead of storing a row that is silently
invisible in every subscription — elsewhere a negative expiryTime means
"a duration from first use", so an API caller reusing that convention
got no error and no links.

Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that
no component renders; it is leftover from the WireGuard PR this one was
split from.

i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire
instead of shipping an English duplicate under pages.clients, and
translate namePrefix, lastFetchAt, lastFetchError and neverFetched into
all 12 non-English locales.

Cover the persistence path that had no test: the fetch-status writer over
a real DB against a failing then a succeeding server, a cache hit writing
nothing, and the negative-expiry rejection.

---------

Co-authored-by: MHSanaei <[email protected]>
jason zhang 16 ساعت پیش
والد
کامیت
abd320994a

+ 21 - 4
frontend/public/openapi.json

@@ -6540,7 +6540,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
+        "summary": "Replace a client's external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.",
         "operationId": "post_panel_api_clients_email_externalLinks",
         "parameters": [
           {
@@ -6558,19 +6558,36 @@
           "content": {
             "application/json": {
               "schema": {
-                "type": "object"
+                "type": "object",
+                "properties": {
+                  "externalLinks": {
+                    "type": "array",
+                    "items": {
+                      "type": "object"
+                    },
+                    "description": "Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET."
+                  }
+                },
+                "required": [
+                  "externalLinks"
+                ]
               },
               "example": {
                 "externalLinks": [
                   {
                     "kind": "link",
                     "value": "vless://uuid@host:443?...#srv",
-                    "remark": "DE"
+                    "remark": "DE",
+                    "enable": true,
+                    "expiryTime": 0
                   },
                   {
                     "kind": "subscription",
                     "value": "https://provider.example/sub/abc",
-                    "remark": "Provider"
+                    "remark": "Provider",
+                    "enable": false,
+                    "expiryTime": 1767225600000,
+                    "namePrefix": "[zjh] "
                   }
                 ]
               }

+ 12 - 2
frontend/scripts/build-openapi.mjs

@@ -40,6 +40,7 @@ function extractPathParams(openApiPath) {
 
 function mapType(t) {
   const v = String(t || '').toLowerCase();
+  if (v.endsWith('[]')) return 'array';
   if (v === 'number' || v === 'integer' || v === 'int') return 'integer';
   if (v === 'float' || v === 'double') return 'number';
   if (v === 'boolean' || v === 'bool') return 'boolean';
@@ -48,6 +49,15 @@ function mapType(t) {
   return 'string';
 }
 
+function schemaFromType(t) {
+  const v = String(t || '').toLowerCase();
+  if (v.endsWith('[]')) {
+    const itemType = v.slice(0, -2);
+    return { type: 'array', items: { type: mapType(itemType) } };
+  }
+  return { type: mapType(v) };
+}
+
 function tryParseJson(raw) {
   if (typeof raw !== 'string') return undefined;
   try {
@@ -63,7 +73,7 @@ function paramToOpenApi(p) {
     in: p.in,
     required: p.in === 'path' ? true : !p.optional,
     description: p.desc || '',
-    schema: { type: mapType(p.type) },
+    schema: schemaFromType(p.type),
   };
   if (p.defaultValue !== undefined) out.schema.default = p.defaultValue;
   return out;
@@ -109,7 +119,7 @@ function buildOperation(ep, tag) {
     const required = [];
     for (const bp of bodyParams) {
       properties[bp.name] = {
-        type: mapType(bp.type),
+        ...schemaFromType(bp.type),
         description: bp.desc || '',
       };
       if (!bp.optional) required.push(bp.name);

+ 8 - 1
frontend/src/hooks/useClients.ts

@@ -35,7 +35,14 @@ import { DefaultsPayloadSchema } from '@/schemas/defaults';
 import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
 
 // One row sent to POST /clients/:email/externalLinks.
-export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
+export type ExternalLinkInput = {
+  kind: 'link' | 'subscription';
+  value: string;
+  remark: string;
+  enable: boolean;
+  expiryTime: number;
+  namePrefix: string;
+};
 
 export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
 

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

@@ -597,7 +597,7 @@ export const sections: readonly Section[] = [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [\n      { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n      { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n    ]\n  }\n}',
       },
       {
         method: 'GET',
@@ -665,12 +665,12 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/:email/externalLinks',
-        summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
+        summary: 'Replace a client\'s external links and external subscriptions. Sends the full set; the server replaces all rows. Disabled rows stay saved for editing but are not emitted in generated subscriptions.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
-          { name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
+          { name: 'externalLinks', in: 'body', type: 'object[]', desc: 'Full replacement list; the server replaces all rows. Each row supports { kind, value, remark, enable, expiryTime, namePrefix }. kind=link: value must be a supported share link such as vless://, vmess://, trojan://, ss://, hysteria2://, or wireguard://, and remark overrides the exported node name. kind=subscription: value must be an http(s) subscription URL, and namePrefix is prepended to fetched node names. Omit enable to default true; enable=false or an expired expiryTime keeps the row saved but excludes it from generated subscriptions. expiryTime is a unix millisecond timestamp where 0 means never expire; a negative value is rejected. Rows are matched by kind+value across saves, so id is ignored on write. lastFetchAt and lastFetchError are read-only status fields returned by GET.' },
         ],
-        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n  ]\n}',
+        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE", "enable": true, "expiryTime": 0 },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] " }\n  ]\n}',
         response: '{\n  "success": true\n}',
       },
       {

+ 105 - 30
frontend/src/pages/clients/ClientFormModal.tsx

@@ -22,7 +22,7 @@ import {
 import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
-import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
+import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
 
 import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -50,6 +50,11 @@ interface ExternalLinkRow {
   kind: 'link' | 'subscription';
   value: string;
   remark: string;
+  enable: boolean;
+  expiryTime: number;
+  namePrefix: string;
+  lastFetchAt: number;
+  lastFetchError: string;
 }
 
 interface ApiMsg<T = unknown> {
@@ -157,6 +162,11 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     value: l.value || '',
     remark: l.remark || '',
+    enable: l.enable !== false,
+    expiryTime: Number(l.expiryTime) || 0,
+    namePrefix: l.namePrefix || '',
+    lastFetchAt: Number(l.lastFetchAt) || 0,
+    lastFetchError: l.lastFetchError || '',
   }));
 }
 
@@ -232,7 +242,16 @@ export default function ClientFormModal({
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   function addExternalLinkRow(kind: 'link' | 'subscription') {
-    appendExternalLink({ kind, value: '', remark: '' });
+    appendExternalLink({
+      kind,
+      value: '',
+      remark: '',
+      enable: true,
+      expiryTime: 0,
+      namePrefix: '',
+      lastFetchAt: 0,
+      lastFetchError: '',
+    });
   }
 
   useEffect(() => {
@@ -622,7 +641,14 @@ reset: Number(values.reset) || 0,
     }
 
     const externalLinks: ExternalLinkInput[] = values.externalLinks
-      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
+      .map((r) => ({
+        kind: r.kind,
+        value: r.value.trim(),
+        remark: (r.remark || '').trim(),
+        enable: r.enable !== false,
+        expiryTime: Number(r.expiryTime) || 0,
+        namePrefix: (r.namePrefix || '').trim(),
+      }))
       .filter((r) => r.value !== '');
 
     setSubmitting(true);
@@ -1043,24 +1069,40 @@ reset: Number(values.reset) || 0,
                         {linkRows.length === 0 ? (
                           <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
                         ) : linkRows.map(({ field, index }) => (
-                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                            <FormField name={`externalLinks.${index}.value`} noStyle>
-                              <Input
-                                style={{ flex: 1 }}
-                                aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
-                                placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
-                              />
-                            </FormField>
-                            <FormField name={`externalLinks.${index}.remark`} noStyle>
-                              <Input
-                                style={{ width: 140 }}
-                                aria-label={t('remark')}
-                                placeholder={t('remark')}
+                          <div key={field.id} className="external-link-card">
+                            <div className="external-link-row">
+                              <div className="external-link-enable">
+                                <FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
+                                  <Switch size="small" />
+                                </FormField>
+                                <span>{t('enable')}</span>
+                              </div>
+                              <FormField name={`externalLinks.${index}.value`} noStyle>
+                                <Input
+                                  aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                                  placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                                />
+                              </FormField>
+                              <Tooltip title={t('delete')}>
+                                <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                              </Tooltip>
+                            </div>
+                            <div className="external-link-details two-cols">
+                              <FormField name={`externalLinks.${index}.remark`} noStyle>
+                                <Input aria-label={t('remark')} placeholder={t('remark')} />
+                              </FormField>
+                              <Controller
+                                control={methods.control}
+                                name={`externalLinks.${index}.expiryTime`}
+                                render={({ field: expiryField }) => (
+                                  <DateTimePicker
+                                    value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
+                                    onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                    placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                  />
+                                )}
                               />
-                            </FormField>
-                            <Tooltip title={t('delete')}>
-                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
-                            </Tooltip>
+                            </div>
                           </div>
                         ))}
                       </div>
@@ -1072,17 +1114,50 @@ reset: Number(values.reset) || 0,
                         {subscriptionRows.length === 0 ? (
                           <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
                         ) : subscriptionRows.map(({ field, index }) => (
-                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                            <FormField name={`externalLinks.${index}.value`} noStyle>
-                              <Input
-                                style={{ flex: 1 }}
-                                aria-label="https://provider.example/sub/…"
-                                placeholder="https://provider.example/sub/…"
+                          <div key={field.id} className="external-link-card">
+                            <div className="external-link-row">
+                              <div className="external-link-enable">
+                                <FormField name={`externalLinks.${index}.enable`} valueProp="checked" noStyle>
+                                  <Switch size="small" />
+                                </FormField>
+                                <span>{t('enable')}</span>
+                              </div>
+                              <FormField name={`externalLinks.${index}.value`} noStyle>
+                                <Input
+                                  aria-label="https://provider.example/sub/…"
+                                  placeholder="https://provider.example/sub/…"
+                                />
+                              </FormField>
+                              <Tooltip title={t('delete')}>
+                                <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                              </Tooltip>
+                            </div>
+                            <div className="external-link-details three-cols">
+                              <FormField name={`externalLinks.${index}.remark`} noStyle>
+                                <Input aria-label={t('remark')} placeholder={t('remark')} />
+                              </FormField>
+                              <FormField name={`externalLinks.${index}.namePrefix`} noStyle>
+                                <Input aria-label={t('pages.clients.namePrefix')} placeholder={t('pages.clients.namePrefix')} />
+                              </FormField>
+                              <Controller
+                                control={methods.control}
+                                name={`externalLinks.${index}.expiryTime`}
+                                render={({ field: expiryField }) => (
+                                  <DateTimePicker
+                                    value={Number(expiryField.value) > 0 ? dayjs(Number(expiryField.value)) : null}
+                                    onChange={(v) => expiryField.onChange(v ? v.valueOf() : 0)}
+                                    placeholder={t('pages.inbounds.leaveBlankToNeverExpire')}
+                                  />
+                                )}
                               />
-                            </FormField>
-                            <Tooltip title={t('delete')}>
-                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
-                            </Tooltip>
+                            </div>
+                            <Typography.Text type={field.lastFetchError ? 'danger' : 'secondary'} className="external-link-fetch-status">
+                              {field.lastFetchError
+                                ? `${t('pages.clients.lastFetchError')}: ${field.lastFetchError}`
+                                : field.lastFetchAt > 0
+                                  ? `${t('pages.clients.lastFetchAt')}: ${dayjs(field.lastFetchAt).format('YYYY-MM-DD HH:mm:ss')}`
+                                  : t('pages.clients.neverFetched')}
+                            </Typography.Text>
                           </div>
                         ))}
                       </div>

+ 70 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -83,6 +83,76 @@
   line-height: 18px;
 }
 
+.external-link-card {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-bottom: 12px;
+  padding: 10px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 6px;
+  background: var(--ant-color-fill-quaternary);
+}
+
+.external-link-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+
+.external-link-row .ant-input {
+  flex: 1;
+  min-width: 0;
+}
+
+.external-link-enable {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 78px;
+  color: var(--ant-color-text-secondary);
+  white-space: nowrap;
+}
+
+.external-link-details {
+  display: grid;
+  gap: 10px;
+}
+
+.external-link-details.two-cols {
+  grid-template-columns: minmax(0, 1fr) minmax(220px, 0.8fr);
+}
+
+.external-link-details.three-cols {
+  grid-template-columns: minmax(0, 1fr) minmax(160px, 0.8fr) minmax(220px, 0.8fr);
+}
+
+.external-link-fetch-status {
+  font-size: 12px;
+  line-height: 1.4;
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+  .external-link-row {
+    align-items: stretch;
+    flex-wrap: wrap;
+  }
+
+  .external-link-enable {
+    width: 100%;
+  }
+
+  .external-link-row .ant-input {
+    flex-basis: calc(100% - 44px);
+  }
+
+  .external-link-details.two-cols,
+  .external-link-details.three-cols {
+    grid-template-columns: 1fr;
+  }
+}
+
 .card-toolbar {
   display: flex;
   align-items: center;

+ 6 - 0
frontend/src/schemas/client.ts

@@ -106,9 +106,15 @@ export const ClientPageResponseSchema = z.object({
 // A per-client external link surfaced in the client's subscription:
 // kind=link is a single share link, kind=subscription is a remote sub URL.
 export const ExternalLinkSchema = z.object({
+  id: z.number().int().optional().default(0),
   kind: z.enum(['link', 'subscription']).default('link'),
   value: z.string(),
   remark: z.string().optional().default(''),
+  enable: z.preprocess((v) => (v == null ? true : v), z.boolean()).default(true),
+  expiryTime: z.number().int().optional().default(0),
+  namePrefix: z.string().optional().default(''),
+  lastFetchAt: z.number().int().optional().default(0),
+  lastFetchError: z.string().optional().default(''),
 }).loose();
 
 export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);

+ 40 - 0
internal/database/db.go

@@ -137,6 +137,12 @@ func initModels() error {
 	if err := normalizeInboundSubSortIndex(); err != nil {
 		return err
 	}
+	if err := normalizeClientExternalLinkEnable(); err != nil {
+		return err
+	}
+	if err := normalizeClientExternalLinkTimestamps(); err != nil {
+		return err
+	}
 	if err := repairOverflowedTrafficCounters(); err != nil {
 		return err
 	}
@@ -965,6 +971,40 @@ func normalizeInboundSubSortIndex() error {
 	return nil
 }
 
+// normalizeClientExternalLinkEnable keeps external-link rows written before the
+// enable column existed enabled; disabled rows from newer builds stay false.
+func normalizeClientExternalLinkEnable() error {
+	res := db.Exec("UPDATE client_external_links SET enable = ? WHERE enable IS NULL", true)
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link enable: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Normalized enable on %d client external link(s)", res.RowsAffected)
+	}
+	return nil
+}
+
+// normalizeClientExternalLinkTimestamps zeroes the NULLs an older build could
+// leave behind, so the sub-side expiry predicate never drops a legacy row.
+func normalizeClientExternalLinkTimestamps() error {
+	res := db.Exec("UPDATE client_external_links SET expiry_time = 0 WHERE expiry_time IS NULL")
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link expiry_time: %v", res.Error)
+		return res.Error
+	}
+	expiryRows := res.RowsAffected
+	res = db.Exec("UPDATE client_external_links SET last_fetch_at = 0 WHERE last_fetch_at IS NULL")
+	if res.Error != nil {
+		log.Printf("Error normalizing client external link last_fetch_at: %v", res.Error)
+		return res.Error
+	}
+	if expiryRows+res.RowsAffected > 0 {
+		log.Printf("Normalized timestamps on %d client external link(s)", expiryRows+res.RowsAffected)
+	}
+	return nil
+}
+
 // repairOverflowedTrafficCounters heals traffic counters that historic
 // compounding bugs pushed past int64: on SQLite an overflowing INTEGER is
 // silently promoted to REAL, after which the column no longer scans into the

+ 12 - 7
internal/database/model/model.go

@@ -1015,13 +1015,18 @@ func (ClientHwid) TableName() string { return "client_hwids" }
 //   - "subscription": a remote subscription URL. The panel fetches it (cached),
 //     decodes its links, and merges them into the client's subscription.
 type ClientExternalLink struct {
-	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
-	ClientId  int    `json:"clientId" gorm:"index;column:client_id"`
-	Kind      string `json:"kind" gorm:"column:kind"`
-	Value     string `json:"value" gorm:"column:value"`
-	Remark    string `json:"remark" gorm:"column:remark"`
-	SortIndex int    `json:"sortIndex" gorm:"column:sort_index"`
-	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	Id             int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	ClientId       int    `json:"clientId" gorm:"index;column:client_id"`
+	Kind           string `json:"kind" gorm:"column:kind"`
+	Value          string `json:"value" gorm:"column:value"`
+	Remark         string `json:"remark" gorm:"column:remark"`
+	Enable         *bool  `json:"enable" gorm:"column:enable;default:true"`
+	ExpiryTime     int64  `json:"expiryTime" gorm:"column:expiry_time;default:0"`
+	NamePrefix     string `json:"namePrefix" gorm:"column:name_prefix"`
+	LastFetchAt    int64  `json:"lastFetchAt" gorm:"column:last_fetch_at;default:0"`
+	LastFetchError string `json:"lastFetchError" gorm:"column:last_fetch_error"`
+	SortIndex      int    `json:"sortIndex" gorm:"column:sort_index"`
+	CreatedAt      int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }
 
 func (ClientExternalLink) TableName() string { return "client_external_links" }

+ 38 - 19
internal/sub/external_config.go

@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"net/url"
 	"strings"
+	"time"
 
 	"github.com/goccy/go-json"
 
@@ -16,11 +17,12 @@ import (
 // externalLinkEntry is one client × external-link row, resolved for a
 // subscription request. Email/Enable come from the owning client.
 type externalLinkEntry struct {
-	Kind   string
-	Value  string
-	Remark string
-	Email  string
-	Enable bool
+	Kind       string
+	Value      string
+	Remark     string
+	NamePrefix string
+	Email      string
+	Enable     bool
 }
 
 // expandedLink is a single share link contributed by an entry, with the display
@@ -50,7 +52,10 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 	}
 
 	var rows []model.ClientExternalLink
+	now := time.Now().UnixMilli()
 	if err := db.Where("client_id IN ?", clientIds).
+		Where("(enable IS NULL OR enable = ?)", true).
+		Where("(expiry_time IS NULL OR expiry_time <= 0 OR expiry_time > ?)", now).
 		Order("client_id ASC, sort_index ASC, id ASC").
 		Find(&rows).Error; err != nil {
 		return nil, err
@@ -63,27 +68,28 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 	for _, r := range rows {
 		rec := byId[r.ClientId]
 		out = append(out, externalLinkEntry{
-			Kind:   r.Kind,
-			Value:  r.Value,
-			Remark: r.Remark,
-			Email:  rec.Email,
-			Enable: rec.Enable,
+			Kind:       r.Kind,
+			Value:      r.Value,
+			Remark:     r.Remark,
+			NamePrefix: r.NamePrefix,
+			Email:      rec.Email,
+			Enable:     rec.Enable,
 		})
 	}
 	return out, nil
 }
 
-// expandEntry turns one entry into the concrete share links it contributes. A
-// "subscription" entry is fetched (cached) and its links keep their own names
-// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
-// otherwise the link's original name — never blank, so Clash/JSON do not fall
-// back to the client email.
+// expandEntry turns one entry into the concrete share links it contributes.
+// Names are never blank, so Clash/JSON do not fall back to the client email.
 func expandEntry(e externalLinkEntry) []expandedLink {
 	if e.Kind == model.ExternalLinkKindSubscription {
-		links := fetchSubscriptionLinks(e.Value)
-		out := make([]expandedLink, 0, len(links))
-		for _, l := range links {
-			out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
+		res := fetchSubscriptionLinks(e.Value)
+		if res.fetched {
+			recordExternalSubscriptionFetch(e.Value, res.err)
+		}
+		out := make([]expandedLink, 0, len(res.links))
+		for _, l := range res.links {
+			out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)})
 		}
 		return out
 	}
@@ -129,6 +135,19 @@ func linkDisplayName(rawLink string) string {
 	return ""
 }
 
+// prefixedLinkName falls back to the client email so a prefixed row never
+// renders as the bare prefix when the link carries no name of its own.
+func prefixedLinkName(displayName, prefix, fallback string) string {
+	if strings.TrimSpace(prefix) == "" {
+		return displayName
+	}
+	name := displayName
+	if name == "" {
+		name = strings.TrimSpace(fallback)
+	}
+	return prefix + name
+}
+
 // applyRemarkToLink rewrites a share link's display name to remark (when set),
 // leaving everything else byte-for-byte. vmess carries its remark in the base64
 // JSON `ps`; every other scheme carries it in the URL #fragment.

+ 26 - 0
internal/sub/external_config_test.go

@@ -5,6 +5,7 @@ import (
 	"net/url"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/goccy/go-json"
 
@@ -98,6 +99,31 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
 	}
 }
 
+func TestExpandEntrySubscriptionAppliesNamePrefix(t *testing.T) {
+	const subURL = "https://provider.example/sub-prefix"
+	subscriptionCache.Lock()
+	subscriptionCache.m[subURL] = subscriptionCacheEntry{
+		links:     []string{"trojan://[email protected]:8443#HK-01"},
+		fetchedAt: time.Now(),
+	}
+	subscriptionCache.Unlock()
+	t.Cleanup(func() {
+		subscriptionCache.Lock()
+		delete(subscriptionCache.m, subURL)
+		subscriptionCache.Unlock()
+	})
+
+	got := expandEntry(externalLinkEntry{
+		Kind:       model.ExternalLinkKindSubscription,
+		Value:      subURL,
+		NamePrefix: "[zjh] ",
+		Email:      "zjh",
+	})
+	if len(got) != 1 || got[0].Name != "[zjh] HK-01" {
+		t.Fatalf("expandEntry = %#v", got)
+	}
+}
+
 func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
 	got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:8443#orig", Remark: ""})
 	if len(got) != 1 || got[0].Name != "orig" {

+ 40 - 6
internal/sub/external_subscription.go

@@ -8,6 +8,10 @@ import (
 	"strings"
 	"sync"
 	"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/logger"
 )
 
 // External subscription fetching: a "subscription" external link is a remote
@@ -42,26 +46,34 @@ var subscriptionCache = struct {
 	inflight: make(map[string]*subscriptionFetch),
 }
 
+// subscriptionFetchResult reports whether this caller performed the network
+// fetch, so only it records status and cache hits stay read-only.
+type subscriptionFetchResult struct {
+	links   []string
+	fetched bool
+	err     error
+}
+
 // fetchSubscriptionLinks returns the share links contained in a remote
 // subscription URL, using a short-lived cache. On any failure it returns the
 // last cached value (if present) or nil — never an error, so the rest of the
 // client's subscription still renders.
-func fetchSubscriptionLinks(rawURL string) []string {
+func fetchSubscriptionLinks(rawURL string) subscriptionFetchResult {
 	rawURL = strings.TrimSpace(rawURL)
 	if rawURL == "" {
-		return nil
+		return subscriptionFetchResult{}
 	}
 
 	subscriptionCache.Lock()
 	cached, ok := subscriptionCache.m[rawURL]
 	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
 		subscriptionCache.Unlock()
-		return cached.links
+		return subscriptionFetchResult{links: cached.links}
 	}
 	if fetch, waiting := subscriptionCache.inflight[rawURL]; waiting {
 		subscriptionCache.Unlock()
 		<-fetch.done
-		return fetch.links
+		return subscriptionFetchResult{links: fetch.links}
 	}
 	fetch := &subscriptionFetch{done: make(chan struct{})}
 	subscriptionCache.inflight[rawURL] = fetch
@@ -78,7 +90,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
 		if ok {
 			fetch.links = cached.links
 		}
-		return fetch.links
+		return subscriptionFetchResult{links: fetch.links, fetched: true, err: err}
 	}
 
 	subscriptionCache.Lock()
@@ -86,7 +98,7 @@ func fetchSubscriptionLinks(rawURL string) []string {
 	trimSubscriptionCacheLocked(rawURL)
 	subscriptionCache.Unlock()
 	fetch.links = links
-	return fetch.links
+	return subscriptionFetchResult{links: links, fetched: true}
 }
 
 func trimSubscriptionCacheLocked(keep string) {
@@ -109,6 +121,28 @@ func trimSubscriptionCacheLocked(keep string) {
 	}
 }
 
+// recordExternalSubscriptionFetch stamps status on every row holding this URL,
+// keyed by value because row ids churn on save and the cache is per URL.
+func recordExternalSubscriptionFetch(rawURL string, fetchErr error) {
+	rawURL = strings.TrimSpace(rawURL)
+	if rawURL == "" {
+		return
+	}
+	lastFetchError := ""
+	if fetchErr != nil {
+		lastFetchError = fetchErr.Error()
+	}
+	if err := database.GetDB().
+		Model(&model.ClientExternalLink{}).
+		Where("kind = ? AND value = ?", model.ExternalLinkKindSubscription, rawURL).
+		Updates(map[string]any{
+			"last_fetch_at":    time.Now().UnixMilli(),
+			"last_fetch_error": lastFetchError,
+		}).Error; err != nil {
+		logger.Warningf("sub: recording fetch status for external subscription %q: %v", rawURL, err)
+	}
+}
+
 func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
 	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, rawURL, nil)
 	if err != nil {

+ 124 - 4
internal/sub/external_subscription_test.go

@@ -10,6 +10,9 @@ import (
 	"sync/atomic"
 	"testing"
 	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
 func resetSubscriptionCache(t *testing.T) {
@@ -44,7 +47,7 @@ func TestFetchSubscriptionLinksSharesConcurrentRefresh(t *testing.T) {
 	var wg sync.WaitGroup
 	for range callers {
 		wg.Go(func() {
-			results <- fetchSubscriptionLinks(srv.URL)
+			results <- fetchSubscriptionLinks(srv.URL).links
 		})
 	}
 
@@ -73,7 +76,7 @@ func TestFetchSubscriptionLinksBoundsCacheSize(t *testing.T) {
 	defer srv.Close()
 
 	for i := range subscriptionCacheCapacity + 1 {
-		links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i))
+		links := fetchSubscriptionLinks(srv.URL + "?id=" + strconv.Itoa(i)).links
 		if len(links) != 1 {
 			t.Fatalf("links at %d = %#v", i, links)
 		}
@@ -122,12 +125,12 @@ func TestFetchSubscriptionLinksSharesStaleResultAfterRefreshFailure(t *testing.T
 	var wg sync.WaitGroup
 	for range callers {
 		wg.Go(func() {
-			results <- fetchSubscriptionLinks(staleURL)
+			results <- fetchSubscriptionLinks(staleURL).links
 		})
 	}
 
 	time.Sleep(100 * time.Millisecond)
-	if links := fetchSubscriptionLinks(srv.URL + "/fresh"); len(links) != 1 || links[0] != "vless://[email protected]:443" {
+	if links := fetchSubscriptionLinks(srv.URL + "/fresh").links; len(links) != 1 || links[0] != "vless://[email protected]:443" {
 		t.Fatalf("fresh links = %#v", links)
 	}
 	close(release)
@@ -178,3 +181,120 @@ func TestDoFetchSubscriptionLinks_AcceptsBodyAtLimit(t *testing.T) {
 		t.Fatalf("links = %v, want [%q]", links, link)
 	}
 }
+
+func TestRecordExternalSubscriptionFetchStampsEveryRowForTheURL(t *testing.T) {
+	initMutDB(t)
+	resetSubscriptionCache(t)
+	db := database.GetDB()
+
+	var failing atomic.Bool
+	failing.Store(true)
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if failing.Load() {
+			w.WriteHeader(http.StatusBadGateway)
+			return
+		}
+		_, _ = w.Write([]byte("vless://[email protected]:443#Node"))
+	}))
+	defer srv.Close()
+
+	owners := []model.ClientRecord{
+		{Email: "[email protected]", SubID: "sub-fetch", UUID: "uuid-1", Enable: true},
+		{Email: "[email protected]", SubID: "sub-fetch", UUID: "uuid-2", Enable: true},
+	}
+	for i := range owners {
+		if err := db.Create(&owners[i]).Error; err != nil {
+			t.Fatalf("seed client %d: %v", i, err)
+		}
+		row := model.ClientExternalLink{
+			ClientId: owners[i].Id,
+			Kind:     model.ExternalLinkKindSubscription,
+			Value:    srv.URL,
+		}
+		if err := db.Create(&row).Error; err != nil {
+			t.Fatalf("seed external link %d: %v", i, err)
+		}
+	}
+
+	svc := NewSubService("")
+	entries, err := svc.getClientExternalLinksBySubId("sub-fetch")
+	if err != nil {
+		t.Fatalf("getClientExternalLinksBySubId: %v", err)
+	}
+	if len(entries) != 2 {
+		t.Fatalf("entries = %d, want 2", len(entries))
+	}
+
+	for _, e := range entries {
+		expandEntry(e)
+	}
+
+	var rows []model.ClientExternalLink
+	if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
+		t.Fatalf("read rows: %v", err)
+	}
+	if len(rows) != 2 {
+		t.Fatalf("rows = %d, want 2", len(rows))
+	}
+	for _, row := range rows {
+		if row.LastFetchAt <= 0 {
+			t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
+		}
+		if row.LastFetchError != errBadStatus.Error() {
+			t.Fatalf("row %d lastFetchError = %q, want %q", row.Id, row.LastFetchError, errBadStatus)
+		}
+	}
+
+	failing.Store(false)
+	resetSubscriptionCache(t)
+	for _, e := range entries {
+		expandEntry(e)
+	}
+
+	if err := db.Where("value = ?", srv.URL).Find(&rows).Error; err != nil {
+		t.Fatalf("re-read rows: %v", err)
+	}
+	for _, row := range rows {
+		if row.LastFetchError != "" {
+			t.Fatalf("row %d lastFetchError = %q, want cleared after a good fetch", row.Id, row.LastFetchError)
+		}
+		if row.LastFetchAt <= 0 {
+			t.Fatalf("row %d lastFetchAt = %d, want a stamped timestamp", row.Id, row.LastFetchAt)
+		}
+	}
+}
+
+func TestExpandEntryCacheHitWritesNothing(t *testing.T) {
+	initMutDB(t)
+	resetSubscriptionCache(t)
+	db := database.GetDB()
+
+	const subURL = "https://provider.example/cached"
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-cached", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	row := model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindSubscription, Value: subURL}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("seed external link: %v", err)
+	}
+
+	subscriptionCache.Lock()
+	subscriptionCache.m[subURL] = subscriptionCacheEntry{
+		links:     []string{"vless://[email protected]:443#Node"},
+		fetchedAt: time.Now(),
+	}
+	subscriptionCache.Unlock()
+
+	if got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindSubscription, Value: subURL}); len(got) != 1 {
+		t.Fatalf("expandEntry = %#v, want the cached link", got)
+	}
+
+	var after model.ClientExternalLink
+	if err := db.First(&after, row.Id).Error; err != nil {
+		t.Fatalf("read row: %v", err)
+	}
+	if after.LastFetchAt != 0 || after.LastFetchError != "" {
+		t.Fatalf("cache hit wrote fetch status: %#v", after)
+	}
+}

+ 11 - 0
internal/sub/mutation_audit_test.go

@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -24,6 +25,10 @@ func initMutDB(t *testing.T) {
 	t.Cleanup(func() { _ = database.CloseDB() })
 }
 
+func externalLinkEnabled(v bool) *bool {
+	return &v
+}
+
 // --- json_service.go:40 — rules are merged into routing only when non-empty ---
 
 func TestSubJsonService_CustomRulesPrepended(t *testing.T) {
@@ -307,6 +312,12 @@ func TestGetClientExternalLinksBySubId(t *testing.T) {
 	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://a", Remark: "first", SortIndex: 1}).Error; err != nil {
 		t.Fatalf("seed link a: %v", err)
 	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://disabled", Remark: "disabled", Enable: externalLinkEnabled(false), SortIndex: 3}).Error; err != nil {
+		t.Fatalf("seed disabled link: %v", err)
+	}
+	if err := db.Create(&model.ClientExternalLink{ClientId: rec.Id, Kind: model.ExternalLinkKindLink, Value: "trojan://expired", Remark: "expired", ExpiryTime: time.Now().Add(-time.Hour).UnixMilli(), SortIndex: 4}).Error; err != nil {
+		t.Fatalf("seed expired link: %v", err)
+	}
 
 	out, err = s.getClientExternalLinksBySubId("sub-ok")
 	if err != nil {

+ 35 - 7
internal/web/service/client_external_link.go

@@ -14,9 +14,12 @@ import (
 
 // ExternalLinkInput is one row from the client form's Links tab.
 type ExternalLinkInput struct {
-	Kind   string `json:"kind"`
-	Value  string `json:"value"`
-	Remark string `json:"remark"`
+	Kind       string `json:"kind"`
+	Value      string `json:"value"`
+	Remark     string `json:"remark"`
+	Enable     *bool  `json:"enable"`
+	ExpiryTime int64  `json:"expiryTime"`
+	NamePrefix string `json:"namePrefix"`
 }
 
 func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
@@ -55,11 +58,21 @@ func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalL
 		default:
 			return nil, common.NewError("unknown external link kind: " + kind)
 		}
+		if in.ExpiryTime < 0 {
+			return nil, common.NewError("external link expiryTime must be 0 (never) or a future unix millisecond timestamp: " + value)
+		}
+		enable := true
+		if in.Enable != nil {
+			enable = *in.Enable
+		}
 		out = append(out, model.ClientExternalLink{
-			Kind:      kind,
-			Value:     value,
-			Remark:    strings.TrimSpace(in.Remark),
-			SortIndex: len(out),
+			Kind:       kind,
+			Value:      value,
+			Remark:     strings.TrimSpace(in.Remark),
+			Enable:     &enable,
+			ExpiryTime: in.ExpiryTime,
+			NamePrefix: in.NamePrefix,
+			SortIndex:  len(out),
 		})
 	}
 	return out, nil
@@ -78,10 +91,25 @@ func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkI
 	}
 	db := database.GetDB()
 	return db.Transaction(func(tx *gorm.DB) error {
+		var existing []model.ClientExternalLink
+		if err := tx.Where("client_id = ?", id).Find(&existing).Error; err != nil {
+			return err
+		}
+		byKindValue := make(map[string]model.ClientExternalLink, len(existing))
+		for _, row := range existing {
+			key := row.Kind + "\x00" + row.Value
+			if _, ok := byKindValue[key]; !ok {
+				byKindValue[key] = row
+			}
+		}
 		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
 			return err
 		}
 		for i := range rows {
+			if old, ok := byKindValue[rows[i].Kind+"\x00"+rows[i].Value]; ok {
+				rows[i].LastFetchAt = old.LastFetchAt
+				rows[i].LastFetchError = old.LastFetchError
+			}
 			rows[i].ClientId = id
 			if err := tx.Create(&rows[i]).Error; err != nil {
 				return err

+ 124 - 0
internal/web/service/client_external_link_test.go

@@ -0,0 +1,124 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func externalLinkBool(v bool) *bool {
+	return &v
+}
+
+func TestSetExternalLinksPersistsEnableState(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-links", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+
+	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#on", Remark: "Primary", Enable: externalLinkBool(true), ExpiryTime: 1767225600000},
+		{Kind: model.ExternalLinkKindSubscription, Value: "https://provider.example/sub", Remark: "Provider", Enable: externalLinkBool(false), NamePrefix: "[zjh] "},
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#default"},
+	}); err != nil {
+		t.Fatalf("set external links: %v", err)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("rows = %d, want 3", len(rows))
+	}
+	if rows[0].Enable == nil || *rows[0].Enable != true {
+		t.Fatalf("first row enable = %#v, want true", rows[0].Enable)
+	}
+	if rows[1].Enable == nil || *rows[1].Enable != false {
+		t.Fatalf("second row enable = %#v, want false", rows[1].Enable)
+	}
+	if rows[2].Enable == nil || *rows[2].Enable != true {
+		t.Fatalf("omitted enable should default true, got %#v", rows[2].Enable)
+	}
+	if rows[0].Remark != "Primary" || rows[0].ExpiryTime != 1767225600000 {
+		t.Fatalf("first row fields not persisted: %#v", rows[0])
+	}
+	if rows[1].Remark != "Provider" || rows[1].NamePrefix != "[zjh] " {
+		t.Fatalf("subscription fields not persisted: %#v", rows[1])
+	}
+}
+
+func TestSetExternalLinksPreservesFetchStatus(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-status", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+	row := model.ClientExternalLink{
+		ClientId:       rec.Id,
+		Kind:           model.ExternalLinkKindSubscription,
+		Value:          "https://provider.example/sub",
+		Remark:         "old",
+		LastFetchAt:    1767220000000,
+		LastFetchError: "timeout",
+		SortIndex:      0,
+	}
+	if err := db.Create(&row).Error; err != nil {
+		t.Fatalf("create external link: %v", err)
+	}
+
+	if err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: row.Kind, Value: row.Value, Remark: "new", Enable: externalLinkBool(true)},
+	}); err != nil {
+		t.Fatalf("set external links: %v", err)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("rows = %d, want 1", len(rows))
+	}
+	if rows[0].LastFetchAt != row.LastFetchAt || rows[0].LastFetchError != row.LastFetchError {
+		t.Fatalf("fetch status not preserved: %#v", rows[0])
+	}
+	if rows[0].Remark != "new" {
+		t.Fatalf("editable fields not updated: %#v", rows[0])
+	}
+}
+
+func TestSetExternalLinksRejectsNegativeExpiry(t *testing.T) {
+	setupBulkDB(t)
+	db := database.GetDB()
+	svc := &ClientService{}
+
+	rec := model.ClientRecord{Email: "[email protected]", SubID: "sub-negative", UUID: "uuid", Enable: true}
+	if err := db.Create(&rec).Error; err != nil {
+		t.Fatalf("create client: %v", err)
+	}
+
+	err := svc.SetExternalLinksForRecord(rec.Id, []ExternalLinkInput{
+		{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:443#neg", ExpiryTime: -86400000},
+	})
+	want := "external link expiryTime must be 0 (never) or a future unix millisecond timestamp: trojan://[email protected]:443#neg\n"
+	if err == nil || err.Error() != want {
+		t.Fatalf("err = %v, want %q", err, want)
+	}
+
+	rows, err := svc.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		t.Fatalf("get external links: %v", err)
+	}
+	if len(rows) != 0 {
+		t.Fatalf("rows = %d, want the rejected save to persist nothing", len(rows))
+	}
+}

+ 4 - 0
internal/web/translation/ar-EG.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "إضافة اشتراك خارجي",
       "noExternalLinks": "لا توجد روابط خارجية بعد.",
       "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
+      "namePrefix": "بادئة الاسم",
+      "lastFetchAt": "آخر جلب",
+      "lastFetchError": "خطأ في الجلب",
+      "neverFetched": "لم يتم الجلب بعد",
       "submitEdit": "حفظ التغييرات",
       "clientCount": "عدد العملاء",
       "bulk": "إضافة مجمعة",

+ 4 - 0
internal/web/translation/en-US.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Add External Subscription",
       "noExternalLinks": "No external links yet.",
       "noExternalSubscriptions": "No external subscriptions yet.",
+      "namePrefix": "Name prefix",
+      "lastFetchAt": "Last fetch",
+      "lastFetchError": "Fetch error",
+      "neverFetched": "Not fetched yet",
       "submitEdit": "Save Changes",
       "clientCount": "Number of Clients",
       "bulk": "Add Bulk",

+ 4 - 0
internal/web/translation/es-ES.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Añadir suscripción externa",
       "noExternalLinks": "Aún no hay enlaces externos.",
       "noExternalSubscriptions": "Aún no hay suscripciones externas.",
+      "namePrefix": "Prefijo de nombre",
+      "lastFetchAt": "Última obtención",
+      "lastFetchError": "Error de obtención",
+      "neverFetched": "Aún no obtenido",
       "submitEdit": "Guardar cambios",
       "clientCount": "Número de clientes",
       "bulk": "Añadir en lote",

+ 4 - 0
internal/web/translation/fa-IR.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "افزودن سابسکریپشن خارجی",
       "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
       "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
+      "namePrefix": "پیشوند نام",
+      "lastFetchAt": "آخرین دریافت",
+      "lastFetchError": "خطای دریافت",
+      "neverFetched": "هنوز دریافت نشده",
       "submitEdit": "ذخیره تغییرات",
       "clientCount": "تعداد کلاینت‌ها",
       "bulk": "افزودن گروهی",

+ 4 - 0
internal/web/translation/id-ID.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Tambah Langganan Eksternal",
       "noExternalLinks": "Belum ada tautan eksternal.",
       "noExternalSubscriptions": "Belum ada langganan eksternal.",
+      "namePrefix": "Awalan nama",
+      "lastFetchAt": "Pengambilan terakhir",
+      "lastFetchError": "Galat pengambilan",
+      "neverFetched": "Belum diambil",
       "submitEdit": "Simpan perubahan",
       "clientCount": "Jumlah klien",
       "bulk": "Tambah massal",

+ 4 - 0
internal/web/translation/ja-JP.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "外部サブスクリプションを追加",
       "noExternalLinks": "外部リンクはまだありません。",
       "noExternalSubscriptions": "外部サブスクリプションはまだありません。",
+      "namePrefix": "名前の接頭辞",
+      "lastFetchAt": "最終取得",
+      "lastFetchError": "取得エラー",
+      "neverFetched": "未取得",
       "submitEdit": "変更を保存",
       "clientCount": "クライアント数",
       "bulk": "一括追加",

+ 4 - 0
internal/web/translation/pt-BR.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Adicionar assinatura externa",
       "noExternalLinks": "Ainda não há links externos.",
       "noExternalSubscriptions": "Ainda não há assinaturas externas.",
+      "namePrefix": "Prefixo do nome",
+      "lastFetchAt": "Última busca",
+      "lastFetchError": "Erro na busca",
+      "neverFetched": "Ainda não buscado",
       "submitEdit": "Salvar alterações",
       "clientCount": "Número de clientes",
       "bulk": "Adicionar em lote",

+ 4 - 0
internal/web/translation/ru-RU.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Добавить внешнюю подписку",
       "noExternalLinks": "Пока нет внешних ссылок.",
       "noExternalSubscriptions": "Пока нет внешних подписок.",
+      "namePrefix": "Префикс имени",
+      "lastFetchAt": "Последнее обновление",
+      "lastFetchError": "Ошибка обновления",
+      "neverFetched": "Ещё не загружено",
       "submitEdit": "Сохранить изменения",
       "clientCount": "Количество клиентов",
       "bulk": "Массовое добавление",

+ 4 - 0
internal/web/translation/tr-TR.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Harici Abonelik Ekle",
       "noExternalLinks": "Henüz harici bağlantı yok.",
       "noExternalSubscriptions": "Henüz harici abonelik yok.",
+      "namePrefix": "Ad öneki",
+      "lastFetchAt": "Son çekme",
+      "lastFetchError": "Çekme hatası",
+      "neverFetched": "Henüz çekilmedi",
       "submitEdit": "Değişiklikleri Kaydet",
       "clientCount": "Kullanıcı Sayısı",
       "bulk": "Toplu Ekle",

+ 4 - 0
internal/web/translation/uk-UA.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Додати зовнішню підписку",
       "noExternalLinks": "Зовнішніх посилань ще немає.",
       "noExternalSubscriptions": "Зовнішніх підписок ще немає.",
+      "namePrefix": "Префікс імені",
+      "lastFetchAt": "Останнє оновлення",
+      "lastFetchError": "Помилка оновлення",
+      "neverFetched": "Ще не завантажено",
       "submitEdit": "Зберегти зміни",
       "clientCount": "Кількість клієнтів",
       "bulk": "Масове додавання",

+ 4 - 0
internal/web/translation/vi-VN.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "Thêm đăng ký ngoài",
       "noExternalLinks": "Chưa có liên kết ngoài.",
       "noExternalSubscriptions": "Chưa có đăng ký ngoài.",
+      "namePrefix": "Tiền tố tên",
+      "lastFetchAt": "Lần tải gần nhất",
+      "lastFetchError": "Lỗi tải",
+      "neverFetched": "Chưa tải",
       "submitEdit": "Lưu thay đổi",
       "clientCount": "Số lượng khách hàng",
       "bulk": "Thêm hàng loạt",

+ 4 - 0
internal/web/translation/zh-CN.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "添加外部订阅",
       "noExternalLinks": "暂无外部链接。",
       "noExternalSubscriptions": "暂无外部订阅。",
+      "namePrefix": "名称前缀",
+      "lastFetchAt": "最后拉取",
+      "lastFetchError": "拉取失败",
+      "neverFetched": "尚未拉取",
       "submitEdit": "保存更改",
       "clientCount": "客户端数量",
       "bulk": "批量添加",

+ 4 - 0
internal/web/translation/zh-TW.json

@@ -693,6 +693,10 @@
       "addExternalSubscription": "新增外部訂閱",
       "noExternalLinks": "尚無外部連結。",
       "noExternalSubscriptions": "尚無外部訂閱。",
+      "namePrefix": "名稱前綴",
+      "lastFetchAt": "最後拉取",
+      "lastFetchError": "拉取失敗",
+      "neverFetched": "尚未拉取",
       "submitEdit": "儲存變更",
       "clientCount": "客戶端數量",
       "bulk": "批次新增",