feat: Cloudflare多账号子资产管理(zone/worker/r2/tunnel/mail子资产模型+模态框)

This commit is contained in:
gouki
2026-08-02 23:41:49 +00:00
parent dd2638601a
commit af17294f24
5 changed files with 73 additions and 4 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ engine = assets_engine
def init_db() -> None: def init_db() -> None:
"""分库建表(幂等,可重复调用)""" """分库建表(幂等,可重复调用)"""
from app.models.asset import AIAccount, Asset, DomainDetail, VPSDetail # noqa: F401 from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
from app.models.monitor import ( # noqa: F401 from app.models.monitor import ( # noqa: F401
EventLog, EventLog,
MetricPoint, MetricPoint,
@@ -38,7 +38,7 @@ def init_db() -> None:
) )
from app.models.provider import Provider # noqa: F401 from app.models.provider import Provider # noqa: F401
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount] asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail]
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog] metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
SQLModel.metadata.create_all( SQLModel.metadata.create_all(
+28
View File
@@ -162,3 +162,31 @@ class AIAccount(SQLModel, table=True):
last_synced_at: Optional[datetime] = Field( last_synced_at: Optional[datetime] = Field(
default=None, description="最近一次自动同步余额/用量的时间" default=None, description="最近一次自动同步余额/用量的时间"
) )
class CloudflareDetail(SQLModel, table=True):
"""Cloudflare 子资产详情(一对一关联 Asset)
一个 Cloudflare 账号可有多个子资产(zone/worker/r2/tunnel/mail)。
每个子资产是一个 Asset(asset_type=cloudflare) + CloudflareDetail。
"""
__tablename__ = "cloudflare_details"
id: Optional[int] = Field(default=None, primary_key=True)
asset_id: int = Field(
foreign_key="assets.id", unique=True, index=True, description="关联资产 ID"
)
account_email: Optional[str] = Field(
default=None, index=True, description="Cloudflare 账号邮箱(区分多账号)"
)
sub_type: str = Field(
default="zone", index=True, description="子资产类型:zone/worker/r2/tunnel/mail/dns_record/other"
)
sub_name: Optional[str] = Field(
default=None, description="子资产名称(如 worker 名、bucket 名)"
)
zone_name: Optional[str] = Field(
default=None, index=True, description="关联的 zone/域名"
)
status: Optional[str] = Field(default=None, description="子资产状态")
+18
View File
@@ -61,6 +61,16 @@ class AIAccountIn(SQLModel):
monthly_limit: Optional[float] = None monthly_limit: Optional[float] = None
class CloudflareDetailIn(SQLModel):
"""Cloudflare 子资产详情输入"""
account_email: Optional[str] = None
sub_type: str = "zone"
sub_name: Optional[str] = None
zone_name: Optional[str] = None
status: Optional[str] = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 资产主表模型 # 资产主表模型
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -90,6 +100,7 @@ class AssetCreate(AssetBase):
vps_detail: Optional[VPSDetailIn] = None vps_detail: Optional[VPSDetailIn] = None
domain_detail: Optional[DomainDetailIn] = None domain_detail: Optional[DomainDetailIn] = None
ai_detail: Optional[AIAccountIn] = None ai_detail: Optional[AIAccountIn] = None
cloudflare_detail: Optional[CloudflareDetailIn] = None
class AssetUpdate(SQLModel): class AssetUpdate(SQLModel):
@@ -113,6 +124,7 @@ class AssetUpdate(SQLModel):
vps_detail: Optional[VPSDetailIn] = None vps_detail: Optional[VPSDetailIn] = None
domain_detail: Optional[DomainDetailIn] = None domain_detail: Optional[DomainDetailIn] = None
ai_detail: Optional[AIAccountIn] = None ai_detail: Optional[AIAccountIn] = None
cloudflare_detail: Optional[CloudflareDetailIn] = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -160,6 +172,11 @@ class AIAccountRead(SQLModel):
has_api_key: bool = False has_api_key: bool = False
class CloudflareDetailRead(CloudflareDetailIn):
id: int
asset_id: int
class AssetRead(AssetBase): class AssetRead(AssetBase):
"""资产完整输出""" """资产完整输出"""
@@ -171,3 +188,4 @@ class AssetRead(AssetBase):
vps_detail: Optional[VPSDetailRead] = None vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None ai_detail: Optional[AIAccountRead] = None
cloudflare_detail: Optional[CloudflareDetailRead] = None
+9 -2
View File
@@ -15,6 +15,7 @@ from app.models.asset import (
Asset, Asset,
AssetStatus, AssetStatus,
AssetType, AssetType,
CloudflareDetail,
DomainDetail, DomainDetail,
VPSDetail, VPSDetail,
) )
@@ -24,6 +25,7 @@ from app.schemas.asset import (
AssetCreate, AssetCreate,
AssetRead, AssetRead,
AssetUpdate, AssetUpdate,
CloudflareDetailRead,
DomainDetailRead, DomainDetailRead,
VPSDetailRead, VPSDetailRead,
) )
@@ -33,6 +35,7 @@ DETAIL_MAP = {
AssetType.VPS: ("vps_detail", VPSDetail), AssetType.VPS: ("vps_detail", VPSDetail),
AssetType.DOMAIN: ("domain_detail", DomainDetail), AssetType.DOMAIN: ("domain_detail", DomainDetail),
AssetType.AI_AGENT: ("ai_detail", AIAccount), AssetType.AI_AGENT: ("ai_detail", AIAccount),
AssetType.CLOUDFLARE: ("cloudflare_detail", CloudflareDetail),
} }
# 允许排序的字段白名单 # 允许排序的字段白名单
@@ -71,6 +74,8 @@ def _detail_to_read(detail):
read = AIAccountRead.model_validate(detail) read = AIAccountRead.model_validate(detail)
read.has_api_key = bool(detail.api_key_encrypted or detail.api_key) read.has_api_key = bool(detail.api_key_encrypted or detail.api_key)
return read return read
if isinstance(detail, CloudflareDetail):
return CloudflareDetailRead.model_validate(detail)
return None return None
@@ -94,6 +99,8 @@ def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
read.domain_detail = _detail_to_read(detail) read.domain_detail = _detail_to_read(detail)
elif isinstance(detail, AIAccount): elif isinstance(detail, AIAccount):
read.ai_detail = _detail_to_read(detail) read.ai_detail = _detail_to_read(detail)
elif isinstance(detail, CloudflareDetail):
read.cloudflare_detail = _detail_to_read(detail)
return read return read
@@ -151,7 +158,7 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
) )
asset_data = data.model_dump( asset_data = data.model_dump(
exclude={"vps_detail", "domain_detail", "ai_detail"} exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
asset = Asset(**asset_data) asset = Asset(**asset_data)
session.add(asset) session.add(asset)
@@ -183,7 +190,7 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
main_fields = data.model_dump( main_fields = data.model_dump(
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail"} exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
for key, value in main_fields.items(): for key, value in main_fields.items():
setattr(asset, key, value) setattr(asset, key, value)
+16
View File
@@ -35,6 +35,7 @@ function emptyAssetForm() {
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' }, vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null }, domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null }, ai_detail: { provider: '', api_key: '', plan: '', balance: null, currency: 'USD', monthly_usage: null, monthly_limit: null },
cloudflare_detail: { account_email: '', sub_type: 'zone', sub_name: '', zone_name: '', status: '' },
}; };
} }
@@ -56,6 +57,9 @@ function buildAssetPayload(f) {
} else if (f.asset_type === 'ai_agent') { } else if (f.asset_type === 'ai_agent') {
const a = f.ai_detail; const a = f.ai_detail;
p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null }; p.ai_detail = { provider: a.provider, api_key: a.api_key || null, plan: a.plan || null, balance: a.balance ? Number(a.balance) : null, currency: a.currency || 'USD', monthly_usage: a.monthly_usage ? Number(a.monthly_usage) : null, monthly_limit: a.monthly_limit ? Number(a.monthly_limit) : null };
} else if (f.asset_type === 'cloudflare') {
const c = f.cloudflare_detail;
p.cloudflare_detail = { account_email: c.account_email || null, sub_type: c.sub_type || 'zone', sub_name: c.sub_name || null, zone_name: c.zone_name || null, status: c.status || null };
} }
return p; return p;
} }
@@ -101,6 +105,7 @@ function openAssetEdit(a) {
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail); if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail); if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail); if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);
if (a.cloudflare_detail) Object.assign(form.cloudflare_detail, a.cloudflare_detail);
form.vps_detail.ssh_key = ''; form.vps_detail.password = ''; form.vps_detail.ssh_key = ''; form.vps_detail.password = '';
store.assetModal = { show: true, editing: a.id, form }; store.assetModal = { show: true, editing: a.id, form };
} }
@@ -830,6 +835,17 @@ const AssetModal = {
</div> </div>
</fieldset> </fieldset>
<fieldset v-if="f.asset_type==='cloudflare'" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">Cloudflare 子资产详情</legend>
<div class="grid grid-cols-2 gap-2">
<label class="block"><span class="text-xs text-slate-400">账号邮箱</span><input v-model="f.cloudflare_detail.account_email" placeholder="账号邮箱(区分多账号)" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">子资产类型</span><select v-model="f.cloudflare_detail.sub_type" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"><option value="zone">zone(解析)</option><option value="worker">Worker</option><option value="r2">R2</option><option value="tunnel">Tunnel</option><option value="mail">Mail</option><option value="dns_record">DNS记录</option><option value="other">其他</option></select></label>
<label class="block"><span class="text-xs text-slate-400">子资产名称</span><input v-model="f.cloudflare_detail.sub_name" placeholder="如 worker 名 / bucket 名" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="block"><span class="text-xs text-slate-400">关联 zone/域名</span><input v-model="f.cloudflare_detail.zone_name" placeholder="example.com" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
<label class="col-span-2 block"><span class="text-xs text-slate-400">状态</span><input v-model="f.cloudflare_detail.status" placeholder="active / paused 等" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
</fieldset>
<label class="block"><span class="text-xs text-slate-500">备注</span> <label class="block"><span class="text-xs text-slate-500">备注</span>
<textarea v-model="f.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label> <textarea v-model="f.remark" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>