feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报
容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒, 避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true 时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器, 用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
@@ -31,6 +31,7 @@ from app.models.asset import (
|
||||
DomainDetail,
|
||||
VPSDetail,
|
||||
)
|
||||
from app.models.credential import Credential
|
||||
from app.models.provider import Provider
|
||||
|
||||
_VALID_STATUS = {s.value for s in AssetStatus}
|
||||
@@ -47,6 +48,19 @@ def _load_account_config(account: Account) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _credential_login_config(session: Session, account: Account) -> dict:
|
||||
"""从凭据表取面板登录所需的用户名/密码(凭据表是唯一事实源)"""
|
||||
if not account.credential_id:
|
||||
return {}
|
||||
cred = session.get(Credential, account.credential_id)
|
||||
if not cred or not cred.password_encrypted:
|
||||
return {}
|
||||
password = crypto.decrypt(cred.password_encrypted)
|
||||
if not password:
|
||||
return {}
|
||||
return {"username": cred.username or account.name, "password": password}
|
||||
|
||||
|
||||
def _find_provider_by_platform(session: Session, platform: str) -> Provider:
|
||||
"""按账号的 platform(slug 或名称)定位平台"""
|
||||
provider = session.exec(select(Provider).where(Provider.slug == platform)).first()
|
||||
@@ -96,6 +110,16 @@ def _build_adapter_for_account(session: Session, account: Account):
|
||||
)
|
||||
provider = _find_provider_by_platform(session, account.platform)
|
||||
config = _load_account_config(account)
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
# 面板登录类适配器不重复存密码:用户名/密码取自关联凭据,
|
||||
# base_url 取自平台的管理面板地址。
|
||||
config = {**_credential_login_config(session, account), **config}
|
||||
config.setdefault("base_url", provider.console_url or provider.website or "")
|
||||
if not config.get("password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该平台通过账单面板登录核对,请为账号关联带密码的登录凭据",
|
||||
)
|
||||
adapter = _build_adapter(provider, config)
|
||||
return provider, adapter
|
||||
|
||||
@@ -121,14 +145,29 @@ def test_provider(session: Session, provider_id: int) -> dict:
|
||||
return test_account(session, account.id)
|
||||
|
||||
|
||||
def _account_has_credentials(session: Session, account: Account, provider: Provider) -> bool:
|
||||
"""账号是否具备该平台可用的同步凭证
|
||||
|
||||
普通云厂商看 api_config_encrypted;WHMCS 面板类平台看关联登录凭据。
|
||||
"""
|
||||
if account.api_config_encrypted:
|
||||
return True
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
return bool(_credential_login_config(session, account))
|
||||
return False
|
||||
|
||||
|
||||
def _first_account_with_config(session: Session, provider: Provider):
|
||||
"""找该平台下第一个配置了 API 凭证的账号(兼容旧入口)"""
|
||||
return session.exec(
|
||||
"""找该平台下第一个具备可用凭证的账号(兼容旧入口)"""
|
||||
accounts = session.exec(
|
||||
select(Account)
|
||||
.where(Account.platform == provider.slug)
|
||||
.where(Account.api_config_encrypted.is_not(None)) # type: ignore[union-attr]
|
||||
.order_by(Account.id.asc())
|
||||
).first()
|
||||
).all()
|
||||
for account in accounts:
|
||||
if _account_has_credentials(session, account, provider):
|
||||
return account
|
||||
return None
|
||||
|
||||
|
||||
def test_account(session: Session, account_id: int) -> dict:
|
||||
@@ -158,6 +197,32 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
).first()
|
||||
|
||||
|
||||
def _find_unlinked_vps(session: Session, provider: Provider, vps) -> Optional[Asset]:
|
||||
"""在手工登记、尚无 external_id 的同平台 VPS 中按 IP/名称找对应项
|
||||
|
||||
手工登记资产没有云端实例 ID,直接同步会重复建一条;
|
||||
能唯一对上时补写 external_id,把手工台账接进自动同步。
|
||||
"""
|
||||
candidates = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.provider_id == provider.id,
|
||||
Asset.asset_type == AssetType.VPS,
|
||||
Asset.external_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
vps_ip = (vps.ip_address or "").strip()
|
||||
vps_name = (vps.name or "").strip().lower()
|
||||
for asset in candidates:
|
||||
detail = session.exec(
|
||||
select(VPSDetail).where(VPSDetail.asset_id == asset.id)
|
||||
).first()
|
||||
if vps_ip and detail and (detail.ip_address or "").strip() == vps_ip:
|
||||
return asset
|
||||
if vps_name and (asset.name or "").strip().lower() == vps_name:
|
||||
return asset
|
||||
return None
|
||||
|
||||
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
@@ -165,6 +230,10 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
|
||||
# 漏了这一步,云端同步进来的 VPS 永远进不了续费提醒——而那是台账的核心用途。
|
||||
expiry = parse_iso_date(vps.expiry_date)
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
if not existing:
|
||||
existing = _find_unlinked_vps(session, provider, vps)
|
||||
if existing:
|
||||
existing.external_id = vps.external_id
|
||||
if existing:
|
||||
existing.name = vps.name or existing.name
|
||||
existing.status = _norm_status(vps.status)
|
||||
@@ -460,7 +529,8 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
Session(SQLite Session 不能跨线程共享,WAL 模式下多连接读写安全)。
|
||||
|
||||
未配凭证或平台无适配器的账号计入 skipped 而非 failed——否则每次定时同步
|
||||
都会把手工登记的账号报成一堆错误。
|
||||
都会把手工登记的账号报成一堆错误。WHMCS 面板类账号也跳过:续费检查会
|
||||
按需登录核对,避免全量同步重命名手写资产或为历史服务新建台账。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@@ -470,11 +540,9 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
targets = []
|
||||
skipped_no_config = []
|
||||
skipped_no_adapter = []
|
||||
skipped_panel = []
|
||||
for acc in accounts:
|
||||
label = f"{acc.platform}/{acc.name}"
|
||||
if not acc.api_config_encrypted:
|
||||
skipped_no_config.append(label)
|
||||
continue
|
||||
try:
|
||||
provider = _find_provider_by_platform(session, acc.platform)
|
||||
except HTTPException:
|
||||
@@ -483,6 +551,14 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
if not provider.sdk_type or not registry.is_supported(provider.sdk_type):
|
||||
skipped_no_adapter.append(f"{label}({provider.sdk_type or '未配 sdk_type'})")
|
||||
continue
|
||||
if provider.sdk_type == "whmcs-panel":
|
||||
# 面板类平台由续费检查按需核对:定时全量同步会把手写资产名改成面板名、
|
||||
# 并可能为面板里的历史服务新建台账,副作用大于收益。
|
||||
skipped_panel.append(label)
|
||||
continue
|
||||
if not _account_has_credentials(session, acc, provider):
|
||||
skipped_no_config.append(label)
|
||||
continue
|
||||
targets.append((acc.id, label))
|
||||
|
||||
def _sync_one(account_id: int) -> dict:
|
||||
@@ -531,6 +607,7 @@ def sync_all_assets(session: Session, max_workers: int = 5) -> dict:
|
||||
"updated": updated,
|
||||
"skipped_no_config": len(skipped_no_config),
|
||||
"skipped_no_adapter": len(skipped_no_adapter),
|
||||
"skipped_panel": len(skipped_panel),
|
||||
"errors": errors,
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user