评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
197 lines
6.0 KiB
Python
197 lines
6.0 KiB
Python
"""资产相关 Schema(请求/响应模型)
|
||
|
||
设计说明:
|
||
- *In 系列:详情表输入模型(不含 id/asset_id,由服务端填充)
|
||
- AssetCreate/AssetUpdate:主表字段 + 按类型嵌套的可选 detail
|
||
- AssetRead:完整输出,含计算字段 days_to_expiry(距到期天数)
|
||
"""
|
||
|
||
from datetime import date, datetime
|
||
from typing import Optional
|
||
|
||
from sqlmodel import SQLModel
|
||
|
||
from app.models.asset import AssetStatus, AssetType
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 详情表输入模型
|
||
# --------------------------------------------------------------------------- #
|
||
class VPSDetailIn(SQLModel):
|
||
"""VPS 详情输入"""
|
||
|
||
ip_address: str
|
||
tailscale_ip: Optional[str] = None
|
||
region: Optional[str] = None
|
||
os: Optional[str] = None
|
||
cpu_cores: int = 1
|
||
memory_gb: float = 1
|
||
disk_gb: int = 20
|
||
bandwidth_gb: Optional[int] = None
|
||
ssh_port: int = 22
|
||
panel_url: Optional[str] = None
|
||
ssh_user: Optional[str] = None
|
||
login_method: Optional[str] = "key"
|
||
ssh_key: Optional[str] = None # 明文输入,服务端加密存储
|
||
password: Optional[str] = None # 明文输入,服务端加密存储
|
||
purpose: Optional[str] = None
|
||
|
||
|
||
class DomainDetailIn(SQLModel):
|
||
"""域名详情输入"""
|
||
|
||
domain_name: str
|
||
registrar: Optional[str] = None
|
||
dns_provider: Optional[str] = None
|
||
cloudflare_account: Optional[str] = None
|
||
is_using: bool = True
|
||
redirect_target: Optional[str] = None
|
||
bind_asset_id: Optional[int] = None
|
||
|
||
|
||
class AIAccountIn(SQLModel):
|
||
"""AI 账号详情输入"""
|
||
|
||
provider: str
|
||
api_key: Optional[str] = None
|
||
plan: Optional[str] = None
|
||
balance: Optional[float] = None
|
||
currency: str = "USD"
|
||
monthly_usage: 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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 资产主表模型
|
||
# --------------------------------------------------------------------------- #
|
||
class AssetBase(SQLModel):
|
||
"""资产主表公共字段"""
|
||
|
||
name: str
|
||
asset_type: AssetType
|
||
provider: str
|
||
provider_id: Optional[int] = None
|
||
renewal_cycle: Optional[str] = None
|
||
renew_url: Optional[str] = None
|
||
cancel_url: Optional[str] = None
|
||
account_id: Optional[int] = None
|
||
expiry_date: Optional[date] = None
|
||
auto_renew: bool = False
|
||
cost: float = 0
|
||
currency: str = "USD"
|
||
status: AssetStatus = AssetStatus.ACTIVE
|
||
is_archived: bool = False
|
||
remark: Optional[str] = None
|
||
|
||
|
||
class AssetCreate(AssetBase):
|
||
"""创建资产:按 asset_type 携带对应 detail"""
|
||
|
||
vps_detail: Optional[VPSDetailIn] = None
|
||
domain_detail: Optional[DomainDetailIn] = None
|
||
ai_detail: Optional[AIAccountIn] = None
|
||
cloudflare_detail: Optional[CloudflareDetailIn] = None
|
||
|
||
|
||
class AssetUpdate(SQLModel):
|
||
"""更新资产:所有字段可选,仅更新传入项"""
|
||
|
||
name: Optional[str] = None
|
||
asset_type: Optional[AssetType] = None
|
||
provider: Optional[str] = None
|
||
provider_id: Optional[int] = None
|
||
renewal_cycle: Optional[str] = None
|
||
renew_url: Optional[str] = None
|
||
cancel_url: Optional[str] = None
|
||
account_id: Optional[int] = None
|
||
expiry_date: Optional[date] = None
|
||
auto_renew: Optional[bool] = None
|
||
cost: Optional[float] = None
|
||
currency: Optional[str] = None
|
||
status: Optional[AssetStatus] = None
|
||
is_archived: Optional[bool] = None
|
||
remark: Optional[str] = None
|
||
vps_detail: Optional[VPSDetailIn] = None
|
||
domain_detail: Optional[DomainDetailIn] = None
|
||
ai_detail: Optional[AIAccountIn] = None
|
||
cloudflare_detail: Optional[CloudflareDetailIn] = None
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 输出模型
|
||
# --------------------------------------------------------------------------- #
|
||
class VPSDetailRead(SQLModel):
|
||
"""VPS 详情输出(不含敏感明文,仅返回凭证存在标记)"""
|
||
|
||
id: int
|
||
asset_id: int
|
||
ip_address: str
|
||
tailscale_ip: Optional[str] = None
|
||
region: Optional[str] = None
|
||
os: Optional[str] = None
|
||
cpu_cores: int = 1
|
||
memory_gb: float = 1
|
||
disk_gb: int = 20
|
||
bandwidth_gb: Optional[int] = None
|
||
ssh_port: int = 22
|
||
panel_url: Optional[str] = None
|
||
ssh_user: Optional[str] = None
|
||
login_method: Optional[str] = "key"
|
||
purpose: Optional[str] = None
|
||
has_ssh_key: bool = False
|
||
has_password: bool = False
|
||
|
||
|
||
class DomainDetailRead(DomainDetailIn):
|
||
id: int
|
||
asset_id: int
|
||
|
||
|
||
class AIAccountRead(SQLModel):
|
||
"""AI 账号输出(不暴露 API Key 明文,仅返回 has_api_key)"""
|
||
|
||
id: int
|
||
asset_id: int
|
||
provider: str
|
||
plan: Optional[str] = None
|
||
balance: Optional[float] = None
|
||
currency: str = "USD"
|
||
monthly_usage: Optional[float] = None
|
||
monthly_limit: Optional[float] = None
|
||
last_synced_at: Optional[datetime] = None
|
||
has_api_key: bool = False
|
||
|
||
|
||
class CloudflareDetailRead(CloudflareDetailIn):
|
||
id: int
|
||
asset_id: int
|
||
|
||
|
||
class AssetRead(AssetBase):
|
||
"""资产完整输出"""
|
||
|
||
id: int
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
days_to_expiry: Optional[int] = None
|
||
provider_name: Optional[str] = None
|
||
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
|
||
# 云端实例 ID:只出不进(AssetCreate/AssetUpdate 不收,由 sync_service 写入)。
|
||
# 前端据此判断「云操作」「DNS」按钮是否渲染——没有它按钮永远出不来,
|
||
# 手工登记的资产则会渲染出点了必 400 的按钮。
|
||
external_id: Optional[str] = None
|
||
vps_detail: Optional[VPSDetailRead] = None
|
||
domain_detail: Optional[DomainDetailRead] = None
|
||
ai_detail: Optional[AIAccountRead] = None
|
||
cloudflare_detail: Optional[CloudflareDetailRead] = None
|