mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(cloudflare): 支持分组级自动与手动 TTL 配置 (#62)
This commit is contained in:
@@ -17,6 +17,10 @@ sidebar: false
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ✨ 新功能
|
||||||
|
|
||||||
|
- Cloudflare 指向分组支持选择自动 TTL 或手动填写 60~86400 秒,适用于节点绑定与自定义 A、AAAA、CNAME;灰云成员使用分组配置,橙云成员始终自动,旧分组保留原有 300 秒行为。
|
||||||
|
|
||||||
## [v3.5.9] - 2026-11-07
|
## [v3.5.9] - 2026-11-07
|
||||||
|
|
||||||
### ✨ 新功能
|
### ✨ 新功能
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ erDiagram
|
|||||||
| `backup_node_id` | 备用(可空;一期仅存储) |
|
| `backup_node_id` | 备用(可空;一期仅存储) |
|
||||||
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
|
| `active_node_id` | 当前生效节点;一期等于 primary;二期 failover 改写 |
|
||||||
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
|
| `default_proxied` | 分组默认橙云;**仅影响新加入成员** |
|
||||||
|
| `ttl` | 分组级 TTL:`1` 为自动,手动填写 `60~86400` 的整数秒;仅对 DNS-only 成员生效,无成员级覆盖 |
|
||||||
| `enabled` | 是否参与同步 |
|
| `enabled` | 是否参与同步 |
|
||||||
|
|
||||||
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
|
约束:主备不得为同一节点;选作生效目标的节点须有合法 IPv4。
|
||||||
@@ -140,10 +141,20 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
|
|||||||
| name | ZoneDomain 的 FQDN |
|
| name | ZoneDomain 的 FQDN |
|
||||||
| content | 分组 `active_node` 的 IPv4 |
|
| content | 分组 `active_node` 的 IPv4 |
|
||||||
| proxied | 成员 `proxied` |
|
| proxied | 成员 `proxied` |
|
||||||
| ttl | 橙云开启时由 CF 强制 Auto;关闭时使用统一默认(如 300) |
|
| ttl | 橙云成员始终传 `1`(自动);DNS-only 成员使用分组 `ttl`,升级前分组继续使用 300 秒 |
|
||||||
|
|
||||||
一期不写 AAAA。节点 IP 非合法 IPv4 → 该成员 `error`。
|
一期不写 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 配置。
|
||||||
|
|
||||||
### 触发
|
### 触发
|
||||||
|
|
||||||
| 触发 | 行为 |
|
| 触发 | 行为 |
|
||||||
|
|||||||
@@ -15517,6 +15517,11 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"target_mode": {
|
"target_mode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ttl": {
|
||||||
|
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15559,6 +15564,11 @@ const docTemplate = `{
|
|||||||
"target_mode": {
|
"target_mode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15510,6 +15510,11 @@
|
|||||||
},
|
},
|
||||||
"target_mode": {
|
"target_mode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ttl": {
|
||||||
|
"description": "TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。",
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -15552,6 +15557,11 @@
|
|||||||
"target_mode": {
|
"target_mode": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"ttl": {
|
||||||
|
"description": "TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。",
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,6 +338,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
target_mode:
|
target_mode:
|
||||||
type: string
|
type: string
|
||||||
|
ttl:
|
||||||
|
description: TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
|
||||||
|
example: 1
|
||||||
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
cloudflare.GroupItem:
|
cloudflare.GroupItem:
|
||||||
properties:
|
properties:
|
||||||
@@ -365,6 +369,10 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
target_mode:
|
target_mode:
|
||||||
type: string
|
type: string
|
||||||
|
ttl:
|
||||||
|
description: TTL 为 1 时自动,否则为 DNS-only 成员使用的秒数。
|
||||||
|
example: 1
|
||||||
|
type: integer
|
||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} 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 { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -64,6 +70,12 @@ export function GroupDialog({
|
|||||||
const [backupNodeID, setBackupNodeID] = useState('none');
|
const [backupNodeID, setBackupNodeID] = useState('none');
|
||||||
const [defaultProxied, setDefaultProxied] = useState(true);
|
const [defaultProxied, setDefaultProxied] = useState(true);
|
||||||
const [enabled, setEnabled] = 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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -83,6 +95,9 @@ export function GroupDialog({
|
|||||||
);
|
);
|
||||||
setDefaultProxied(group?.default_proxied ?? true);
|
setDefaultProxied(group?.default_proxied ?? true);
|
||||||
setEnabled(group?.enabled ?? 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]);
|
}, [group, open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -209,6 +224,54 @@ export function GroupDialog({
|
|||||||
</Field>
|
</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'>
|
<Field orientation='horizontal'>
|
||||||
<FieldLabel htmlFor='cf-default-proxied'>
|
<FieldLabel htmlFor='cf-default-proxied'>
|
||||||
{t('defaultProxied')}
|
{t('defaultProxied')}
|
||||||
@@ -235,10 +298,12 @@ export function GroupDialog({
|
|||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
pending ||
|
pending ||
|
||||||
|
invalidTTL ||
|
||||||
!name.trim() ||
|
!name.trim() ||
|
||||||
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
|
(targetMode === 'node' ? !primaryNodeID : !recordContent.trim())
|
||||||
}
|
}
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
if (invalidTTL) return;
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
target_mode: targetMode,
|
target_mode: targetMode,
|
||||||
@@ -251,9 +316,10 @@ export function GroupDialog({
|
|||||||
? Number(backupNodeID)
|
? Number(backupNodeID)
|
||||||
: null,
|
: null,
|
||||||
default_proxied: defaultProxied,
|
default_proxied: defaultProxied,
|
||||||
|
ttl,
|
||||||
enabled,
|
enabled,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
{t('save')}
|
{t('save')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -371,6 +371,11 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
: 'nodeTargetMode',
|
: 'nodeTargetMode',
|
||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{group.ttl === 1
|
||||||
|
? t('ttlAutoSummary')
|
||||||
|
: t('ttlSecondsSummary', { seconds: group.ttl ?? 300 })}
|
||||||
|
</Badge>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1390,6 +1390,8 @@ export interface CloudflareGroup {
|
|||||||
primary_node: CloudflareNodeOption;
|
primary_node: CloudflareNodeOption;
|
||||||
backup_node: CloudflareNodeOption | null;
|
backup_node: CloudflareNodeOption | null;
|
||||||
active_node: CloudflareNodeOption;
|
active_node: CloudflareNodeOption;
|
||||||
|
/** 1 表示自动,手动 TTL 为 60..86400 秒。 */
|
||||||
|
ttl: number;
|
||||||
default_proxied: boolean;
|
default_proxied: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
@@ -1404,6 +1406,8 @@ export interface CloudflareGroupPayload {
|
|||||||
record_content: string;
|
record_content: string;
|
||||||
primary_node_id: number;
|
primary_node_id: number;
|
||||||
backup_node_id: number | null;
|
backup_node_id: number | null;
|
||||||
|
/** 省略时保留旧调用方行为;1 表示自动,手动为 60..86400 秒。 */
|
||||||
|
ttl?: number;
|
||||||
default_proxied: boolean;
|
default_proxied: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"cloudflare": {
|
"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",
|
"move": "Move",
|
||||||
"batchMove": "Batch Move",
|
"batchMove": "Batch Move",
|
||||||
"batchRemove": "Batch Delete",
|
"batchRemove": "Batch Delete",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"cloudflare": {
|
"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": "Переместить",
|
"move": "Переместить",
|
||||||
"batchMove": "Пакетное перемещение",
|
"batchMove": "Пакетное перемещение",
|
||||||
"batchRemove": "Пакетное удаление",
|
"batchRemove": "Пакетное удаление",
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
|
"ttlAutoSummary": "灰云 TTL:自动(约 300 秒)",
|
||||||
|
"ttlSecondsSummary": "灰云 TTL:{seconds} 秒",
|
||||||
|
"groupDialog": {
|
||||||
|
"ttl": "分组 TTL",
|
||||||
|
"ttlAuto": "自动",
|
||||||
|
"ttlManual": "手动",
|
||||||
|
"ttlSeconds": "TTL(秒)",
|
||||||
|
"ttlHint": "修改将应用于分组内所有灰云(仅 DNS)成员;橙云始终自动。Auto 当前约为 300 秒。",
|
||||||
|
"ttlError": "请输入 60 至 86400 之间的整数秒数。"
|
||||||
|
},
|
||||||
"move": "移动",
|
"move": "移动",
|
||||||
"batchMove": "批量移动",
|
"batchMove": "批量移动",
|
||||||
"batchRemove": "批量删除",
|
"batchRemove": "批量删除",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const mockGroup = {
|
|||||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
backup_node: null,
|
backup_node: null,
|
||||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
ttl: 300,
|
||||||
default_proxied: true,
|
default_proxied: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
member_count: 2,
|
member_count: 2,
|
||||||
@@ -79,6 +80,7 @@ const mockTargetGroup = {
|
|||||||
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
backup_node: null,
|
backup_node: null,
|
||||||
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
|
ttl: 1,
|
||||||
default_proxied: false,
|
default_proxied: false,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
member_count: 0,
|
member_count: 0,
|
||||||
@@ -171,6 +173,21 @@ describe('Cloudflare group detail refresh', () => {
|
|||||||
vi.useRealTimers();
|
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 () => {
|
it('refreshes detail data when the refresh button is clicked', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ describe('Cloudflare overview', () => {
|
|||||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
backup_node: null,
|
backup_node: null,
|
||||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
ttl: 300,
|
||||||
default_proxied: true,
|
default_proxied: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
member_count: 2,
|
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"
|
errGroupTargetModeInvalid = "select a valid target mode"
|
||||||
errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
|
errRecordTypeInvalid = "select a CNAME, A or AAAA record type"
|
||||||
errRecordContentInvalid = "invalid DNS record value; check the domain name or IP address"
|
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"
|
errNodeInvalid = "select a valid edge node"
|
||||||
errNodeIPv4Required = "the active node requires a valid IPv4 address"
|
errNodeIPv4Required = "the active node requires a valid IPv4 address"
|
||||||
errGroupDisabled = "target group is disabled"
|
errGroupDisabled = "target group is disabled"
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
automaticTTL = 1
|
||||||
|
legacyDefaultTTL = 300
|
||||||
|
minManualTTL = 60
|
||||||
|
maxManualTTL = 86400
|
||||||
|
)
|
||||||
|
|
||||||
var clientFactory = func(token string) Client { return NewHTTPClient(token) }
|
var clientFactory = func(token string) Client { return NewHTTPClient(token) }
|
||||||
|
|
||||||
// SetClientFactoryForTest replaces Cloudflare client construction for tests.
|
// 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 {
|
if targetMode != model.CFPointingTargetModeNode && targetMode != model.CFPointingTargetModeCustom {
|
||||||
return nil, errors.New(errGroupTargetModeInvalid)
|
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)
|
primaryNodeID := uint(0)
|
||||||
var backupNodeID *uint
|
var backupNodeID *uint
|
||||||
recordType := strings.ToUpper(strings.TrimSpace(input.RecordType))
|
recordType := strings.ToUpper(strings.TrimSpace(input.RecordType))
|
||||||
@@ -259,6 +276,7 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
|
|||||||
existing.TargetMode = targetMode
|
existing.TargetMode = targetMode
|
||||||
existing.RecordType = recordType
|
existing.RecordType = recordType
|
||||||
existing.RecordContent = recordContent
|
existing.RecordContent = recordContent
|
||||||
|
existing.TTL = ttl
|
||||||
existing.PrimaryNodeID = primaryNodeID
|
existing.PrimaryNodeID = primaryNodeID
|
||||||
existing.ActiveNodeID = activeNodeID
|
existing.ActiveNodeID = activeNodeID
|
||||||
existing.BackupNodeID = backupNodeID
|
existing.BackupNodeID = backupNodeID
|
||||||
@@ -267,6 +285,14 @@ func groupFromInput(ctx context.Context, existing *model.CFPointingGroup, input
|
|||||||
return existing, nil
|
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 {
|
func equalOptionalUint(left, right *uint) bool {
|
||||||
if left == nil || right == nil {
|
if left == nil || right == nil {
|
||||||
return left == right
|
return left == right
|
||||||
@@ -419,6 +445,7 @@ func buildGroupItem(ctx context.Context, group *model.CFPointingGroup) (*GroupIt
|
|||||||
return nil, err
|
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 := &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 {
|
if group.BackupNodeID != nil {
|
||||||
backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID)
|
backup, backupErr := lookupGroupNode(ctx, group.ID, *group.BackupNodeID)
|
||||||
if backupErr != nil {
|
if backupErr != nil {
|
||||||
|
|||||||
@@ -76,9 +76,9 @@ func reconcileMember(ctx context.Context, memberID uint) error {
|
|||||||
}
|
}
|
||||||
input.Name = state.Domain.Domain
|
input.Name = state.Domain.Domain
|
||||||
input.Proxied = state.Member.Proxied
|
input.Proxied = state.Member.Proxied
|
||||||
input.TTL = 300
|
input.TTL = groupTTL(&state.Group)
|
||||||
if input.Proxied {
|
if input.Proxied {
|
||||||
input.TTL = 1
|
input.TTL = automaticTTL
|
||||||
}
|
}
|
||||||
recordID := state.Member.CFRecordID
|
recordID := state.Member.CFRecordID
|
||||||
if recordID != "" {
|
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"`
|
BackupNodeID *uint `json:"backup_node_id"`
|
||||||
DefaultProxied bool `json:"default_proxied"`
|
DefaultProxied bool `json:"default_proxied"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
// TTL 为 1 时自动,否则为 60~86400 秒的整数;省略时创建默认自动,更新保留原值。
|
||||||
|
TTL *int `json:"ttl" example:"1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupItem is the admin-facing pointing group summary.
|
// GroupItem is the admin-facing pointing group summary.
|
||||||
@@ -56,6 +58,8 @@ type GroupItem struct {
|
|||||||
MemberCount int64 `json:"member_count"`
|
MemberCount int64 `json:"member_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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.
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -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;
|
||||||
+6
@@ -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;
|
||||||
@@ -60,6 +60,8 @@ type CFPointingGroup struct {
|
|||||||
Enabled bool `json:"enabled" gorm:"not null;default:false"`
|
Enabled bool `json:"enabled" gorm:"not null;default:false"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
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.
|
// TableName returns the Cloudflare pointing group table name.
|
||||||
|
|||||||
Reference in New Issue
Block a user