feat(renewal): 提醒前核对平台真实状态,消除手工数据导致的误报

容错策略:核对失败/无可用通道时标记「未核对」并继续按本地数据提醒,
避免因核对链路故障漏掉真正到期的资产;已停止资产仅在 auto_renew=true
时提醒(停止实例不等于停止扣费)。新增 WHMCS 面板只读适配器,
用于 TotHost 这类没有开放 API 的平台。
This commit is contained in:
gouki
2026-10-25 23:05:39 +00:00
parent de8a3caedd
commit abd2fa8206
12 changed files with 1229 additions and 27 deletions
+1
View File
@@ -35,6 +35,7 @@ def load_all() -> None:
namesilo,
tencent,
vultr,
whmcs,
)
+248
View File
@@ -0,0 +1,248 @@
"""WHMCS 账单面板适配器
适用于 TotHost 等使用 WHMCS 客户区、但没有开放 API 的平台。
通过账号关联的登录凭据进入客户区,读取「我的产品/服务」列表:
服务 ID、主机名、下次到期日、状态。
只做只读核对,不执行开通/续费/停机等写操作。
"""
import html as html_lib
import re
from datetime import date
from typing import Optional
from urllib.parse import urlparse
import httpx
from app.adapters.base import BaseAdapter, NormalizedVPS, cloud_error_text
from app.adapters.registry import register
class WHMCSError(Exception):
"""面板登录 / 页面解析失败(消息面向使用者,不含密码)"""
#: WHMCS 客户区状态 CSS 类后缀 -> 本项目资产状态
_STATUS_MAP = {
"active": "active",
"suspended": "stopped",
"inactive": "stopped",
"terminated": "cancelled",
"cancelled": "cancelled",
"fraud": "cancelled",
"pending": "unknown",
}
_IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
#: 登录成功标志:客户区一定会渲染登出入口,但主题会按语言输出不同文案
_LOGGED_IN_RE = re.compile(
r"logout|>\s*(?:退出登录|退出|登出|登出帳號|注销|登録解除|ログアウト)\s*<", re.I
)
@register("whmcs-panel")
class WHMCSAdapter(BaseAdapter):
"""WHMCS 客户区只读适配器
配置由账号凭据注入:
- base_url: 平台 console_url(如 https://billing.example.com/clientarea.php)
- username / password: accounts.credential_id 关联的登录凭据
"""
#: 凭据从 credentials 表注入,不在平台 API 配置里重复填写
required_config: list = []
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
raw = str(self.config.get("base_url") or "").strip()
if not raw:
raise WHMCSError("缺少面板地址(base_url),请先在平台资料中填写管理面板 URL")
if "://" not in raw:
raw = "https://" + raw
parsed = urlparse(raw)
if not parsed.netloc:
raise WHMCSError(f"面板地址无效:{raw}")
self.origin = f"{parsed.scheme}://{parsed.netloc}"
# ------------------------------------------------------------------ #
# 面板交互
# ------------------------------------------------------------------ #
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.origin,
follow_redirects=True,
timeout=25,
headers={"User-Agent": "Mozilla/5.0 (compatible; vps-manager)"},
)
def _login(self, client: httpx.Client) -> None:
username = str(self.config.get("username") or "").strip()
password = str(self.config.get("password") or "")
if not username or not password:
raise WHMCSError("缺少面板登录凭据(请在账号上关联带用户名和密码的登录凭据)")
page = client.get("/login")
if page.status_code >= 400:
raise WHMCSError(f"打开登录页失败:HTTP {page.status_code}")
token = self._extract_token(page.text)
if not token:
raise WHMCSError("登录页缺少 CSRF token,面板结构可能已变化")
resp = client.post(
"/login",
data={"username": username, "password": password, "token": token},
)
body = resp.text or ""
if not _LOGGED_IN_RE.search(body):
if re.search(r"two[- ]?factor|\b2fa\b|第二因素|兩步|双重验证|雙重驗證", body, re.I):
raise WHMCSError("面板启用了二次验证(2FA),无法自动登录")
raise WHMCSError("面板登录失败:账号或密码错误,或需要验证码")
@staticmethod
def _extract_token(page: str) -> Optional[str]:
"""提取登录表单的 CSRF token
优先在 action/id 含 login 的表单内查找——页面上可能同时存在工单、
改密等多个带 token 的表单,取错会导致提交被拒。
"""
patterns = (
r'<input[^>]*name=["\']token["\'][^>]*value=["\']([^"\']+)["\']',
r'<input[^>]*value=["\']([^"\']+)["\'][^>]*name=["\']token["\']',
)
forms = re.findall(r"<form\b[^>]*>.*?</form>", page, re.I | re.S)
login_forms = [
form for form in forms
if re.search(r'(?:action|id|class)=["\'][^"\']*(?:login|dologin)', form, re.I)
]
for form in login_forms or forms:
for pattern in patterns:
found = re.search(pattern, form, re.I | re.S)
if found:
return found.group(1)
for pattern in patterns:
found = re.search(pattern, page, re.I | re.S)
if found:
return found.group(1)
return None
def fetch_services(self, include_terminated: bool = True) -> list:
"""登录面板并返回服务列表(默认包含已删除,供续费核对判断服务是否还在)"""
with self._client() as client:
self._login(client)
resp = client.get("/clientarea.php", params={"action": "products"})
if resp.status_code >= 400:
raise WHMCSError(f"打开服务列表失败:HTTP {resp.status_code}")
services = self.parse_services(resp.text)
if not include_terminated:
services = [s for s in services if s["status"] != "cancelled"]
return services
# ------------------------------------------------------------------ #
# 页面解析
# ------------------------------------------------------------------ #
@classmethod
def parse_services(cls, page: str) -> list:
"""从 WHMCS 服务列表页解析服务行
依赖客户区主题输出:
- 行属性 data-element-id / data-domain
- 到期日 span.w-hidden 中的 ISO 日期
- 状态 span 的 status-{class} 类名(与界面语言无关)
"""
start = re.search(r'<table[^>]*id=["\']tableServicesList["\']', page, re.I)
if not start:
raise WHMCSError("未在面板页面找到服务列表(页面结构可能已变化)")
end = page.find("</table>", start.end())
table = page[start.start(): end + 8 if end != -1 else len(page)]
services = []
for row in re.findall(r"<tr\b[^>]*>(.*?)</tr>", table, re.S | re.I):
service_id = re.search(r'data-element-id=["\'](\d+)["\']', row)
if not service_id:
continue
status = re.search(
r'class=["\'][^"\']*status\s+status-([a-z]+)[^"\']*["\'][^>]*>(.*?)</span>',
row,
re.S | re.I,
)
if not status:
continue
domain_match = re.search(r'data-domain=["\']([^"\']*)["\']', row)
product_match = re.search(r"<strong>(.*?)</strong>", row, re.S | re.I)
due_match = re.search(
r'<span[^>]*class=["\'][^"\']*w-hidden[^"\']*["\'][^>]*>(\d{4}-\d{2}-\d{2})</span>',
row,
re.I,
)
if not due_match:
due_match = re.search(r"(\d{4}-\d{2}-\d{2})", row)
domain = html_lib.unescape(domain_match.group(1)).strip() if domain_match else ""
product = cls._text(product_match.group(1)) if product_match else ""
status_class = status.group(1).lower()
status_text = cls._text(status.group(2))
expiry = None
if due_match:
try:
expiry = date.fromisoformat(due_match.group(1))
except ValueError:
expiry = None
amount = re.search(r'data-order=["\']([\d.]+)["\']', row)
cycle = re.search(r'<small[^>]*class=["\'][^"\']*text-muted[^"\']*["\'][^>]*>(.*?)</small>', row, re.I)
services.append({
"id": service_id.group(1),
"product": product,
"domain": domain,
"label": f"{product} {domain}".strip(),
"expiry_date": expiry,
"status": _STATUS_MAP.get(status_class, "unknown"),
"status_class": status_class,
"status_text": status_text,
"amount": float(amount.group(1)) if amount else None,
"cycle": cls._text(cycle.group(1)) if cycle else "",
})
return services
@staticmethod
def _text(fragment: str) -> str:
return re.sub(r"\s+", " ", html_lib.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
# ------------------------------------------------------------------ #
# BaseAdapter 接口
# ------------------------------------------------------------------ #
def test_connection(self) -> dict:
try:
services = self.fetch_services(include_terminated=True)
return {
"ok": True,
"message": f"面板登录成功,读取到 {len(services)} 个服务",
}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"面板核对失败:{cloud_error_text(e)}"}
def list_vps(self) -> list:
result = []
for service in self.fetch_services(include_terminated=False):
domain = service["domain"]
result.append(
NormalizedVPS(
external_id=str(service["id"]),
name=service["label"] or str(service["id"]),
ip_address=domain if _IPV4_RE.match(domain) else "",
status=service["status"],
expiry_date=service["expiry_date"],
raw={
"hostname": domain,
"product": service["product"],
"status_text": service["status_text"],
"amount": service["amount"],
"cycle": service["cycle"],
},
)
)
return result
+63 -9
View File
@@ -1,25 +1,28 @@
"""续费检查服务
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
发送前会先尝试从平台侧核对真实状态(见 verification_service),
核对到的到期日/状态会回写本地记录,避免按过期的手工数据误报。
"""
import logging
from datetime import date, timedelta
from sqlmodel import Session, select
from app.core.config import settings
from app.models.asset import Asset, AssetStatus
from app.services import notification_service
from app.services import notification_service, verification_service
# 已注销的资产不参与续费提醒
_SKIP_STATUS = {AssetStatus.CANCELLED}
logger = logging.getLogger("vps-manager.renewal")
def get_expiring_assets(session: Session, threshold_days: int) -> list:
"""返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...],按剩余天数升序
"""返回 threshold_days 天内到期(含已过期未处理)的资产列表 [(asset, days), ...]
到期日不设下界:过了续费日仍未注销/续费的资产,提醒不能就此消失。
性能优化:日期范围与状态过滤均下推到 SQL 层。
已注销资产直接排除;已停止资产仅在开启自动续费时提醒
(停止实例不等于停止扣费,自动续费仍可能产生费用)。
"""
today = date.today()
deadline = today + timedelta(days=threshold_days)
@@ -27,9 +30,14 @@ def get_expiring_assets(session: Session, threshold_days: int) -> list:
select(Asset).where(
Asset.expiry_date.is_not(None),
Asset.expiry_date <= deadline,
Asset.status.notin_(_SKIP_STATUS),
Asset.status != AssetStatus.CANCELLED,
)
).all()
assets = [
asset
for asset in assets
if not (asset.status == AssetStatus.STOPPED and not asset.auto_renew)
]
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
expiring.sort(key=lambda x: x[1])
return expiring
@@ -48,7 +56,7 @@ def day_text(days: int) -> str:
return f"{days} 天后"
def build_renewal_message(expiring: list, threshold_days: int) -> str:
def build_renewal_message(expiring: list, threshold_days: int, verification: dict = None) -> str:
"""生成续费提醒消息(纯文本,兼容 Telegram/邮件)"""
if not expiring:
return ""
@@ -60,20 +68,52 @@ def build_renewal_message(expiring: list, threshold_days: int) -> str:
lines = [head, ""]
for asset, days in expiring:
lines.append(f"• {asset.name} [{asset.provider}] ({day_text(days)}) 到期 {asset.expiry_date}")
result = (verification or {}).get(getattr(asset, "id", None))
if result is not None:
if result.verified:
lines.append(f" ↳ 已核对:{result.detail}")
else:
lines.append(f" ↳ 未核对:{result.detail},按手工登记日期提醒")
if verification:
verified = sum(
1 for asset, _ in expiring
if getattr(verification.get(getattr(asset, "id", None)), "verified", False)
)
lines.append("")
lines.append(f"核对:已核对 {verified} 项,未核对 {len(expiring) - verified} 项")
return "\n".join(lines)
def check_renewals(session: Session, threshold_days: int = None, send: bool = True) -> dict:
def check_renewals(
session: Session,
threshold_days: int = None,
send: bool = True,
verify: bool = True,
) -> dict:
"""检查续费并(可选)发送提醒
threshold_days: 提醒阈值天数,默认取配置 RENEWAL_THRESHOLD_DAYS
send: 是否实际发送通知(False 时仅返回预览)
verify: 发送前是否向平台核对真实状态(测试/排查时可关闭)
"""
if threshold_days is None:
threshold_days = settings.RENEWAL_THRESHOLD_DAYS
expiring = get_expiring_assets(session, threshold_days)
message = build_renewal_message(expiring, threshold_days)
verification = {}
if verify and expiring:
try:
verification = verification_service.verify_assets(
session, [asset for asset, _ in expiring]
)
except Exception: # noqa: BLE001
# 核对是增强项:核对服务整体异常时仍按本地数据提醒,不能因此不发消息
logger.exception("续费核对异常,退回本地登记数据")
# 核对可能已把到期日/状态改成最新值,重新筛选
expiring = get_expiring_assets(session, threshold_days)
message = build_renewal_message(expiring, threshold_days, verification)
result = {
"threshold_days": threshold_days,
@@ -84,11 +124,25 @@ def check_renewals(session: Session, threshold_days: int = None, send: bool = Tr
"provider": asset.provider,
"days": days,
"expiry_date": str(asset.expiry_date),
"verified": getattr(verification.get(asset.id), "verified", None),
"verify_source": getattr(verification.get(asset.id), "source", ""),
"verify_detail": getattr(verification.get(asset.id), "detail", ""),
}
for asset, days in expiring
],
"message": message,
}
if verification:
verified_count = sum(
1 for asset, _ in expiring
if getattr(verification.get(asset.id), "verified", False)
)
result["verification"] = {
"verified": verified_count,
"unverified": len(expiring) - verified_count,
}
else:
result["verification"] = None
if send and message:
result["notifications"] = notification_service.notify(message)
+85 -8
View File
@@ -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(),
}
+359
View File
@@ -0,0 +1,359 @@
"""资产真实状态核对服务
续费提醒发送前,尽可能从平台侧确认资产是否还在、是否已续费:
- whmcs-panel:登录账单面板读取服务列表(TotHost 等)
- 云厂商 API:复用 app/adapters 的只读清单能力
- 无可用通道:标记未核对,保留手工登记值并继续提醒(避免漏续费)
核对结果会回写 assets 的 expiry_date / status / external_id,
随后续费检查按更正后的数据重新筛选。
"""
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from typing import Optional
from fastapi import HTTPException
from sqlmodel import Session, select
from app.adapters import registry
from app.adapters.base import cloud_error_text, parse_iso_date
from app.models.asset import (
Account,
Asset,
AssetStatus,
AssetType,
DomainDetail,
VPSDetail,
)
from app.models.provider import Provider
from app.services import sync_service
_WHMCS = "whmcs-panel"
_STATUS_MAP = {
"active": AssetStatus.ACTIVE,
"expired": AssetStatus.EXPIRED,
"stopped": AssetStatus.STOPPED,
"cancelled": AssetStatus.CANCELLED,
"unknown": AssetStatus.UNKNOWN,
}
@dataclass
class VerificationResult:
"""单个资产的核对结果"""
asset_id: int
verified: bool
source: str = ""
status: Optional[AssetStatus] = None
expiry_date: Optional[date] = None
external_id: Optional[str] = None
detail: str = ""
def _error_text(e: Exception) -> str:
if isinstance(e, HTTPException):
return str(e.detail)
return cloud_error_text(e)
def _unverified(asset_id: int, detail: str) -> VerificationResult:
return VerificationResult(asset_id=asset_id, verified=False, detail=detail)
def _status(raw: Optional[str]) -> Optional[AssetStatus]:
return _STATUS_MAP.get((raw or "").strip().lower())
def verify_assets(session: Session, assets: list) -> dict:
"""按账号分组核对资产,返回 {asset_id: VerificationResult}"""
results: dict = {}
if not assets:
return results
groups = defaultdict(list)
for asset in assets:
groups[(asset.provider_id, asset.account_id)].append(asset)
cache: dict = {}
for (provider_id, account_id), group in groups.items():
provider = session.get(Provider, provider_id) if provider_id else None
account = session.get(Account, account_id) if account_id else None
if not provider:
for asset in group:
results[asset.id] = _unverified(asset.id, "资产未关联平台记录")
continue
sdk = (provider.sdk_type or "").strip()
if sdk == _WHMCS:
ctx = _whmcs_context(session, provider, account, cache)
for asset in group:
results[asset.id] = _verify_whmcs_asset(session, provider, asset, group, ctx)
elif sdk and registry.is_supported(sdk):
if all(asset.asset_type == AssetType.AI_AGENT for asset in group):
# AI 适配器只提供余额,没有统一的订阅到期查询,别白跑一次 API
for asset in group:
results[asset.id] = _unverified(
asset.id, "AI 平台没有统一的订阅到期查询接口"
)
continue
ctx = _api_context(session, provider, account, cache)
for asset in group:
results[asset.id] = _verify_api_asset(session, asset, ctx)
else:
for asset in group:
results[asset.id] = _unverified(
asset.id, f"平台「{provider.name}」未接入自动核对"
)
_apply(session, assets, results)
return results
# --------------------------------------------------------------------------- #
# WHMCS 面板通道
# --------------------------------------------------------------------------- #
def _whmcs_context(session: Session, provider: Provider, account: Optional[Account], cache: dict) -> dict:
key = ("whmcs", provider.id, account.id if account else None)
if key in cache:
return cache[key]
if not account:
ctx = {"error": "资产未关联账号,无法确定用哪个面板账号核对"}
else:
try:
_, adapter = sync_service._build_adapter_for_account(session, account)
fetch = getattr(adapter, "fetch_services", None)
if not fetch:
raise RuntimeError("该平台适配器不支持面板服务列表")
services = fetch(include_terminated=True)
ctx = {
"services": {str(s["id"]): s for s in services},
"active": [s for s in services if s["status"] == "active"],
"source": f"{provider.name} 面板",
}
except Exception as e: # noqa: BLE001
ctx = {"error": f"面板核对失败:{_error_text(e)}"}
cache[key] = ctx
return ctx
def _verify_whmcs_asset(
session: Session, provider: Provider, asset: Asset, group: list, ctx: dict
) -> VerificationResult:
if ctx.get("error"):
return _unverified(asset.id, ctx["error"])
services = ctx["services"]
service_id = (asset.external_id or "").strip()
if service_id:
service = services.get(service_id)
if not service:
return VerificationResult(
asset_id=asset.id,
verified=True,
source=ctx["source"],
status=AssetStatus.CANCELLED,
detail="面板中已无该服务(可能已删除)",
)
return _from_whmcs_service(asset, service, ctx["source"])
service = _match_whmcs_service(session, asset, services)
if not service:
# 账号下只有一个未关联资产、且面板只有一个在用服务时,可以安全地唯一对应
unlinked = [a for a in group if not (a.external_id or "").strip()]
if len(unlinked) == 1 and len(ctx["active"]) == 1:
service = ctx["active"][0]
if not service:
return _unverified(
asset.id, "无法与面板服务对应:请设置平台实例 ID 或关联正确的账号"
)
return _from_whmcs_service(asset, service, ctx["source"], external_id=str(service["id"]))
def _match_whmcs_service(session: Session, asset: Asset, services: dict) -> Optional[dict]:
detail = session.exec(
select(VPSDetail).where(VPSDetail.asset_id == asset.id)
).first()
ip = (detail.ip_address or "").strip().lower() if detail else ""
name = (asset.name or "").strip().lower()
for service in services.values():
domain = (service.get("domain") or "").strip().lower()
label = (service.get("label") or "").strip().lower()
if ip and domain and domain == ip:
return service
if name and (domain == name or label == name):
return service
if name and domain and len(domain) >= 4 and (domain in name or name in domain):
return service
return None
def _from_whmcs_service(
asset: Asset, service: dict, source: str, external_id: Optional[str] = None
) -> VerificationResult:
detail = f"服务 {service['label']}:{service.get('status_text') or service.get('status_class')}"
if service.get("expiry_date"):
detail += f",下次到期 {service['expiry_date']}"
return VerificationResult(
asset_id=asset.id,
verified=True,
source=source,
status=_status(service.get("status")),
expiry_date=service.get("expiry_date"),
external_id=external_id,
detail=detail,
)
# --------------------------------------------------------------------------- #
# 云厂商 API 通道
# --------------------------------------------------------------------------- #
def _api_context(session: Session, provider: Provider, account: Optional[Account], cache: dict) -> dict:
key = ("api", provider.id, account.id if account else None)
if key in cache:
return cache[key]
if not account:
ctx = {"error": "资产未关联账号,无法使用云端 API 核对"}
else:
try:
_, adapter = sync_service._build_adapter_for_account(session, account)
caps = adapter.capabilities()
ctx = {"source": f"{provider.name} API", "vps": {}, "domains": {}}
if caps.get("list_vps"):
ctx["vps"] = {str(v.external_id): v for v in adapter.list_vps()}
if caps.get("list_domains"):
ctx["domains"] = {str(d.external_id): d for d in adapter.list_domains()}
if not caps.get("list_vps") and not caps.get("list_domains"):
ctx = {"error": f"{provider.name} 适配器不支持到期信息查询"}
except Exception as e: # noqa: BLE001
ctx = {"error": f"云端 API 核对失败:{_error_text(e)}"}
cache[key] = ctx
return ctx
def _verify_api_asset(session: Session, asset: Asset, ctx: dict) -> VerificationResult:
if ctx.get("error"):
return _unverified(asset.id, ctx["error"])
if asset.asset_type == AssetType.VPS:
items = ctx.get("vps", {})
item = items.get(str(asset.external_id)) if asset.external_id else None
if item is None and not asset.external_id:
item = _match_api_vps(session, asset, items.values())
if item is None:
if asset.external_id:
return VerificationResult(
asset_id=asset.id,
verified=True,
source=ctx["source"],
status=AssetStatus.CANCELLED,
detail="云端列表中已无该实例(可能已删除)",
)
return _unverified(asset.id, "无法与云端实例对应:请设置平台实例 ID")
expiry = parse_iso_date(getattr(item, "expiry_date", None))
detail = f"实例 {getattr(item, 'name', asset.name)} 状态 {getattr(item, 'status', 'unknown')}"
if expiry:
detail += f",到期 {expiry}"
return VerificationResult(
asset_id=asset.id,
verified=True,
source=ctx["source"],
status=sync_service._norm_status(getattr(item, "status", None)),
expiry_date=expiry,
external_id=str(getattr(item, "external_id", "")) or None,
detail=detail,
)
if asset.asset_type == AssetType.DOMAIN:
items = ctx.get("domains", {})
item = items.get(str(asset.external_id)) if asset.external_id else None
if item is None and not asset.external_id:
item = _match_api_domain(session, asset, items.values())
if item is None:
if asset.external_id:
return VerificationResult(
asset_id=asset.id,
verified=True,
source=ctx["source"],
status=AssetStatus.CANCELLED,
detail="云端列表中已无该域名(可能已删除或转出)",
)
return _unverified(asset.id, "无法与云端域名对应:请设置平台实例 ID")
expiry = parse_iso_date(getattr(item, "expiry_date", None))
detail = f"域名 {getattr(item, 'domain_name', asset.name)} 状态 {getattr(item, 'status', 'unknown')}"
if expiry:
detail += f",到期 {expiry}"
return VerificationResult(
asset_id=asset.id,
verified=True,
source=ctx["source"],
status=sync_service._norm_status(getattr(item, "status", None)),
expiry_date=expiry,
external_id=str(getattr(item, "external_id", "")) or None,
detail=detail,
)
return _unverified(asset.id, "该资产类型没有云端到期查询")
def _match_api_vps(session: Session, asset: Asset, items) -> Optional[object]:
detail = session.exec(
select(VPSDetail).where(VPSDetail.asset_id == asset.id)
).first()
ip = (detail.ip_address or "").strip() if detail else ""
name = (asset.name or "").strip().lower()
for item in items:
if ip and (getattr(item, "ip_address", "") or "").strip() == ip:
return item
if name and (getattr(item, "name", "") or "").strip().lower() == name:
return item
return None
def _match_api_domain(session: Session, asset: Asset, items) -> Optional[object]:
detail = session.exec(
select(DomainDetail).where(DomainDetail.asset_id == asset.id)
).first()
domain = (detail.domain_name or "").strip().lower() if detail else ""
name = (asset.name or "").strip().lower()
for item in items:
candidate = (getattr(item, "domain_name", "") or "").strip().lower()
if domain and candidate == domain:
return item
if name and candidate == name:
return item
return None
# --------------------------------------------------------------------------- #
# 回写
# --------------------------------------------------------------------------- #
def _apply(session: Session, assets: list, results: dict) -> None:
changed = False
for asset in assets:
result = results.get(asset.id)
if not result or not result.verified:
continue
touched = False
if result.expiry_date is not None and asset.expiry_date != result.expiry_date:
asset.expiry_date = result.expiry_date
touched = True
if result.status is not None and asset.status != result.status:
asset.status = result.status
touched = True
if result.external_id and not asset.external_id:
asset.external_id = result.external_id
touched = True
if touched:
session.add(asset)
changed = True
if changed:
session.commit()