diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index e5a967d7..c26c23c1 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -17,6 +17,10 @@ sidebar: false
## [Unreleased]
+### ✨ 新功能
+
+- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
+
## [v3.5.9] - 2026-11-07
### ✨ 新功能
diff --git a/docs/design/cloudflare-pointing.md b/docs/design/cloudflare-pointing.md
index e70a15dd..94ca14c3 100644
--- a/docs/design/cloudflare-pointing.md
+++ b/docs/design/cloudflare-pointing.md
@@ -104,6 +104,7 @@ erDiagram
| `backup_node_id` | 备用(可空;一期仅存储) |
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
+| `ttl` | 分组级 TTL:`1` 为自动,手动填写 `60~86400` 的整数秒;仅对 DNS-only 成员生效,无成员级覆盖 |
| `enabled` | 是否参与同步 |
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
@@ -140,10 +141,20 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
| name | ZoneDomain 的 FQDN |
| content | 分组 `active_node` 的 IPv4 |
| proxied | 成员 `proxied` |
-| ttl | 橙云开启时由 CF 强制 Auto;关闭时使用统一默认(如 300) |
+| ttl | 橙云成员始终传 `1`(自动);DNS-only 成员使用分组 `ttl`,升级前分组继续使用 300 秒 |
一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。
+### 分组 TTL
+
+节点绑定和自定义 A / AAAA / CNAME 共用分组 TTL。在分组创建或编辑弹窗选择“自动”或“手动”;手动模式填写 `60~86400` 的整数秒,不接受空值、小数或越界值。本模块采用所有 Cloudflare 套餐通用的范围,暂不开放仅 Enterprise 支持的 30~59 秒。
+
+* 自动通过 Cloudflare API 的 `ttl: 1` 表示,不是 1 秒;Cloudflare 当前的 Auto 为 300 秒,因此切换到自动并不会缩短缓存时间。
+* 成员实际 `proxied=true` 时始终使用自动;分组 TTL 只控制灰云成员,分组默认橙云不影响 TTL 是否可编辑。
+* 修改 TTL 后,已有成员标记为待同步;启用的分组沿用现有整组任务自动同步,停用的分组暂不下发。成员移入另一分组后使用目标分组的 TTL。
+* 新建分组省略 `ttl` 时默认自动;更新省略 `ttl` 时保留原值。分组查询始终返回可直接回填的 `ttl`(自动或手动秒数)。
+* PostgreSQL / SQLite 为分组表新增 `ttl`,默认零值仅用于兼容旧数据,业务层将其解释为 300 秒,不向 Cloudflare 发送 `0`;不新增成员 TTL 字段,也不批量改写已有成员。回滚迁移会删除该字段及已保存的 TTL 配置。
+
### 触发
| 触发 | 行为 |
diff --git a/docs/docs.go b/docs/docs.go
index 07afd15f..875968ff 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -15517,6 +15517,11 @@ const docTemplate = `{
},
"target_mode": {
"type": "string"
+ },
+ "ttl": {
+ "description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
+ "type": "integer",
+ "example": 1
}
}
},
@@ -15559,6 +15564,11 @@ const docTemplate = `{
"target_mode": {
"type": "string"
},
+ "ttl": {
+ "description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
+ "type": "integer",
+ "example": 1
+ },
"updated_at": {
"type": "string"
}
diff --git a/docs/swagger.json b/docs/swagger.json
index f71ba6e3..196e048f 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -15510,6 +15510,11 @@
},
"target_mode": {
"type": "string"
+ },
+ "ttl": {
+ "description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
+ "type": "integer",
+ "example": 1
}
}
},
@@ -15552,6 +15557,11 @@
"target_mode": {
"type": "string"
},
+ "ttl": {
+ "description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
+ "type": "integer",
+ "example": 1
+ },
"updated_at": {
"type": "string"
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index f034875d..f9fde686 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -338,6 +338,10 @@ definitions:
type: string
target_mode:
type: string
+ ttl:
+ description: TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
+ example: 1
+ type: integer
type: object
cloudflare.GroupItem:
properties:
@@ -365,6 +369,10 @@ definitions:
type: string
target_mode:
type: string
+ ttl:
+ description: TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
+ example: 1
+ type: integer
updated_at:
type: string
type: object
diff --git a/frontend/app/(main)/cloudflare/components/group-dialog.tsx b/frontend/app/(main)/cloudflare/components/group-dialog.tsx
index 92c7a424..16f9fe7c 100644
--- a/frontend/app/(main)/cloudflare/components/group-dialog.tsx
+++ b/frontend/app/(main)/cloudflare/components/group-dialog.tsx
@@ -12,7 +12,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
-import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -64,6 +70,12 @@ export function GroupDialog({
const [backupNodeID, setBackupNodeID] = useState('none');
const [defaultProxied, setDefaultProxied] = useState(true);
const [enabled, setEnabled] = useState(true);
+ const [ttlMode, setTtlMode] = useState<'auto' | 'manual'>('auto');
+ const [manualTTL, setManualTTL] = useState('300');
+ const ttl = ttlMode === 'auto' ? 1 : Number(manualTTL);
+ const invalidTTL =
+ ttlMode === 'manual' &&
+ (!manualTTL.trim() || !Number.isInteger(ttl) || ttl < 60 || ttl > 86400);
useEffect(() => {
if (!open) return;
@@ -83,6 +95,9 @@ export function GroupDialog({
);
setDefaultProxied(group?.default_proxied ?? true);
setEnabled(group?.enabled ?? true);
+ const groupTTL = group ? (group.ttl ?? 300) : 1;
+ setTtlMode(groupTTL === 1 ? 'auto' : 'manual');
+ setManualTTL(String(groupTTL === 1 ? 300 : groupTTL));
}, [group, open]);
return (
@@ -209,6 +224,54 @@ export function GroupDialog({
>
)}
+
+ {t('ttl')}
+ {
+ if (value === 'auto' || value === 'manual') {
+ setTtlMode(value);
+ }
+ }}
+ >
+
+ {t('ttlAuto')}
+
+
+ {t('ttlManual')}
+
+
+ {t('ttlHint')}
+
+ {ttlMode === 'manual' && (
+
+
+ {t('ttlSeconds')}
+
+ setManualTTL(event.target.value)}
+ />
+ {invalidTTL && (
+ {t('ttlError')}
+ )}
+
+ )}
{t('defaultProxied')}
@@ -235,10 +298,12 @@ export function GroupDialog({
diff --git a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx
index 4ed4d4ae..2f16b7f8 100644
--- a/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx
+++ b/frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx
@@ -371,6 +371,11 @@ export function CloudflareGroupDetailPageClient() {
: 'nodeTargetMode',
)}
+
+ {group.ttl === 1
+ ? t('ttlAutoSummary')
+ : t('ttlSecondsSummary', { seconds: group.ttl ?? 300 })}
+
diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts
index 91a90a77..a246e4fa 100644
--- a/frontend/lib/services/openflare/types.ts
+++ b/frontend/lib/services/openflare/types.ts
@@ -1390,6 +1390,8 @@ export interface CloudflareGroup {
primary_node: CloudflareNodeOption;
backup_node: CloudflareNodeOption | null;
active_node: CloudflareNodeOption;
+ /** 1 表示自动,手动 TTL 为 60..86400 秒。 */
+ ttl: number;
default_proxied: boolean;
enabled: boolean;
member_count: number;
@@ -1404,6 +1406,8 @@ export interface CloudflareGroupPayload {
record_content: string;
primary_node_id: number;
backup_node_id: number | null;
+ /** 省略时保留旧调用方行为;1 表示自动,手动为 60..86400 秒。 */
+ ttl?: number;
default_proxied: boolean;
enabled: boolean;
}
diff --git a/frontend/messages/fragments/cloudflare.en.json b/frontend/messages/fragments/cloudflare.en.json
index 55084996..23d43273 100644
--- a/frontend/messages/fragments/cloudflare.en.json
+++ b/frontend/messages/fragments/cloudflare.en.json
@@ -1,5 +1,15 @@
{
"cloudflare": {
+ "ttlAutoSummary": "DNS-only TTL: Auto (about 300 seconds)",
+ "ttlSecondsSummary": "DNS-only TTL: {seconds} seconds",
+ "groupDialog": {
+ "ttl": "Group TTL",
+ "ttlAuto": "Auto",
+ "ttlManual": "Manual",
+ "ttlSeconds": "TTL (seconds)",
+ "ttlHint": "Changes apply to all DNS-only (gray-cloud) members in this group; proxied (orange-cloud) members always use Auto. Auto is currently about 300 seconds.",
+ "ttlError": "Enter a whole number of seconds between 60 and 86400."
+ },
"move": "Move",
"batchMove": "Batch Move",
"batchRemove": "Batch Delete",
diff --git a/frontend/messages/fragments/cloudflare.ru.json b/frontend/messages/fragments/cloudflare.ru.json
index 38589319..1719a36d 100644
--- a/frontend/messages/fragments/cloudflare.ru.json
+++ b/frontend/messages/fragments/cloudflare.ru.json
@@ -1,5 +1,15 @@
{
"cloudflare": {
+ "ttlAutoSummary": "TTL только DNS: авто (около 300 секунд)",
+ "ttlSecondsSummary": "TTL только DNS: {seconds} сек.",
+ "groupDialog": {
+ "ttl": "TTL группы",
+ "ttlAuto": "Авто",
+ "ttlManual": "Вручную",
+ "ttlSeconds": "TTL (секунды)",
+ "ttlHint": "Изменения применяются ко всем участникам группы в режиме «только DNS» (серое облако); для проксируемых записей (оранжевое облако) TTL всегда автоматический. Сейчас Auto — около 300 секунд.",
+ "ttlError": "Введите целое число секунд от 60 до 86400."
+ },
"move": "Переместить",
"batchMove": "Пакетное перемещение",
"batchRemove": "Пакетное удаление",
diff --git a/frontend/messages/fragments/cloudflare.zh-CN.json b/frontend/messages/fragments/cloudflare.zh-CN.json
index c42a8d80..41125c48 100644
--- a/frontend/messages/fragments/cloudflare.zh-CN.json
+++ b/frontend/messages/fragments/cloudflare.zh-CN.json
@@ -1,5 +1,15 @@
{
"cloudflare": {
+ "ttlAutoSummary": "灰云 TTL:自动(约 300 秒)",
+ "ttlSecondsSummary": "灰云 TTL:{seconds} 秒",
+ "groupDialog": {
+ "ttl": "分组 TTL",
+ "ttlAuto": "自动",
+ "ttlManual": "手动",
+ "ttlSeconds": "TTL(秒)",
+ "ttlHint": "修改将应用于分组内所有灰云(仅 DNS)成员;橙云始终自动。Auto 当前约为 300 秒。",
+ "ttlError": "请输入 60 至 86400 之间的整数秒数。"
+ },
"move": "移动",
"batchMove": "批量移动",
"batchRemove": "批量删除",
diff --git a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
index 9c739bac..48281099 100644
--- a/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
+++ b/frontend/tests/cloudflare/cloudflare-group-detail.test.tsx
@@ -63,6 +63,7 @@ const mockGroup = {
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
+ ttl: 300,
default_proxied: true,
enabled: true,
member_count: 2,
@@ -79,6 +80,7 @@ const mockTargetGroup = {
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
backup_node: null,
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
+ ttl: 1,
default_proxied: false,
enabled: true,
member_count: 0,
@@ -171,6 +173,21 @@ describe('Cloudflare group detail refresh', () => {
vi.useRealTimers();
});
+ it.each([
+ { ttl: 1, label: '灰云 TTL:自动(约 300 秒)' },
+ { ttl: 600, label: '灰云 TTL:600 秒' },
+ ])(
+ 'shows the group TTL $ttl in the detail summary',
+ async ({ ttl, label }) => {
+ vi.mocked(CloudflareService.getGroup).mockResolvedValue({
+ group: { ...mockGroup, ttl },
+ members: [],
+ });
+ renderPage();
+ expect(await screen.findByText(label)).toBeVisible();
+ },
+ );
+
it('refreshes detail data when the refresh button is clicked', async () => {
renderPage();
diff --git a/frontend/tests/cloudflare/cloudflare-overview.test.tsx b/frontend/tests/cloudflare/cloudflare-overview.test.tsx
index 098eb1da..2bc97ff9 100644
--- a/frontend/tests/cloudflare/cloudflare-overview.test.tsx
+++ b/frontend/tests/cloudflare/cloudflare-overview.test.tsx
@@ -115,6 +115,7 @@ describe('Cloudflare overview', () => {
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
backup_node: null,
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
+ ttl: 300,
default_proxied: true,
enabled: true,
member_count: 2,
diff --git a/frontend/tests/cloudflare/group-dialog.test.tsx b/frontend/tests/cloudflare/group-dialog.test.tsx
new file mode 100644
index 00000000..83dc1b6e
--- /dev/null
+++ b/frontend/tests/cloudflare/group-dialog.test.tsx
@@ -0,0 +1,215 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { NextIntlClientProvider } from 'next-intl';
+import type { ComponentProps } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { GroupDialog } from '@/app/(main)/cloudflare/components/group-dialog';
+import type { CloudflareGroup } from '@/lib/services/openflare';
+import zhCN from '@/messages/zh-CN.json';
+
+const node = { id: 1, name: '主节点', ip: '192.0.2.1' };
+const group: CloudflareGroup = {
+ id: 7,
+ name: '生产节点',
+ target_mode: 'node',
+ record_type: 'A',
+ record_content: '',
+ primary_node: node,
+ backup_node: null,
+ active_node: node,
+ ttl: 900,
+ default_proxied: true,
+ enabled: true,
+ member_count: 2,
+ created_at: '',
+ updated_at: '',
+};
+
+function renderDialog(
+ overrides: Partial> = {},
+) {
+ const props: ComponentProps = {
+ open: true,
+ onOpenChange: vi.fn(),
+ nodes: [],
+ pending: false,
+ onSubmit: vi.fn(),
+ ...overrides,
+ };
+ const view = (nextProps: ComponentProps) => (
+
+
+
+ );
+ const result = render(view(props));
+ return {
+ ...props,
+ rerenderDialog: (nextProps: Partial>) =>
+ result.rerender(view({ ...props, ...nextProps })),
+ };
+}
+
+function fillCustomTarget() {
+ fireEvent.change(screen.getByLabelText('分组名称'), {
+ target: { value: '自定义分组' },
+ });
+ fireEvent.click(screen.getByRole('radio', { name: '自定义' }));
+ fireEvent.change(screen.getByLabelText('记录值'), {
+ target: { value: '192.0.2.10' },
+ });
+}
+
+function manualInput() {
+ return screen.getByRole('spinbutton', { name: 'TTL(秒)' });
+}
+
+function save() {
+ fireEvent.click(screen.getByRole('button', { name: '保存' }));
+}
+
+describe('Cloudflare group TTL', () => {
+ it('defaults new groups to Auto and sends ttl=1', () => {
+ const { onSubmit } = renderDialog();
+ expect(screen.getByRole('radiogroup', { name: '分组 TTL' })).toBeVisible();
+ expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+ expect(screen.getByText(/Auto 当前约为 300 秒/)).toBeVisible();
+
+ fillCustomTarget();
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ target_mode: 'custom', ttl: 1 }),
+ );
+ });
+
+ it.each(['node', 'custom'] as const)(
+ 'saves a manual TTL for a %s target even when default_proxied is enabled',
+ (targetMode) => {
+ const { onSubmit } = renderDialog({
+ group: {
+ ...group,
+ target_mode: targetMode,
+ record_content: targetMode === 'custom' ? '192.0.2.10' : '',
+ },
+ });
+ expect(
+ screen.getByRole('switch', { name: '新成员默认开启橙云' }),
+ ).toBeChecked();
+ expect(screen.getByRole('radio', { name: '手动' })).toBeChecked();
+ expect(manualInput()).toHaveValue(900);
+ expect(manualInput()).toBeEnabled();
+ fireEvent.change(manualInput(), { target: { value: '600' } });
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target_mode: targetMode,
+ ttl: 600,
+ default_proxied: true,
+ }),
+ );
+ },
+ );
+
+ it('allows a new custom group to save a manual TTL', () => {
+ const { onSubmit } = renderDialog();
+ fillCustomTarget();
+ fireEvent.click(screen.getByRole('radio', { name: '手动' }));
+ fireEvent.change(manualInput(), { target: { value: '1200' } });
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ target_mode: 'custom', ttl: 1200 }),
+ );
+ });
+
+ it('reflects Auto on edit and does not allow deselecting it', () => {
+ const { onSubmit } = renderDialog({ group: { ...group, ttl: 1 } });
+ fireEvent.click(screen.getByRole('radio', { name: '自动' }));
+ expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl: 1 }));
+ });
+
+ it.each(['', '60.5', '59', '86401', '0', '-1'])(
+ 'rejects invalid manual TTL %j with an accessible error',
+ (value) => {
+ const { onSubmit } = renderDialog({ group });
+ fireEvent.change(manualInput(), { target: { value } });
+ expect(manualInput()).toHaveAttribute('aria-invalid', 'true');
+ expect(manualInput()).toHaveAccessibleDescription(
+ /请输入 60 至 86400 之间的整数秒数/,
+ );
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ '请输入 60 至 86400 之间的整数秒数。',
+ );
+ expect(screen.getByRole('button', { name: '保存' })).toBeDisabled();
+ save();
+ expect(onSubmit).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each([60, 86400])(
+ 'accepts the inclusive manual TTL boundary %i',
+ (ttl) => {
+ const { onSubmit } = renderDialog({ group });
+ fireEvent.change(manualInput(), { target: { value: String(ttl) } });
+ expect(manualInput()).toHaveAttribute('aria-invalid', 'false');
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl }));
+ },
+ );
+
+ it('switches invalid manual input to Auto and sends 1 without stale errors', () => {
+ const { onSubmit } = renderDialog({ group });
+ fireEvent.change(manualInput(), { target: { value: '' } });
+ expect(screen.getByRole('alert')).toBeVisible();
+ fireEvent.click(screen.getByRole('radio', { name: '自动' }));
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ ttl: 1 }));
+ });
+
+ it('resets a new group to Auto after closing and reopening', () => {
+ const { rerenderDialog, onOpenChange } = renderDialog();
+ fireEvent.click(screen.getByRole('radio', { name: '手动' }));
+ fireEvent.change(manualInput(), { target: { value: '' } });
+ fireEvent.click(screen.getByRole('button', { name: '取消' }));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ rerenderDialog({ open: false });
+ rerenderDialog({ open: true });
+ expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('radio', { name: '手动' }));
+ expect(manualInput()).toHaveValue(300);
+ });
+
+ it('restores saved TTL after reopening and reflects a different group', () => {
+ const { rerenderDialog } = renderDialog({ group });
+ fireEvent.change(manualInput(), { target: { value: '600' } });
+ rerenderDialog({ open: false });
+ rerenderDialog({ open: true });
+ expect(manualInput()).toHaveValue(900);
+ rerenderDialog({ group: { ...group, id: 8, ttl: 1800 } });
+ expect(manualInput()).toHaveValue(1800);
+ rerenderDialog({ group: { ...group, id: 9, ttl: 1 } });
+ expect(screen.getByRole('radio', { name: '自动' })).toBeChecked();
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+ });
+
+ it('uses 300 seconds for legacy responses without ttl', () => {
+ const { ttl: _ttl, ...legacyGroup } = group;
+ const { onSubmit } = renderDialog({
+ group: legacyGroup as CloudflareGroup,
+ });
+ expect(manualInput()).toHaveValue(300);
+ save();
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ ttl: 300 }),
+ );
+ });
+});
diff --git a/internal/apps/openflare/cloudflare/errs.go b/internal/apps/openflare/cloudflare/errs.go
index 4d9424f9..8f77338d 100644
--- a/internal/apps/openflare/cloudflare/errs.go
+++ b/internal/apps/openflare/cloudflare/errs.go
@@ -14,6 +14,7 @@ const (
errGroupTargetModeInvalid = "select a valid target mode"
errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address"
+ errGroupTTLInvalid = "TTL must be 1 (automatic) or an integer between 60 and 86400 seconds"
errNodeInvalid = "select a valid edge node"
errNodeIPv4Required = "the active node requires a valid IPv4 address"
errGroupDisabled = "target group is disabled"
diff --git a/internal/apps/openflare/cloudflare/logics.go b/internal/apps/openflare/cloudflare/logics.go
index 90112da3..1b4bd0db 100644
--- a/internal/apps/openflare/cloudflare/logics.go
+++ b/internal/apps/openflare/cloudflare/logics.go
@@ -19,6 +19,13 @@ import (
"gorm.io/gorm"
)
+const (
+ automaticTTL = 1
+ legacyDefaultTTL = 300
+ minManualTTL = 60
+ maxManualTTL = 86400
+)
+
var clientFactory = func(token string) Client { return NewHTTPClient(token) }
// SetClientFactoryForTest replaces Cloudflare client construction for tests.
@@ -227,6 +234,16 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom {
return nil, errors.New(errGroupTargetModeInvalid)
}
+ ttl := automaticTTL
+ if existing != nil {
+ ttl = groupTTL(existing)
+ }
+ if input.TTL != nil {
+ ttl = *input.TTL
+ }
+ if ttl != automaticTTL && (ttl < minManualTTL || ttl > maxManualTTL) {
+ return nil, errors.New(errGroupTTLInvalid)
+ }
primaryNodeID := uint(0)
var backupNodeID *uint
recordType := strings.ToUpper(strings.TrimSpace(input.RecordType))
@@ -259,6 +276,7 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
existing.TargetMode = targetMode
existing.RecordType = recordType
existing.RecordContent = recordContent
+ existing.TTL = ttl
existing.PrimaryNodeID = primaryNodeID
existing.ActiveNodeID = activeNodeID
existing.BackupNodeID = backupNodeID
@@ -267,6 +285,14 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
return existing, nil
}
+// groupTTL 保留升级前分组的 300 秒行为;数据库零值不直接发送给 Cloudflare。
+func groupTTL(group *model.CFPointingGroup) int {
+ if group.TTL == 0 {
+ return legacyDefaultTTL
+ }
+ return group.TTL
+}
+
func equalOptionalUint(left, right *uint) bool {
if left == nil || right == nil {
return left == right
@@ -419,6 +445,7 @@ func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupIt
return nil, err
}
item := &GroupItem{ID: group.ID, Name: group.Name, TargetMode: targetMode, RecordType: group.RecordType, RecordContent: group.RecordContent, PrimaryNode: nodeOptionForID(group.PrimaryNodeID, primary), ActiveNode: nodeOptionForID(group.ActiveNodeID, active), DefaultProxied: group.DefaultProxied, Enabled: group.Enabled, MemberCount: count, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt}
+ item.TTL = groupTTL(group)
if group.BackupNodeID != nil {
backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID)
if backupErr != nil {
diff --git a/internal/apps/openflare/cloudflare/reconcile.go b/internal/apps/openflare/cloudflare/reconcile.go
index 60793037..81e778e9 100644
--- a/internal/apps/openflare/cloudflare/reconcile.go
+++ b/internal/apps/openflare/cloudflare/reconcile.go
@@ -76,9 +76,9 @@ func reconcileMember(ctx context.Context, memberID uint) error {
}
input.Name = state.Domain.Domain
input.Proxied = state.Member.Proxied
- input.TTL = 300
+ input.TTL = groupTTL(&state.Group)
if input.Proxied {
- input.TTL = 1
+ input.TTL = automaticTTL
}
recordID := state.Member.CFRecordID
if recordID != "" {
diff --git a/internal/apps/openflare/cloudflare/ttl_test.go b/internal/apps/openflare/cloudflare/ttl_test.go
new file mode 100644
index 00000000..b81a68b8
--- /dev/null
+++ b/internal/apps/openflare/cloudflare/ttl_test.go
@@ -0,0 +1,255 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+package cloudflare
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Rain-kl/Wavelet/internal/model"
+ "github.com/Rain-kl/Wavelet/internal/repository"
+ "github.com/Rain-kl/Wavelet/internal/shared/response"
+ "github.com/gin-gonic/gin"
+)
+
+func TestGroupFromInputTTL(t *testing.T) {
+ auto, manual, minimum, maximum := 1, 120, 60, 86400
+ tests := []struct {
+ name string
+ existing *model.CFPointingGroup
+ ttl *int
+ want int
+ }{
+ {name: "new_default_auto", want: 1},
+ {name: "explicit_auto", ttl: &auto, want: 1},
+ {name: "manual", ttl: &manual, want: 120},
+ {name: "minimum", ttl: &minimum, want: 60},
+ {name: "maximum", ttl: &maximum, want: 86400},
+ {name: "legacy_default", existing: &model.CFPointingGroup{}, want: 300},
+ {name: "preserve_manual", existing: &model.CFPointingGroup{TTL: 600}, want: 600},
+ {name: "preserve_auto", existing: &model.CFPointingGroup{TTL: 1}, want: 1},
+ {name: "manual_to_auto", existing: &model.CFPointingGroup{TTL: 600}, ttl: &auto, want: 1},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ group, err := groupFromInput(context.Background(), tt.existing, GroupInput{
+ Name: "custom", TargetMode: "custom", RecordType: "CNAME", RecordContent: "origin.example.com", TTL: tt.ttl,
+ })
+ if err != nil {
+ t.Fatalf("groupFromInput(%s) error = %v", tt.name, err)
+ }
+ if group.TTL != tt.want {
+ t.Errorf("groupFromInput(%s).TTL = %d, want %d", tt.name, group.TTL, tt.want)
+ }
+ })
+ }
+}
+
+func TestCreateGroupHandlerTTL(t *testing.T) {
+ tests := []struct {
+ name string
+ ttlJSON string
+ wantStatus int
+ wantTTL int
+ }{
+ {name: "omitted", wantStatus: http.StatusOK, wantTTL: 1},
+ {name: "auto", ttlJSON: `,"ttl":1`, wantStatus: http.StatusOK, wantTTL: 1},
+ {name: "manual", ttlJSON: `,"ttl":120`, wantStatus: http.StatusOK, wantTTL: 120},
+ {name: "zero", ttlJSON: `,"ttl":0`, wantStatus: http.StatusBadRequest},
+ {name: "negative", ttlJSON: `,"ttl":-1`, wantStatus: http.StatusBadRequest},
+ {name: "below_minimum", ttlJSON: `,"ttl":59`, wantStatus: http.StatusBadRequest},
+ {name: "above_maximum", ttlJSON: `,"ttl":86401`, wantStatus: http.StatusBadRequest},
+ {name: "fraction", ttlJSON: `,"ttl":60.5`, wantStatus: http.StatusBadRequest},
+ {name: "string", ttlJSON: `,"ttl":"auto"`, wantStatus: http.StatusBadRequest},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, _ := setupCloudflareLogicDB(t)
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(response.ErrorHandlerMiddleware())
+ router.POST("/groups", CreateGroupHandler)
+ body := `{"name":"custom","target_mode":"custom","record_type":"CNAME","record_content":"origin.example.com"` + tt.ttlJSON + `}`
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/groups", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(recorder, request)
+ if recorder.Code != tt.wantStatus {
+ t.Fatalf("POST /groups(%s) status = %d, body = %s, want %d", tt.name, recorder.Code, recorder.Body.String(), tt.wantStatus)
+ }
+ if tt.wantStatus != http.StatusOK {
+ groups, err := repository.ListCFPointingGroups(ctx)
+ if err != nil || len(groups) != 1 {
+ t.Errorf("POST /groups(%s) persisted groups = %d, error = %v, want 1 existing group", tt.name, len(groups), err)
+ }
+ return
+ }
+ var envelope struct {
+ Data GroupItem `json:"data"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
+ t.Fatalf("decode POST /groups(%s) error = %v", tt.name, err)
+ }
+ if envelope.Data.TTL != tt.wantTTL {
+ t.Errorf("POST /groups(%s).ttl = %d, want %d", tt.name, envelope.Data.TTL, tt.wantTTL)
+ }
+ stored, err := repository.GetCFPointingGroup(ctx, envelope.Data.ID)
+ if err != nil {
+ t.Fatalf("GetCFPointingGroup(%d) error = %v", envelope.Data.ID, err)
+ }
+ if stored.TTL != tt.wantTTL {
+ t.Errorf("persisted POST /groups(%s).ttl = %d, want %d", tt.name, stored.TTL, tt.wantTTL)
+ }
+ })
+ }
+}
+
+func TestUpdateGroupTTLAndReadback(t *testing.T) {
+ ctx, memberID := setupCloudflareLogicDB(t)
+ state, err := repository.GetCFPointingMemberContext(ctx, memberID)
+ if err != nil {
+ t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
+ }
+ legacy, err := GetGroup(ctx, state.Group.ID)
+ if err != nil || legacy.Group.TTL != 300 {
+ t.Fatalf("GetGroup(legacy) = %+v, error = %v, want ttl 300", legacy, err)
+ }
+ dispatchCount := 0
+ restoreDispatch := SetDispatchTaskForTest(func(context.Context, string, []byte, string) (string, error) {
+ dispatchCount++
+ return "ttl-task", nil
+ })
+ t.Cleanup(restoreDispatch)
+ ttl := 120
+ input := GroupInput{Name: state.Group.Name, PrimaryNodeID: state.Group.PrimaryNodeID, Enabled: true, TTL: &ttl}
+ if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"sync_status": model.CFMemberSyncOK}); err != nil {
+ t.Fatalf("UpdateCFPointingMemberColumns(%d) error = %v", memberID, err)
+ }
+ updated, err := UpdateGroup(ctx, state.Group.ID, input)
+ if err != nil || updated.TTL != 120 {
+ t.Fatalf("UpdateGroup(ttl=120) = %+v, error = %v, want ttl 120", updated, err)
+ }
+ member, err := repository.GetCFPointingMemberByID(ctx, memberID)
+ if err != nil {
+ t.Fatalf("GetCFPointingMemberByID(%d) error = %v", memberID, err)
+ }
+ if member.SyncStatus != model.CFMemberSyncPending || dispatchCount != 1 {
+ t.Errorf("UpdateGroup(ttl=120) member status = %s, dispatches = %d, want pending and 1", member.SyncStatus, dispatchCount)
+ }
+ // 旧客户端省略新增字段时,不应将已设置的手动 TTL 重置为自动。
+ input.TTL = nil
+ if _, err := UpdateGroup(ctx, state.Group.ID, input); err != nil {
+ t.Fatalf("UpdateGroup(omitted ttl) error = %v", err)
+ }
+ detail, err := GetGroup(ctx, state.Group.ID)
+ if err != nil || detail.Group.TTL != 120 {
+ t.Fatalf("GetGroup(after omitted ttl) = %+v, error = %v, want ttl 120", detail, err)
+ }
+ groups, err := ListGroups(ctx)
+ if err != nil || len(groups) != 1 || groups[0].TTL != 120 {
+ t.Errorf("ListGroups() = %+v, error = %v, want one group with ttl 120", groups, err)
+ }
+ invalidTTL := 0
+ input.TTL = &invalidTTL
+ if _, err := UpdateGroup(ctx, state.Group.ID, input); err == nil {
+ t.Fatal("UpdateGroup(ttl=0) error = nil, want validation error")
+ }
+ stored, err := repository.GetCFPointingGroup(ctx, state.Group.ID)
+ if err != nil || stored.TTL != 120 || dispatchCount != 2 {
+ t.Errorf("UpdateGroup(invalid ttl) stored = %+v, error = %v, dispatches = %d, want ttl 120 and 2", stored, err, dispatchCount)
+ }
+}
+
+func TestReconcileMemberGroupTTL(t *testing.T) {
+ tests := []struct {
+ name string
+ targetMode string
+ recordType string
+ content string
+ ttl int
+ proxied bool
+ wantTTL int
+ }{
+ {name: "node_manual", targetMode: "node", recordType: "A", content: "203.0.113.10", ttl: 120, wantTTL: 120},
+ {name: "cname_manual", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 60, wantTTL: 60},
+ {name: "a_manual", targetMode: "custom", recordType: "A", content: "203.0.113.20", ttl: 600, wantTTL: 600},
+ {name: "aaaa_manual", targetMode: "custom", recordType: "AAAA", content: "2001:db8::1", ttl: 86400, wantTTL: 86400},
+ {name: "cname_auto", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 1, wantTTL: 1},
+ {name: "cname_legacy", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", wantTTL: 300},
+ {name: "cname_proxied", targetMode: "custom", recordType: "CNAME", content: "origin.example.com", ttl: 120, proxied: true, wantTTL: 1},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, memberID := setupCloudflareLogicDB(t)
+ state, err := repository.GetCFPointingMemberContext(ctx, memberID)
+ if err != nil {
+ t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
+ }
+ group := state.Group
+ group.TargetMode, group.RecordType, group.RecordContent, group.TTL = tt.targetMode, tt.recordType, tt.content, tt.ttl
+ if err := repository.SaveCFPointingGroup(ctx, &group); err != nil {
+ t.Fatalf("SaveCFPointingGroup(%s) error = %v", tt.name, err)
+ }
+ if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"proxied": tt.proxied}); err != nil {
+ t.Fatalf("UpdateCFPointingMemberColumns(%s) error = %v", tt.name, err)
+ }
+ fake := &fakeClient{}
+ t.Cleanup(SetClientFactoryForTest(func(string) Client { return fake }))
+ if err := ReconcileMember(ctx, memberID); err != nil {
+ t.Fatalf("ReconcileMember(%s) error = %v", tt.name, err)
+ }
+ if fake.created == nil {
+ t.Fatal("ReconcileMember() did not create a DNS record")
+ }
+ if got := *fake.created; got.TTL != tt.wantTTL || got.Type != tt.recordType || got.Content != tt.content || got.Proxied != tt.proxied {
+ t.Errorf("ReconcileMember(%s) input = %+v, want type=%s content=%s proxied=%t ttl=%d", tt.name, got, tt.recordType, tt.content, tt.proxied, tt.wantTTL)
+ }
+ })
+ }
+}
+
+type cachedTTLClient struct {
+ fakeClient
+}
+
+func (client *cachedTTLClient) GetRecord(_ context.Context, _, recordID string) (*DNSRecord, error) {
+ return &DNSRecord{ID: recordID, Type: "A", TTL: 300}, nil
+}
+
+func TestReconcileMemberUpdatesGroupTTL(t *testing.T) {
+ for _, cached := range []bool{false, true} {
+ t.Run(fmt.Sprintf("cached_%t", cached), func(t *testing.T) {
+ ctx, memberID := setupCloudflareLogicDB(t)
+ state, err := repository.GetCFPointingMemberContext(ctx, memberID)
+ if err != nil {
+ t.Fatalf("GetCFPointingMemberContext(%d) error = %v", memberID, err)
+ }
+ state.Group.TTL = 180
+ if err := repository.SaveCFPointingGroup(ctx, &state.Group); err != nil {
+ t.Fatalf("SaveCFPointingGroup(ttl=180) error = %v", err)
+ }
+ if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"proxied": false}); err != nil {
+ t.Fatalf("UpdateCFPointingMemberColumns(%d) error = %v", memberID, err)
+ }
+ fake := &cachedTTLClient{fakeClient: fakeClient{records: []DNSRecord{{ID: "existing", Type: "A", TTL: 300}}}}
+ if cached {
+ if err := repository.UpdateCFPointingMemberColumns(ctx, memberID, map[string]any{"cf_record_id": "existing"}); err != nil {
+ t.Fatalf("UpdateCFPointingMemberColumns(cached) error = %v", err)
+ }
+ }
+ t.Cleanup(SetClientFactoryForTest(func(string) Client { return fake }))
+ if err := ReconcileMember(ctx, memberID); err != nil {
+ t.Fatalf("ReconcileMember(cached=%t) error = %v", cached, err)
+ }
+ if fake.updated == nil || fake.updated.TTL != 180 || fake.created != nil {
+ t.Errorf("ReconcileMember(cached=%t) updated = %+v, created = %+v, want update ttl=180 only", cached, fake.updated, fake.created)
+ }
+ })
+ }
+}
diff --git a/internal/apps/openflare/cloudflare/types.go b/internal/apps/openflare/cloudflare/types.go
index 1275c141..80c3895b 100644
--- a/internal/apps/openflare/cloudflare/types.go
+++ b/internal/apps/openflare/cloudflare/types.go
@@ -39,6 +39,8 @@ type GroupInput struct {
BackupNodeID *uint `json:"backup_node_id"`
DefaultProxied bool `json:"default_proxied"`
Enabled bool `json:"enabled"`
+ // TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
+ TTL *int `json:"ttl" example:"1"`
}
// GroupItem is the admin-facing pointing group summary.
@@ -56,6 +58,8 @@ type GroupItem struct {
MemberCount int64 `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+ // TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
+ TTL int `json:"ttl" example:"1"`
}
// MemberCreateInput adds a ZoneDomain to a group. Nil Proxied copies the group default.
diff --git a/internal/infra/persistence/migrator/cloudflare_group_ttl_migration_test.go b/internal/infra/persistence/migrator/cloudflare_group_ttl_migration_test.go
new file mode 100644
index 00000000..7bfc784e
--- /dev/null
+++ b/internal/infra/persistence/migrator/cloudflare_group_ttl_migration_test.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Arctel.net
+// SPDX-License-Identifier: Apache-2.0
+
+package migrator
+
+import (
+ "bytes"
+ "io/fs"
+ "path/filepath"
+ "testing"
+
+ "github.com/glebarez/sqlite"
+ "github.com/pressly/goose/v3"
+ "gorm.io/gorm"
+)
+
+func TestCloudflareGroupTTLMigrationsMatch(t *testing.T) {
+ postgresSQL, err := fs.ReadFile(migrationFS, "goose/postgres/202611080001_add_cloudflare_group_ttl.sql")
+ if err != nil {
+ t.Fatalf("read PostgreSQL TTL migration: %v", err)
+ }
+ sqliteSQL, err := fs.ReadFile(migrationFS, "goose/sqlite/202611080001_add_cloudflare_group_ttl.sql")
+ if err != nil {
+ t.Fatalf("read SQLite TTL migration: %v", err)
+ }
+ if !bytes.Equal(postgresSQL, sqliteSQL) {
+ t.Error("Cloudflare TTL migrations differ, want identical portable ADD/DROP COLUMN statements")
+ }
+}
+
+func TestCloudflareGroupTTLMigrationSQLiteUpDownUp(t *testing.T) {
+ conn, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "cloudflare-ttl.db")), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("gorm.Open(sqlite) error = %v", err)
+ }
+ sqlDB, err := conn.DB()
+ if err != nil {
+ t.Fatalf("conn.DB() error = %v", err)
+ }
+ sqlDB.SetMaxOpenConns(1)
+ t.Cleanup(func() {
+ if err := sqlDB.Close(); err != nil {
+ t.Errorf("sqlDB.Close() error = %v", err)
+ }
+ })
+ goose.SetBaseFS(migrationFS)
+ if err := goose.SetDialect(dialectSqlite); err != nil {
+ t.Fatalf("goose.SetDialect(sqlite) error = %v", err)
+ }
+ const previousVersion = int64(202610260002)
+ const ttlVersion = int64(202611080001)
+ if err := goose.UpTo(sqlDB, "goose/sqlite", previousVersion); err != nil {
+ t.Fatalf("goose.UpTo(previous) error = %v", err)
+ }
+ if err := conn.Exec("INSERT INTO of_cf_pointing_groups (name, primary_node_id, active_node_id) VALUES ('legacy', 0, 0)").Error; err != nil {
+ t.Fatalf("insert legacy group error = %v", err)
+ }
+ for cycle := range 2 {
+ if err := goose.UpTo(sqlDB, "goose/sqlite", ttlVersion); err != nil {
+ t.Fatalf("goose.UpTo(ttl), cycle %d error = %v", cycle, err)
+ }
+ var ttl int
+ if err := conn.Raw("SELECT ttl FROM of_cf_pointing_groups WHERE name = 'legacy'").Scan(&ttl).Error; err != nil {
+ t.Fatalf("read migrated ttl, cycle %d error = %v", cycle, err)
+ }
+ if ttl != 0 {
+ t.Errorf("migrated legacy ttl, cycle %d = %d, want compatibility sentinel 0", cycle, ttl)
+ }
+ if conn.Migrator().HasColumn("of_cf_pointing_members", "ttl") {
+ t.Error("TTL migration added member-level ttl, want group-level only")
+ }
+ if err := conn.Exec("UPDATE of_cf_pointing_groups SET ttl = 120 WHERE name = 'legacy'").Error; err != nil {
+ t.Fatalf("write manual ttl error = %v", err)
+ }
+ if err := goose.DownTo(sqlDB, "goose/sqlite", previousVersion); err != nil {
+ t.Fatalf("goose.DownTo(previous), cycle %d error = %v", cycle, err)
+ }
+ if conn.Migrator().HasColumn("of_cf_pointing_groups", "ttl") {
+ t.Errorf("goose.DownTo(previous), cycle %d left ttl column", cycle)
+ }
+ var count int64
+ if err := conn.Table("of_cf_pointing_groups").Where("name = ?", "legacy").Count(&count).Error; err != nil || count != 1 {
+ t.Fatalf("legacy groups after rollback = %d, error = %v, want 1", count, err)
+ }
+ }
+}
diff --git a/internal/infra/persistence/migrator/goose/postgres/202611080001_add_cloudflare_group_ttl.sql b/internal/infra/persistence/migrator/goose/postgres/202611080001_add_cloudflare_group_ttl.sql
new file mode 100644
index 00000000..9735b6dd
--- /dev/null
+++ b/internal/infra/persistence/migrator/goose/postgres/202611080001_add_cloudflare_group_ttl.sql
@@ -0,0 +1,6 @@
+-- +goose Up
+-- 零值由业务层解释为旧分组的 300 秒 TTL;新建分组显式保存所选值。
+ALTER TABLE of_cf_pointing_groups ADD COLUMN ttl INTEGER NOT NULL DEFAULT 0;
+
+-- +goose Down
+ALTER TABLE of_cf_pointing_groups DROP COLUMN ttl;
diff --git a/internal/infra/persistence/migrator/goose/sqlite/202611080001_add_cloudflare_group_ttl.sql b/internal/infra/persistence/migrator/goose/sqlite/202611080001_add_cloudflare_group_ttl.sql
new file mode 100644
index 00000000..9735b6dd
--- /dev/null
+++ b/internal/infra/persistence/migrator/goose/sqlite/202611080001_add_cloudflare_group_ttl.sql
@@ -0,0 +1,6 @@
+-- +goose Up
+-- 零值由业务层解释为旧分组的 300 秒 TTL;新建分组显式保存所选值。
+ALTER TABLE of_cf_pointing_groups ADD COLUMN ttl INTEGER NOT NULL DEFAULT 0;
+
+-- +goose Down
+ALTER TABLE of_cf_pointing_groups DROP COLUMN ttl;
diff --git a/internal/model/openflare_cloudflare.go b/internal/model/openflare_cloudflare.go
index 6ece79ba..6b46c966 100644
--- a/internal/model/openflare_cloudflare.go
+++ b/internal/model/openflare_cloudflare.go
@@ -60,6 +60,8 @@ type CFPointingGroup struct {
Enabled bool `json:"enabled" gorm:"not null;default:false"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ // TTL 为 1 时表示自动;零值兼容旧分组的 300 秒 TTL。
+ TTL int `json:"ttl" gorm:"not null;default:0"`
}
// TableName returns the Cloudflare pointing group table name.