feat(cloudflare): 支持分组级自动与手动 TTL 配置 (#62)

This commit is contained in:
Deqiying
2026-11-08 22:07:26 +08:00
committed by GitHub
parent 18dee29b73
commit e0ba758afa
23 changed files with 775 additions and 7 deletions
+4
View File
@@ -17,6 +17,10 @@ sidebar: false
## [Unreleased]
### ✨ 新功能
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
## [v3.5.9] - 2026-11-07
### ✨ 新功能
+12 -1
View File
@@ -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 配置。
### 触发
| 触发 | 行为 |
+10
View File
@@ -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"
}
+10
View File
@@ -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"
}
+8
View File
@@ -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
@@ -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({
</Field>
</>
)}
<Field>
<FieldLabel id='cf-ttl-mode-label'>{t('ttl')}</FieldLabel>
<ToggleGroup
type='single'
variant='outline'
value={ttlMode}
aria-labelledby='cf-ttl-mode-label'
aria-describedby='cf-ttl-hint'
className='grid w-full grid-cols-2'
onValueChange={(value) => {
if (value === 'auto' || value === 'manual') {
setTtlMode(value);
}
}}
>
<ToggleGroupItem value='auto' className='w-full'>
{t('ttlAuto')}
</ToggleGroupItem>
<ToggleGroupItem value='manual' className='w-full'>
{t('ttlManual')}
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription id='cf-ttl-hint'>{t('ttlHint')}</FieldDescription>
</Field>
{ttlMode === 'manual' && (
<Field data-invalid={invalidTTL}>
<FieldLabel htmlFor='cf-ttl-seconds'>
{t('ttlSeconds')}
</FieldLabel>
<Input
id='cf-ttl-seconds'
type='number'
min={60}
max={86400}
step={1}
required
value={manualTTL}
aria-invalid={invalidTTL}
aria-describedby={
invalidTTL ? 'cf-ttl-hint cf-ttl-error' : 'cf-ttl-hint'
}
onChange={(event) => setManualTTL(event.target.value)}
/>
{invalidTTL && (
<FieldError id='cf-ttl-error'>{t('ttlError')}</FieldError>
)}
</Field>
)}
<Field orientation='horizontal'>
<FieldLabel htmlFor='cf-default-proxied'>
{t('defaultProxied')}
@@ -235,10 +298,12 @@ export function GroupDialog({
<Button
disabled={
pending ||
invalidTTL ||
!name.trim() ||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
}
onClick={() =>
onClick={() => {
if (invalidTTL) return;
onSubmit({
name: name.trim(),
target_mode: targetMode,
@@ -251,9 +316,10 @@ export function GroupDialog({
? Number(backupNodeID)
: null,
default_proxied: defaultProxied,
ttl,
enabled,
})
}
});
}}
>
{t('save')}
</Button>
@@ -371,6 +371,11 @@ export function CloudflareGroupDetailPageClient() {
: 'nodeTargetMode',
)}
</Badge>
<Badge variant='outline'>
{group.ttl === 1
? t('ttlAutoSummary')
: t('ttlSecondsSummary', { seconds: group.ttl ?? 300 })}
</Badge>
</CardContent>
</Card>
+4
View File
@@ -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;
}
@@ -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",
@@ -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": "Пакетное удаление",
@@ -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": "批量删除",
@@ -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();
@@ -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,
@@ -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<ComponentProps<typeof GroupDialog>> = {},
) {
const props: ComponentProps<typeof GroupDialog> = {
open: true,
onOpenChange: vi.fn(),
nodes: [],
pending: false,
onSubmit: vi.fn(),
...overrides,
};
const view = (nextProps: ComponentProps<typeof GroupDialog>) => (
<NextIntlClientProvider
locale='zh-CN'
messages={zhCN}
timeZone='Asia/Shanghai'
>
<GroupDialog {...nextProps} />
</NextIntlClientProvider>
);
const result = render(view(props));
return {
...props,
rerenderDialog: (nextProps: Partial<ComponentProps<typeof GroupDialog>>) =>
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 }),
);
});
});
@@ -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"
@@ -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 {
@@ -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 != "" {
@@ -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)
}
})
}
}
@@ -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.
@@ -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)
}
}
}
@@ -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;
@@ -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;
+2
View File
@@ -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.