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.