Compare commits
29
Commits
f1cdcf716c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c4dc9af5 | ||
|
|
cc8c91ddd9 | ||
|
|
6865bb7914 | ||
|
|
d711ad5827 | ||
|
|
2fbb658126 | ||
|
|
8fea6c2288 | ||
|
|
b4705331c0 | ||
|
|
bc1b804931 | ||
|
|
09a7b7aa42 | ||
|
|
88e7050cf0 | ||
|
|
8dbccdb238 | ||
|
|
750a986821 | ||
|
|
e6323c5e46 | ||
|
|
270ffc81f9 | ||
|
|
32a4e0da36 | ||
|
|
e4a8cc68b4 | ||
|
|
dc2134cf5a | ||
|
|
cf18cadb6b | ||
|
|
ee5a4c81cf | ||
|
|
74f9688ba1 | ||
|
|
6829b211ac | ||
|
|
fcb3153c50 | ||
|
|
1c93ac00eb | ||
|
|
a33a2b71de | ||
|
|
4883837dda | ||
|
|
4dba505d82 | ||
|
|
4f855ba0da | ||
|
|
1b7d4823c3 | ||
|
|
7f1268f508 |
+30
-2
@@ -4,8 +4,8 @@
|
||||
APP_NAME=VPS 资产管理系统
|
||||
|
||||
# 写操作(POST/PUT/DELETE)鉴权密钥
|
||||
# 留空 = 不校验(适用于纯 Tailscale 内网环境)
|
||||
# 配置后,所有写操作需在请求头携带 X-API-Key: <此密钥>
|
||||
# Tailscale 内网(100.64.0.0/10,如 100.89.x.x)与本机来源自动放行,无需携带;
|
||||
# 外部来源必须携带 X-API-Key: <此密钥>;留空则外部来源一律拒绝(仅内网可用)
|
||||
API_KEY=
|
||||
|
||||
# Agent 上报鉴权密钥(可选)
|
||||
@@ -29,3 +29,31 @@ SMTP_TO=
|
||||
|
||||
# ---- 续费提醒阈值(天)----
|
||||
RENEWAL_THRESHOLD_DAYS=30
|
||||
|
||||
# ---- 监控数据保留策略(自动清理,天数)----
|
||||
METRICS_RETENTION_DAYS=30
|
||||
SECURITY_RETENTION_DAYS=90
|
||||
EVENT_LOG_RETENTION_DAYS=180
|
||||
# 自动清理间隔(小时),0 表示禁用后台自动清理
|
||||
CLEANUP_INTERVAL_HOURS=24
|
||||
|
||||
# ---- S3 兼容对象存储备份(可选,未配置则仅本地备份)----
|
||||
# 备份文件通过 AWS SigV4 签名上传,兼容 Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等
|
||||
# R2 示例:S3_ENDPOINT_URL=https://<account-id>.r2.cloudflarestorage.com,S3_REGION=auto
|
||||
# OSS 示例:S3_ENDPOINT_URL=https://oss-cn-hangzhou.aliyuncs.com,S3_REGION=oss-cn-hangzhou
|
||||
# COS 示例:S3_ENDPOINT_URL=https://cos.ap-shanghai.myqcloud.com,S3_REGION=ap-shanghai
|
||||
S3_ENDPOINT_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET=
|
||||
# 远端对象前缀(默认 vps-manager,对象结构:<prefix>/assets|metrics/<文件名>)
|
||||
S3_PREFIX=vps-manager
|
||||
# 区域(R2 为 auto,MinIO 随意,OSS/COS 填各自区域)
|
||||
S3_REGION=auto
|
||||
|
||||
# ---- Agent 上报频率限制 ----
|
||||
# 同一资产两次上报的最小间隔(秒),0 表示不限制
|
||||
AGENT_REPORT_MIN_INTERVAL=30
|
||||
|
||||
# ---- CORS 允许来源(逗号分隔)----
|
||||
CORS_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://dify.taile5765c.ts.net
|
||||
|
||||
@@ -3,5 +3,6 @@ __pycache__/
|
||||
.venv/
|
||||
data/
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
.pytest_cache/
|
||||
|
||||
+10
-4
@@ -34,15 +34,21 @@ def collect_metrics() -> dict:
|
||||
|
||||
|
||||
def _get_ip() -> str:
|
||||
"""获取本机出口 IP(不实际发包)"""
|
||||
"""获取本机出口 IP(UDP 不实际发包;设超时避免无网环境阻塞)"""
|
||||
s = None
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(2) # 无网络环境下 connect 可能阻塞,加超时保护
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
return ip
|
||||
return s.getsockname()[0]
|
||||
except Exception:
|
||||
return ""
|
||||
finally:
|
||||
if s is not None:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def collect_server_info() -> dict:
|
||||
|
||||
+20
-10
@@ -4,10 +4,12 @@
|
||||
VPS_MANAGER_URL 中心地址,如 http://100.89.0.11:8000
|
||||
VPS_ASSET_ID 本机对应的资产 ID
|
||||
VPS_AGENT_KEY Agent Key(中心配置了 AGENT_KEY 时必填)
|
||||
VPS_AGENT_RETRY 上报失败重试次数(默认 2,指数退避)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -18,6 +20,7 @@ def main() -> int:
|
||||
url = os.environ.get("VPS_MANAGER_URL", "").rstrip("/")
|
||||
asset_id = os.environ.get("VPS_ASSET_ID", "")
|
||||
key = os.environ.get("VPS_AGENT_KEY", "")
|
||||
max_retry = int(os.environ.get("VPS_AGENT_RETRY", "2"))
|
||||
|
||||
if not url or not asset_id:
|
||||
print("错误:需设置 VPS_MANAGER_URL 和 VPS_ASSET_ID")
|
||||
@@ -31,16 +34,23 @@ def main() -> int:
|
||||
}
|
||||
headers = {"X-Agent-Key": key} if key else {}
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
|
||||
)
|
||||
print(f"上报完成 HTTP {resp.status_code}: {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return 0
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"上报失败: {e}")
|
||||
return 1
|
||||
for attempt in range(max_retry + 1):
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{url}/api/agent/report", json=payload, headers=headers, timeout=30
|
||||
)
|
||||
print(f"上报完成 HTTP {resp.status_code}: {resp.text}")
|
||||
resp.raise_for_status()
|
||||
return 0
|
||||
except Exception as e: # noqa: BLE001
|
||||
if attempt < max_retry:
|
||||
wait = 2 ** attempt
|
||||
print(f"上报失败({attempt + 1}/{max_retry + 1}),{wait}s 后重试: {e}")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
print(f"上报失败(已重试 {max_retry} 次): {e}")
|
||||
return 1
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+35
-5
@@ -100,11 +100,41 @@ class OpenAIAdapter(_AIBase):
|
||||
|
||||
@register("minimax-api")
|
||||
class MinimaxAdapter(_AIBase):
|
||||
# Minimax 需 group_id,余额接口因账号类型而异,此处为骨架待完善
|
||||
required_config = ["api_key", "group_id"]
|
||||
"""Minimax 适配器(余额查询)
|
||||
|
||||
接口:GET https://api.minimax.chat/v1/balance
|
||||
认证:Bearer api_key(group_id 仅部分旧接口需要,余额查询非必需)
|
||||
响应示例:{"balance": 123.45, "currency": "CNY", ...}
|
||||
注:Minimax 国内版端点为 api.minimaxi.com,国际版为 api.minimax.chat,
|
||||
两者 API Key 不通用;默认使用国际版端点,可通过 config["base_url"] 覆盖。
|
||||
"""
|
||||
|
||||
required_config = ["api_key"]
|
||||
BASE = "https://api.minimax.chat/v1"
|
||||
|
||||
def _base_url(self) -> str:
|
||||
return (self.config.get("base_url") or self.BASE).rstrip("/")
|
||||
|
||||
def _balance(self) -> AccountInfo:
|
||||
data = self._get(self._base_url() + "/balance")
|
||||
balance = data.get("balance")
|
||||
try:
|
||||
balance = float(balance) if balance is not None else None
|
||||
except (TypeError, ValueError):
|
||||
balance = None
|
||||
currency = data.get("currency") or "CNY"
|
||||
return AccountInfo(balance=balance, currency=currency, raw=data)
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
if not self.config.get("group_id"):
|
||||
return {"ok": False, "message": "Minimax 需配置 group_id(适配器余额接口待完善)"}
|
||||
return {"ok": False, "message": "Minimax 适配器余额接口待完善(请提供具体接口文档)"}
|
||||
try:
|
||||
acc = self._balance()
|
||||
if acc.balance is not None:
|
||||
return {"ok": True, "message": f"连接成功,余额 {acc.balance} {acc.currency}"}
|
||||
return {"ok": True, "message": "连接成功(未返回余额字段)"}
|
||||
except httpx.HTTPStatusError as e:
|
||||
return self._http_error(e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def get_account(self) -> AccountInfo:
|
||||
return self._balance()
|
||||
|
||||
+27
-19
@@ -79,25 +79,33 @@ class AliyunAdapter(BaseAdapter):
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def list_vps(self) -> list:
|
||||
data = self._call("DescribeInstances", {"PageSize": "100"})
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
for inst in data.get("Instances", {}).get("Instance", []):
|
||||
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
|
||||
eip = inst.get("EipAddress", {}).get("IpAddress")
|
||||
mem_mb = inst.get("Memory") or 0
|
||||
status_map = {"Running": "active", "Stopped": "stopped"}
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
name=inst.get("InstanceName") or inst.get("InstanceId"),
|
||||
ip_address=eip or (public_ips[0] if public_ips else None),
|
||||
region=inst.get("RegionId"),
|
||||
os=inst.get("OSName"),
|
||||
cpu_cores=inst.get("Cpu"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
currency="CNY",
|
||||
raw=inst,
|
||||
page = 1
|
||||
while True:
|
||||
data = self._call("DescribeInstances", {"PageSize": "100", "PageNumber": str(page)})
|
||||
instances = data.get("Instances", {}).get("Instance", [])
|
||||
for inst in instances:
|
||||
public_ips = inst.get("PublicIpAddress", {}).get("IpAddress", [])
|
||||
eip = inst.get("EipAddress", {}).get("IpAddress")
|
||||
mem_mb = inst.get("Memory") or 0
|
||||
status_map = {"Running": "active", "Stopped": "stopped"}
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
name=inst.get("InstanceName") or inst.get("InstanceId"),
|
||||
ip_address=eip or (public_ips[0] if public_ips else None),
|
||||
region=inst.get("RegionId"),
|
||||
os=inst.get("OSName"),
|
||||
cpu_cores=inst.get("Cpu"),
|
||||
memory_gb=round(mem_mb / 1024, 1) if mem_mb else None,
|
||||
status=status_map.get(inst.get("Status"), inst.get("Status") or "unknown"),
|
||||
currency="CNY",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
if not instances or page * 100 >= total:
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Cloudflare 适配器(REST API v4,Bearer Token)
|
||||
|
||||
API 文档:https://developers.cloudflare.com/api/
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read)
|
||||
所需配置:{"api_token": "..."}(建议用 API Token,权限含 Zone:Read / Account:Read / Registrar:Read)
|
||||
Cloudflare 无传统 VPS,主要同步托管域名(zones);Workers/R2/Tunnel 子资产留待后续阶段。
|
||||
域名到期日通过 Registrar API(/accounts/{id}/registrar/domains)补充获取。
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -42,16 +43,21 @@ class CloudflareAdapter(BaseAdapter):
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def list_domains(self) -> list:
|
||||
# 先拉取 Registrar 域名到期日映射(domain_name -> expires_at ISO 日期)
|
||||
expiry_map = self._registrar_expiry_map()
|
||||
|
||||
result = []
|
||||
page = 1
|
||||
while True:
|
||||
data = self._get(f"/zones?per_page=50&page={page}")
|
||||
for z in data.get("result", []):
|
||||
name = z.get("name")
|
||||
result.append(
|
||||
NormalizedDomain(
|
||||
external_id=z.get("id"),
|
||||
domain_name=z.get("name"),
|
||||
domain_name=name,
|
||||
registrar="cloudflare",
|
||||
expiry_date=expiry_map.get(name),
|
||||
status="active" if z.get("status") == "active" else (z.get("status") or "unknown"),
|
||||
raw=z,
|
||||
)
|
||||
@@ -61,3 +67,31 @@ class CloudflareAdapter(BaseAdapter):
|
||||
break
|
||||
page += 1
|
||||
return result
|
||||
|
||||
def _registrar_expiry_map(self) -> dict:
|
||||
"""拉取 Cloudflare Registrar 域名到期日映射 {domain_name: YYYY-MM-DD}
|
||||
|
||||
流程:/accounts → 对每个 account 调 /accounts/{id}/registrar/domains。
|
||||
若 Token 无 Registrar 权限或账号无 Registrar 域名,静默返回空映射。
|
||||
"""
|
||||
expiry = {}
|
||||
try:
|
||||
accounts = self._get("/accounts?per_page=50").get("result", [])
|
||||
except Exception: # noqa: BLE001
|
||||
return expiry
|
||||
for acc in accounts:
|
||||
acc_id = acc.get("id")
|
||||
if not acc_id:
|
||||
continue
|
||||
try:
|
||||
domains = self._get(f"/accounts/{acc_id}/registrar/domains").get("result", [])
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for d in domains:
|
||||
# Registrar API 返回的 id 是域名本身(如 example.com)
|
||||
name = d.get("id") or d.get("domain_name") or ""
|
||||
expires_at = d.get("expires_at")
|
||||
if name and expires_at:
|
||||
# expires_at 为 ISO8601 时间戳,截取日期部分
|
||||
expiry[name] = expires_at[:10]
|
||||
return expiry
|
||||
|
||||
+25
-17
@@ -94,24 +94,32 @@ class TencentAdapter(BaseAdapter):
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def list_vps(self) -> list:
|
||||
data = self._call("DescribeInstances", {"Limit": 100})
|
||||
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
|
||||
result = []
|
||||
status_map = {"RUNNING": "active", "STOPPED": "stopped"}
|
||||
for inst in data.get("InstanceSet", []):
|
||||
public_ips = inst.get("PublicIpAddresses", [])
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
name=inst.get("InstanceName") or inst.get("InstanceId"),
|
||||
ip_address=public_ips[0] if public_ips else None,
|
||||
region=inst.get("Placement", {}).get("Zone"),
|
||||
os=inst.get("OsName"),
|
||||
cpu_cores=inst.get("CPU"),
|
||||
memory_gb=inst.get("Memory"),
|
||||
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
|
||||
status=status_map.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
currency="CNY",
|
||||
raw=inst,
|
||||
offset = 0
|
||||
while True:
|
||||
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
|
||||
inst_set = data.get("InstanceSet", [])
|
||||
for inst in inst_set:
|
||||
public_ips = inst.get("PublicIpAddresses", [])
|
||||
result.append(
|
||||
NormalizedVPS(
|
||||
external_id=inst.get("InstanceId"),
|
||||
name=inst.get("InstanceName") or inst.get("InstanceId"),
|
||||
ip_address=public_ips[0] if public_ips else None,
|
||||
region=inst.get("Placement", {}).get("Zone"),
|
||||
os=inst.get("OsName"),
|
||||
cpu_cores=inst.get("CPU"),
|
||||
memory_gb=inst.get("Memory"),
|
||||
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
|
||||
status=status_map.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
|
||||
currency="CNY",
|
||||
raw=inst,
|
||||
)
|
||||
)
|
||||
)
|
||||
total = int(data.get("TotalCount") or 0)
|
||||
offset += len(inst_set)
|
||||
if not inst_set or offset >= total:
|
||||
break
|
||||
return result
|
||||
|
||||
+24
-2
@@ -1,7 +1,8 @@
|
||||
"""应用配置
|
||||
|
||||
从项目根目录的 .env 文件加载配置(若存在)。
|
||||
API_KEY 留空表示不启用写操作鉴权(适用于纯内网环境)。
|
||||
API_KEY 用于保护写操作:Tailscale 内网/本机来源放行,外部来源必须携带正确密钥;
|
||||
留空则外部来源一律拒绝(仅内网可用)。
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -17,7 +18,8 @@ class Settings:
|
||||
"""应用配置项"""
|
||||
|
||||
APP_NAME: str = os.getenv("APP_NAME", "VPS 资产管理系统")
|
||||
# 写操作(POST/PUT/DELETE)鉴权密钥;留空则不校验
|
||||
# 写操作(POST/PUT/DELETE)鉴权密钥;Tailscale 内网/本机来源忽略校验,
|
||||
# 外部来源必须携带 X-API-Key(留空则外部来源直接拒绝)
|
||||
API_KEY: str = os.getenv("API_KEY", "")
|
||||
# Agent 上报鉴权密钥;配置后 Agent 上报需携带 X-Agent-Key
|
||||
AGENT_KEY: str = os.getenv("AGENT_KEY", "")
|
||||
@@ -39,5 +41,25 @@ class Settings:
|
||||
# ---- 续费提醒 ----
|
||||
RENEWAL_THRESHOLD_DAYS: int = int(os.getenv("RENEWAL_THRESHOLD_DAYS", "30"))
|
||||
|
||||
# ---- 监控数据保留策略(自动清理)----
|
||||
# MetricPoint 时序数据保留天数(Agent 高频上报,默认 30 天)
|
||||
METRICS_RETENTION_DAYS: int = int(os.getenv("METRICS_RETENTION_DAYS", "30"))
|
||||
# SecurityCheck 安全检查历史保留天数(默认 90 天)
|
||||
SECURITY_RETENTION_DAYS: int = int(os.getenv("SECURITY_RETENTION_DAYS", "90"))
|
||||
# EventLog 事件日志保留天数(默认 180 天)
|
||||
EVENT_LOG_RETENTION_DAYS: int = int(os.getenv("EVENT_LOG_RETENTION_DAYS", "180"))
|
||||
# 自动清理间隔(小时),0 表示禁用后台自动清理
|
||||
CLEANUP_INTERVAL_HOURS: int = int(os.getenv("CLEANUP_INTERVAL_HOURS", "24"))
|
||||
|
||||
# ---- Agent 上报频率限制 ----
|
||||
# 同一 asset_id 两次上报的最小间隔(秒),0 表示不限制
|
||||
AGENT_REPORT_MIN_INTERVAL: int = int(os.getenv("AGENT_REPORT_MIN_INTERVAL", "30"))
|
||||
|
||||
# ---- CORS 允许来源(逗号分隔),默认本地 + Tailscale ----
|
||||
CORS_ORIGINS: str = os.getenv(
|
||||
"CORS_ORIGINS",
|
||||
"http://127.0.0.1:8000,http://localhost:8000,https://dify.taile5765c.ts.net",
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
+42
-1
@@ -2,8 +2,13 @@
|
||||
|
||||
用于加密存储 SSH 密钥、密码、API Key、平台 API 配置等敏感信息。
|
||||
MASTER_KEY 从 .env 读取,不入库。
|
||||
|
||||
密钥托管(Key Escrow):MASTER_KEY 遗失时全量密文不可解,故提供
|
||||
build_escrow/recover_master_key——用离线保管的 RESTORE_KEY 加密 MASTER_KEY
|
||||
本身生成 escrow 文件(随 data/ 备份流转),丢失时凭 RESTORE_KEY 找回。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
@@ -11,8 +16,9 @@ from cryptography.fernet import Fernet, InvalidToken
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_fernet() -> Fernet:
|
||||
"""获取 Fernet 实例(MASTER_KEY 未配置时抛错)"""
|
||||
"""获取 Fernet 实例(进程内缓存,避免每次加解密重建;MASTER_KEY 未配置时抛错)"""
|
||||
key = settings.MASTER_KEY
|
||||
if not key:
|
||||
raise RuntimeError(
|
||||
@@ -38,3 +44,38 @@ def decrypt(token: Optional[str]) -> Optional[str]:
|
||||
return _get_fernet().decrypt(token.encode()).decode()
|
||||
except (InvalidToken, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------- 密钥托管(Key Escrow) ---------------- #
|
||||
|
||||
ESCROW_PREFIX = "v1:"
|
||||
|
||||
|
||||
def _fernet_from_key(key: str) -> Fernet:
|
||||
"""按给定密钥构造独立 Fernet 实例(不读 settings、不走进程缓存)"""
|
||||
return Fernet(key.encode() if isinstance(key, str) else key)
|
||||
|
||||
|
||||
def build_escrow(restore_key: str) -> str:
|
||||
"""用 RESTORE_KEY 加密当前 MASTER_KEY,生成 escrow token('v1:' + Fernet token)
|
||||
|
||||
escrow 文件内容只有离线保管的 RESTORE_KEY 能解开:数据库/备份泄露也无法
|
||||
还原 MASTER_KEY。恢复时凭 escrow 找回 MASTER_KEY,全量密文零迁移。
|
||||
"""
|
||||
master_key = settings.MASTER_KEY
|
||||
if not master_key:
|
||||
raise RuntimeError("MASTER_KEY 未配置,无法建立托管(请先在 .env 配置 MASTER_KEY)")
|
||||
return ESCROW_PREFIX + _fernet_from_key(restore_key).encrypt(master_key.encode()).decode()
|
||||
|
||||
|
||||
def recover_master_key(restore_key: str, escrow: str) -> str:
|
||||
"""用 RESTORE_KEY 解密 escrow token,还原 MASTER_KEY 明文
|
||||
|
||||
钥匙错误、前缀缺失或文件损坏时抛 ValueError。
|
||||
"""
|
||||
if not escrow.startswith(ESCROW_PREFIX):
|
||||
raise ValueError("escrow 格式无效(缺少 v1: 前缀)")
|
||||
try:
|
||||
return _fernet_from_key(restore_key).decrypt(escrow[len(ESCROW_PREFIX):].encode()).decode()
|
||||
except (InvalidToken, ValueError) as e:
|
||||
raise ValueError("恢复钥匙错误或 escrow 已损坏") from e
|
||||
|
||||
+77
-10
@@ -1,37 +1,104 @@
|
||||
"""API Key 认证依赖
|
||||
|
||||
用于保护写操作(POST/PUT/DELETE)。
|
||||
- 若 settings.API_KEY 为空,则放行所有请求(纯内网场景)。
|
||||
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
|
||||
- 来自可信内网(Tailscale 100.64.0.0/10、本机回环)的请求直接放行;
|
||||
- 其余来源:API_KEY 已配置则校验 X-API-Key,未配置则拒绝(防止外部裸奔)。
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from fastapi import Header, HTTPException, Request, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# 可信来源网段:Tailscale 使用 CGNAT 100.64.0.0/10 分配内网 IP(如 100.89.x.x);
|
||||
# 回环地址覆盖 tailscale serve 代理转发与本地开发场景。
|
||||
TRUSTED_NETWORKS = [
|
||||
ipaddress.ip_network("100.64.0.0/10"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
]
|
||||
|
||||
async def require_api_key(
|
||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||
) -> None:
|
||||
"""校验 API Key(可选启用)"""
|
||||
|
||||
def _key_matches(provided: Optional[str], expected: str) -> bool:
|
||||
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
|
||||
if not provided:
|
||||
return False
|
||||
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""获取客户端真实 IP
|
||||
|
||||
tailscale serve 转发到本机时 request.client 为回环地址,真实来源在
|
||||
X-Forwarded-For 头中;仅信任来自回环的 XFF,防止外部直连时伪造
|
||||
X-Forwarded-For 绕过鉴权。
|
||||
"""
|
||||
host = request.client.host if request.client else ""
|
||||
if host in ("127.0.0.1", "::1"):
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return host
|
||||
|
||||
|
||||
def _is_trusted(ip: str) -> bool:
|
||||
"""判断来源 IP 是否属于可信内网(Tailscale 网段 / 本机回环)"""
|
||||
if not ip:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(addr in net for net in TRUSTED_NETWORKS)
|
||||
|
||||
|
||||
def _is_funnel_request(request: Request) -> bool:
|
||||
"""判断是否 tailscale funnel 公网流量
|
||||
|
||||
tailscale serve 转发时会把客户端伪造的 Tailscale-* 头清空后按真实
|
||||
情况设置:tailnet 内无此头,funnel 公网流量为 "?1"(不可伪造)。
|
||||
"""
|
||||
return request.headers.get("tailscale-funnel-request") == "?1"
|
||||
|
||||
|
||||
async def _require_key(x_api_key: Optional[str]) -> None:
|
||||
"""按外部来源校验 API Key(可信内网已在调用方排除)"""
|
||||
if not settings.API_KEY:
|
||||
return
|
||||
if x_api_key != settings.API_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="外部访问需要 API Key,请先在 .env 配置 API_KEY 并设置到前端",
|
||||
)
|
||||
if not _key_matches(x_api_key, settings.API_KEY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="API Key 无效或缺失",
|
||||
)
|
||||
|
||||
|
||||
async def require_api_key(
|
||||
request: Request,
|
||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||
) -> None:
|
||||
"""校验 API Key:Tailscale 内网/本机放行,外部来源必须携带有效 Key"""
|
||||
# funnel 公网流量:即使来源 IP 恰在 tailnet 网段也强制校验(双保险)
|
||||
if _is_funnel_request(request):
|
||||
await _require_key(x_api_key)
|
||||
return
|
||||
if _is_trusted(_client_ip(request)):
|
||||
return
|
||||
await _require_key(x_api_key)
|
||||
|
||||
|
||||
async def require_agent_key(
|
||||
x_agent_key: Optional[str] = Header(default=None, alias="X-Agent-Key"),
|
||||
) -> None:
|
||||
"""校验 Agent 上报 Key(可选启用)"""
|
||||
if not settings.AGENT_KEY:
|
||||
return
|
||||
if x_agent_key != settings.AGENT_KEY:
|
||||
if not _key_matches(x_agent_key, settings.AGENT_KEY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Agent Key 无效或缺失",
|
||||
|
||||
+27
-6
@@ -10,57 +10,78 @@ from app.models.provider import Provider
|
||||
|
||||
PRESET_PROVIDERS = [
|
||||
# ---- VPS / 云服务商 ----
|
||||
# 综合平台:services 列出可提供的服务,卡片上会显示多标签
|
||||
{"slug": "aliyun", "name": "阿里云", "name_en": "Aliyun", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://aliyun.com", "console_url": "https://ecs.console.aliyun.com", "sdk_type": "aliyun-sdk"},
|
||||
{"slug": "tencent", "name": "腾讯云", "name_en": "Tencent Cloud", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://cloud.tencent.com", "console_url": "https://console.cloud.tencent.com/cvm", "sdk_type": "tencent-sdk"},
|
||||
{"slug": "aliyun-intl", "name": "阿里国际", "name_en": "Alibaba Cloud", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://alibabacloud.com", "console_url": "https://ecs.console.alibabacloud.com", "sdk_type": "alibabacloud-sdk"},
|
||||
{"slug": "tencent-intl", "name": "腾讯国际", "name_en": "Tencent Cloud Intl", "category": "vps",
|
||||
"services": "vps,domain,ssl_cert,cdn,dns",
|
||||
"website": "https://intl.cloud.tencent.com", "console_url": "https://console.intl.cloud.tencent.com", "sdk_type": "tencent-intl-sdk"},
|
||||
{"slug": "vultr", "name": "Vultr", "name_en": "Vultr", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://vultr.com", "console_url": "https://my.vultr.com", "sdk_type": "vultr-api"},
|
||||
{"slug": "digitalocean", "name": "DigitalOcean", "name_en": "DigitalOcean", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://digitalocean.com", "console_url": "https://cloud.digitalocean.com", "sdk_type": "do-api"},
|
||||
{"slug": "linode", "name": "Linode", "name_en": "Akamai Linode", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://linode.com", "console_url": "https://cloud.linode.com", "sdk_type": "linode-api"},
|
||||
{"slug": "cloudcone", "name": "CloudCone", "name_en": "CloudCone", "category": "vps",
|
||||
"services": "vps",
|
||||
"website": "https://cloudcone.com", "console_url": "https://app.cloudcone.com", "sdk_type": "cloudcone-api"},
|
||||
{"slug": "zeabur", "name": "Zeabur", "name_en": "Zeabur", "category": "vps",
|
||||
"services": "vps,domain",
|
||||
"website": "https://zeabur.com", "console_url": "https://dash.zeabur.com", "sdk_type": "zeabur-api"},
|
||||
# ---- 域名注册商 ----
|
||||
{"slug": "namesilo", "name": "Namesilo", "name_en": "Namesilo", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://namesilo.com", "console_url": "https://www.namesilo.com/account_domains.php", "sdk_type": "namesilo-api"},
|
||||
{"slug": "xinwang", "name": "新网", "name_en": "Xinnet", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://xinnet.com", "console_url": "https://www.xinnet.com", "sdk_type": None},
|
||||
{"slug": "dynadot", "name": "Dynadot", "name_en": "Dynadot", "category": "domain",
|
||||
"services": "domain,dns",
|
||||
"website": "https://dynadot.com", "console_url": "https://www.dynadot.com/account/domains", "sdk_type": "dynadot-api"},
|
||||
# ---- AI 服务商 ----
|
||||
{"slug": "openai", "name": "OpenAI", "name_en": "OpenAI", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://openai.com", "console_url": "https://platform.openai.com", "sdk_type": "openai-api"},
|
||||
{"slug": "minimax", "name": "Minimax", "name_en": "Minimax", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://minimax.io", "console_url": "https://platform.minimaxi.com", "sdk_type": "minimax-api"},
|
||||
{"slug": "kimi", "name": "Kimi", "name_en": "Moonshot", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://moonshot.cn", "console_url": "https://platform.moonshot.cn", "sdk_type": "moonshot-api"},
|
||||
{"slug": "agnes", "name": "Agnes", "name_en": "Agnes", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": None, "console_url": None, "sdk_type": None},
|
||||
{"slug": "deepseek", "name": "DeepSeek", "name_en": "DeepSeek", "category": "ai_agent",
|
||||
"services": "ai_agent",
|
||||
"website": "https://deepseek.com", "console_url": "https://platform.deepseek.com", "sdk_type": "deepseek-api"},
|
||||
# ---- Cloudflare ----
|
||||
{"slug": "cloudflare", "name": "Cloudflare", "name_en": "Cloudflare", "category": "cloudflare",
|
||||
"services": "cloudflare,dns,ssl_cert,cdn",
|
||||
"website": "https://cloudflare.com", "console_url": "https://dash.cloudflare.com", "sdk_type": "cloudflare-api"},
|
||||
]
|
||||
|
||||
|
||||
def seed_providers(session: Session) -> int:
|
||||
"""初始化预设平台(已存在的 slug 跳过),返回新增数量"""
|
||||
"""初始化预设平台(已存在的 slug 跳过),返回新增数量
|
||||
|
||||
单次查询获取全部已存在 slug,避免逐条 SELECT 的 N 次往返。
|
||||
"""
|
||||
existing_slugs = set(session.exec(select(Provider.slug)).all())
|
||||
added = 0
|
||||
for data in PRESET_PROVIDERS:
|
||||
existing = session.exec(
|
||||
select(Provider).where(Provider.slug == data["slug"])
|
||||
).first()
|
||||
if not existing:
|
||||
if data["slug"] not in existing_slugs:
|
||||
session.add(Provider(**data))
|
||||
added += 1
|
||||
session.commit()
|
||||
if added:
|
||||
session.commit()
|
||||
return added
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""UTC 时间工具(替代已弃用的 datetime.utcnow)
|
||||
|
||||
Python 3.12+ 起 datetime.utcnow() 被标记为弃用,
|
||||
统一使用 datetime.now(timezone.utc) 的快捷封装。
|
||||
注意:SQLite 不保存时区信息,为兼容既有数据与比较逻辑,
|
||||
默认返回 naive UTC 时间(与 utcnow 行为一致,但来源非弃用 API)。
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""返回当前 UTC 时间(naive,与 datetime.utcnow() 行为一致)"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 格式字符串(带 Z 后缀标识 UTC)"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""TOTP 动态验证码(RFC 6238,零依赖自实现)
|
||||
|
||||
用于凭据库 2FA:存储 base32 secret,按 30s 步长生成 6 位动态码。
|
||||
不引第三方库(pyotp)的原因:零 SSH 部署链路不重装依赖,避免 update.sh
|
||||
缺包导致服务自毁;正确性用 RFC 6238 附录 B 官方向量在 pytest 锚定
|
||||
(tests/test_totp.py)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
PERIOD = 30 # 步长(秒),标准值
|
||||
DIGITS = 6 # 码位数,标准值
|
||||
|
||||
|
||||
def b32decode(secret: str) -> bytes:
|
||||
"""Base32 解码(容错:去空白、大写化、自动补 = 填充)"""
|
||||
s = "".join(secret.split()).upper()
|
||||
return base64.b32decode(s + "=" * ((-len(s)) % 8))
|
||||
|
||||
|
||||
def random_secret() -> str:
|
||||
"""生成 20 字节随机 base32 secret(供「生成随机密钥」入口)"""
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=")
|
||||
|
||||
|
||||
def _hotp(key: bytes, counter: int, digits: int = DIGITS) -> str:
|
||||
"""RFC 4226 HOTP:HMAC-SHA1(key, counter) 动态截断 → digits 位十进制码"""
|
||||
digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
|
||||
return str(code % (10 ** digits)).zfill(digits)
|
||||
|
||||
|
||||
def totp_at(
|
||||
secret_b32: str, ts: float | None = None, period: int = PERIOD, digits: int = DIGITS
|
||||
) -> tuple[str, int]:
|
||||
"""计算指定时刻的 TOTP 码
|
||||
|
||||
返回 (code, expires_in):expires_in 为当前码剩余有效秒数(前端倒计时用)。
|
||||
ts 为 None 时取当前时间。
|
||||
"""
|
||||
now = time.time() if ts is None else ts
|
||||
counter = int(now // period)
|
||||
left = period - int(now % period)
|
||||
return _hotp(b32decode(secret_b32), counter, digits), left
|
||||
|
||||
|
||||
def verify(
|
||||
secret_b32: str,
|
||||
code: str,
|
||||
window: int = 1,
|
||||
ts: float | None = None,
|
||||
period: int = PERIOD,
|
||||
digits: int = DIGITS,
|
||||
) -> bool:
|
||||
"""校验用户输入的动态码(±window 个步进,容忍时钟偏差)
|
||||
|
||||
常量时间比较,防时序旁路。录入绑定时用 window=1 即可。
|
||||
"""
|
||||
code = "".join(code.split())
|
||||
if not code.isdigit() or len(code) != digits:
|
||||
return False
|
||||
now = time.time() if ts is None else ts
|
||||
base_counter = int(now // period)
|
||||
key = b32decode(secret_b32)
|
||||
return any(
|
||||
hmac.compare_digest(_hotp(key, base_counter + off, digits), code)
|
||||
for off in range(-window, window + 1)
|
||||
)
|
||||
|
||||
|
||||
def parse_otpauth_uri(uri: str) -> dict:
|
||||
"""解析 otpauth://totp/Label?secret=...&issuer=...&period=30&digits=6
|
||||
|
||||
返回 {secret, issuer, account};Label 形如 "Issuer:account" 或 "account",
|
||||
无 issuer 参数时从 Label 前缀提取。非 TOTP、缺 secret 或非标准参数抛 ValueError
|
||||
(v1 仅支持 6 位/30s 标准参数,避免录入成功但生成码对不上)。
|
||||
"""
|
||||
parsed = urlparse(uri.strip())
|
||||
if parsed.scheme != "otpauth":
|
||||
raise ValueError("不是 otpauth:// 链接")
|
||||
if parsed.netloc.lower() != "totp":
|
||||
raise ValueError(f"仅支持 TOTP(当前类型:{parsed.netloc})")
|
||||
params = parse_qs(parsed.query)
|
||||
secret = (params.get("secret") or [""])[0]
|
||||
if not secret:
|
||||
raise ValueError("链接缺少 secret 参数")
|
||||
period = int((params.get("period") or [PERIOD])[0])
|
||||
digits = int((params.get("digits") or [DIGITS])[0])
|
||||
if period != PERIOD or digits != DIGITS:
|
||||
raise ValueError(f"暂仅支持 {DIGITS} 位/{PERIOD}s 标准参数(当前 {digits} 位/{period}s)")
|
||||
|
||||
label = unquote(parsed.path.lstrip("/"))
|
||||
issuer = (params.get("issuer") or [""])[0]
|
||||
account = label
|
||||
if ":" in label:
|
||||
prefix, _, rest = label.partition(":")
|
||||
account = rest or prefix
|
||||
if not issuer:
|
||||
issuer = prefix
|
||||
return {"secret": secret, "issuer": issuer or None, "account": account or None}
|
||||
|
||||
|
||||
def build_otpauth_uri(secret_b32: str, issuer: str | None, account: str | None) -> str:
|
||||
"""重建 otpauth URI(导出/换机重新绑定用)"""
|
||||
label = f"{issuer}:{account}" if issuer and account else (account or issuer or "")
|
||||
q = f"secret={secret_b32}"
|
||||
if issuer:
|
||||
q += f"&issuer={quote(issuer)}"
|
||||
return f"otpauth://totp/{quote(label)}?{q}"
|
||||
+328
-8
@@ -1,6 +1,6 @@
|
||||
"""SQLite 数据库连接与初始化(分库)
|
||||
|
||||
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount
|
||||
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
|
||||
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
||||
"""
|
||||
|
||||
@@ -16,20 +16,39 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
|
||||
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
|
||||
|
||||
# timeout:sqlite3 内置 busy 等待(秒),随连接创建生效。
|
||||
# SQLite 默认使用 NullPool(每请求新建连接),PRAGMA busy_timeout 只在单连接有效,
|
||||
# 必须通过 connect_args 传递,否则并发写入(Agent 上报 + 清理任务)会报 database is locked。
|
||||
assets_engine = create_engine(
|
||||
ASSETS_DB_URL, echo=False, connect_args={"check_same_thread": False}
|
||||
ASSETS_DB_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
metrics_engine = create_engine(
|
||||
METRICS_DB_URL, echo=False, connect_args={"check_same_thread": False}
|
||||
METRICS_DB_URL,
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
|
||||
|
||||
def _enable_wal(engine) -> None:
|
||||
"""启用 WAL 模式,提升 SQLite 并发读写能力(journal_mode 持久化到库文件,设置一次即可)"""
|
||||
import sqlalchemy as sa
|
||||
with engine.connect() as conn:
|
||||
conn.execute(sa.text("PRAGMA journal_mode=WAL"))
|
||||
|
||||
|
||||
_enable_wal(assets_engine)
|
||||
_enable_wal(metrics_engine)
|
||||
|
||||
# 兼容旧代码:默认 engine 指向资产库
|
||||
engine = assets_engine
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""分库建表(幂等,可重复调用)"""
|
||||
from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.credential import Credential # noqa: F401
|
||||
from app.models.monitor import ( # noqa: F401
|
||||
EventLog,
|
||||
MetricPoint,
|
||||
@@ -37,8 +56,9 @@ def init_db() -> None:
|
||||
ServerInfo,
|
||||
)
|
||||
from app.models.provider import Provider # noqa: F401
|
||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail]
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Credential, Account]
|
||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||
|
||||
SQLModel.metadata.create_all(
|
||||
@@ -48,6 +68,7 @@ def init_db() -> None:
|
||||
metrics_engine, tables=[m.__table__ for m in metric_models]
|
||||
)
|
||||
_migrate_assets_db()
|
||||
_migrate_indexes()
|
||||
|
||||
|
||||
def _migrate_assets_db() -> None:
|
||||
@@ -64,23 +85,322 @@ def _migrate_assets_db() -> None:
|
||||
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR"))
|
||||
if "cancel_url" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR"))
|
||||
if "account_id" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN account_id INTEGER"))
|
||||
# 资产账号引用从 name 字符串迁移到 account_id 外键(幂等)
|
||||
_backfill_asset_account_id(conn)
|
||||
if insp.has_table("providers"):
|
||||
cols = {c["name"] for c in insp.get_columns("providers")}
|
||||
if "last_synced_at" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN last_synced_at DATETIME"))
|
||||
if "services" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE providers ADD COLUMN services VARCHAR DEFAULT ''"))
|
||||
# 为已有预设平台补充 services(仅补空值,用户自定义行不动)
|
||||
_backfill_provider_services(conn)
|
||||
if insp.has_table("ai_accounts"):
|
||||
cols = {c["name"] for c in insp.get_columns("ai_accounts")}
|
||||
if "api_key_encrypted" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR"))
|
||||
# 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段
|
||||
_migrate_plaintext_api_keys(conn)
|
||||
if insp.has_table("accounts"):
|
||||
cols = {c["name"] for c in insp.get_columns("accounts")}
|
||||
if "login_user" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_user VARCHAR"))
|
||||
if "login_password_encrypted" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_password_encrypted VARCHAR"))
|
||||
if "api_config_encrypted" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR"))
|
||||
if "last_synced_at" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
|
||||
if "credential_id" not in cols:
|
||||
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN credential_id INTEGER"))
|
||||
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
|
||||
_backfill_account_credentials(conn)
|
||||
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备
|
||||
_dedupe_accounts(conn)
|
||||
# 凭据中心化:账号登录密码搬入 credentials(唯一事实源)
|
||||
_migrate_account_credentials_to_vault(conn)
|
||||
|
||||
|
||||
def _migrate_indexes() -> None:
|
||||
"""为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
# 旧的全局 name 唯一索引(早期 Account.name unique=True 产物)会阻止跨平台同名,
|
||||
# 与新的 (platform,name) 联合唯一冲突,需先删除
|
||||
with assets_engine.begin() as conn:
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_accounts_name"))
|
||||
|
||||
stmts = [
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"),
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
|
||||
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
|
||||
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (platform, name)"),
|
||||
# 已有 accounts 表不会因模型加 index=True 自动补索引,显式创建
|
||||
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_accounts_credential_id ON accounts (credential_id)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
|
||||
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
|
||||
]
|
||||
for eng, sql in stmts:
|
||||
with eng.begin() as conn:
|
||||
conn.execute(sa.text(sql))
|
||||
|
||||
|
||||
def _migrate_plaintext_api_keys(conn) -> None:
|
||||
"""一次性迁移:将 ai_accounts 中残留的明文 api_key 加密后存入 api_key_encrypted,并清空原字段"""
|
||||
import sqlalchemy as sa
|
||||
from app.core.crypto import encrypt
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text("SELECT id, api_key FROM ai_accounts WHERE api_key IS NOT NULL AND api_key != ''")
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
for row in rows:
|
||||
encrypted = encrypt(row[1])
|
||||
if encrypted:
|
||||
conn.execute(
|
||||
sa.text("UPDATE ai_accounts SET api_key_encrypted = :enc, api_key = NULL WHERE id = :id"),
|
||||
{"enc": encrypted, "id": row[0]},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_provider_services(conn) -> None:
|
||||
"""为已有预设平台补充 services 服务列表(只更新 services 为空的预设 slug)"""
|
||||
import sqlalchemy as sa
|
||||
from app.core.seed import PRESET_PROVIDERS
|
||||
|
||||
for data in PRESET_PROVIDERS:
|
||||
services = data.get("services")
|
||||
if not services:
|
||||
continue
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE providers SET services = :services "
|
||||
"WHERE slug = :slug AND (services IS NULL OR services = '')"
|
||||
),
|
||||
{"services": services, "slug": data["slug"]},
|
||||
)
|
||||
|
||||
|
||||
def _dedupe_accounts(conn) -> None:
|
||||
"""建 (platform,name) 唯一索引前的防护:合并存量重复账号。
|
||||
|
||||
platform 统一按 COALESCE(platform,'') 规范化比较(避免 NULL 绕过)。
|
||||
重复时保留 id 最小者,将其余行的资产引用(account_id/account)与凭证并入后删除。
|
||||
当前数据量小,预期无重复;此函数仅在发现重复时产生写操作。
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT COALESCE(platform,'') AS p, name, COUNT(*) AS c, MIN(id) AS keep_id "
|
||||
"FROM accounts GROUP BY p, name HAVING c > 1"
|
||||
)
|
||||
).fetchall()
|
||||
for p, name, _c, keep_id in rows:
|
||||
dups = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id FROM accounts WHERE COALESCE(platform,'') = :p AND name = :n AND id != :keep"
|
||||
),
|
||||
{"p": p, "n": name, "keep": keep_id},
|
||||
).fetchall()
|
||||
for (dup_id,) in dups:
|
||||
# 资产引用并入保留行
|
||||
conn.execute(
|
||||
sa.text("UPDATE assets SET account_id = :keep WHERE account_id = :dup"),
|
||||
{"keep": keep_id, "dup": dup_id},
|
||||
)
|
||||
# 凭证:保留行为空时才从重复行拷贝
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET "
|
||||
"api_config_encrypted = COALESCE(api_config_encrypted, (SELECT api_config_encrypted FROM accounts WHERE id = :dup)), "
|
||||
"login_password_encrypted = COALESCE(login_password_encrypted, (SELECT login_password_encrypted FROM accounts WHERE id = :dup)), "
|
||||
"login_user = COALESCE(login_user, (SELECT login_user FROM accounts WHERE id = :dup)) "
|
||||
"WHERE id = :keep"
|
||||
),
|
||||
{"keep": keep_id, "dup": dup_id},
|
||||
)
|
||||
conn.execute(sa.text("DELETE FROM accounts WHERE id = :dup"), {"dup": dup_id})
|
||||
|
||||
|
||||
def _migrate_account_credentials_to_vault(conn) -> None:
|
||||
"""凭据中心化迁移(幂等):把账号登录密码搬入 credentials 表。
|
||||
|
||||
规则(spec §5):
|
||||
- 仅迁移 login_password_encrypted 非空且 credential_id 为空的账号(重跑不重复建)
|
||||
- site = providers.name(platform=slug 匹配)→ 否则 platform 原文 → 否则 '未分类'
|
||||
- username = COALESCE(login_user, name)
|
||||
- login_type 写入 enum 成员名 'PASSWORD'(SQLAlchemy Enum 列存名不存值,
|
||||
与现有 assets.asset_type='VPS' 等形态一致)
|
||||
- 密文原样搬入(不解密再加密,避免中间态明文暴露)
|
||||
- 搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime, timezone
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT a.id, a.name, a.login_user, a.login_password_encrypted, "
|
||||
"COALESCE(a.platform, ''), COALESCE(p.name, '') "
|
||||
"FROM accounts a LEFT JOIN providers p ON p.slug = COALESCE(a.platform, '') "
|
||||
"WHERE a.login_password_encrypted IS NOT NULL AND a.login_password_encrypted != '' "
|
||||
"AND a.credential_id IS NULL"
|
||||
)
|
||||
).fetchall()
|
||||
for acc_id, name, login_user, pwd_enc, platform, provider_name in rows:
|
||||
site = provider_name or platform or "未分类"
|
||||
username = login_user or name
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO credentials (site, username, login_type, password_encrypted, "
|
||||
"created_at, updated_at) VALUES (:site, :username, 'PASSWORD', :pwd, :now, :now)"
|
||||
),
|
||||
{"site": site, "username": username, "pwd": pwd_enc, "now": now},
|
||||
)
|
||||
cred_id = conn.execute(sa.text("SELECT last_insert_rowid()")).scalar()
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET credential_id = :cid, login_password_encrypted = NULL "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"cid": cred_id, "id": acc_id},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_asset_account_id(conn) -> None:
|
||||
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。
|
||||
|
||||
匹配规则:按 accounts.name 匹配;同名多个时优先 platform 与 asset.provider 一致者。
|
||||
匹配不到(历史自由文本/未登记账号)则跳过并保留 account 字符串,供人工处理。
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, account, provider FROM assets "
|
||||
"WHERE account_id IS NULL AND account IS NOT NULL AND account != ''"
|
||||
)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
for asset_id, acc_name, provider in rows:
|
||||
# 同名账号可能多个:优先 platform 与资产 provider 一致的
|
||||
cand = conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, COALESCE(platform,'') FROM accounts WHERE name = :n "
|
||||
"ORDER BY (COALESCE(platform,'') = :prov) DESC, id ASC"
|
||||
),
|
||||
{"n": acc_name, "prov": provider or ""},
|
||||
).fetchall()
|
||||
if cand:
|
||||
conn.execute(
|
||||
sa.text("UPDATE assets SET account_id = :aid WHERE id = :id"),
|
||||
{"aid": cand[0][0], "id": asset_id},
|
||||
)
|
||||
|
||||
|
||||
def _backfill_account_credentials(conn) -> None:
|
||||
"""凭证下沉迁移(幂等,仅在首次加列后产生效果):
|
||||
|
||||
1. 平台凭证下沉:api_config_encrypted 非空的 Provider → 确保存在
|
||||
「{slug}-默认」账号并拷入凭证(不删平台原值,保留可回滚)。
|
||||
2. AI 资产挂靠:account 为空的 ai_agent 资产 → 按 ai_accounts.provider
|
||||
找/建账号并关联;账号无 API 配置时把该资产的 api_key 写入账号配置。
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app.core.crypto import decrypt, encrypt
|
||||
|
||||
def _ensure_account(name: str, platform: str) -> int:
|
||||
# 按 (platform, name) 查,避免同名跨平台账号混淆
|
||||
row = conn.execute(
|
||||
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
|
||||
{"name": name, "platform": platform},
|
||||
).first()
|
||||
if row:
|
||||
return row[0]
|
||||
conn.execute(
|
||||
sa.text("INSERT INTO accounts (name, platform, created_at) VALUES (:name, :platform, :ts)"),
|
||||
{"name": name, "platform": platform, "ts": datetime.now(timezone.utc).replace(tzinfo=None)},
|
||||
)
|
||||
return conn.execute(
|
||||
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
|
||||
{"name": name, "platform": platform},
|
||||
).first()[0]
|
||||
|
||||
# 1. 平台凭证下沉到默认账号
|
||||
for pid, slug, cfg in conn.execute(
|
||||
sa.text(
|
||||
"SELECT id, slug, api_config_encrypted FROM providers "
|
||||
"WHERE api_config_encrypted IS NOT NULL AND api_config_encrypted != ''"
|
||||
)
|
||||
).fetchall():
|
||||
acc_name = f"{slug}-默认"
|
||||
_ensure_account(acc_name, slug)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET api_config_encrypted = :cfg "
|
||||
"WHERE name = :name AND COALESCE(platform,'') = :platform "
|
||||
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
|
||||
),
|
||||
{"cfg": cfg, "name": acc_name, "platform": slug},
|
||||
)
|
||||
|
||||
# 2. 无账号的 AI 资产按 provider 挂靠(account_id 外键 + account 字符串兼容)
|
||||
rows = conn.execute(
|
||||
sa.text(
|
||||
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
|
||||
"JOIN ai_accounts ai ON ai.asset_id = a.id "
|
||||
"WHERE a.asset_type = 'ai_agent' AND a.account_id IS NULL AND (a.account IS NULL OR a.account = '')"
|
||||
)
|
||||
).fetchall()
|
||||
for asset_id, provider in rows:
|
||||
if not provider:
|
||||
continue
|
||||
acc_id = _ensure_account(provider, provider)
|
||||
conn.execute(
|
||||
sa.text("UPDATE assets SET account = :acc, account_id = :aid WHERE id = :id"),
|
||||
{"acc": provider, "aid": acc_id, "id": asset_id},
|
||||
)
|
||||
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
|
||||
key_row = conn.execute(
|
||||
sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"),
|
||||
{"id": asset_id},
|
||||
).first()
|
||||
if key_row and key_row[0]:
|
||||
api_key = decrypt(key_row[0])
|
||||
if api_key:
|
||||
cfg_json = json.dumps({"api_key": api_key})
|
||||
cfg_enc = encrypt(cfg_json)
|
||||
if cfg_enc:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE accounts SET api_config_encrypted = :cfg "
|
||||
"WHERE name = :name AND COALESCE(platform,'') = :platform "
|
||||
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
|
||||
),
|
||||
{"cfg": cfg_enc, "name": provider, "platform": provider},
|
||||
)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""资产库会话(默认)"""
|
||||
with Session(assets_engine) as session:
|
||||
"""资产库会话(默认)
|
||||
|
||||
expire_on_commit=False:commit 后不失效对象属性,避免后续访问触发隐式
|
||||
重新加载查询;需要最新值的场景由调用方显式 session.refresh()。
|
||||
"""
|
||||
with Session(assets_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def get_metrics_session() -> Generator[Session, None, None]:
|
||||
"""监控 / 日志库会话"""
|
||||
with Session(metrics_engine) as session:
|
||||
with Session(metrics_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
+106
-15
@@ -8,75 +8,166 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.seed import seed_providers
|
||||
from app.database import assets_engine, init_db
|
||||
from app.routers import agent, assets, monitor, notify, providers, stats, sync
|
||||
from app.routers import accounts, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
|
||||
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _asset_version() -> str:
|
||||
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
|
||||
|
||||
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
||||
时存下的条目会被视为新鲜而不再回源验证)。
|
||||
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
|
||||
避免每次页面请求都遍历 stat 整个 static 目录。
|
||||
"""
|
||||
global _ASSET_VERSION_CACHE
|
||||
if _ASSET_VERSION_CACHE is None:
|
||||
latest = 0
|
||||
for p in STATIC_DIR.rglob("*"):
|
||||
if p.is_file():
|
||||
latest = max(latest, int(p.stat().st_mtime))
|
||||
_ASSET_VERSION_CACHE = str(latest)
|
||||
return _ASSET_VERSION_CACHE
|
||||
|
||||
|
||||
_ASSET_VERSION_CACHE: str | None = None
|
||||
|
||||
|
||||
def _read_app_version() -> str:
|
||||
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev)"""
|
||||
try:
|
||||
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
|
||||
except OSError:
|
||||
return "dev"
|
||||
|
||||
|
||||
def _read_git_commit() -> str:
|
||||
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown)"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return out.stdout.strip() or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
APP_VERSION = _read_app_version()
|
||||
GIT_COMMIT = _read_git_commit()
|
||||
|
||||
# 全局日志配置:统一格式,便于生产环境排查
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
|
||||
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
|
||||
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用启动时自动建表并初始化预设平台"""
|
||||
"""应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务"""
|
||||
init_db()
|
||||
with Session(assets_engine) as session:
|
||||
seed_providers(session)
|
||||
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
|
||||
yield
|
||||
cleanup_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
|
||||
app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
|
||||
|
||||
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
|
||||
logging.getLogger(__name__).info("vps-manager 启动:版本 %s,commit %s", APP_VERSION, GIT_COMMIT)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
|
||||
allow_credentials=True,
|
||||
)
|
||||
|
||||
app.include_router(assets.router)
|
||||
app.include_router(providers.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(credentials.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(notify.router)
|
||||
app.include_router(ssl.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
|
||||
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
|
||||
class NoCacheStaticFiles(StarletteStaticFiles):
|
||||
def file_response(self, *args, **kwargs):
|
||||
resp = super().file_response(*args, **kwargs)
|
||||
resp.headers.setdefault("Cache-Control", "no-cache")
|
||||
return resp
|
||||
|
||||
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def index() -> HTMLResponse:
|
||||
"""前端 SPA 入口"""
|
||||
html = jinja_env.get_template("index.html").render(app_name=settings.APP_NAME)
|
||||
return HTMLResponse(html)
|
||||
"""前端 SPA 入口
|
||||
|
||||
no-cache:HTML 必须每次回源校验,否则浏览器启发式缓存会持有旧 HTML,
|
||||
其中引用的 ?v= 静态资源版本号也是旧的,导致部署后用户看到旧版。
|
||||
"""
|
||||
html = jinja_env.get_template("index.html").render(
|
||||
app_name=settings.APP_NAME, asset_version=_asset_version(),
|
||||
app_version=APP_VERSION, git_commit=GIT_COMMIT,
|
||||
)
|
||||
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@app.get("/sw.js", include_in_schema=False)
|
||||
def service_worker() -> FileResponse:
|
||||
"""Service Worker(置于根路径以使 scope 覆盖全站)"""
|
||||
"""Service Worker(置于根路径以使 scope 覆盖全站)
|
||||
|
||||
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
|
||||
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
|
||||
"""
|
||||
return FileResponse(
|
||||
STATIC_DIR / "sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Service-Worker-Allowed": "/"},
|
||||
headers={
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check() -> dict:
|
||||
"""健康检查(version 动态读取,便于验证自动更新)"""
|
||||
return {"status": "ok", "app": "vps-manager", "version": app.version}
|
||||
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本)"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"app": "vps-manager",
|
||||
"version": APP_VERSION,
|
||||
"commit": GIT_COMMIT,
|
||||
}
|
||||
|
||||
+51
-5
@@ -1,18 +1,22 @@
|
||||
"""资产数据库模型
|
||||
|
||||
包含四种核心资产模型:
|
||||
包含五种核心模型:
|
||||
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
|
||||
- VPSDetail: VPS 资产详情(与 Asset 一对一关联)
|
||||
- DomainDetail: 域名资产详情(与 Asset 一对一关联)
|
||||
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
|
||||
- Account: 平台账号字典(Asset.account_id 外键引用)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index, UniqueConstraint
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class AssetType(str, Enum):
|
||||
"""资产类型"""
|
||||
@@ -38,6 +42,8 @@ class Asset(SQLModel, table=True):
|
||||
"""资产主表:所有资产在此统一登记,用于续费提醒与状态总览"""
|
||||
|
||||
__tablename__ = "assets"
|
||||
# SDK 同步去重查询:provider_id + external_id + asset_type
|
||||
__table_args__ = (Index("ix_assets_provider_ext_type", "provider_id", "external_id", "asset_type"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, description="资产名称,如:博客主站 VPS")
|
||||
@@ -59,7 +65,10 @@ class Asset(SQLModel, table=True):
|
||||
default=None, description="取消订阅网址(去哪取消)"
|
||||
)
|
||||
account: Optional[str] = Field(
|
||||
default=None, description="所属账号标识(如 Cloudflare 账号邮箱)"
|
||||
default=None, description="所属账号标识(历史字段,迁移后由 account_id 取代,仅作迁移数据源)"
|
||||
)
|
||||
account_id: Optional[int] = Field(
|
||||
default=None, foreign_key="accounts.id", index=True, description="所属账号 ID(外键关联 accounts)"
|
||||
)
|
||||
expiry_date: Optional[date] = Field(
|
||||
default=None, index=True, description="到期/续费日期,用于续费提醒"
|
||||
@@ -72,10 +81,10 @@ class Asset(SQLModel, table=True):
|
||||
default=False, description="已归档:不再使用但可能仍在续费,需重点排查"
|
||||
)
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow,
|
||||
sa_column_kwargs={"onupdate": datetime.utcnow},
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
|
||||
@@ -190,3 +199,40 @@ class CloudflareDetail(SQLModel, table=True):
|
||||
default=None, index=True, description="关联的 zone/域名"
|
||||
)
|
||||
status: Optional[str] = Field(default=None, description="子资产状态")
|
||||
|
||||
|
||||
class Account(SQLModel, table=True):
|
||||
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
|
||||
|
||||
资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
|
||||
凭证层:登录凭据(用户名/密码/2FA)统一存 credentials 表(唯一事实源),
|
||||
本表通过 credential_id 关联;API 配置 JSON(SDK 同步用)仍留在本表。
|
||||
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
|
||||
"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
# (platform, name) 联合唯一:同一邮箱/用户名可跨平台复用,同平台内不允许重名
|
||||
__table_args__ = (UniqueConstraint("platform", "name", name="uq_accounts_platform_name"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, description="账号标识(如邮箱/用户名)")
|
||||
platform: Optional[str] = Field(
|
||||
default=None, index=True, description="所属平台(slug 或名称)"
|
||||
)
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱")
|
||||
login_password_encrypted: Optional[str] = Field(
|
||||
default=None, description="(已弃用)登录密码已迁至 credentials 表,仅留历史值"
|
||||
)
|
||||
credential_id: Optional[int] = Field(
|
||||
default=None, foreign_key="credentials.id", index=True,
|
||||
description="关联登录凭据 ID(登录密码唯一事实源在 credentials 表)"
|
||||
)
|
||||
api_config_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 API 配置 JSON(access_key/secret/api_key 等,供 SDK 同步)"
|
||||
)
|
||||
last_synced_at: Optional[datetime] = Field(
|
||||
default=None, description="最近一次 SDK 同步时间"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""登录凭据模型(密码库)
|
||||
|
||||
Credential 是登录凭据的唯一事实源,粒度 = 站点 × 登录方式:
|
||||
- 同一邮箱注册多个站点 = 多条记录(username 重复是常态而非冗余)
|
||||
- 密码相同也各存一份(改密逐站发生,不做共享联动)
|
||||
- 授权登录(OAuth)是正常条目:password 为空 + login_type=oauth
|
||||
- 2FA(TOTP secret)挂在凭据上,是登录凭据的一部分
|
||||
|
||||
平台账号(Account)通过 credential_id 关联本表,账号密码读写全部重定向到此;
|
||||
普通网站/邮箱等游离凭据直接建在本表,与平台/资产体系解耦。
|
||||
|
||||
敏感字段(password / otp secret)用 Fernet 加密存储(MASTER_KEY 不入库),
|
||||
Read schema 永不返回明文/密文,只给 has_password / has_otp 布尔标记。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class LoginType(str, Enum):
|
||||
"""登录方式"""
|
||||
|
||||
PASSWORD = "password" # 用户名 + 密码
|
||||
OAUTH = "oauth" # 授权登录(Google/Apple/GitHub 等,本站无密码)
|
||||
OTHER = "other" # 其他(魔法链接、硬件 key 等)
|
||||
|
||||
|
||||
class Credential(SQLModel, table=True):
|
||||
"""登录凭据(密码库条目)
|
||||
|
||||
不设 (site, username) 库级唯一约束:SQLite 对 NULL 不友好,且同站多账号合法;
|
||||
重复录入由服务层提示(允许继续)。
|
||||
"""
|
||||
|
||||
__tablename__ = "credentials"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
site: str = Field(index=True, description="站点/服务名,如 GitHub、阿里云")
|
||||
username: Optional[str] = Field(
|
||||
default=None, index=True, description="登录用户名/邮箱"
|
||||
)
|
||||
login_type: LoginType = Field(
|
||||
default=LoginType.PASSWORD, index=True, description="登录方式"
|
||||
)
|
||||
oauth_provider: Optional[str] = Field(
|
||||
default=None, description="授权登录来源:google/apple/github/wechat 等"
|
||||
)
|
||||
password_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的登录密码(Fernet);oauth 登录为空"
|
||||
)
|
||||
otp_secret_encrypted: Optional[str] = Field(
|
||||
default=None, description="加密的 TOTP base32 secret(2FA),动态码由服务端生成"
|
||||
)
|
||||
url: Optional[str] = Field(default=None, description="登录页地址")
|
||||
note: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
@@ -9,17 +9,21 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Index
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class MetricPoint(SQLModel, table=True):
|
||||
"""资源监控时序数据"""
|
||||
|
||||
__tablename__ = "metric_points"
|
||||
__table_args__ = (Index("ix_metric_points_asset_ts", "asset_id", "ts"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(index=True, description="关联资产 ID")
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True, description="采集时间")
|
||||
ts: datetime = Field(default_factory=utcnow, index=True, description="采集时间")
|
||||
cpu_pct: Optional[float] = Field(default=None, description="CPU 使用率 %")
|
||||
mem_pct: Optional[float] = Field(default=None, description="内存使用率 %")
|
||||
disk_pct: Optional[float] = Field(default=None, description="磁盘使用率 %")
|
||||
@@ -46,17 +50,18 @@ class ServerInfo(SQLModel, table=True):
|
||||
public_ip: Optional[str] = Field(default=None)
|
||||
tailscale_ip: Optional[str] = Field(default=None)
|
||||
status: Optional[str] = Field(default="online", description="online/offline")
|
||||
last_seen: datetime = Field(default_factory=datetime.utcnow, description="最近上报时间")
|
||||
last_seen: datetime = Field(default_factory=utcnow, description="最近上报时间")
|
||||
|
||||
|
||||
class SecurityCheck(SQLModel, table=True):
|
||||
"""安全检查项结果"""
|
||||
|
||||
__tablename__ = "security_checks"
|
||||
__table_args__ = (Index("ix_security_checks_asset_ts", "asset_id", "ts"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(index=True)
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
ts: datetime = Field(default_factory=utcnow, index=True)
|
||||
check_item: str = Field(description="检查项,如 ssh_config/firewall/updates")
|
||||
status: str = Field(default="unknown", description="pass/warn/fail/unknown")
|
||||
detail: Optional[str] = Field(default=None, description="检查详情")
|
||||
@@ -69,7 +74,7 @@ class EventLog(SQLModel, table=True):
|
||||
__tablename__ = "event_logs"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
ts: datetime = Field(default_factory=datetime.utcnow, index=True)
|
||||
ts: datetime = Field(default_factory=utcnow, index=True)
|
||||
level: str = Field(default="info", index=True, description="info/warning/error")
|
||||
source: str = Field(default="system", description="来源,如 agent/api/backup")
|
||||
asset_id: Optional[int] = Field(default=None, index=True)
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class ProviderCategory(str, Enum):
|
||||
"""平台分类"""
|
||||
@@ -31,6 +33,9 @@ class Provider(SQLModel, table=True):
|
||||
name: str = Field(index=True, description="显示名,如 阿里云")
|
||||
name_en: Optional[str] = Field(default=None, description="英文名")
|
||||
category: ProviderCategory = Field(index=True, description="平台分类")
|
||||
services: str = Field(
|
||||
default="", description="提供的服务列表(逗号分隔):vps/domain/ai_agent/cloudflare/ssl_cert/cdn/dns/other,综合平台可多项"
|
||||
)
|
||||
website: Optional[str] = Field(default=None, description="官网")
|
||||
console_url: Optional[str] = Field(default=None, description="管理面板 URL")
|
||||
sdk_type: Optional[str] = Field(
|
||||
@@ -44,4 +49,4 @@ class Provider(SQLModel, table=True):
|
||||
last_synced_at: Optional[datetime] = Field(
|
||||
default=None, description="最近一次 SDK 同步时间"
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow, description="创建时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""SSL 相关数据库模型
|
||||
|
||||
- Subdomain: 域名资产下的子域名记录(关联域名资产 Asset)
|
||||
- SiteCert: 站点证书监控记录(探测 https 站点证书的到期情况)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
|
||||
|
||||
class Subdomain(SQLModel, table=True):
|
||||
"""子域名记录(挂在域名资产下)"""
|
||||
|
||||
__tablename__ = "subdomains"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
asset_id: int = Field(
|
||||
foreign_key="assets.id", index=True, description="所属域名资产 ID"
|
||||
)
|
||||
host: str = Field(index=True, description="子域名主机名,如 www / api / blog(@ 表示根域名)")
|
||||
record_type: Optional[str] = Field(
|
||||
default=None, description="DNS 记录类型:A/CNAME/AAAA/MX/TXT"
|
||||
)
|
||||
record_value: Optional[str] = Field(
|
||||
default=None, description="DNS 记录值(IP 或目标域名)"
|
||||
)
|
||||
is_active: bool = Field(default=True, description="是否启用/解析中")
|
||||
note: Optional[str] = Field(default=None, description="备注(用途)")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
|
||||
|
||||
class SiteCert(SQLModel, table=True):
|
||||
"""站点证书监控记录(探测 https 站点证书到期情况)"""
|
||||
|
||||
__tablename__ = "site_certs"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
hostname: str = Field(index=True, description="探测目标主机名,如 www.example.com")
|
||||
port: int = Field(default=443, description="探测端口")
|
||||
asset_id: Optional[int] = Field(
|
||||
default=None, foreign_key="assets.id", index=True, description="关联资产 ID(可选)"
|
||||
)
|
||||
issuer: Optional[str] = Field(default=None, description="签发机构 CN")
|
||||
subject_cn: Optional[str] = Field(default=None, description="证书主体 CN")
|
||||
valid_from: Optional[date] = Field(default=None, description="证书生效日期")
|
||||
valid_to: Optional[date] = Field(default=None, description="证书到期日期")
|
||||
fingerprint: Optional[str] = Field(default=None, description="证书指纹 SHA-256")
|
||||
status: str = Field(
|
||||
default="unknown",
|
||||
index=True,
|
||||
description="状态:valid/expiring/expired/error/unknown",
|
||||
)
|
||||
error: Optional[str] = Field(default=None, description="探测失败原因")
|
||||
last_checked_at: Optional[datetime] = Field(default=None, description="最近探测时间")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
updated_at: datetime = Field(
|
||||
default_factory=utcnow,
|
||||
sa_column_kwargs={"onupdate": utcnow},
|
||||
description="更新时间",
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""平台账号 CRUD 路由
|
||||
|
||||
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||
资产的 Asset.account_id 外键引用账号;删除账号时引用资产的 account_id 置 NULL。
|
||||
凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.services import account_service, sync_service
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AccountRead], summary="账号列表(含关联资产数)")
|
||||
def list_accounts(session: Session = Depends(get_session)):
|
||||
return account_service.list_accounts(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AccountRead,
|
||||
status_code=201,
|
||||
summary="创建账号",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_account(data: AccountCreate, session: Session = Depends(get_session)):
|
||||
return account_service.create_account(session, data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{account_id}",
|
||||
response_model=AccountRead,
|
||||
summary="更新账号(重命名会同步更新引用该账号的资产)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_account(
|
||||
account_id: int, data: AccountUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
return account_service.update_account(session, account_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
summary="删除账号(资产中已填的账号名不受影响)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return account_service.delete_account(session, account_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{account_id}/password",
|
||||
summary="查看账号登录密码明文(解密返回)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def reveal_password(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return account_service.reveal_password(session, account_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{account_id}/test",
|
||||
summary="测试账号凭证有效性(按账号所属平台的 SDK)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def test_account(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.test_account(session, account_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{account_id}/sync",
|
||||
summary="同步账号资产到本地库(产出自动挂到该账号名下)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def sync_account(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return sync_service.sync_account(session, account_id)
|
||||
+43
-4
@@ -1,17 +1,53 @@
|
||||
"""Agent 上报接收路由(数据写入 metrics.db)"""
|
||||
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import require_agent_key
|
||||
from app.database import get_metrics_session
|
||||
from app.core.timeutils import utcnow
|
||||
from app.database import assets_engine, get_metrics_session
|
||||
from app.models.asset import Asset
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||
from app.schemas.agent import AgentReport
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
|
||||
# 内存级频率限制:{asset_id: 上次上报的 monotonic 时间戳}
|
||||
# 同步路由运行在线程池,多线程并发读写需加锁保证“读-判断-写”原子性
|
||||
_last_report_ts: dict = {}
|
||||
_rate_limit_lock = threading.Lock()
|
||||
|
||||
|
||||
def _check_rate_limit(asset_id: int) -> None:
|
||||
"""限制同一资产的上报频率,防止配置错误的 Agent 高频写入填满数据库"""
|
||||
interval = settings.AGENT_REPORT_MIN_INTERVAL
|
||||
if interval <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
with _rate_limit_lock:
|
||||
last = _last_report_ts.get(asset_id)
|
||||
if last is not None and (now - last) < interval:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"上报过于频繁,同一资产最小间隔 {interval} 秒",
|
||||
)
|
||||
_last_report_ts[asset_id] = now
|
||||
|
||||
|
||||
def _validate_asset_id(asset_id: int) -> None:
|
||||
"""校验上报的 asset_id 在资产库中真实存在,防止脏数据写入"""
|
||||
with Session(assets_engine) as session:
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"asset_id={asset_id} 不存在,请先在资产列表中登记该服务器",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/report",
|
||||
@@ -19,6 +55,9 @@ router = APIRouter(prefix="/api/agent", tags=["agent"])
|
||||
dependencies=[Depends(require_agent_key)],
|
||||
)
|
||||
def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -> dict:
|
||||
_validate_asset_id(data.asset_id)
|
||||
_check_rate_limit(data.asset_id)
|
||||
|
||||
# 资源监控时序
|
||||
if data.metrics:
|
||||
session.add(MetricPoint(asset_id=data.asset_id, **data.metrics.model_dump()))
|
||||
@@ -26,7 +65,7 @@ def report(data: AgentReport, session: Session = Depends(get_metrics_session)) -
|
||||
# 服务器信息快照(每资产一条,覆盖更新)
|
||||
if data.server_info:
|
||||
info = data.server_info.model_dump()
|
||||
info["last_seen"] = datetime.utcnow()
|
||||
info["last_seen"] = utcnow()
|
||||
existing = session.exec(
|
||||
select(ServerInfo).where(ServerInfo.asset_id == data.asset_id)
|
||||
).first()
|
||||
|
||||
@@ -20,19 +20,20 @@ router = APIRouter(prefix="/api/assets", tags=["assets"])
|
||||
@router.get("", response_model=List[AssetRead], summary="资产列表")
|
||||
def list_assets(
|
||||
asset_type: Optional[AssetType] = Query(default=None, description="按类型筛选"),
|
||||
status: Optional[AssetStatus] = Query(default=None, description="按状态筛选"),
|
||||
status_filter: Optional[AssetStatus] = Query(default=None, alias="status", description="按状态筛选"),
|
||||
is_archived: Optional[bool] = Query(default=None, description="按归档标记筛选"),
|
||||
q: Optional[str] = Query(default=None, description="搜索名称/服务商"),
|
||||
provider: Optional[str] = Query(default=None, description="按平台筛选(slug 或平台 ID)"),
|
||||
sort: str = Query(default="expiry_date", description="排序字段"),
|
||||
order: str = Query(default="asc", description="排序方向 asc/desc"),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return asset_service.list_assets(
|
||||
session, asset_type, status, is_archived, q, sort, order
|
||||
session, asset_type, status_filter, is_archived, q, provider, sort, order
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export", summary="导出所有资产(数据迁移/备份)")
|
||||
@router.get("/export", summary="导出所有资产(数据迁移/备份)", dependencies=[Depends(require_api_key)])
|
||||
def export_assets(session: Session = Depends(get_session)) -> dict:
|
||||
return asset_service.export_assets(session)
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""登录凭据(密码库)CRUD 路由
|
||||
|
||||
统一入口 /api/credentials,写操作(POST/PUT/DELETE)受 API Key 保护
|
||||
(Tailscale 内网/本机放行,外部来源必须携带 X-API-Key)。
|
||||
|
||||
- 明文密码经 /{id}/password 专用接口解密返回(与账号 reveal 语义一致)
|
||||
- 2FA 动态码由服务端生成(secret 永不出库),/{id}/otp 响应 no-store 防缓存残留
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.credential import (
|
||||
CredentialCreate,
|
||||
CredentialRead,
|
||||
CredentialUpdate,
|
||||
OtpBindRequest,
|
||||
)
|
||||
from app.services import credential_service
|
||||
|
||||
router = APIRouter(prefix="/api/credentials", tags=["credentials"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[CredentialRead], summary="凭据列表(密码库,支持搜索/过滤)")
|
||||
def list_credentials(
|
||||
q: Optional[str] = Query(default=None, description="搜索站点/用户名/备注"),
|
||||
login_type: Optional[str] = Query(default=None, description="登录方式:password/oauth/other"),
|
||||
has_otp: Optional[bool] = Query(default=None, description="是否已绑定 2FA"),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return credential_service.list_credentials(
|
||||
session, q=q, login_type=login_type, has_otp=has_otp
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=CredentialRead,
|
||||
status_code=201,
|
||||
summary="创建凭据(可同时绑定 2FA,需当前动态码校验)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_credential(data: CredentialCreate, session: Session = Depends(get_session)):
|
||||
return credential_service.create_credential(session, data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{credential_id}",
|
||||
response_model=CredentialRead,
|
||||
summary="更新凭据(密码:留空不改,空串清除)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_credential(
|
||||
credential_id: int, data: CredentialUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
return credential_service.update_credential(session, credential_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}",
|
||||
summary="删除凭据(账号/资产不受影响,仅解除关联)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_credential(credential_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return credential_service.delete_credential(session, credential_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{credential_id}/password",
|
||||
summary="查看凭据密码明文(解密返回)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def reveal_password(credential_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return credential_service.reveal_password(session, credential_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{credential_id}/otp",
|
||||
summary="获取当前 2FA 动态码(服务端生成,secret 不出库)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def current_otp(
|
||||
credential_id: int, response: Response, session: Session = Depends(get_session)
|
||||
) -> dict:
|
||||
# 动态码禁止缓存(浏览器/代理),避免有效期内残留
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return credential_service.current_otp(session, credential_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
summary="绑定 2FA(secret + 当前动态码,校验通过才落库)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def bind_otp(
|
||||
credential_id: int, data: OtpBindRequest, session: Session = Depends(get_session)
|
||||
):
|
||||
return credential_service.bind_otp(session, credential_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{credential_id}/otp",
|
||||
response_model=CredentialRead,
|
||||
summary="解绑 2FA",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def unbind_otp(credential_id: int, session: Session = Depends(get_session)):
|
||||
return credential_service.unbind_otp(session, credential_id)
|
||||
+17
-14
@@ -5,9 +5,10 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_metrics_session
|
||||
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
|
||||
from app.services import security_service
|
||||
from app.models.monitor import MetricPoint, ServerInfo
|
||||
from app.services import cleanup_service, security_service
|
||||
|
||||
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||
|
||||
@@ -17,6 +18,19 @@ def security_overview(session: Session = Depends(get_metrics_session)):
|
||||
return security_service.get_security_overview(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cleanup",
|
||||
summary="手动触发监控数据清理(按保留周期删除过期数据)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def cleanup(
|
||||
metrics_days: Optional[int] = Query(default=None, description="时序数据保留天数(默认取配置)"),
|
||||
security_days: Optional[int] = Query(default=None, description="安全检查保留天数(默认取配置)"),
|
||||
event_log_days: Optional[int] = Query(default=None, description="事件日志保留天数(默认取配置)"),
|
||||
):
|
||||
return cleanup_service.cleanup_metrics(metrics_days, security_days, event_log_days)
|
||||
|
||||
|
||||
@router.get("/{asset_id}/metrics", summary="资源监控时序(倒序,最新在前)")
|
||||
def metrics(
|
||||
asset_id: int,
|
||||
@@ -41,18 +55,7 @@ def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Opti
|
||||
|
||||
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
|
||||
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
|
||||
stmt = (
|
||||
select(SecurityCheck)
|
||||
.where(SecurityCheck.asset_id == asset_id)
|
||||
.order_by(SecurityCheck.ts.desc())
|
||||
.limit(50)
|
||||
)
|
||||
checks = session.exec(stmt).all()
|
||||
latest_by_item = {}
|
||||
for check in checks:
|
||||
if check.check_item not in latest_by_item:
|
||||
latest_by_item[check.check_item] = check
|
||||
return list(latest_by_item.values())
|
||||
return security_service.latest_checks(session, asset_id)
|
||||
|
||||
|
||||
@router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""SSL 监控路由:子域名管理与站点证书监控
|
||||
|
||||
- /api/subdomains:域名资产下的子域名 CRUD
|
||||
- /api/site-certs:https 站点证书的探测与监控
|
||||
写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.ssl import SiteCertCreate, SubdomainCreate, SubdomainUpdate
|
||||
from app.services import ssl_service
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["ssl"])
|
||||
|
||||
|
||||
def _guard(exc: ValueError) -> HTTPException:
|
||||
"""service 抛出的 ValueError 统一转 404"""
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
|
||||
|
||||
# ---------------- 子域名 ----------------
|
||||
|
||||
@router.get("/subdomains", summary="子域名列表(可按域名资产筛选)")
|
||||
def list_subdomains(
|
||||
asset_id: Optional[int] = Query(default=None, description="按域名资产 ID 筛选"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list:
|
||||
return ssl_service.list_subdomains(session, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/subdomains",
|
||||
status_code=201,
|
||||
summary="创建子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_subdomain(data: SubdomainCreate, session: Session = Depends(get_session)) -> dict:
|
||||
sub = ssl_service.create_subdomain(session, data.model_dump())
|
||||
return {
|
||||
"id": sub.id,
|
||||
"asset_id": sub.asset_id,
|
||||
"host": sub.host,
|
||||
"record_type": sub.record_type,
|
||||
"record_value": sub.record_value,
|
||||
"is_active": sub.is_active,
|
||||
"note": sub.note,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/subdomains/{sub_id}",
|
||||
summary="更新子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_subdomain(sub_id: int, data: SubdomainUpdate, session: Session = Depends(get_session)) -> dict:
|
||||
try:
|
||||
sub = ssl_service.update_subdomain(session, sub_id, data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
return {
|
||||
"id": sub.id,
|
||||
"asset_id": sub.asset_id,
|
||||
"host": sub.host,
|
||||
"record_type": sub.record_type,
|
||||
"record_value": sub.record_value,
|
||||
"is_active": sub.is_active,
|
||||
"note": sub.note,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/subdomains/{sub_id}",
|
||||
status_code=204,
|
||||
summary="删除子域名",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_subdomain(sub_id: int, session: Session = Depends(get_session)):
|
||||
try:
|
||||
ssl_service.delete_subdomain(session, sub_id)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
|
||||
|
||||
# ---------------- 站点证书监控 ----------------
|
||||
|
||||
@router.get("/site-certs", summary="站点证书监控列表(可按状态/资产筛选)")
|
||||
def list_site_certs(
|
||||
status: Optional[str] = Query(default=None, description="按状态筛选"),
|
||||
asset_id: Optional[int] = Query(default=None, description="按关联资产筛选"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list:
|
||||
return ssl_service.list_site_certs(session, status, asset_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs",
|
||||
status_code=201,
|
||||
summary="创建证书探测目标(创建后立即探测一次)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_site_cert(data: SiteCertCreate, session: Session = Depends(get_session)) -> dict:
|
||||
try:
|
||||
cert = ssl_service.create_site_cert(
|
||||
session, data.hostname, data.port, data.asset_id
|
||||
)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
return ssl_service._to_dict(cert)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs/check-all",
|
||||
summary="全量重新探测所有站点证书",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def check_all_site_certs(session: Session = Depends(get_session)) -> dict:
|
||||
return ssl_service.check_all_site_certs(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/site-certs/{cert_id}/check",
|
||||
summary="重新探测单条站点证书",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def check_one_site_cert(cert_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
cert = session.get(ssl_service.SiteCert, cert_id)
|
||||
if not cert:
|
||||
raise HTTPException(status_code=404, detail=f"证书监控记录不存在(id={cert_id})")
|
||||
ssl_service.check_one(session, cert)
|
||||
return ssl_service._to_dict(cert)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/site-certs/{cert_id}",
|
||||
status_code=204,
|
||||
summary="删除证书探测目标",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_site_cert(cert_id: int, session: Session = Depends(get_session)):
|
||||
try:
|
||||
ssl_service.delete_site_cert(session, cert_id)
|
||||
except ValueError as e:
|
||||
raise _guard(e) from e
|
||||
@@ -0,0 +1,45 @@
|
||||
"""平台账号 Schema
|
||||
|
||||
凭证语义约定:
|
||||
- login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。
|
||||
- login_password 的唯一事实源在 credentials 表(账号通过 credential_id 关联),
|
||||
本 schema 字段仅为录入入口,服务层重定向写入凭据。
|
||||
- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config / has_otp)。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class AccountBase(SQLModel):
|
||||
name: str
|
||||
platform: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
login_user: Optional[str] = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
login_password: Optional[str] = None # 登录密码(明文传入,加密存储)
|
||||
api_config: Optional[str] = None # API 配置 JSON 字符串(加密存储,供 SDK 同步)
|
||||
|
||||
|
||||
class AccountUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
login_user: Optional[str] = None
|
||||
login_password: Optional[str] = None # None = 不修改
|
||||
api_config: Optional[str] = None # None = 不修改
|
||||
|
||||
|
||||
class AccountRead(AccountBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||
has_login_password: bool = False
|
||||
has_api_config: bool = False
|
||||
credential_id: Optional[int] = None # 关联的登录凭据(密码唯一事实源)
|
||||
has_otp: bool = False # 关联凭据是否绑定了 2FA
|
||||
last_synced_at: Optional[datetime] = None
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
@@ -40,4 +41,5 @@ class AgentReport(SQLModel):
|
||||
asset_id: int
|
||||
metrics: Optional[MetricsIn] = None
|
||||
server_info: Optional[ServerInfoIn] = None
|
||||
security: Optional[List[SecurityCheckIn]] = None
|
||||
# 限制单次上报的检查项数量,防止恶意/异常 Agent 发送超大数组撑爆内存
|
||||
security: Optional[List[SecurityCheckIn]] = Field(default=None, max_length=100)
|
||||
|
||||
@@ -84,7 +84,7 @@ class AssetBase(SQLModel):
|
||||
renewal_cycle: Optional[str] = None
|
||||
renew_url: Optional[str] = None
|
||||
cancel_url: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
account_id: Optional[int] = None
|
||||
expiry_date: Optional[date] = None
|
||||
auto_renew: bool = False
|
||||
cost: float = 0
|
||||
@@ -113,7 +113,7 @@ class AssetUpdate(SQLModel):
|
||||
renewal_cycle: Optional[str] = None
|
||||
renew_url: Optional[str] = None
|
||||
cancel_url: Optional[str] = None
|
||||
account: Optional[str] = None
|
||||
account_id: Optional[int] = None
|
||||
expiry_date: Optional[date] = None
|
||||
auto_renew: Optional[bool] = None
|
||||
cost: Optional[float] = None
|
||||
@@ -185,6 +185,7 @@ class AssetRead(AssetBase):
|
||||
updated_at: datetime
|
||||
days_to_expiry: Optional[int] = None
|
||||
provider_name: Optional[str] = None
|
||||
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
|
||||
vps_detail: Optional[VPSDetailRead] = None
|
||||
domain_detail: Optional[DomainDetailRead] = None
|
||||
ai_detail: Optional[AIAccountRead] = None
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""登录凭据 Schema(密码库)
|
||||
|
||||
凭证语义约定(与账号一致):
|
||||
- password / otp_secret:Create 时传入即加密存储;Update 时 None 表示不修改、'' 表示清除。
|
||||
- 2FA 绑定必须携带当前 6 位动态码(otp_code)做服务端校验,防 secret 手误成废条目。
|
||||
- Read 永不返回明文/密文,只给 has_password / has_otp 布尔标记;明文经专用 reveal 接口。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.models.credential import LoginType
|
||||
|
||||
|
||||
class CredentialBase(SQLModel):
|
||||
site: str
|
||||
username: Optional[str] = None
|
||||
login_type: LoginType = LoginType.PASSWORD
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class CredentialCreate(CredentialBase):
|
||||
password: Optional[str] = None # 明文传入,加密存储
|
||||
otp_secret: Optional[str] = None # base32 secret 或 otpauth:// URI
|
||||
otp_code: Optional[str] = None # 当前 6 位动态码(otp_secret 提供时必填)
|
||||
|
||||
|
||||
class CredentialUpdate(SQLModel):
|
||||
site: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
login_type: Optional[LoginType] = None
|
||||
oauth_provider: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
password: Optional[str] = None # None=不修改,''=清除,非空=重加密
|
||||
|
||||
|
||||
class CredentialRead(CredentialBase):
|
||||
id: int
|
||||
has_password: bool = False
|
||||
has_otp: bool = False
|
||||
duplicate: bool = False # 同 (site, username) 已存在其它条目(录入提示用)
|
||||
account_id: Optional[int] = None # 反向关联的平台账号(展示"来自平台账号")
|
||||
account_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OtpBindRequest(SQLModel):
|
||||
secret: str # base32 secret 或 otpauth:// URI
|
||||
code: str # 当前 6 位动态码(服务端校验通过才落库)
|
||||
@@ -13,6 +13,7 @@ class ProviderBase(SQLModel):
|
||||
name: str
|
||||
name_en: Optional[str] = None
|
||||
category: ProviderCategory
|
||||
services: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
console_url: Optional[str] = None
|
||||
sdk_type: Optional[str] = None
|
||||
@@ -31,6 +32,7 @@ class ProviderUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
name_en: Optional[str] = None
|
||||
category: Optional[ProviderCategory] = None
|
||||
services: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
console_url: Optional[str] = None
|
||||
sdk_type: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""SSL 相关请求模型"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SubdomainCreate(BaseModel):
|
||||
asset_id: int = Field(description="所属域名资产 ID")
|
||||
host: str = Field(min_length=1, max_length=63, description="子域名主机名,如 www/api(@ 表示根域名)")
|
||||
record_type: Optional[str] = Field(default=None, description="DNS 记录类型")
|
||||
record_value: Optional[str] = Field(default=None, description="DNS 记录值")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
note: Optional[str] = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class SubdomainUpdate(BaseModel):
|
||||
host: Optional[str] = Field(default=None, min_length=1, max_length=63)
|
||||
record_type: Optional[str] = Field(default=None)
|
||||
record_value: Optional[str] = Field(default=None)
|
||||
is_active: Optional[bool] = Field(default=None)
|
||||
note: Optional[str] = Field(default=None)
|
||||
|
||||
|
||||
class SiteCertCreate(BaseModel):
|
||||
hostname: str = Field(min_length=1, max_length=253, description="探测目标主机名")
|
||||
port: int = Field(default=443, ge=1, le=65535, description="探测端口")
|
||||
asset_id: Optional[int] = Field(default=None, description="关联资产 ID(可选)")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""平台账号业务逻辑
|
||||
|
||||
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
|
||||
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
|
||||
凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表
|
||||
(credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Account, Asset
|
||||
from app.models.credential import Credential
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.services import credential_service
|
||||
|
||||
|
||||
def _asset_counts(session: Session) -> Dict[int, int]:
|
||||
"""按 account_id 统计引用资产数"""
|
||||
rows = session.exec(
|
||||
select(Asset.account_id, func.count(Asset.id))
|
||||
.where(Asset.account_id.is_not(None)) # type: ignore[union-attr]
|
||||
.group_by(Asset.account_id)
|
||||
).all()
|
||||
return {aid: cnt for aid, cnt in rows}
|
||||
|
||||
|
||||
def _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]:
|
||||
"""批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1)"""
|
||||
wanted = [i for i in ids if i]
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = session.exec(
|
||||
select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr]
|
||||
).all()
|
||||
return {c.id: c for c in rows}
|
||||
|
||||
|
||||
def _cred_of(session: Session, account: Account) -> Optional[Credential]:
|
||||
"""取单个账号关联的凭据(未关联返回 None)"""
|
||||
return session.get(Credential, account.credential_id) if account.credential_id else None
|
||||
|
||||
|
||||
def _to_read(
|
||||
account: Account, counts: Dict[int, int], cred: Optional[Credential] = None
|
||||
) -> AccountRead:
|
||||
read = AccountRead.model_validate(account)
|
||||
read.asset_count = counts.get(account.id, 0)
|
||||
# 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用)
|
||||
read.credential_id = account.credential_id
|
||||
read.has_login_password = bool(cred and cred.password_encrypted)
|
||||
read.has_otp = bool(cred and cred.otp_secret_encrypted)
|
||||
read.has_api_config = bool(account.api_config_encrypted)
|
||||
return read
|
||||
|
||||
|
||||
def list_accounts(session: Session) -> List[AccountRead]:
|
||||
counts = _asset_counts(session)
|
||||
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
|
||||
creds = _credential_map(session, [a.credential_id for a in accounts])
|
||||
return [_to_read(a, counts, creds.get(a.credential_id)) for a in accounts]
|
||||
|
||||
|
||||
def _get_account(session: Session, account_id: int) -> Account:
|
||||
account = session.get(Account, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||
return account
|
||||
|
||||
|
||||
def _norm_platform(platform: str | None) -> str:
|
||||
"""platform 规范化为非空字符串(避免 NULL 绕过 (platform,name) 唯一约束)"""
|
||||
return (platform or "").strip()
|
||||
|
||||
|
||||
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
|
||||
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
|
||||
|
||||
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
|
||||
避免出现同名账号在不同"空平台"上各存一条。
|
||||
"""
|
||||
stmt = select(Account).where(
|
||||
Account.name == name,
|
||||
func.coalesce(Account.platform, "") == platform,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
stmt = stmt.where(Account.id != exclude_id)
|
||||
if session.exec(stmt).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"该平台下账号已存在:{name}",
|
||||
)
|
||||
|
||||
|
||||
def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
name = data.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||
platform = _norm_platform(data.platform)
|
||||
_check_name_taken(session, name, platform)
|
||||
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
# 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id;
|
||||
# 下方 commit 失败(如并发重名)时凭据随事务一并回滚
|
||||
if data.login_password:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
try:
|
||||
session.add(account)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"该平台下账号已存在:{name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
|
||||
account = _get_account(session, account_id)
|
||||
# platform 可能随本次更新变化,校验重名时用更新后的值
|
||||
new_platform = _norm_platform(data.platform) if data.platform is not None else _norm_platform(account.platform)
|
||||
if data.name is not None:
|
||||
new_name = data.name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||
if new_name != account.name or new_platform != _norm_platform(account.platform):
|
||||
_check_name_taken(session, new_name, new_platform, exclude_id=account.id)
|
||||
account.name = new_name
|
||||
if data.platform is not None:
|
||||
account.platform = new_platform
|
||||
if data.remark is not None:
|
||||
account.remark = data.remark or None
|
||||
if data.login_user is not None:
|
||||
account.login_user = data.login_user or None
|
||||
# 登录密码重定向到关联凭据:None=不修改,''=清除,非空=重加密;
|
||||
# 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致)
|
||||
if data.login_password is not None or account.credential_id:
|
||||
credential_service.upsert_for_account(session, account, data.login_password)
|
||||
if data.api_config is not None:
|
||||
account.api_config_encrypted = crypto.encrypt(data.api_config)
|
||||
try:
|
||||
session.add(account)
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"该平台下账号已存在:{account.name}",
|
||||
)
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session), _cred_of(session, account))
|
||||
|
||||
|
||||
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
account = _get_account(session, account_id)
|
||||
# 引用该账号的资产:account_id 置 NULL(保留资产,仅解除关联)
|
||||
assets = session.exec(select(Asset).where(Asset.account_id == account_id)).all()
|
||||
for a in assets:
|
||||
a.account_id = None
|
||||
session.add(a)
|
||||
affected = len(assets)
|
||||
session.delete(account)
|
||||
session.commit()
|
||||
return {"affected_assets": affected}
|
||||
|
||||
|
||||
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
|
||||
"""解密返回账号登录密码明文(唯一事实源在关联凭据)。
|
||||
|
||||
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
|
||||
"""
|
||||
account = _get_account(session, account_id)
|
||||
cred = _cred_of(session, account)
|
||||
plain = crypto.decrypt(cred.password_encrypted) if cred else None
|
||||
if not plain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
|
||||
)
|
||||
return {"login_user": cred.username or account.login_user or "", "password": plain}
|
||||
+188
-61
@@ -3,14 +3,16 @@
|
||||
统一处理 Asset 主表与其一对一详情表(VPSDetail/DomainDetail/AIAccount)的联动。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import (
|
||||
Account,
|
||||
AIAccount,
|
||||
Asset,
|
||||
AssetStatus,
|
||||
@@ -30,6 +32,10 @@ from app.schemas.asset import (
|
||||
VPSDetailRead,
|
||||
)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("vps-manager.assets")
|
||||
|
||||
# 资产类型 -> (AssetCreate/Update 中的字段名, 详情表模型)
|
||||
DETAIL_MAP = {
|
||||
AssetType.VPS: ("vps_detail", VPSDetail),
|
||||
@@ -88,11 +94,29 @@ def _provider_name(session: Session, asset: Asset) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _account_name(session: Session, asset: Asset) -> Optional[str]:
|
||||
"""取关联账号标识(展示用)"""
|
||||
if asset.account_id:
|
||||
account = session.get(Account, asset.account_id)
|
||||
if account:
|
||||
return account.name
|
||||
return None
|
||||
|
||||
|
||||
def _validate_account(session: Session, account_id: Optional[int]) -> None:
|
||||
"""account_id 非空时确认账号存在,防外键悬空"""
|
||||
if account_id is not None and not session.get(Account, account_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号不存在:id={account_id}"
|
||||
)
|
||||
|
||||
|
||||
def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
|
||||
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
|
||||
read = AssetRead.model_validate(asset)
|
||||
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
||||
read.provider_name = _provider_name(session, asset)
|
||||
read.account_name = _account_name(session, asset)
|
||||
if isinstance(detail, VPSDetail):
|
||||
read.vps_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, DomainDetail):
|
||||
@@ -140,11 +164,56 @@ def _apply_detail_update(asset_type: AssetType, existing, detail_in) -> None:
|
||||
setattr(existing, key, value)
|
||||
|
||||
|
||||
def _get_details_batch(session: Session, assets: list) -> dict:
|
||||
"""批量预加载所有资产的详情和 Provider 名,避免 N+1 查询"""
|
||||
if not assets:
|
||||
return {}
|
||||
asset_ids = [a.id for a in assets]
|
||||
# 批量查 Provider 名
|
||||
provider_ids = {a.provider_id for a in assets if a.provider_id}
|
||||
provider_map = {}
|
||||
if provider_ids:
|
||||
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
|
||||
provider_map[p.id] = p.name
|
||||
# 批量查 Account 名
|
||||
account_ids = {a.account_id for a in assets if a.account_id}
|
||||
account_map = {}
|
||||
if account_ids:
|
||||
for acc in session.exec(select(Account).where(Account.id.in_(account_ids))).all():
|
||||
account_map[acc.id] = acc.name
|
||||
# 批量查各类型详情
|
||||
detail_map = {}
|
||||
for asset_type, (_, model) in DETAIL_MAP.items():
|
||||
typed_ids = [a.id for a in assets if a.asset_type == asset_type]
|
||||
if typed_ids:
|
||||
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
|
||||
detail_map[d.asset_id] = d
|
||||
return {"providers": provider_map, "accounts": account_map, "details": detail_map}
|
||||
|
||||
|
||||
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
|
||||
"""用批量预加载的数据组装 AssetRead(避免逐个查询)"""
|
||||
read = AssetRead.model_validate(asset)
|
||||
read.days_to_expiry = _days_to_expiry(asset.expiry_date)
|
||||
read.provider_name = batch["providers"].get(asset.provider_id)
|
||||
read.account_name = batch.get("accounts", {}).get(asset.account_id)
|
||||
detail = batch["details"].get(asset.id)
|
||||
if isinstance(detail, VPSDetail):
|
||||
read.vps_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, DomainDetail):
|
||||
read.domain_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, AIAccount):
|
||||
read.ai_detail = _detail_to_read(detail)
|
||||
elif isinstance(detail, CloudflareDetail):
|
||||
read.cloudflare_detail = _detail_to_read(detail)
|
||||
return read
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情"""
|
||||
def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
|
||||
"""创建资产及详情(不 commit,由调用方统一提交)"""
|
||||
item = DETAIL_MAP.get(data.asset_type)
|
||||
detail_in = None
|
||||
model = None
|
||||
@@ -160,19 +229,24 @@ def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
asset_data = data.model_dump(
|
||||
exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
|
||||
)
|
||||
_validate_account(session, asset_data.get("account_id"))
|
||||
asset = Asset(**asset_data)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,但不提交
|
||||
|
||||
detail = None
|
||||
if model is not None and detail_in is not None:
|
||||
detail = _build_detail(data.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
session.commit()
|
||||
session.refresh(detail)
|
||||
return asset
|
||||
|
||||
return _to_read(session, asset, detail)
|
||||
|
||||
def create_asset(session: Session, data: AssetCreate) -> AssetRead:
|
||||
"""创建资产及其详情(单次事务提交)"""
|
||||
asset = _create_asset_no_commit(session, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
logger.info("创建资产 id=%s name=%s type=%s", asset.id, asset.name, asset.asset_type)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
@@ -183,8 +257,8 @@ def get_asset(session: Session, asset_id: int) -> AssetRead:
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(仅更新传入字段)"""
|
||||
def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate) -> Asset:
|
||||
"""更新资产主表及详情(不 commit,由调用方统一提交)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -192,12 +266,13 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
main_fields = data.model_dump(
|
||||
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
|
||||
)
|
||||
if "account_id" in main_fields:
|
||||
_validate_account(session, main_fields["account_id"])
|
||||
for key, value in main_fields.items():
|
||||
setattr(asset, key, value)
|
||||
session.add(asset)
|
||||
|
||||
# 详情表:以更新后的 asset_type 为准
|
||||
detail = None
|
||||
item = DETAIL_MAP.get(asset.asset_type)
|
||||
if item:
|
||||
field, model = item
|
||||
@@ -209,22 +284,22 @@ def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRea
|
||||
if existing:
|
||||
_apply_detail_update(asset.asset_type, existing, detail_in)
|
||||
session.add(existing)
|
||||
detail = existing
|
||||
else:
|
||||
detail = _build_detail(asset.asset_type, asset.id, detail_in)
|
||||
session.add(detail)
|
||||
else:
|
||||
detail = existing
|
||||
return asset
|
||||
|
||||
|
||||
def update_asset(session: Session, asset_id: int, data: AssetUpdate) -> AssetRead:
|
||||
"""更新资产主表及详情(单次事务提交)"""
|
||||
asset = _update_asset_no_commit(session, asset_id, data)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
if detail is not None:
|
||||
session.refresh(detail)
|
||||
return _to_read(session, asset, detail)
|
||||
return _to_read(session, asset, _get_detail(session, asset))
|
||||
|
||||
|
||||
def delete_asset(session: Session, asset_id: int) -> None:
|
||||
"""删除资产及其详情"""
|
||||
"""删除资产及其详情,并清理 metrics.db 中的关联监控数据(避免孤儿数据)"""
|
||||
asset = session.get(Asset, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="资产不存在")
|
||||
@@ -233,46 +308,77 @@ def delete_asset(session: Session, asset_id: int) -> None:
|
||||
session.delete(detail)
|
||||
session.delete(asset)
|
||||
session.commit()
|
||||
logger.info("删除资产 id=%s name=%s", asset_id, asset.name)
|
||||
_cleanup_metrics_for_asset(asset_id)
|
||||
|
||||
|
||||
def _cleanup_metrics_for_asset(asset_id: int) -> None:
|
||||
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog
|
||||
|
||||
使用批量 DELETE(而非逐行加载后删除):监控时序数据可能上万行,
|
||||
逐行删除会全部载入内存且产生数万次 ORM 操作。
|
||||
"""
|
||||
from sqlmodel import delete
|
||||
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||
|
||||
with Session(metrics_engine) as ms:
|
||||
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
|
||||
ms.exec(delete(model).where(model.asset_id == asset_id))
|
||||
ms.commit()
|
||||
|
||||
|
||||
def export_assets(session: Session) -> dict:
|
||||
"""导出所有资产(含 detail)为 JSON,用于数据迁移/备份"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = []
|
||||
for asset in assets:
|
||||
read = _to_read(session, asset, _get_detail(session, asset))
|
||||
read = _to_read_batch(session, asset, batch)
|
||||
result.append(read.model_dump(mode="json"))
|
||||
return {
|
||||
"count": len(result),
|
||||
"exported_at": datetime.utcnow().isoformat(),
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"assets": result,
|
||||
}
|
||||
|
||||
|
||||
def import_assets(session: Session, assets_data: list) -> dict:
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)"""
|
||||
"""从导出数据导入资产(按 name+asset_type+provider 去重:存在则更新,不存在则创建)
|
||||
|
||||
整个导入在单个事务中完成:全部成功才提交,任何一条失败则回滚,避免部分导入。
|
||||
"""
|
||||
created = 0
|
||||
updated = 0
|
||||
errors = []
|
||||
skip_fields = {"id", "created_at", "updated_at", "days_to_expiry", "provider_name"}
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
update_asset(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
create_asset(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
try:
|
||||
for item in assets_data:
|
||||
try:
|
||||
payload = {k: v for k, v in item.items() if k not in skip_fields}
|
||||
existing = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.name == payload.get("name"),
|
||||
Asset.asset_type == payload.get("asset_type"),
|
||||
Asset.provider == payload.get("provider"),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
_update_asset_no_commit(session, existing.id, AssetUpdate(**payload))
|
||||
updated += 1
|
||||
else:
|
||||
_create_asset_no_commit(session, AssetCreate(**payload))
|
||||
created += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{item.get('name', '?')}: {e}")
|
||||
if errors:
|
||||
session.rollback()
|
||||
return {"created": 0, "updated": 0, "errors": errors}
|
||||
session.commit()
|
||||
except Exception: # noqa: BLE001
|
||||
session.rollback()
|
||||
raise
|
||||
return {"created": created, "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@@ -282,6 +388,7 @@ def list_assets(
|
||||
asset_status: Optional[AssetStatus] = None,
|
||||
is_archived: Optional[bool] = None,
|
||||
q: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
sort: str = "expiry_date",
|
||||
order: str = "asc",
|
||||
) -> List[AssetRead]:
|
||||
@@ -293,6 +400,12 @@ def list_assets(
|
||||
stmt = stmt.where(Asset.status == asset_status)
|
||||
if is_archived is not None:
|
||||
stmt = stmt.where(Asset.is_archived == is_archived)
|
||||
if provider:
|
||||
# 兼容两种关联方式:provider slug 文本或 provider_id 外键
|
||||
conds = [Asset.provider == provider]
|
||||
if provider.isdigit():
|
||||
conds.append(Asset.provider_id == int(provider))
|
||||
stmt = stmt.where(or_(*conds))
|
||||
if q:
|
||||
pattern = f"%{q}%"
|
||||
stmt = stmt.where(Asset.name.like(pattern) | Asset.provider.like(pattern))
|
||||
@@ -301,15 +414,20 @@ def list_assets(
|
||||
stmt = stmt.order_by(sort_col.desc() if order == "desc" else sort_col.asc())
|
||||
|
||||
assets = session.exec(stmt).all()
|
||||
return [_to_read(session, a, _get_detail(session, a)) for a in assets]
|
||||
batch = _get_details_batch(session, assets)
|
||||
return [_to_read_batch(session, a, batch) for a in assets]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 统计
|
||||
# --------------------------------------------------------------------------- #
|
||||
def get_overview(session: Session) -> dict:
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计"""
|
||||
assets = session.exec(select(Asset)).all()
|
||||
"""资产总览:数量分布、异常数、到期预警、支出合计
|
||||
|
||||
性能优化:仅查询统计所需的列,避免加载完整 Asset 实体。
|
||||
"""
|
||||
stmt = select(Asset.asset_type, Asset.status, Asset.expiry_date, Asset.cost)
|
||||
rows = session.exec(stmt).all()
|
||||
today = date.today()
|
||||
|
||||
by_type: dict = {}
|
||||
@@ -319,22 +437,24 @@ def get_overview(session: Session) -> dict:
|
||||
year_cost = 0.0
|
||||
month_cost = 0.0
|
||||
|
||||
for a in assets:
|
||||
by_type[a.asset_type.value] = by_type.get(a.asset_type.value, 0) + 1
|
||||
by_status[a.status.value] = by_status.get(a.status.value, 0) + 1
|
||||
if a.status in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
for asset_type, status_val, expiry_date, cost in rows:
|
||||
tval = asset_type.value if hasattr(asset_type, "value") else asset_type
|
||||
sval = status_val.value if hasattr(status_val, "value") else status_val
|
||||
by_type[tval] = by_type.get(tval, 0) + 1
|
||||
by_status[sval] = by_status.get(sval, 0) + 1
|
||||
if status_val in (AssetStatus.STOPPED, AssetStatus.EXPIRED):
|
||||
abnormal += 1
|
||||
if a.expiry_date:
|
||||
days = (a.expiry_date - today).days
|
||||
if expiry_date:
|
||||
days = (expiry_date - today).days
|
||||
if 0 <= days <= 30:
|
||||
expiring_30 += 1
|
||||
if a.expiry_date.year == today.year:
|
||||
year_cost += a.cost
|
||||
if a.expiry_date.month == today.month:
|
||||
month_cost += a.cost
|
||||
if expiry_date.year == today.year:
|
||||
year_cost += cost
|
||||
if expiry_date.month == today.month:
|
||||
month_cost += cost
|
||||
|
||||
return {
|
||||
"total": len(assets),
|
||||
"total": len(rows),
|
||||
"by_type": by_type,
|
||||
"by_status": by_status,
|
||||
"abnormal_count": abnormal,
|
||||
@@ -345,13 +465,20 @@ def get_overview(session: Session) -> dict:
|
||||
|
||||
|
||||
def get_expiring(session: Session, days: int = 30) -> List[AssetRead]:
|
||||
"""N 天内到期资产列表(按剩余天数升序)"""
|
||||
"""N 天内到期资产列表(按剩余天数升序)
|
||||
|
||||
性能优化:过滤条件下推到 SQL 层,仅查询 [today, today+days] 区间内的资产。
|
||||
"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
result = []
|
||||
for a in assets:
|
||||
delta = (a.expiry_date - today).days
|
||||
if 0 <= delta <= days:
|
||||
result.append(_to_read(session, a, _get_detail(session, a)))
|
||||
deadline = today + timedelta(days=days)
|
||||
assets = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date >= today,
|
||||
Asset.expiry_date <= deadline,
|
||||
)
|
||||
).all()
|
||||
batch = _get_details_batch(session, assets)
|
||||
result = [_to_read_batch(session, a, batch) for a in assets]
|
||||
result.sort(key=lambda x: x.days_to_expiry if x.days_to_expiry is not None else 10**9)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""监控数据自动清理服务
|
||||
|
||||
按配置的保留周期清理 metrics.db 中的历史数据,防止 SQLite 无限膨胀:
|
||||
- MetricPoint: 高频时序数据,默认保留 30 天
|
||||
- SecurityCheck: 安全检查历史,默认保留 90 天
|
||||
- EventLog: 事件日志,默认保留 180 天
|
||||
|
||||
提供两种触发方式:
|
||||
1. 应用启动后由 asyncio 后台任务按 CLEANUP_INTERVAL_HOURS 周期执行
|
||||
2. 通过 API 手动触发(POST /api/monitor/cleanup)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlmodel import Session, delete, select
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.timeutils import utcnow
|
||||
from app.database import metrics_engine
|
||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck
|
||||
|
||||
logger = logging.getLogger("vps-manager.cleanup")
|
||||
|
||||
# 单批删除行数上限:避免大表单条 DELETE 长时间持有写锁,阻塞 Agent 上报
|
||||
_BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def _delete_in_batches(session: Session, model, cutoff, batch_size: int = _BATCH_SIZE) -> int:
|
||||
"""分批删除指定模型的过期数据(ts < cutoff),返回总删除行数
|
||||
|
||||
SQLite 不支持 DELETE ... LIMIT,用子查询 SELECT id ... LIMIT 实现分批。
|
||||
"""
|
||||
total = 0
|
||||
while True:
|
||||
subq = select(model.id).where(model.ts < cutoff).limit(batch_size)
|
||||
ids = [row[0] if isinstance(row, tuple) else row for row in session.exec(subq).all()]
|
||||
if not ids:
|
||||
break
|
||||
session.exec(delete(model).where(model.id.in_(ids)))
|
||||
session.commit() # 每批独立提交,缩短写锁持有时间
|
||||
total += len(ids)
|
||||
if len(ids) < batch_size:
|
||||
break
|
||||
return total
|
||||
|
||||
|
||||
def cleanup_metrics(
|
||||
metrics_days: int | None = None,
|
||||
security_days: int | None = None,
|
||||
event_log_days: int | None = None,
|
||||
) -> dict:
|
||||
"""按保留天数清理过期监控数据,返回各类删除条数"""
|
||||
metrics_days = metrics_days if metrics_days is not None else settings.METRICS_RETENTION_DAYS
|
||||
security_days = security_days if security_days is not None else settings.SECURITY_RETENTION_DAYS
|
||||
event_log_days = event_log_days if event_log_days is not None else settings.EVENT_LOG_RETENTION_DAYS
|
||||
|
||||
now = utcnow()
|
||||
result = {"metric_points": 0, "security_checks": 0, "event_logs": 0}
|
||||
|
||||
with Session(metrics_engine) as session:
|
||||
if metrics_days > 0:
|
||||
cutoff = now - timedelta(days=metrics_days)
|
||||
result["metric_points"] = _delete_in_batches(session, MetricPoint, cutoff)
|
||||
|
||||
if security_days > 0:
|
||||
cutoff = now - timedelta(days=security_days)
|
||||
result["security_checks"] = _delete_in_batches(session, SecurityCheck, cutoff)
|
||||
|
||||
if event_log_days > 0:
|
||||
cutoff = now - timedelta(days=event_log_days)
|
||||
result["event_logs"] = _delete_in_batches(session, EventLog, cutoff)
|
||||
|
||||
logger.info(
|
||||
"监控数据清理完成:metric_points=%s, security_checks=%s, event_logs=%s",
|
||||
result["metric_points"],
|
||||
result["security_checks"],
|
||||
result["event_logs"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def cleanup_loop() -> None:
|
||||
"""后台周期清理任务(CLEANUP_INTERVAL_HOURS=0 时不启动)"""
|
||||
interval_hours = settings.CLEANUP_INTERVAL_HOURS
|
||||
if interval_hours <= 0:
|
||||
logger.info("监控数据自动清理已禁用(CLEANUP_INTERVAL_HOURS=0)")
|
||||
return
|
||||
interval_sec = interval_hours * 3600
|
||||
logger.info("监控数据自动清理已启动,间隔 %s 小时", interval_hours)
|
||||
while True:
|
||||
try:
|
||||
# 在线程池执行同步 DB 操作,避免阻塞事件循环
|
||||
await asyncio.to_thread(cleanup_metrics)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("监控数据自动清理执行失败")
|
||||
await asyncio.sleep(interval_sec)
|
||||
@@ -0,0 +1,317 @@
|
||||
"""登录凭据(密码库)业务逻辑
|
||||
|
||||
唯一事实源:credentials 表。账号(Account)通过 credential_id 关联,
|
||||
其登录密码的读写全部重定向到本模块(见 account_service)。
|
||||
|
||||
安全约定:
|
||||
- password / otp secret 用 Fernet 加密(MASTER_KEY 不入库),Read 只给布尔标记
|
||||
- 2FA 绑定必须携带当前动态码做服务端校验(防 secret 手误录入成废条目)
|
||||
- 动态码由服务端生成(secret 永不出库),前端只做倒计时展示
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto, totp
|
||||
from app.models.asset import Account
|
||||
from app.models.credential import Credential, LoginType
|
||||
from app.models.provider import Provider
|
||||
from app.schemas.credential import (
|
||||
CredentialCreate,
|
||||
CredentialRead,
|
||||
CredentialUpdate,
|
||||
OtpBindRequest,
|
||||
)
|
||||
|
||||
|
||||
def _get_credential(session: Session, credential_id: int) -> Credential:
|
||||
cred = session.get(Credential, credential_id)
|
||||
if not cred:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="凭据不存在")
|
||||
return cred
|
||||
|
||||
|
||||
def _account_index(session: Session) -> Dict[int, Account]:
|
||||
"""credential_id → Account 反向映射(填充「来自平台账号」标记)"""
|
||||
rows = session.exec(
|
||||
select(Account).where(Account.credential_id.is_not(None)) # type: ignore[union-attr]
|
||||
).all()
|
||||
return {a.credential_id: a for a in rows}
|
||||
|
||||
|
||||
def _norm_pair(site: str, username: Optional[str]) -> tuple:
|
||||
"""重复判定键:站点 + 用户名(大小写/空白归一化)"""
|
||||
return (site.strip().lower(), (username or "").strip().lower())
|
||||
|
||||
|
||||
def _duplicate_flags(creds: List[Credential]) -> Dict[int, bool]:
|
||||
"""同 (site, username) 存在多条时全部标记 duplicate(录入提示用)"""
|
||||
seen: Dict[tuple, int] = {}
|
||||
for c in creds:
|
||||
k = _norm_pair(c.site, c.username)
|
||||
seen[k] = seen.get(k, 0) + 1
|
||||
return {c.id: seen[_norm_pair(c.site, c.username)] > 1 for c in creds}
|
||||
|
||||
|
||||
def _has_duplicate(session: Session, cred: Credential) -> bool:
|
||||
"""单条判重(创建/更新响应用):同站点同用户名是否还有其它条目"""
|
||||
site, username = _norm_pair(cred.site, cred.username)
|
||||
stmt = select(Credential).where(Credential.id != cred.id)
|
||||
rows = session.exec(stmt).all()
|
||||
return any(_norm_pair(c.site, c.username) == (site, username) for c in rows)
|
||||
|
||||
|
||||
def _to_read(
|
||||
cred: Credential, duplicate: bool = False, account: Optional[Account] = None
|
||||
) -> CredentialRead:
|
||||
read = CredentialRead.model_validate(cred)
|
||||
read.has_password = bool(cred.password_encrypted)
|
||||
read.has_otp = bool(cred.otp_secret_encrypted)
|
||||
read.duplicate = duplicate
|
||||
if account:
|
||||
read.account_id = account.id
|
||||
read.account_name = account.name
|
||||
return read
|
||||
|
||||
|
||||
def list_credentials(
|
||||
session: Session,
|
||||
q: Optional[str] = None,
|
||||
login_type: Optional[str] = None,
|
||||
has_otp: Optional[bool] = None,
|
||||
) -> List[CredentialRead]:
|
||||
"""凭据列表:搜索 site/username/note/授权来源,可按登录方式与 2FA 绑定过滤"""
|
||||
stmt = select(Credential)
|
||||
if login_type:
|
||||
stmt = stmt.where(Credential.login_type == login_type)
|
||||
if has_otp is True:
|
||||
stmt = stmt.where(Credential.otp_secret_encrypted.is_not(None)) # type: ignore[union-attr]
|
||||
elif has_otp is False:
|
||||
stmt = stmt.where(Credential.otp_secret_encrypted.is_(None)) # type: ignore[union-attr]
|
||||
if q and q.strip():
|
||||
like = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Credential.site.like(like), # type: ignore[union-attr]
|
||||
Credential.username.like(like), # type: ignore[union-attr]
|
||||
Credential.note.like(like), # type: ignore[union-attr]
|
||||
Credential.oauth_provider.like(like), # type: ignore[union-attr]
|
||||
)
|
||||
)
|
||||
creds = list(
|
||||
session.exec(
|
||||
stmt.order_by(Credential.site.asc(), Credential.username.asc())
|
||||
).all()
|
||||
)
|
||||
dups = _duplicate_flags(creds)
|
||||
accounts = _account_index(session)
|
||||
return [_to_read(c, dups.get(c.id, False), accounts.get(c.id)) for c in creds]
|
||||
|
||||
|
||||
def _account_of(session: Session, credential_id: int) -> Optional[Account]:
|
||||
"""反向查关联账号(响应里标注「来自平台账号」)"""
|
||||
return session.exec(
|
||||
select(Account).where(Account.credential_id == credential_id)
|
||||
).first()
|
||||
|
||||
|
||||
def get_credential(session: Session, credential_id: int) -> CredentialRead:
|
||||
cred = _get_credential(session, credential_id)
|
||||
return _to_read(
|
||||
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_otp_secret(raw: str) -> str:
|
||||
"""录入归一化:otpauth:// URI 取 secret;纯 base32 做可解码自检
|
||||
|
||||
无效输入统一转 400,避免存入永远算不出码的废 secret。
|
||||
"""
|
||||
raw = raw.strip()
|
||||
try:
|
||||
if raw.lower().startswith("otpauth://"):
|
||||
secret = totp.parse_otpauth_uri(raw)["secret"]
|
||||
else:
|
||||
secret = raw
|
||||
totp.totp_at(secret) # base32 可解码性自检
|
||||
return secret
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"2FA secret 无效:{e}"
|
||||
) from e
|
||||
|
||||
|
||||
def _require_valid_otp_code(secret: str, code: Optional[str]) -> None:
|
||||
"""2FA 绑定校验:必须提供当前动态码且验证通过(沿用 GitHub 添加 TOTP 模式)"""
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="绑定 2FA 需同时填写当前 6 位动态码(用于校验 secret 录入正确)",
|
||||
)
|
||||
if not totp.verify(secret, code.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="验证码不匹配:请确认 secret 录入正确,且填写的是当前动态码",
|
||||
)
|
||||
|
||||
|
||||
def create_credential(session: Session, data: CredentialCreate) -> CredentialRead:
|
||||
site = data.site.strip()
|
||||
if not site:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
|
||||
cred = Credential(
|
||||
site=site,
|
||||
username=(data.username or "").strip() or None,
|
||||
login_type=data.login_type,
|
||||
oauth_provider=(data.oauth_provider or "").strip() or None,
|
||||
url=(data.url or "").strip() or None,
|
||||
note=(data.note or "").strip() or None,
|
||||
)
|
||||
cred.password_encrypted = crypto.encrypt(data.password)
|
||||
if data.otp_secret and data.otp_secret.strip():
|
||||
secret = _normalize_otp_secret(data.otp_secret)
|
||||
_require_valid_otp_code(secret, data.otp_code)
|
||||
cred.otp_secret_encrypted = crypto.encrypt(secret)
|
||||
session.add(cred)
|
||||
session.commit()
|
||||
session.refresh(cred)
|
||||
return _to_read(cred, duplicate=_has_duplicate(session, cred))
|
||||
|
||||
|
||||
def update_credential(
|
||||
session: Session, credential_id: int, data: CredentialUpdate
|
||||
) -> CredentialRead:
|
||||
cred = _get_credential(session, credential_id)
|
||||
if data.site is not None:
|
||||
site = data.site.strip()
|
||||
if not site:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="站点名称不能为空")
|
||||
cred.site = site
|
||||
if data.username is not None:
|
||||
cred.username = data.username.strip() or None
|
||||
if data.login_type is not None:
|
||||
cred.login_type = data.login_type
|
||||
if data.oauth_provider is not None:
|
||||
cred.oauth_provider = data.oauth_provider.strip() or None
|
||||
if data.url is not None:
|
||||
cred.url = data.url.strip() or None
|
||||
if data.note is not None:
|
||||
cred.note = data.note.strip() or None
|
||||
# 密码:None=不修改,''=清除,非空=重加密(crypto.encrypt 对空串返回 None)
|
||||
if data.password is not None:
|
||||
cred.password_encrypted = crypto.encrypt(data.password)
|
||||
session.add(cred)
|
||||
session.commit()
|
||||
session.refresh(cred)
|
||||
return _to_read(
|
||||
cred, duplicate=_has_duplicate(session, cred), account=_account_of(session, cred.id)
|
||||
)
|
||||
|
||||
|
||||
def delete_credential(session: Session, credential_id: int) -> Dict[str, int]:
|
||||
"""删除凭据:账号/资产不受影响,仅解除关联(credential_id 置 NULL)"""
|
||||
cred = _get_credential(session, credential_id)
|
||||
accounts = session.exec(
|
||||
select(Account).where(Account.credential_id == credential_id)
|
||||
).all()
|
||||
for a in accounts:
|
||||
a.credential_id = None
|
||||
session.add(a)
|
||||
affected = len(accounts)
|
||||
session.delete(cred)
|
||||
session.commit()
|
||||
return {"affected_accounts": affected}
|
||||
|
||||
|
||||
def reveal_password(session: Session, credential_id: int) -> Dict[str, str]:
|
||||
"""解密返回凭据密码明文(供前端「查看/复制密码」)。
|
||||
|
||||
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
|
||||
"""
|
||||
cred = _get_credential(session, credential_id)
|
||||
plain = crypto.decrypt(cred.password_encrypted)
|
||||
if not plain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="该凭据未配置登录密码(可能是授权登录)",
|
||||
)
|
||||
return {"site": cred.site, "username": cred.username or "", "password": plain}
|
||||
|
||||
|
||||
def bind_otp(session: Session, credential_id: int, data: OtpBindRequest) -> CredentialRead:
|
||||
"""绑定 2FA:secret 归一化 + 当前动态码校验通过才落库"""
|
||||
cred = _get_credential(session, credential_id)
|
||||
secret = _normalize_otp_secret(data.secret)
|
||||
_require_valid_otp_code(secret, data.code)
|
||||
cred.otp_secret_encrypted = crypto.encrypt(secret)
|
||||
session.add(cred)
|
||||
session.commit()
|
||||
session.refresh(cred)
|
||||
return _to_read(cred, account=_account_of(session, cred.id))
|
||||
|
||||
|
||||
def unbind_otp(session: Session, credential_id: int) -> CredentialRead:
|
||||
"""解绑 2FA"""
|
||||
cred = _get_credential(session, credential_id)
|
||||
cred.otp_secret_encrypted = None
|
||||
session.add(cred)
|
||||
session.commit()
|
||||
session.refresh(cred)
|
||||
return _to_read(cred, account=_account_of(session, cred.id))
|
||||
|
||||
|
||||
def current_otp(session: Session, credential_id: int) -> Dict[str, object]:
|
||||
"""生成当前动态码(secret 永不出库,前端凭 expires_in 做本地倒计时)"""
|
||||
cred = _get_credential(session, credential_id)
|
||||
secret = crypto.decrypt(cred.otp_secret_encrypted)
|
||||
if not secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="该凭据未绑定 2FA"
|
||||
)
|
||||
code, expires_in = totp.totp_at(secret)
|
||||
return {"code": code, "expires_in": expires_in}
|
||||
|
||||
|
||||
# ---------------- 账号侧复用(account_service 调用) ---------------- #
|
||||
|
||||
|
||||
def provider_display_name(session: Session, platform: Optional[str]) -> str:
|
||||
"""平台 slug → 显示名(凭据 site 用):providers.name → 原文 → '未分类'"""
|
||||
platform = (platform or "").strip()
|
||||
if platform:
|
||||
p = session.exec(select(Provider).where(Provider.slug == platform)).first()
|
||||
return p.name if p else platform
|
||||
return "未分类"
|
||||
|
||||
|
||||
def upsert_for_account(
|
||||
session: Session, account: Account, password: Optional[str] = None
|
||||
) -> Optional[int]:
|
||||
"""账号侧凭据同步(同一事务内调用,不 commit,由调用方提交):
|
||||
|
||||
- 账号无关联凭据:提供了密码才创建(避免为无密码账号建空条目),回填 credential_id
|
||||
- 已有关联凭据:同步 site/username(账号改名/换平台保持一致);
|
||||
password 非 None 时按语义更新(''=清除,非空=重加密)
|
||||
返回 credential_id(无凭据时 None)。
|
||||
"""
|
||||
site = provider_display_name(session, account.platform)
|
||||
username = account.login_user or account.name
|
||||
cred = session.get(Credential, account.credential_id) if account.credential_id else None
|
||||
if cred is None:
|
||||
if not password:
|
||||
return account.credential_id
|
||||
cred = Credential(site=site, username=username, login_type=LoginType.PASSWORD)
|
||||
cred.password_encrypted = crypto.encrypt(password)
|
||||
session.add(cred)
|
||||
session.flush() # 先取 id 供 account.credential_id 回填(同一事务)
|
||||
account.credential_id = cred.id
|
||||
return cred.id
|
||||
cred.site = site
|
||||
cred.username = username
|
||||
if password is not None:
|
||||
cred.password_encrypted = crypto.encrypt(password)
|
||||
session.add(cred)
|
||||
return cred.id
|
||||
@@ -6,6 +6,7 @@ from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core import crypto
|
||||
from app.models.asset import Asset
|
||||
from app.models.provider import Provider, ProviderCategory
|
||||
from app.schemas.provider import ProviderCreate, ProviderRead, ProviderUpdate
|
||||
|
||||
@@ -73,6 +74,15 @@ def delete_provider(session: Session, provider_id: int) -> None:
|
||||
provider = session.get(Provider, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="平台不存在")
|
||||
# 数据完整性:存在关联资产时禁止删除,避免 provider_id 悬空引用
|
||||
linked = session.exec(
|
||||
select(Asset.id).where(Asset.provider_id == provider_id).limit(1)
|
||||
).first()
|
||||
if linked is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="该平台下仍有关联资产,请先迁移或删除相关资产",
|
||||
)
|
||||
session.delete(provider)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
检查即将到期的资产,生成提醒消息并通过通知渠道发送。
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -16,16 +16,21 @@ _SKIP_STATUS = {AssetStatus.CANCELLED}
|
||||
|
||||
|
||||
def get_expiring_assets(session: Session, threshold_days: int) -> list:
|
||||
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序"""
|
||||
"""返回 threshold_days 天内到期的资产列表 [(asset, days), ...],按剩余天数升序
|
||||
|
||||
性能优化:日期范围与状态过滤均下推到 SQL 层。
|
||||
"""
|
||||
today = date.today()
|
||||
assets = session.exec(select(Asset).where(Asset.expiry_date.is_not(None))).all()
|
||||
expiring = []
|
||||
for asset in assets:
|
||||
if asset.status in _SKIP_STATUS:
|
||||
continue
|
||||
days = (asset.expiry_date - today).days
|
||||
if 0 <= days <= threshold_days:
|
||||
expiring.append((asset, days))
|
||||
deadline = today + timedelta(days=threshold_days)
|
||||
assets = session.exec(
|
||||
select(Asset).where(
|
||||
Asset.expiry_date.is_not(None),
|
||||
Asset.expiry_date >= today,
|
||||
Asset.expiry_date <= deadline,
|
||||
Asset.status.notin_(_SKIP_STATUS),
|
||||
)
|
||||
).all()
|
||||
expiring = [(asset, (asset.expiry_date - today).days) for asset in assets]
|
||||
expiring.sort(key=lambda x: x[1])
|
||||
return expiring
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
评分按检查项加权:pass 满分、warn 半分、fail/unknown 零分。
|
||||
"""
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.monitor import SecurityCheck
|
||||
@@ -14,19 +15,26 @@ DEFAULT_WEIGHT = 20
|
||||
|
||||
|
||||
def latest_checks(session: Session, asset_id: int) -> list:
|
||||
"""取每个检查项的最新一条"""
|
||||
stmt = (
|
||||
select(SecurityCheck)
|
||||
"""取每个检查项的最新一条
|
||||
|
||||
用 GROUP BY max(ts) 子查询精确取每项最新记录:若用 limit(50) 后去重,
|
||||
当某个检查项连续上报超过 50 次时会把其他项的最新记录挤出,导致评分失真。
|
||||
"""
|
||||
latest_ts = (
|
||||
select(
|
||||
SecurityCheck.check_item,
|
||||
func.max(SecurityCheck.ts).label("max_ts"),
|
||||
)
|
||||
.where(SecurityCheck.asset_id == asset_id)
|
||||
.order_by(SecurityCheck.ts.desc())
|
||||
.limit(50)
|
||||
.group_by(SecurityCheck.check_item)
|
||||
.subquery()
|
||||
)
|
||||
checks = session.exec(stmt).all()
|
||||
latest = {}
|
||||
for check in checks:
|
||||
if check.check_item not in latest:
|
||||
latest[check.check_item] = check
|
||||
return list(latest.values())
|
||||
stmt = select(SecurityCheck).where(SecurityCheck.asset_id == asset_id).join(
|
||||
latest_ts,
|
||||
(SecurityCheck.check_item == latest_ts.c.check_item)
|
||||
& (SecurityCheck.ts == latest_ts.c.max_ts),
|
||||
)
|
||||
return list(session.exec(stmt).all())
|
||||
|
||||
|
||||
def compute_security_score(checks: list):
|
||||
@@ -70,8 +78,42 @@ def get_asset_security(session: Session, asset_id: int) -> dict:
|
||||
|
||||
|
||||
def get_security_overview(session: Session) -> list:
|
||||
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)"""
|
||||
asset_ids = session.exec(select(SecurityCheck.asset_id).distinct()).all()
|
||||
result = [get_asset_security(session, aid) for aid in asset_ids]
|
||||
"""所有有安全检查数据的服务器评分总览(按评分升序,风险高的在前)
|
||||
|
||||
性能优化:用子查询取每个 (asset_id, check_item) 的最新 ts,仅拉取最新记录,
|
||||
避免全表扫描(历史数据量大时内存可控)。
|
||||
"""
|
||||
latest_ts = (
|
||||
select(
|
||||
SecurityCheck.asset_id,
|
||||
SecurityCheck.check_item,
|
||||
func.max(SecurityCheck.ts).label("max_ts"),
|
||||
)
|
||||
.group_by(SecurityCheck.asset_id, SecurityCheck.check_item)
|
||||
.subquery()
|
||||
)
|
||||
stmt = select(SecurityCheck).join(
|
||||
latest_ts,
|
||||
(SecurityCheck.asset_id == latest_ts.c.asset_id)
|
||||
& (SecurityCheck.check_item == latest_ts.c.check_item)
|
||||
& (SecurityCheck.ts == latest_ts.c.max_ts),
|
||||
)
|
||||
all_checks = session.exec(stmt).all()
|
||||
|
||||
# 按 asset_id 分组
|
||||
latest_by_asset: dict = {}
|
||||
for check in all_checks:
|
||||
latest_by_asset.setdefault(check.asset_id, []).append(check)
|
||||
|
||||
result = []
|
||||
for asset_id, checks in latest_by_asset.items():
|
||||
score = compute_security_score(checks)
|
||||
result.append({
|
||||
"asset_id": asset_id,
|
||||
"score": score,
|
||||
"level": score_level(score),
|
||||
"checks_count": len(checks),
|
||||
"suggestions": [c.suggestion for c in checks if c.suggestion],
|
||||
})
|
||||
result.sort(key=lambda x: (x["score"] is None, x["score"] if x["score"] is not None else 0))
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
"""SSL 监控服务:站点证书探测 + 子域名管理
|
||||
|
||||
- probe_site_cert: 通过 ssl socket 探测站点证书信息(不校验证书链,仅读取到期信息)
|
||||
- create/check/check_all: 站点证书的创建与刷新
|
||||
- 子域名 CRUD:list/create/update/delete
|
||||
"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import Asset
|
||||
from app.models.ssl import SiteCert, Subdomain
|
||||
|
||||
PROBE_TIMEOUT = 8 # 探测连接超时(秒)
|
||||
EXPIRING_THRESHOLD = 30 # 到期提醒阈值(天)
|
||||
|
||||
|
||||
def days_until(d: Optional[date]) -> Optional[int]:
|
||||
"""计算距今天数(负数为已过期)"""
|
||||
if d is None:
|
||||
return None
|
||||
return (d - date.today()).days
|
||||
|
||||
|
||||
def _judge_status(days: Optional[int]) -> str:
|
||||
"""按剩余天数定级:expired / expiring / valid"""
|
||||
if days is None:
|
||||
return "unknown"
|
||||
if days < 0:
|
||||
return "expired"
|
||||
if days <= EXPIRING_THRESHOLD:
|
||||
return "expiring"
|
||||
return "valid"
|
||||
|
||||
|
||||
def probe_site_cert(hostname: str, port: int = 443) -> dict:
|
||||
"""探测目标站点证书信息(探测失败时抛异常)
|
||||
|
||||
返回字段:subject_cn / issuer / valid_from / valid_to / fingerprint / san_list
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
# 不校验证书链:即使证书已过期/自签名也能读到到期信息
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
with socket.create_connection((hostname, port), timeout=PROBE_TIMEOUT) as sock:
|
||||
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
|
||||
der = ssock.getpeercert(binary_form=True)
|
||||
cert = x509.load_der_x509_certificate(der)
|
||||
try:
|
||||
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
|
||||
san_list = san.value.get_values_for_type(x509.DNSName)
|
||||
except x509.ExtensionNotFound:
|
||||
san_list = []
|
||||
return {
|
||||
"subject_cn": cert.subject.rfc4514_string() or None,
|
||||
"issuer": cert.issuer.rfc4514_string() or None,
|
||||
"valid_from": cert.not_valid_before_utc.date(),
|
||||
"valid_to": cert.not_valid_after_utc.date(),
|
||||
"fingerprint": cert.fingerprint(hashes.SHA256()).hex(),
|
||||
"san_list": san_list,
|
||||
}
|
||||
|
||||
|
||||
def _to_dict(cert: SiteCert, asset_name: Optional[str] = None) -> dict:
|
||||
"""SiteCert 模型转 API 返回结构(含动态计算的剩余天数)"""
|
||||
days = days_until(cert.valid_to)
|
||||
return {
|
||||
"id": cert.id,
|
||||
"hostname": cert.hostname,
|
||||
"port": cert.port,
|
||||
"asset_id": cert.asset_id,
|
||||
"asset_name": asset_name,
|
||||
"issuer": cert.issuer,
|
||||
"subject_cn": cert.subject_cn,
|
||||
"valid_from": cert.valid_from.isoformat() if cert.valid_from else None,
|
||||
"valid_to": cert.valid_to.isoformat() if cert.valid_to else None,
|
||||
"days_to_expiry": days,
|
||||
"status": cert.status,
|
||||
"error": cert.error,
|
||||
"last_checked_at": cert.last_checked_at.isoformat() if cert.last_checked_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------- 子域名 CRUD ----------------
|
||||
|
||||
def list_subdomains(session: Session, asset_id: Optional[int] = None) -> list:
|
||||
stmt = select(Subdomain).order_by(Subdomain.host)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(Subdomain.asset_id == asset_id)
|
||||
subs = session.exec(stmt).all()
|
||||
# 带上所属域名,便于前端展示全名
|
||||
assets = session.exec(select(Asset).where(Asset.id.in_({s.asset_id for s in subs}))).all()
|
||||
name_map = {a.id: a.name for a in assets}
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"asset_id": s.asset_id,
|
||||
"asset_name": name_map.get(s.asset_id),
|
||||
"host": s.host,
|
||||
"record_type": s.record_type,
|
||||
"record_value": s.record_value,
|
||||
"is_active": s.is_active,
|
||||
"note": s.note,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
}
|
||||
for s in subs
|
||||
]
|
||||
|
||||
|
||||
def create_subdomain(session: Session, data: dict) -> Subdomain:
|
||||
sub = Subdomain(
|
||||
asset_id=data["asset_id"],
|
||||
host=data["host"].strip().lower(),
|
||||
record_type=data.get("record_type"),
|
||||
record_value=data.get("record_value"),
|
||||
is_active=data.get("is_active", True),
|
||||
note=data.get("note"),
|
||||
)
|
||||
session.add(sub)
|
||||
session.commit()
|
||||
session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def update_subdomain(session: Session, sub_id: int, data: dict) -> Subdomain:
|
||||
sub = session.get(Subdomain, sub_id)
|
||||
if not sub:
|
||||
raise ValueError(f"子域名记录不存在(id={sub_id})")
|
||||
if "host" in data and data["host"]:
|
||||
sub.host = data["host"].strip().lower()
|
||||
for key in ("record_type", "record_value", "note"):
|
||||
if key in data:
|
||||
setattr(sub, key, data[key])
|
||||
if "is_active" in data:
|
||||
sub.is_active = bool(data["is_active"])
|
||||
session.add(sub)
|
||||
session.commit()
|
||||
session.refresh(sub)
|
||||
return sub
|
||||
|
||||
|
||||
def delete_subdomain(session: Session, sub_id: int) -> None:
|
||||
sub = session.get(Subdomain, sub_id)
|
||||
if not sub:
|
||||
raise ValueError(f"子域名记录不存在(id={sub_id})")
|
||||
session.delete(sub)
|
||||
session.commit()
|
||||
|
||||
|
||||
# ---------------- 站点证书监控 ----------------
|
||||
|
||||
def _apply_probe(cert: SiteCert, info: dict) -> SiteCert:
|
||||
"""把探测结果写入模型并定级"""
|
||||
cert.subject_cn = info["subject_cn"]
|
||||
cert.issuer = info["issuer"]
|
||||
cert.valid_from = info["valid_from"]
|
||||
cert.valid_to = info["valid_to"]
|
||||
cert.fingerprint = info["fingerprint"]
|
||||
cert.error = None
|
||||
cert.status = _judge_status(days_until(info["valid_to"]))
|
||||
cert.last_checked_at = utcnow()
|
||||
return cert
|
||||
|
||||
|
||||
def check_one(session: Session, cert: SiteCert) -> SiteCert:
|
||||
"""重新探测单条证书记录(失败则标记 error,保留旧到期信息)"""
|
||||
try:
|
||||
info = probe_site_cert(cert.hostname, cert.port)
|
||||
_apply_probe(cert, info)
|
||||
except Exception as e: # noqa: BLE001
|
||||
cert.status = "error"
|
||||
cert.error = str(e)[:200]
|
||||
cert.last_checked_at = utcnow()
|
||||
session.add(cert)
|
||||
session.commit()
|
||||
session.refresh(cert)
|
||||
return cert
|
||||
|
||||
|
||||
def create_site_cert(session: Session, hostname: str, port: int = 443, asset_id: Optional[int] = None) -> SiteCert:
|
||||
"""创建探测目标并立即探测一次;hostname+port 已存在则复用并刷新"""
|
||||
hostname = hostname.strip().lower()
|
||||
existing = session.exec(
|
||||
select(SiteCert).where(SiteCert.hostname == hostname, SiteCert.port == port)
|
||||
).first()
|
||||
if existing:
|
||||
if asset_id is not None:
|
||||
existing.asset_id = asset_id
|
||||
return check_one(session, existing)
|
||||
cert = SiteCert(hostname=hostname, port=port, asset_id=asset_id)
|
||||
session.add(cert)
|
||||
session.commit()
|
||||
session.refresh(cert)
|
||||
return check_one(session, cert)
|
||||
|
||||
|
||||
def list_site_certs(session: Session, status: Optional[str] = None, asset_id: Optional[int] = None) -> list:
|
||||
stmt = select(SiteCert)
|
||||
if status:
|
||||
stmt = stmt.where(SiteCert.status == status)
|
||||
if asset_id is not None:
|
||||
stmt = stmt.where(SiteCert.asset_id == asset_id)
|
||||
certs = session.exec(stmt.order_by(SiteCert.id)).all()
|
||||
ids = {c.asset_id for c in certs if c.asset_id}
|
||||
assets = session.exec(select(Asset).where(Asset.id.in_(ids))).all() if ids else []
|
||||
name_map = {a.id: a.name for a in assets}
|
||||
return [_to_dict(c, name_map.get(c.asset_id)) for c in certs]
|
||||
|
||||
|
||||
def delete_site_cert(session: Session, cert_id: int) -> None:
|
||||
cert = session.get(SiteCert, cert_id)
|
||||
if not cert:
|
||||
raise ValueError(f"证书监控记录不存在(id={cert_id})")
|
||||
session.delete(cert)
|
||||
session.commit()
|
||||
|
||||
|
||||
def check_all_site_certs(session: Session) -> dict:
|
||||
"""全量刷新所有站点证书,返回统计与异常清单
|
||||
|
||||
探测为纯网络 IO(单次最长 8s),用线程池并发探测后统一写库:
|
||||
串行时 N 个站点最坏耗时 N×8s,并发后接近单站点耗时;
|
||||
写库集中在主线程一次 commit(原来逐条 commit 产生 N 次事务)。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
certs = session.exec(select(SiteCert)).all()
|
||||
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
|
||||
problems = []
|
||||
if not certs:
|
||||
return {"stats": stats, "problems": problems}
|
||||
|
||||
# 并发探测(不碰数据库,线程安全)
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(certs))) as pool:
|
||||
futures = {pool.submit(probe_site_cert, c.hostname, c.port): c for c in certs}
|
||||
for future in futures:
|
||||
cert = futures[future]
|
||||
try:
|
||||
_apply_probe(cert, future.result())
|
||||
except Exception as e: # noqa: BLE001
|
||||
cert.status = "error"
|
||||
cert.error = str(e)[:200]
|
||||
cert.last_checked_at = utcnow()
|
||||
session.add(cert)
|
||||
session.commit()
|
||||
|
||||
for cert in certs:
|
||||
session.refresh(cert)
|
||||
if cert.status == "error":
|
||||
stats["error"] += 1
|
||||
problems.append({"hostname": cert.hostname, "detail": cert.error})
|
||||
elif cert.status == "expired":
|
||||
stats["expired"] += 1
|
||||
problems.append(
|
||||
{"hostname": cert.hostname, "detail": f"证书已过期 {abs(days_until(cert.valid_to))} 天"}
|
||||
)
|
||||
elif cert.status == "expiring":
|
||||
stats["expiring"] += 1
|
||||
problems.append(
|
||||
{"hostname": cert.hostname, "detail": f"{days_until(cert.valid_to)} 天后到期"}
|
||||
)
|
||||
else:
|
||||
stats["ok"] += 1
|
||||
return {"stats": stats, "problems": problems}
|
||||
|
||||
|
||||
def build_cert_message(cert: SiteCert) -> str:
|
||||
"""生成单条证书提醒文案"""
|
||||
days = days_until(cert.valid_to)
|
||||
if days is None:
|
||||
return f"- {cert.hostname}:证书信息未知"
|
||||
if days < 0:
|
||||
return f"- ⚠️ {cert.hostname}:证书已过期 {abs(days)} 天"
|
||||
return f"- {cert.hostname}:{days} 天后到期({cert.valid_to})"
|
||||
+150
-41
@@ -3,20 +3,28 @@
|
||||
将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo)
|
||||
写入或更新到资产库。以 (provider_id, external_id) 作为去重键,
|
||||
已存在则更新状态/详情,不存在则新建资产。
|
||||
|
||||
凭证层级:API 配置存在账号(Account.api_config_encrypted)上,
|
||||
同步按账号维度进行(test_account/sync_account);同步产出的资产
|
||||
自动挂到该账号名下(Asset.account)。平台不再持有凭证。
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
logger = logging.getLogger("vps-manager.sync")
|
||||
|
||||
from app.adapters import registry
|
||||
from app.adapters.base import BaseAdapter
|
||||
from app.core import crypto
|
||||
from app.core.timeutils import utcnow
|
||||
from app.models.asset import (
|
||||
AIAccount,
|
||||
Account,
|
||||
Asset,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
@@ -29,7 +37,7 @@ _VALID_STATUS = {s.value for s in AssetStatus}
|
||||
|
||||
|
||||
def _load_config(provider: Provider) -> dict:
|
||||
"""解密平台的 API 配置 JSON"""
|
||||
"""(已弃用)解密平台级 API 配置,仅为兼容历史数据保留"""
|
||||
plain = crypto.decrypt(provider.api_config_encrypted)
|
||||
if not plain:
|
||||
return {}
|
||||
@@ -39,6 +47,30 @@ def _load_config(provider: Provider) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_account_config(account: Account) -> dict:
|
||||
"""解密账号的 API 配置 JSON"""
|
||||
plain = crypto.decrypt(account.api_config_encrypted)
|
||||
if not plain:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(plain)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _find_provider_by_platform(session: Session, platform: str) -> Provider:
|
||||
"""按账号的 platform(slug 或名称)定位平台"""
|
||||
provider = session.exec(select(Provider).where(Provider.slug == platform)).first()
|
||||
if not provider:
|
||||
provider = session.exec(select(Provider).where(Provider.name == platform)).first()
|
||||
if not provider:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"账号所属平台不存在:{platform}(请先在账号编辑中选择有效平台)",
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def _get_provider(session: Session, provider_id: int) -> Provider:
|
||||
provider = session.get(Provider, provider_id)
|
||||
if not provider:
|
||||
@@ -46,7 +78,7 @@ def _get_provider(session: Session, provider_id: int) -> Provider:
|
||||
return provider
|
||||
|
||||
|
||||
def _build_adapter(provider: Provider) -> BaseAdapter:
|
||||
def _build_adapter(provider: Provider, config: dict) -> BaseAdapter:
|
||||
if not provider.sdk_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type)"
|
||||
@@ -56,7 +88,27 @@ def _build_adapter(provider: Provider) -> BaseAdapter:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"暂不支持的 SDK 类型:{provider.sdk_type}",
|
||||
)
|
||||
return registry.get_adapter(provider.sdk_type, _load_config(provider))
|
||||
return registry.get_adapter(provider.sdk_type, config)
|
||||
|
||||
|
||||
def _get_account(session: Session, account_id: int) -> Account:
|
||||
account = session.get(Account, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||
return account
|
||||
|
||||
|
||||
def _build_adapter_for_account(session: Session, account: Account):
|
||||
"""按账号构建适配器:平台定 sdk_type,账号提供凭证"""
|
||||
if not account.platform:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该账号未指定所属平台,无法确定 SDK 类型",
|
||||
)
|
||||
provider = _find_provider_by_platform(session, account.platform)
|
||||
config = _load_account_config(account)
|
||||
adapter = _build_adapter(provider, config)
|
||||
return provider, adapter
|
||||
|
||||
|
||||
def _norm_status(raw: Optional[str]) -> AssetStatus:
|
||||
@@ -69,15 +121,37 @@ def _missing_config(adapter: BaseAdapter) -> list:
|
||||
|
||||
|
||||
def test_provider(session: Session, provider_id: int) -> dict:
|
||||
"""测试平台连接 / 凭证有效性"""
|
||||
"""(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||||
provider = _get_provider(session, provider_id)
|
||||
adapter = _build_adapter(provider)
|
||||
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type}
|
||||
account = _first_account_with_config(session, provider)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
|
||||
)
|
||||
return test_account(session, account.id)
|
||||
|
||||
|
||||
def _first_account_with_config(session: Session, provider: Provider):
|
||||
"""找该平台下第一个配置了 API 凭证的账号(兼容旧入口)"""
|
||||
return 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()
|
||||
|
||||
|
||||
def test_account(session: Session, account_id: int) -> dict:
|
||||
"""测试账号凭证有效性(按账号 platform 定 SDK,凭证取自账号)"""
|
||||
account = _get_account(session, account_id)
|
||||
provider, adapter = _build_adapter_for_account(session, account)
|
||||
base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type, "account": account.name}
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)",
|
||||
"message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)",
|
||||
**base,
|
||||
}
|
||||
result = adapter.test_connection()
|
||||
@@ -95,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
|
||||
).first()
|
||||
|
||||
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dict:
|
||||
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for vps in adapter.list_vps():
|
||||
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
|
||||
@@ -105,6 +179,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
|
||||
if vps.monthly_cost is not None:
|
||||
existing.cost = vps.monthly_cost
|
||||
existing.currency = vps.currency
|
||||
if account_id:
|
||||
existing.account_id = account_id
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
select(VPSDetail).where(VPSDetail.asset_id == existing.id)
|
||||
@@ -125,13 +201,13 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
|
||||
provider=provider.slug,
|
||||
provider_id=provider.id,
|
||||
external_id=vps.external_id,
|
||||
account_id=account_id,
|
||||
status=_norm_status(vps.status),
|
||||
cost=vps.monthly_cost or 0,
|
||||
currency=vps.currency,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
session.add(
|
||||
VPSDetail(
|
||||
asset_id=asset.id,
|
||||
@@ -148,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic
|
||||
return {"created": created, "updated": updated}
|
||||
|
||||
|
||||
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> dict:
|
||||
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
|
||||
created = updated = 0
|
||||
for dom in adapter.list_domains():
|
||||
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
|
||||
@@ -156,6 +232,8 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
|
||||
existing.status = _norm_status(dom.status)
|
||||
if dom.expiry_date:
|
||||
existing.expiry_date = dom.expiry_date
|
||||
if account_id:
|
||||
existing.account_id = account_id
|
||||
session.add(existing)
|
||||
detail = session.exec(
|
||||
select(DomainDetail).where(DomainDetail.asset_id == existing.id)
|
||||
@@ -172,12 +250,12 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
|
||||
provider=provider.slug,
|
||||
provider_id=provider.id,
|
||||
external_id=dom.external_id,
|
||||
account_id=account_id,
|
||||
status=_norm_status(dom.status),
|
||||
expiry_date=dom.expiry_date,
|
||||
)
|
||||
session.add(asset)
|
||||
session.commit()
|
||||
session.refresh(asset)
|
||||
session.flush() # 获取 asset.id,统一在循环外提交
|
||||
session.add(
|
||||
DomainDetail(
|
||||
asset_id=asset.id,
|
||||
@@ -191,38 +269,51 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) ->
|
||||
|
||||
|
||||
def sync_provider(session: Session, provider_id: int) -> dict:
|
||||
"""同步平台资产到本地库"""
|
||||
"""(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号"""
|
||||
provider = _get_provider(session, provider_id)
|
||||
adapter = _build_adapter(provider)
|
||||
account = _first_account_with_config(session, provider)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置",
|
||||
)
|
||||
return sync_account(session, account.id)
|
||||
|
||||
|
||||
def sync_account(session: Session, account_id: int) -> dict:
|
||||
"""同步账号资产到本地库(凭证取自账号,同步产出自动挂到该账号名下)"""
|
||||
account = _get_account(session, account_id)
|
||||
provider, adapter = _build_adapter_for_account(session, account)
|
||||
missing = _missing_config(adapter)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)",
|
||||
detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)",
|
||||
)
|
||||
caps = adapter.capabilities()
|
||||
result = {"provider": provider.slug, "sdk_type": provider.sdk_type}
|
||||
result = {"provider": provider.slug, "sdk_type": provider.sdk_type, "account": account.name}
|
||||
|
||||
if caps["list_vps"]:
|
||||
try:
|
||||
result["vps"] = _sync_vps(session, provider, adapter)
|
||||
result["vps"] = _sync_vps(session, provider, adapter, account.id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["vps_error"] = str(e)
|
||||
if caps["list_domains"]:
|
||||
try:
|
||||
result["domains"] = _sync_domains(session, provider, adapter)
|
||||
result["domains"] = _sync_domains(session, provider, adapter, account.id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["domains_error"] = str(e)
|
||||
if caps["get_account"]:
|
||||
try:
|
||||
result["account"] = adapter.get_account().to_dict()
|
||||
result["account_info"] = adapter.get_account().to_dict()
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["account_error"] = str(e)
|
||||
result["account_info_error"] = str(e)
|
||||
|
||||
provider.last_synced_at = datetime.utcnow()
|
||||
session.add(provider)
|
||||
account.last_synced_at = utcnow()
|
||||
session.add(account)
|
||||
session.commit()
|
||||
result["last_synced_at"] = provider.last_synced_at.isoformat()
|
||||
result["last_synced_at"] = account.last_synced_at.isoformat()
|
||||
logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -262,7 +353,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
detail=f"无法确定 AI 适配器({ai.provider})",
|
||||
)
|
||||
|
||||
api_key = crypto.decrypt(ai.api_key_encrypted) or ai.api_key
|
||||
api_key = crypto.decrypt(ai.api_key_encrypted)
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="未配置 API Key"
|
||||
@@ -286,7 +377,7 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
if acc.balance is not None:
|
||||
ai.balance = acc.balance
|
||||
ai.currency = acc.currency
|
||||
ai.last_synced_at = datetime.utcnow()
|
||||
ai.last_synced_at = utcnow()
|
||||
session.add(ai)
|
||||
session.commit()
|
||||
session.refresh(ai)
|
||||
@@ -297,28 +388,46 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def sync_all_ai_balances(session: Session) -> dict:
|
||||
"""遍历所有 AI 账号资产,逐个刷新余额(单个失败不中断整体)"""
|
||||
def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||
"""并发刷新所有 AI 账号余额(单个失败不中断整体)
|
||||
|
||||
每个账号需调用外部 API(单次最长 30s),串行时总耗时随账号数线性增长;
|
||||
改为线程池并发后显著提速。注意:SQLite Session 不能跨线程共享,
|
||||
每个 worker 使用独立 Session(WAL 模式下多连接读写安全)。
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from app.database import assets_engine
|
||||
|
||||
ai_assets = session.exec(
|
||||
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
|
||||
).all()
|
||||
asset_ids = [(a.id, a.name) for a in ai_assets]
|
||||
|
||||
def _refresh_one(asset_id: int):
|
||||
with Session(assets_engine) as worker_session:
|
||||
refresh_ai_balance(worker_session, asset_id)
|
||||
|
||||
success = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
for asset in ai_assets:
|
||||
try:
|
||||
refresh_ai_balance(session, asset.id)
|
||||
success += 1
|
||||
except HTTPException as e:
|
||||
failed += 1
|
||||
errors.append(f"{asset.name}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{asset.name}: {e}")
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_refresh_one, aid): name for aid, name in asset_ids}
|
||||
for future in futures:
|
||||
name = futures[future]
|
||||
try:
|
||||
future.result()
|
||||
success += 1
|
||||
except HTTPException as e:
|
||||
failed += 1
|
||||
errors.append(f"{name}: {e.detail}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed += 1
|
||||
errors.append(f"{name}: {e}")
|
||||
return {
|
||||
"total": len(ai_assets),
|
||||
"total": len(asset_ids),
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"errors": errors,
|
||||
"synced_at": datetime.utcnow().isoformat(),
|
||||
"synced_at": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>{{ app_name }}</title>
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="manifest" href="/static/manifest.json?v={{ asset_version }}">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png?v={{ asset_version }}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="资产管理">
|
||||
<script src="/static/js/tailwind.js"></script>
|
||||
<script src="/static/js/tailwind.js?v={{ asset_version }}"></script>
|
||||
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||
<script src="/static/js/vue.global.prod.js"></script>
|
||||
<script src="/static/js/chart.umd.js"></script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
|
||||
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}", version: "{{ app_version }}", commit: "{{ git_commit }}" };</script>
|
||||
<style>
|
||||
html { -webkit-tap-highlight-color: transparent; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
@@ -24,8 +24,21 @@
|
||||
</head>
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-800 dark:text-slate-200 antialiased">
|
||||
<div id="app"></div>
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/api.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/store.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/dashboard.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/assets.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/subscriptions.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/cloudflare.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/providers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/servers.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/domains.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/ai.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/monitor.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/settings.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/views/vault.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/modals.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/app.js?v={{ asset_version }}"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
"""pytest 全局配置:确保项目根在 sys.path
|
||||
|
||||
tests/ 无 __init__.py,pytest prepend 模式只会把 tests/ 加入 sys.path,
|
||||
直接运行 `pytest` 时 import app 会失败(python -m pytest 因 cwd 入 path 才可用)。
|
||||
此文件让两种运行方式行为一致。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
+177
-8
@@ -1,9 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# 数据库分库备份脚本
|
||||
# 数据库分库备份脚本(本地 + S3 兼容对象存储)
|
||||
# 用法:backup.sh [assets|metrics|all]
|
||||
# assets -> 备份 assets.db,保留 14 份(建议每日)
|
||||
# assets -> 备份 assets.db + master_key.escrow,保留 14 份(建议每日)
|
||||
# metrics -> 备份 metrics.db,保留 4 份(建议每周)
|
||||
# all -> 两者都备份
|
||||
# all -> 上述都备份
|
||||
#
|
||||
# master_key.escrow 随 assets 周期备份(覆盖式单份):它是被离线 RESTORE_KEY
|
||||
# 加密的 MASTER_KEY 托管档,进备份/S3 安全;缺了它,整机丢失时即使有库备份
|
||||
# 和离线钥匙也无法恢复。
|
||||
#
|
||||
# S3 上传(可选):配置 .env 中 S3_* 项后,备份文件自动上传到
|
||||
# 任意 S3 兼容对象存储(Cloudflare R2 / MinIO / 阿里云 OSS / 腾讯云 COS 等),
|
||||
# 远端保留份数与本地一致。未配置 S3 时仅本地备份(向后兼容)。
|
||||
# 实现为纯 Python 标准库(AWS SigV4 签名),零额外依赖。
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/vps-manager}"
|
||||
@@ -12,9 +21,16 @@ cd "$APP_DIR"
|
||||
TARGET="${1:-all}"
|
||||
|
||||
python3 - "$TARGET" <<'PYEOF'
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
target = sys.argv[1]
|
||||
@@ -22,13 +38,14 @@ data_dir = Path("data")
|
||||
backup_root = data_dir / "backups"
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# ---------------- 本地备份 ----------------
|
||||
|
||||
def backup(db_name: str, keep: int) -> None:
|
||||
def backup(db_name: str, keep: int) -> Path | None:
|
||||
src = data_dir / db_name
|
||||
stem = db_name.replace(".db", "")
|
||||
if not src.exists():
|
||||
print(f"[skip] {db_name} 不存在")
|
||||
return
|
||||
return None
|
||||
out_dir = backup_root / stem
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst = out_dir / f"{stem}_{ts}.db"
|
||||
@@ -47,10 +64,162 @@ def backup(db_name: str, keep: int) -> None:
|
||||
for old in files[keep:]:
|
||||
old.unlink()
|
||||
print(f"[ok] {db_name} -> {dst}(保留 {keep} 份)")
|
||||
return dst
|
||||
|
||||
# ---------------- S3 上传(纯标准库 SigV4) ----------------
|
||||
|
||||
def load_env(path: Path) -> dict:
|
||||
"""极简 .env 解析(KEY=VALUE,忽略注释/空行),os.environ 优先"""
|
||||
env = {}
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for k, v in os.environ.items():
|
||||
if v:
|
||||
env[k] = v
|
||||
return env
|
||||
|
||||
|
||||
def _sign(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _sig_key(secret: str, date: str, region: str, service: str) -> bytes:
|
||||
k = _sign(f"AWS4{secret}".encode(), date)
|
||||
k = _sign(k, region)
|
||||
k = _sign(k, service)
|
||||
return _sign(k, "aws4_request")
|
||||
|
||||
|
||||
def _s3_request(endpoint: str, access: str, secret: str, region: str,
|
||||
method: str, bucket: str, key: str, query: str,
|
||||
body: bytes, content_type: str = "application/octet-stream") -> bytes:
|
||||
"""S3 API 请求(path-style),返回响应体"""
|
||||
now = datetime.now(timezone.utc)
|
||||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||||
date = now.strftime("%Y%m%d")
|
||||
payload_hash = hashlib.sha256(body).hexdigest()
|
||||
host = urllib.parse.urlparse(endpoint).netloc
|
||||
q = urllib.parse.quote(key, safe="/")
|
||||
|
||||
canonical_uri = f"/{bucket}/{q}"
|
||||
canonical_query = query # 已按 RFC3986 编码
|
||||
canonical_headers = (
|
||||
f"host:{host}\n"
|
||||
f"x-amz-content-sha256:{payload_hash}\n"
|
||||
f"x-amz-date:{amz_date}\n"
|
||||
)
|
||||
signed_headers = "host;x-amz-content-sha256;x-amz-date"
|
||||
canonical_request = "\n".join(
|
||||
[method, canonical_uri, canonical_query, canonical_headers, signed_headers, payload_hash]
|
||||
)
|
||||
scope = f"{date}/{region}/s3/aws4_request"
|
||||
string_to_sign = "\n".join(
|
||||
["AWS4-HMAC-SHA256", amz_date, scope,
|
||||
hashlib.sha256(canonical_request.encode()).hexdigest()]
|
||||
)
|
||||
signature = hmac.new(
|
||||
_sig_key(secret, date, region, "s3"), string_to_sign.encode(), hashlib.sha256
|
||||
).hexdigest()
|
||||
auth = (
|
||||
f"AWS4-HMAC-SHA256 Credential={access}/{scope}, "
|
||||
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||
)
|
||||
|
||||
url = f"{endpoint.rstrip('/')}/{bucket}/{q}"
|
||||
if query:
|
||||
url += "?" + query
|
||||
req = urllib.request.Request(
|
||||
url, data=body, method=method,
|
||||
headers={
|
||||
"Authorization": auth,
|
||||
"x-amz-content-sha256": payload_hash,
|
||||
"x-amz-date": amz_date,
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=90) as resp:
|
||||
return resp.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"S3 {method} {key} 失败: HTTP {e.code} {e.read().decode(errors='replace')[:300]}")
|
||||
|
||||
|
||||
def s3_upload(env: dict, dst: Path, stem: str, keep: int) -> None:
|
||||
"""上传单个备份文件到 S3,并清理远端过期备份(保留 keep 份)"""
|
||||
endpoint = env.get("S3_ENDPOINT_URL", "")
|
||||
access = env.get("S3_ACCESS_KEY", "")
|
||||
secret = env.get("S3_SECRET_KEY", "")
|
||||
bucket = env.get("S3_BUCKET", "")
|
||||
if not (endpoint and access and secret and bucket):
|
||||
print("[s3] 未配置 S3_*(ENDPOINT/ACCESS_KEY/SECRET_KEY/BUCKET),跳过远端备份")
|
||||
return
|
||||
region = env.get("S3_REGION", "auto")
|
||||
prefix = env.get("S3_PREFIX", "vps-manager").rstrip("/")
|
||||
key = f"{prefix}/{stem}/{dst.name}"
|
||||
|
||||
body = dst.read_bytes()
|
||||
_s3_request(endpoint, access, secret, region, "PUT", bucket, key, "", body)
|
||||
print(f"[s3] {dst.name} -> {endpoint}/{bucket}/{key}")
|
||||
|
||||
# 列出同目录下已有备份,按 key 排序(时间戳命名即字典序),清理过期对象
|
||||
# 注意:prefix 必须带参数名且值按 RFC3986 编码(含 / -> %2F),否则会列出全桶并误删其他目录
|
||||
list_query = "list-type=2&prefix=" + urllib.parse.quote(f"{prefix}/{stem}/", safe="")
|
||||
resp = _s3_request(endpoint, access, secret, region, "GET", bucket, "", list_query, b"")
|
||||
root = ET.fromstring(resp)
|
||||
ns = {"s3": "http://s3.amazonaws.com/doc/2006-03-01/"}
|
||||
keys = [e.text for e in root.findall(".//s3:Key", ns) if e.text]
|
||||
keys.sort(reverse=True)
|
||||
for old_key in keys[keep:]:
|
||||
_s3_request(endpoint, access, secret, region, "DELETE", bucket, old_key, "", b"")
|
||||
print(f"[s3] 清理过期备份: {old_key}")
|
||||
|
||||
|
||||
def run(db_name: str, keep: int) -> None:
|
||||
dst = backup(db_name, keep)
|
||||
if dst is None:
|
||||
return
|
||||
try:
|
||||
env = load_env(Path(".env"))
|
||||
s3_upload(env, dst, db_name.replace(".db", ""), keep)
|
||||
except Exception as e:
|
||||
# 本地备份已成功;S3 失败需要显式暴露(systemd 会标记 failed)
|
||||
print(f"[s3][error] {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def backup_escrow() -> None:
|
||||
"""备份 MASTER_KEY 托管档(覆盖式单份 + S3)
|
||||
|
||||
escrow 多版本无意义(解出的都是当前 MASTER_KEY),故固定文件名覆盖;
|
||||
重建托管(--force)后下次备份自动带上新档。
|
||||
"""
|
||||
import shutil
|
||||
src = data_dir / "master_key.escrow"
|
||||
if not src.exists():
|
||||
print("[skip] master_key.escrow 不存在(尚未建立托管)")
|
||||
return
|
||||
out_dir = backup_root / "escrow"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst = out_dir / "master_key.escrow"
|
||||
shutil.copy2(src, dst)
|
||||
dst.chmod(0o600)
|
||||
print(f"[ok] master_key.escrow -> {dst}(覆盖式单份)")
|
||||
try:
|
||||
env = load_env(Path(".env"))
|
||||
s3_upload(env, dst, "escrow", 14)
|
||||
except Exception as e:
|
||||
print(f"[s3][error] escrow 上传失败: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if target in ("assets", "all"):
|
||||
backup("assets.db", 14)
|
||||
run("assets.db", 14)
|
||||
backup_escrow()
|
||||
if target in ("metrics", "all"):
|
||||
backup("metrics.db", 4)
|
||||
run("metrics.db", 4)
|
||||
PYEOF
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# vps-manager 本地一键部署(零 SSH)
|
||||
#
|
||||
# 流程:
|
||||
# 1. 检查工作区干净 → git push 到 Gitea
|
||||
# 2. 轮询 cc1 的 /health(含 commit 字段)直到线上版本追平本地 HEAD
|
||||
# (cc1 上 vps-manager-update.timer 每 1 分钟自动拉取并重启)
|
||||
# 3. 验证核心 API 全部 200
|
||||
#
|
||||
# 可配置项(环境变量):
|
||||
# DEPLOY_HEALTH_URL 健康检查地址(默认 Tailscale HTTPS 域名)
|
||||
# DEPLOY_TIMEOUT 等待超时秒数(默认 720;首次使用含旧 5 分钟 timer 周期余量)
|
||||
# GIT_BRANCH 推送分支(默认 main)
|
||||
#
|
||||
# 用法:./deploy/deploy.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-https://dify.taile5765c.ts.net/health}"
|
||||
TIMEOUT="${DEPLOY_TIMEOUT:-720}"
|
||||
BRANCH="${GIT_BRANCH:-main}"
|
||||
POLL_INTERVAL=15
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> [1/3] 推送代码"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "⚠️ 存在未提交变更,请先 commit:"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
git push origin "$BRANCH"
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
# /health 返回 7 位短哈希,比对统一用短哈希
|
||||
LOCAL_SHORT=$(git rev-parse --short=7 HEAD)
|
||||
echo " 本地版本 ${LOCAL:0:8} 已推送,等待 cc1 自动更新…"
|
||||
|
||||
echo "==> [2/3] 等待 cc1 更新(轮询 ${HEALTH_URL})"
|
||||
fetch_remote_commit() {
|
||||
curl -s --max-time 10 "$HEALTH_URL" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('commit',''))" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
deadline=$(( $(date +%s) + TIMEOUT ))
|
||||
REMOTE=""
|
||||
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||
REMOTE=$(fetch_remote_commit)
|
||||
if [ "$REMOTE" = "$LOCAL_SHORT" ]; then
|
||||
echo " ✅ cc1 已更新至 ${LOCAL:0:8}"
|
||||
break
|
||||
fi
|
||||
echo " 线上 ${REMOTE:0:8} | 目标 ${LOCAL:0:8},${POLL_INTERVAL}s 后重试…"
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
|
||||
if [ "$REMOTE" != "$LOCAL_SHORT" ]; then
|
||||
echo "❌ 部署超时(${TIMEOUT}s 内 cc1 未更新至 ${LOCAL:0:8},当前线上 ${REMOTE:0:8})"
|
||||
echo " 排查:ssh cc1 'journalctl -u vps-manager-update.service -n 30'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> [3/3] 验证核心 API"
|
||||
BASE="${HEALTH_URL%/health}"
|
||||
FAILED=0
|
||||
for path in /api/assets /api/accounts /api/providers /api/stats/overview; do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$BASE$path" || echo "000")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo " ✓ $path -> $code"
|
||||
else
|
||||
echo " ❌ $path -> $code"
|
||||
FAILED=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "❌ 部署后验证失败,请检查 cc1 服务日志"
|
||||
exit 1
|
||||
fi
|
||||
echo "🎉 部署完成:${LOCAL:0:8} 已在 cc1 生效并通过验证"
|
||||
@@ -54,6 +54,12 @@ SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
SMTP_TO=
|
||||
RENEWAL_THRESHOLD_DAYS=30
|
||||
S3_ENDPOINT_URL=
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET=
|
||||
S3_PREFIX=vps-manager
|
||||
S3_REGION=auto
|
||||
EOF
|
||||
chmod 600 .env
|
||||
echo " 已生成 .env(MASTER_KEY 自动生成,请妥善保管)"
|
||||
@@ -85,6 +91,7 @@ fi
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "部署完成!"
|
||||
echo " 当前版本: v$(cat VERSION 2>/dev/null | tr -d '[:space:]' || echo dev) ($(git rev-parse --short HEAD 2>/dev/null || echo unknown))"
|
||||
echo " 本地访问:http://127.0.0.1:8000"
|
||||
echo " Tailscale 内网:http://<本机tailscale_ip>:8000"
|
||||
echo " 如需 PWA/HTTPS:tailscale serve --bg --https=443 http://127.0.0.1:8000"
|
||||
|
||||
Regular → Executable
+62
-9
@@ -1,28 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# vps-manager 自动更新脚本(由 systemd timer 定时触发)
|
||||
# 从 Gitea 拉取最新代码,如有更新则重启服务
|
||||
# 部署位置:/opt/vps-manager/deploy/update.sh
|
||||
# 1. 自动同步 deploy/ 下的 systemd 单元文件(有变更才拷贝 + daemon-reload)
|
||||
# 2. 从 Gitea 拉取最新代码,如有更新则装依赖并重启服务
|
||||
#
|
||||
# 可配置项(环境变量):
|
||||
# VPS_MANAGER_DIR 项目根目录(默认 /opt/vps-manager)
|
||||
# VPS_MANAGER_SVC systemd 服务名(默认 vps-manager)
|
||||
# GIT_BRANCH 跟踪的远端分支(默认 main)
|
||||
#
|
||||
# 使用示例:
|
||||
# VPS_MANAGER_DIR=/srv/vps ./update.sh
|
||||
# 或在 systemd unit 中设置 Environment=VPS_MANAGER_DIR=/srv/vps
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd /opt/vps-manager
|
||||
APP_DIR="${VPS_MANAGER_DIR:-/opt/vps-manager}"
|
||||
SERVICE_NAME="${VPS_MANAGER_SVC:-vps-manager}"
|
||||
BRANCH="${GIT_BRANCH:-main}"
|
||||
|
||||
git fetch -q origin main
|
||||
cd "$APP_DIR"
|
||||
|
||||
# 输出当前运行版本号(语义版本 + git 短哈希),便于比对本地与线上是否一致
|
||||
print_version() {
|
||||
local semver commit
|
||||
semver=$(cat VERSION 2>/dev/null | tr -d '[:space:]')
|
||||
[ -z "$semver" ] && semver="dev"
|
||||
commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
echo "[$(date '+%F %T')] 当前版本: v${semver} (${commit})"
|
||||
}
|
||||
|
||||
# 同步 systemd 单元文件:deploy/ 下的 .service/.timer 与 /etc/systemd/system
|
||||
# 不一致时拷贝并 reload;timer 变更额外 restart 使新调度周期立即生效。
|
||||
# 每次都执行(而非仅版本更新时),保证单元变更不依赖代码变更触发。
|
||||
sync_units() {
|
||||
local changed=0 f base
|
||||
for f in "$APP_DIR"/deploy/*.service "$APP_DIR"/deploy/*.timer; do
|
||||
[ -f "$f" ] || continue
|
||||
base=$(basename "$f")
|
||||
if ! cmp -s "$f" "/etc/systemd/system/$base"; then
|
||||
cp "$f" "/etc/systemd/system/$base"
|
||||
echo "[$(date '+%F %T')] 单元已更新: $base"
|
||||
changed=1
|
||||
case "$base" in
|
||||
*.timer) systemctl restart "$base" 2>/dev/null || true ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
systemctl daemon-reload
|
||||
echo "[$(date '+%F %T')] systemd 单元同步完成"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_units
|
||||
|
||||
git fetch -q origin "$BRANCH"
|
||||
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse origin/main)
|
||||
REMOTE=$(git rev-parse "origin/$BRANCH")
|
||||
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
echo "[$(date '+%F %T')] 代码已是最新,无需更新"
|
||||
print_version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[$(date '+%F %T')] 检测到新版本: ${LOCAL:0:8} -> ${REMOTE:0:8}"
|
||||
git pull --ff-only -q origin main
|
||||
# 部署节点始终与远端完全一致:reset --hard 丢弃任何本地变更(含文件 mode 变化),
|
||||
# 避免 cc1 上误改文件导致 pull 拒绝合并、更新链路卡死
|
||||
git reset --hard -q "origin/$BRANCH"
|
||||
|
||||
# 依赖如有变更自动安装(无变化时 pip 会快速跳过)
|
||||
.venv/bin/pip install -q -r requirements.txt 2>/dev/null || true
|
||||
if [ -x ".venv/bin/pip" ]; then
|
||||
.venv/bin/pip install -q -r requirements.txt 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 重启服务加载新代码
|
||||
systemctl restart vps-manager
|
||||
echo "[$(date '+%F %T')] 已重启 vps-manager 服务"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
echo "[$(date '+%F %T')] 已重启 $SERVICE_NAME 服务"
|
||||
print_version
|
||||
|
||||
@@ -8,4 +8,5 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/vps-manager/deploy/update.sh
|
||||
# 用 bash 显式调用,不依赖脚本可执行位(避免编辑后 mode 丢失导致更新链路自毁)
|
||||
ExecStart=/bin/bash /opt/vps-manager/deploy/update.sh
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# vps-manager 自动更新定时器(每 5 分钟检查一次)
|
||||
# vps-manager 自动更新定时器(每 1 分钟检查一次,配合 deploy.sh 实现准实时自动部署)
|
||||
# 部署位置:/etc/systemd/system/vps-manager-update.timer
|
||||
# 本文件由 update.sh 的 sync_units 自动同步到部署机,修改后只需 push 即可生效
|
||||
|
||||
[Unit]
|
||||
Description=Periodic update check for vps-manager
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=5min
|
||||
OnUnitActiveSec=1min
|
||||
Unit=vps-manager-update.service
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# Goal Spec:凭据库(密码 + 2FA)与账号体系联动
|
||||
|
||||
> 供 goal 机制执行的规格说明。执行顺序:M0 → M1 → M2 → M3(M4 待用户圈选后启动)。
|
||||
|
||||
## 1. Goal Objective(可复制的目标摘要)
|
||||
|
||||
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
|
||||
|
||||
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
|
||||
|
||||
本地 pytest + uvicorn 端到端验证截图后提交,再由 Gitea → cc1 timer 自动部署并做生产验证。
|
||||
|
||||
## 2. 领域模型决策(已与用户确认)
|
||||
|
||||
1. **凭据粒度 = 站点 × 登录方式**。同一 gmail 注册 A~F 六个网站 = 六条 credential,邮箱只是 `username` 的取值,重复出现是常态而非冗余。
|
||||
2. **密码相同不共享**。A/B 站都是 `xxx` 也各存一份密文(改密逐站发生,禁止联动耦合)。
|
||||
3. **授权登录(OAuth/SSO)是一条正常记录**:`login_type='oauth'` + `oauth_provider='google'` 等,`password` 为空,仍占一条便于检索回忆。
|
||||
4. **2FA 挂在 credential 上**(登录凭据的一部分,非独立孤岛);账号体系通过关联的凭据使用 2FA。
|
||||
5. **唯一事实源 = credentials 表**。`accounts.login_password_encrypted` 迁移后清空、停止写入;Account 的 `login_user` 字段保留(非机密,供展示);`api_config_encrypted`(SDK 用)留在 Account 不动。
|
||||
6. **账号与凭据解耦生命周期**:删除账号不影响其凭据条目(凭据库独立留存);账号侧新建/改密自动同步到凭据。
|
||||
7. **TOTP 不引第三方库**:`app/core/totp.py` 自实现 RFC 6238(HMAC-SHA1 + Base32,6 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
|
||||
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
|
||||
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**:Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
|
||||
|
||||
## 3. MASTER_KEY 密钥托管(M0,最先落地)
|
||||
|
||||
### 3.1 设计决策
|
||||
|
||||
- **问题**:Fernet 单钥架构下 MASTER_KEY 遗失 = 全量密文不可解;其中 2FA secret 无法像密码一样逐站重置,损失不可逆。
|
||||
- **方案(Key Escrow)**:
|
||||
1. 系统生成随机 `RESTORE_KEY`(Fernet key 格式,44 字符 urlsafe base64);
|
||||
2. 用 RESTORE_KEY 加密当前 MASTER_KEY,得 escrow token(`v1:` 前缀 + Fernet token),写入 `data/master_key.escrow`;
|
||||
3. escrow 随 data/ 备份流转(其内容被 RESTORE_KEY 加密,库/备份泄露也解不开);RESTORE_KEY **只离线保存**:用户抄写 2~3 份(密码管理器 / 纸质 / 可信家人);
|
||||
4. MASTER_KEY 遗失时:运行 `scripts/recover_master_key.py`,输入 RESTORE_KEY → 解密 escrow 找回 MASTER_KEY → 写回 .env → 重启服务,全量密文可解、零迁移。
|
||||
- **恢复钥匙形态**:系统生成随机强钥匙(已确认),不引入口令派生(防弱口令拖库风险)。
|
||||
- **恢复入口**:一次性 CLI 脚本(已确认),零新增网络接口/攻击面;日常运行不读取 escrow。
|
||||
- **边界**:RESTORE_KEY 自身遗失 = escrow 失效 → 缓解手段是离线多副本抄写;不做 Shamir 秘密拆分(单用户规模过度设计)。
|
||||
- **托管对象是 key 本身而非用户数据**:escrow 泄露最坏影响 = 需轮换 MASTER_KEY,不直接暴露任何业务密文(还要同时拿到离线 RESTORE_KEY 才有意义)。
|
||||
|
||||
### 3.2 实现清单
|
||||
|
||||
- `app/core/crypto.py` 增加(不动现有 encrypt/decrypt 与 lru 缓存):
|
||||
- `_fernet_from_key(key: str) -> Fernet`:按给定 key 构造独立实例(不走 MASTER_KEY 缓存)。
|
||||
- `build_escrow(restore_key: str) -> str`:读 `settings.MASTER_KEY`,返回 `'v1:' + Fernet(restore_key).encrypt(master_key_bytes)`。
|
||||
- `recover_master_key(restore_key: str, escrow: str) -> str`:解析 `v1:` 前缀并解密,失败抛错(钥匙错误/文件损坏)。
|
||||
- 新增 `scripts/setup_key_escrow.py`:
|
||||
- 生成 RESTORE_KEY(`Fernet.generate_key()`);
|
||||
- 立即用 build_escrow + recover_master_key 回验自检;
|
||||
- 写入 `data/master_key.escrow`(权限 0600);幂等:文件已存在且回验通过则跳过并提示,`--force` 才重建;
|
||||
- 终端**仅此一次**打印 RESTORE_KEY,附保存建议(3 个离线位置),随后清屏提示已保存到 .env 旁说明文件(`data/master_key.escrow.README`,含步骤简述、不含钥匙)。
|
||||
- 新增 `scripts/recover_master_key.py`:
|
||||
- `--key <RESTORE_KEY>` 必填(也支持环境变量 `RESTORE_KEY`,避免 shell 历史残留);
|
||||
- 读 `data/master_key.escrow` → `recover_master_key`;失败提示"恢复钥匙错误或 escrow 损坏";
|
||||
- 默认仅打印找回的 MASTER_KEY;`--write` 则备份 `.env` 为 `.env.bak-pre-recover` 后回写 MASTER_KEY 行,提示重启服务。
|
||||
- `.gitignore`:确认 `data/` 已忽略(escrow 绝不进 git);M0 落地时手动把 escrow 文件复制一份到离线备份介质。
|
||||
|
||||
### 3.3 M0 验收门槛
|
||||
|
||||
1. 本地运行 setup 脚本生成 escrow,终端显示 RESTORE_KEY(留存截图一次后即离线保存)。
|
||||
2. **丢失演练**:备份 `.env` → 用错误 MASTER_KEY 启动,验证真实数据解密失败 → 运行 recover(不带 --write)找回原 key → 回写 .env → 重启 → 既有真实账号密码可正常解密查看。
|
||||
3. escrow 文件权限 0600、未纳入 git、已复制离线备份。
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 新表 `credentials`
|
||||
|
||||
`app/models/credential.py`,挂 assets.db:
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| id | int PK | |
|
||||
| site | str, index | 站点/服务名(如 "GitHub"、"阿里云"),迁移自平台名 |
|
||||
| username | str, index, nullable | 登录用户名/邮箱 |
|
||||
| login_type | str, default 'password' | password / oauth / other |
|
||||
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
|
||||
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
|
||||
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
|
||||
| url | str, nullable | 登录页地址(可选跳转) |
|
||||
| note | str, nullable | 备注 |
|
||||
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
|
||||
|
||||
- 不建 `(site, username)` 唯一约束(SQLite 对 NULL 不友好 + 同站多账号合法);服务层创建时提示重复但允许继续。
|
||||
- 2FA 展示所需信息均从 secret 派生:`issuer=site`、`account=username`,导出时可重建 otpauth URI。
|
||||
|
||||
### 4.2 `accounts` 加列
|
||||
|
||||
`Account` 增加 `credential_id: Optional[int] = Field(default=None, foreign_key="credentials.id", index=True)`。
|
||||
|
||||
### 4.3 相关文件
|
||||
|
||||
- `app/database.py`:`init_db` 的 `asset_models` 加入 `Credential`;`_migrate_assets_db` 增加迁移函数(见 §5)。
|
||||
- `app/main.py`:`include_router(credentials.router)`。
|
||||
|
||||
## 5. 幂等迁移(_migrate_assets_db 内)
|
||||
|
||||
```text
|
||||
_migrate_credentials():
|
||||
1. accounts 表存在时:若无 credential_id 列 → ALTER TABLE accounts ADD COLUMN credential_id INTEGER
|
||||
2. 建表/加列后执行 backfill(幂等条件:credential_id 已非空则跳过):
|
||||
遍历 accounts 中 login_password_encrypted 非空 且 credential_id IS NULL 的行:
|
||||
site = providers.name(platform=slug 匹配)→ 否则 platform → 否则 '未分类'
|
||||
username = COALESCE(login_user, name)
|
||||
login_type = 'password'
|
||||
password_encrypted = 原密文原样搬入(不解密再加密,避免中间态暴露)
|
||||
credential_id = 新行 id
|
||||
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
|
||||
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_username(IF NOT EXISTS)
|
||||
```
|
||||
|
||||
**执行前必须备份**:`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
|
||||
|
||||
**回滚**:git revert 后执行逆迁移(credential.password_encrypted 写回 account.login_password_encrypted 并解除关联)——单用户数据量小,直接在 sqlite3/脚本内完成,仅在需要时编写。
|
||||
|
||||
## 6. 后端改动清单
|
||||
|
||||
### 6.1 新增 `app/core/totp.py`(无依赖)
|
||||
|
||||
- `b32decode(secret)`:容错去空格/补 `=`。
|
||||
- `totp_at(secret_b32, ts=None)` → `(code6, period_left_seconds)`:RFC 6238 标准实现。
|
||||
- `verify(secret_b32, code, window=1)`:允许 ±1 步进(录入校验容时钟偏差)。
|
||||
- `parse_otpauth_uri(uri)` → `{secret, issuer, account}`:支持用户直接粘贴 `otpauth://totp/...` 录入(仅取 secret 入库,issuer/account 仅回填建议)。
|
||||
- `random_secret()`:`secrets` 生成 20 字节 → Base32(供"生成随机密钥"按钮,可选)。
|
||||
|
||||
### 6.2 新增 `app/schemas/credential.py`
|
||||
|
||||
```text
|
||||
CredentialBase: site, username, login_type='password', oauth_provider, url, note
|
||||
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
|
||||
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
|
||||
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
|
||||
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
|
||||
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
|
||||
```
|
||||
|
||||
### 6.3 新增 `app/services/credential_service.py`
|
||||
|
||||
- `list_credentials(session, q, login_type, has_otp)`:搜索 site/username/note;反向查 account 填充 account_name。
|
||||
- `create_credential`:`(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`;otp 见 §6.5。
|
||||
- `update_credential / delete_credential`。
|
||||
- `reveal_password(credential_id)` → `{username, password}`(同账号 reveal 语义)。
|
||||
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
|
||||
- 依赖注入、404、加密方式与 `account_service` 完全同构。
|
||||
|
||||
### 6.4 新增 `app/routers/credentials.py`(prefix `/api/credentials`)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `` ?q=&login_type=&has_otp= | 读 | 列表 |
|
||||
| POST | `` | require_api_key | 创建(含可选 otp 绑定) |
|
||||
| PUT | `/{id}` | require_api_key | 更新(含改密/清密) |
|
||||
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
|
||||
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
|
||||
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
|
||||
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,verify 通过才存 |
|
||||
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
|
||||
|
||||
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
|
||||
- 所有写操作与账号/资产路由一致使用 `require_api_key`(内网放行规则自动生效)。
|
||||
|
||||
### 6.5 2FA 绑定与生成规则
|
||||
|
||||
- **绑定**:`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
|
||||
- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
|
||||
|
||||
### 6.6 改造 `app/services/account_service.py`
|
||||
|
||||
- `_to_read`:`has_login_password` 改判 `bool(account.credential_id)`;`AccountRead` 增 `credential_id`、`has_otp` 两字段(schema 同步加,均默认 False/None,兼容老前端)。 `has_otp` 需查关联 credential —— list 时批量取 `credential_id in (...)` 后填充。
|
||||
- `create_account`:保存 `login_password` 时(解密前不落库):
|
||||
- 同一事务内创建 credential(site=平台显示名规则同迁移,username=COALESCE(login_user,name)),记 `account.credential_id`。
|
||||
- `update_account`:改名/换平台时同步 credential.site/username(保持引用一致);`login_password` 非空 → upsert 到关联 credential(没有则新建并回填 id);`''` → 清 credential.password;`None` → 不动。
|
||||
- `reveal_password`:改从关联 credential 解密(未关联 → 404 同旧语义)。
|
||||
- `delete_account`:**不删** credential(生命周期解耦,凭据库独立留存)。
|
||||
|
||||
### 6.7 `AccountRead` schema 扩展
|
||||
|
||||
```text
|
||||
credential_id: Optional[int] = None
|
||||
has_otp: bool = False
|
||||
```
|
||||
|
||||
## 7. 前端改动清单
|
||||
|
||||
### 7.1 导航与路由(`app.js` + `static/js/views/vault.js` 新建)
|
||||
|
||||
- `NAVS` 增加 `{ key: 'vault', label: '凭据库', icon: '🔐' }`(放在「平台」之前)。
|
||||
- `VIEW_MAP` 增加 `vault: 'vault-view'`。
|
||||
- 新建 `static/js/views/vault.js`:`VaultView` 组件。
|
||||
- `index.html` 在 modals.js 之前加入 `<script src="/static/js/views/vault.js?v={{ asset_version }}">`。
|
||||
- `app.js` 注册 `app.component('vault-view', VaultView)`。
|
||||
|
||||
### 7.2 凭据库视图(vault.js)行为
|
||||
|
||||
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
|
||||
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
|
||||
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
|
||||
- **2FA 区块**(`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`)+ 复制验证码 + 解绑。
|
||||
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
|
||||
|
||||
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal)
|
||||
|
||||
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、password(type=password,编辑时留空=不改)、note。
|
||||
- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
|
||||
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
|
||||
|
||||
### 7.4 账号体系联动(modals.js / accounts-view 相关)
|
||||
|
||||
- 账号列表(accounts-view-modal)与账号弹窗的"查看密码"按钮逻辑不变(后端已重定向)。
|
||||
- 若账号关联凭据且有 2FA:查看密码弹窗旁增加「获取验证码」按钮 → 调 `GET /api/credentials/{credential_id}/otp` 展示动态码(复用展开详情的 2FA 组件逻辑,独立小函数)。
|
||||
- 平台页账号入口保持不动。
|
||||
|
||||
### 7.5 store.js / api.js
|
||||
|
||||
- `store.credentials` + `loadCredentials()`,`loadAll()` 并联加入。
|
||||
- CRUD 函数与 credentialModal state(复制 saveAccount 的 saving 锁模式防双击)。
|
||||
- `Fmt.LOGIN_TYPE_LABELS = { password: '密码', oauth: '授权登录', other: '其他' }` 与 oauth provider 徽章色。
|
||||
|
||||
## 8. 交互打磨(M4,占位待用户圈选后启动)
|
||||
|
||||
用户对交互尚有保留意见,此处列候选独立小改动,启动前由用户勾选范围:
|
||||
|
||||
1. 全局 toast「已复制」取代旧式 alert/瞬时无反馈(可做成 store.toast + 简单组件)。
|
||||
2. OTP 环形/进度条倒计时视觉 + 复制即消失反馈。
|
||||
3. `/` 快捷键聚焦当前页搜索框(移动端不启用)。
|
||||
4. 账号查看密码弹窗合并进凭据详情(统一交互路径,减少两套弹窗)。
|
||||
5. 快速录入:从平台账号弹窗一键「补全 2FA」跳凭据表单并预填 site/username。
|
||||
6. 二维码录入(需要引入前端 QR 解码库/后端解码,成本高,默认不做,除非用户点名)。
|
||||
7. 双击行快速复制密码、长按移动端复制。
|
||||
|
||||
> M4 独立成 goal/任务执行,不阻塞 M0–M3。
|
||||
|
||||
## 9. 测试与验收
|
||||
|
||||
### 9.1 pytest(tests/ 新增 test_totp.py、test_credential_migration.py)
|
||||
|
||||
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32,T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 20000000000,8 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
|
||||
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
|
||||
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
|
||||
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
|
||||
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
|
||||
|
||||
### 9.2 本地端到端(必做,提交前)
|
||||
|
||||
1. `.venv/bin/uvicorn app.main:app --port 8000`(项目根 vps-manager/ 下,load_dotenv 自动读 .env)。
|
||||
2. 先完成 M0 丢失演练(见 §3.3)。
|
||||
3. 手动建测试凭据(含 oauth 条目 + 2FA 绑定真实 secret,用手机验证器/在线 TOTP 工具对码)。
|
||||
4. 浏览器逐项验证:列表搜索、复制密码、OTP 倒计时刷新、账号弹窗查密码仍可用、新增账号自动生成凭据条目。
|
||||
5. 迁移干跑:确认生产量级账号行全部迁移、无残留明文。
|
||||
6. 截图留存(本地验证 + 产物截图惯例),再 git commit + push。
|
||||
|
||||
### 9.3 生产验证(cc1 自动部署后)
|
||||
|
||||
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
|
||||
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
|
||||
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
|
||||
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
|
||||
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
|
||||
|
||||
## 10. 里程碑与验收门槛
|
||||
|
||||
| 里程碑 | 内容 | 完成门槛 |
|
||||
|---|---|---|
|
||||
| M0 | Key Escrow 托管(crypto 扩展 + setup/recover 脚本 + escrow 文件) | 丢失演练通过(§3.3);pytest escrow 回验绿 |
|
||||
| M1 | 模型/schema/totp/迁移/credential service+router/account_service 改造 + pytest | 全绿;迁移干跑幂等 |
|
||||
| M2 | vault 视图 + CredentialModal + 账号联动 + store/api | 本地端到端截图验证通过 |
|
||||
| M3 | commit → push → cc1 生产验证(含生产 escrow 建立) | 生产抽查通过,无回归 |
|
||||
| M4 | 交互打磨(圈选后另行启动) | 独立 |
|
||||
|
||||
## 11. 风险与注意
|
||||
|
||||
- 迁移前**必须**备份 assets.db(本地与生产各一次),迁移函数幂等可重跑。
|
||||
- 迁移/回滚脚本只搬密文、不落明文,避免中间态暴露。
|
||||
- RESTORE_KEY 一旦遗失 escrow 即失效 → 落地时强制抄写 3 个离线位置,并保存一份 escrow 到离线介质;`.env` 与 escrow 尽量分介质存放。
|
||||
- 旧前端页面在部署后需刷新(HTML no-cache + asset_version 已保证)。
|
||||
- credentials 是个人全量密码仓库:`reveal` / `otp` 接口均走 require_api_key,Tailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
|
||||
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
|
||||
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`(SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。
|
||||
@@ -0,0 +1,133 @@
|
||||
# 迁移 Runbook:vps-manager 换宿主平台
|
||||
|
||||
适用场景:当前宿主为 cc1(Tailscale `100.89.0.11`,HTTPS `https://dify.taile5765c.ts.net`);
|
||||
选定长期平台后按本手册完成整体搬迁。目标:**数据零丢失、密文零重录、服务可回滚**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 迁移面:什么要搬,什么能重建
|
||||
|
||||
| 类别 | 内容 | 迁移方式 |
|
||||
|---|---|---|
|
||||
| **唯一状态(必须搬)** | `data/assets.db`、`data/metrics.db`、`data/master_key.escrow`、`data/backups/`、`.env` | `make_migration_bundle.py` 打包 → `restore_migration_bundle.py` 恢复 |
|
||||
| 可重建(不搬) | 代码 | 新机 `git clone` Gitea 仓库(`setup.sh` 自动) |
|
||||
| 可重建(不搬) | Python venv 与依赖 | `setup.sh`(`pip install -r requirements.txt`) |
|
||||
| 可重建(不搬) | systemd units 与全部 timer | `deploy/*.service\|timer` 在仓库内,`setup.sh` 安装 |
|
||||
| 可重建(不搬) | Tailscale HTTPS serve | 一条 `tailscale serve --bg --https=443 http://127.0.0.1:8000` |
|
||||
| **机器外状态** | 离线 `RESTORE_KEY` 抄本、Gitea deploy key、S3 凭据 | 手工:RESTORE_KEY 抄本随身;新机需可访问 Gitea(SSH key 或 HTTPS+token) |
|
||||
|
||||
关键不变量:**`.env` 里的 `MASTER_KEY` 必须原样搬过去**——库内所有密文(平台账号
|
||||
api_config、凭据密码、2FA secret)都用它加密。MASTER_KEY 不变,密文即可直接读,无需重录。
|
||||
|
||||
---
|
||||
|
||||
## 2. 迁移前可选预处理(让将来迁移更省事)
|
||||
|
||||
一次性做完,之后换平台时 agent 与访问地址都无需改动:
|
||||
|
||||
1. **agent 地址去 IP 化**:把各被管 VPS 的 `/etc/vps-agent.env` 中
|
||||
`VPS_MANAGER_URL=http://100.89.0.11:8000` 改为 MagicDNS 名,例如
|
||||
`http://dify.<tailnet>.ts.net:8000`;迁移时新设备沿用同名 `dify` 即可零改动。
|
||||
```bash
|
||||
sed -i 's#http://100.89.0.11:8000#http://<新地址>:8000#' /etc/vps-agent.env && systemctl restart vps-agent
|
||||
```
|
||||
2. **建立 MASTER_KEY 托管**(若尚未做):`python scripts/setup_key_escrow.py`,
|
||||
RESTORE_KEY 抄到 2~3 个离线位置——迁移途中 `.env` 出意外时这是唯一后路。
|
||||
3. 确认备份 timer 正常:`systemctl list-timers | grep vps-`,且 `data/backups/escrow/` 有托管档。
|
||||
|
||||
---
|
||||
|
||||
## 3. 迁移步骤
|
||||
|
||||
### 3.1 源机(cc1)打包
|
||||
```bash
|
||||
ssh cc1
|
||||
cd /opt/vps-manager
|
||||
.venv/bin/python scripts/make_migration_bundle.py /tmp/vps-bundle.tar.gz
|
||||
```
|
||||
记录输出的**包校验和**与文件数。打包用 sqlite backup API 在线快照,**无需停服**。
|
||||
|
||||
### 3.2 传输
|
||||
仅用 `scp` 或加密介质直传新机,不经第三方网盘/聊天工具:
|
||||
```bash
|
||||
scp /tmp/vps-bundle.tar.gz root@<新机>:/tmp/
|
||||
```
|
||||
|
||||
### 3.3 新机部署基座
|
||||
```bash
|
||||
sudo tailscale up # 入网;建议设备名沿用 dify(保持 HTTPS URL 不变)
|
||||
git clone <Gitea仓库> /tmp/vps-manager-setup && cd /tmp/vps-manager-setup
|
||||
sudo bash deploy/setup.sh # 依赖/venv/units/.env(临时)/服务启动
|
||||
```
|
||||
`setup.sh` 生成的临时 `.env` 会在 3.4 被包内 `.env` 覆盖(含真实 MASTER_KEY)。
|
||||
|
||||
### 3.4 停服并恢复状态
|
||||
```bash
|
||||
cd /opt/vps-manager
|
||||
sudo systemctl stop vps-manager vps-manager-update.timer
|
||||
sudo .venv/bin/python scripts/restore_migration_bundle.py /tmp/vps-bundle.tar.gz
|
||||
sudo systemctl start vps-manager
|
||||
```
|
||||
脚本逐文件校验 sha256(不匹配即拒绝),并把新机原有 `data/`、`.env` 备份为
|
||||
`*.pre-restore-<ts>` 以便回滚。
|
||||
|
||||
### 3.5 访问入口
|
||||
```bash
|
||||
sudo tailscale serve --bg --https=443 http://127.0.0.1:8000
|
||||
sudo tailscale status # 确认 https://<设备名>.<tailnet>.ts.net
|
||||
```
|
||||
若 URL 与旧的不同:把新域名加入 `/opt/vps-manager/.env` 的 `CORS_ORIGINS`,
|
||||
`sudo systemctl restart vps-manager`,并更新浏览器书签 / 手机 PWA。
|
||||
|
||||
### 3.6 agent 与通知
|
||||
- 各被管 VPS:若未做 §2.1 预处理,逐台改 `VPS_MANAGER_URL` 指向新机并 `systemctl restart vps-agent`;
|
||||
- 通知渠道(Telegram/SMTP)配置随 `.env` 迁移,无需改动,可用一次「立即同步」验证送达。
|
||||
|
||||
### 3.7 源机(cc1)下线 —— 防双写双通知
|
||||
确认新机运行正常**至少一个同步/备份周期**后:
|
||||
```bash
|
||||
ssh cc1
|
||||
sudo systemctl disable --now vps-manager vps-manager-update.timer \
|
||||
vps-backup-assets.timer vps-backup-metrics.timer vps-sync-ai.timer vps-renewal-check.timer
|
||||
sudo rm -f /tmp/vps-bundle.tar.gz # 迁移包等同最高机密,用完即删
|
||||
```
|
||||
新机侧同样删除 `/tmp/vps-bundle.tar.gz`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 验收清单(迁移后逐项打勾)
|
||||
|
||||
- [ ] `curl https://<新地址>/health` 返回 `status=ok`,version/commit 与源机一致
|
||||
- [ ] 前端各视图数据完整:资产、订阅、域名、平台账号、监控、AI
|
||||
- [ ] **凭据可解密**:`/api/credentials/{id}/password` 能解出明文(抽查 1~2 条)
|
||||
- [ ] **2FA 动态码正确**:`/api/credentials/{id}/otp` 出的码能在目标站点通过验证
|
||||
- [ ] 平台账号 api_config 可解密:一次手动同步成功(验证 API 凭据未失效)
|
||||
- [ ] `systemctl list-timers | grep vps-` 五个 timer 全部 enabled 且有下次触发时间
|
||||
- [ ] 监控数据开始进新机(`metrics.db` 有 agent 上报的新指标)
|
||||
- [ ] 备份 timer 跑过一次:`data/backups/assets/`、`data/backups/escrow/` 有新文件(含 S3)
|
||||
- [ ] 源机所有 timer 已 disable、服务已 stop(防双写)
|
||||
- [ ] 源机与新机上的迁移包均已删除
|
||||
|
||||
---
|
||||
|
||||
## 5. 回滚
|
||||
|
||||
新机异常时(源机尚未下线):
|
||||
```bash
|
||||
sudo systemctl stop vps-manager
|
||||
cd /opt/vps-manager && sudo mv data data.bad && sudo mv data.pre-restore-<ts> data
|
||||
sudo mv .env .env.bad && sudo mv .env.pre-restore-<ts> .env
|
||||
sudo systemctl start vps-manager
|
||||
```
|
||||
源机已下线的极端情况:在源机重新 `systemctl enable --now` 各服务与 timer 即可
|
||||
(其数据未被改动,打包过程只读)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 灾难恢复(与迁移不同的场景)
|
||||
|
||||
- **仅 `.env`/MASTER_KEY 丢失**(库还在):`python scripts/recover_master_key.py --key <RESTORE_KEY> --write`
|
||||
- **整机丢失**(只有备份):新机 `setup.sh` → 从 S3/离线取最新 `data/backups/assets/assets_*.db`
|
||||
与 `metrics_*.db` 放回 `data/` → 取 `data/backups/escrow/master_key.escrow` 放回 `data/` →
|
||||
用离线 RESTORE_KEY 解出 MASTER_KEY 写入 `.env` → 起服验证解密。
|
||||
- **RESTORE_KEY 也丢**:无解,密文不可恢复(这正是 §2.2 要求多处离线抄本的原因)。
|
||||
@@ -0,0 +1,55 @@
|
||||
"""定时检查站点 SSL 证书并发送到期提醒(供 systemd timer 调用)
|
||||
|
||||
用法:python scripts/check_site_certs.py [threshold_days]
|
||||
依赖 .env 中的通知渠道配置(TELEGRAM_*/SMTP_*)。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import assets_engine, init_db
|
||||
from app.models.ssl import SiteCert
|
||||
from app.services import notification_service, ssl_service
|
||||
|
||||
|
||||
def main() -> int:
|
||||
threshold = int(sys.argv[1]) if len(sys.argv) > 1 else ssl_service.EXPIRING_THRESHOLD
|
||||
init_db()
|
||||
with Session(assets_engine) as session:
|
||||
result = ssl_service.check_all_site_certs(session)
|
||||
stats = result["stats"]
|
||||
|
||||
# 即将到期 / 已过期 / 探测失败的证书 → 发提醒
|
||||
certs = session.exec(select(SiteCert)).all()
|
||||
urgent = [
|
||||
c for c in certs
|
||||
if c.status in ("expiring", "expired", "error")
|
||||
]
|
||||
notifications = []
|
||||
if urgent:
|
||||
lines = [ssl_service.build_cert_message(c) for c in urgent]
|
||||
message = "【SSL 证书到期提醒】\n" + "\n".join(lines)
|
||||
notifications = notification_service.notify(
|
||||
message, subject="SSL 证书到期提醒"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[SSL检查] 共 {stats['total']} 个站点 | 正常 {stats['ok']} | "
|
||||
f"即将到期 {stats['expiring']} | 已过期 {stats['expired']} | 探测失败 {stats['error']}"
|
||||
)
|
||||
for p in result["problems"]:
|
||||
print(f" - {p['hostname']}: {p['detail']}")
|
||||
for n in notifications:
|
||||
status = "成功" if n["ok"] else f"失败:{n['message']}"
|
||||
print(f" 通知[{n['channel']}]: {status}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
"""迁移打包:把 vps-manager 的唯一状态打成单一可校验迁移包
|
||||
|
||||
在源机器(如 cc1)运行:
|
||||
.venv/bin/python scripts/make_migration_bundle.py [输出tar.gz路径]
|
||||
|
||||
打包内容(唯一状态;代码/venv/systemd units/tailscale serve 均可由
|
||||
setup.sh 与一条 serve 命令重建,故不在包内):
|
||||
- data/assets.db、data/metrics.db:sqlite backup API 在线一致性快照(无需停服)
|
||||
- data/master_key.escrow:MASTER_KEY 托管档(若已建立)
|
||||
- data/backups/:历史备份(含 escrow 副本)
|
||||
- .env:MASTER_KEY / API_KEY / AGENT_KEY / 通知渠道 / S3 配置
|
||||
- manifest.json + manifest.sha256:版本、commit、主机、逐文件校验和
|
||||
|
||||
安全说明:包内含 MASTER_KEY 与全部密文,等同最高机密。传输仅用 scp 或加密
|
||||
介质,恢复完成并验证后即刻删除;切勿上传公开对象存储或聊天工具。
|
||||
恢复步骤见 scripts/restore_migration_bundle.py 与 docs/migration-runbook.md。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def snap_db(src: Path, dst: Path) -> None:
|
||||
"""sqlite 在线一致性快照:backup API 保证事务边界完整,源库无需停服"""
|
||||
con = sqlite3.connect(str(src))
|
||||
bkp = sqlite3.connect(str(dst))
|
||||
with bkp:
|
||||
con.backup(bkp)
|
||||
bkp.close()
|
||||
con.close()
|
||||
|
||||
|
||||
def read_git_commit() -> str:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"], cwd=BASE,
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return out.stdout.strip() or "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
out = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(
|
||||
f"/tmp/vps-manager-bundle-{datetime.now():%Y%m%d_%H%M%S}.tar.gz"
|
||||
)
|
||||
env_src = BASE / ".env"
|
||||
if not env_src.exists():
|
||||
print("[abort] 缺少 .env(MASTER_KEY 不在包内则迁移无意义)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="vps-bundle-"))
|
||||
root = work / "bundle"
|
||||
data_dst = root / "data"
|
||||
data_dst.mkdir(parents=True)
|
||||
data_src = BASE / "data"
|
||||
|
||||
copied: list[str] = []
|
||||
for db in ("assets.db", "metrics.db"):
|
||||
src = data_src / db
|
||||
if src.exists():
|
||||
snap_db(src, data_dst / db)
|
||||
copied.append(f"data/{db}")
|
||||
else:
|
||||
print(f"[skip] {db} 不存在")
|
||||
escrow = data_src / "master_key.escrow"
|
||||
if escrow.exists():
|
||||
shutil.copy2(escrow, data_dst / "master_key.escrow")
|
||||
(data_dst / "master_key.escrow").chmod(0o600)
|
||||
copied.append("data/master_key.escrow")
|
||||
else:
|
||||
print("[warn] 未建立 MASTER_KEY 托管(master_key.escrow 不存在),恢复链不完整")
|
||||
backups_src = data_src / "backups"
|
||||
if backups_src.is_dir():
|
||||
shutil.copytree(backups_src, data_dst / "backups")
|
||||
for p in sorted((data_dst / "backups").rglob("*")):
|
||||
if p.is_file():
|
||||
copied.append(str(p.relative_to(root)))
|
||||
shutil.copy2(env_src, root / ".env")
|
||||
(root / ".env").chmod(0o600)
|
||||
copied.append(".env")
|
||||
|
||||
manifest = {
|
||||
"app": "vps-manager",
|
||||
"version": (BASE / "VERSION").read_text(encoding="utf-8").strip()
|
||||
if (BASE / "VERSION").exists() else "dev",
|
||||
"commit": read_git_commit(),
|
||||
"source_host": socket.gethostname(),
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"files": {name: {"sha256": sha256_file(root / name), "size": (root / name).stat().st_size}
|
||||
for name in copied},
|
||||
}
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
(root / "manifest.sha256").write_text(
|
||||
"".join(f"{v['sha256']} {name}\n" for name, v in manifest["files"].items()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tarfile.open(out, "w:gz") as tar:
|
||||
tar.add(root, arcname="bundle")
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
print(f"[ok] 迁移包:{out}")
|
||||
print(f" 大小:{out.stat().st_size / 1024:.1f} KB")
|
||||
print(f" 包校验和:{sha256_file(out)}")
|
||||
print(f" 源主机:{manifest['source_host']} · 版本 {manifest['version']} ({manifest['commit']})")
|
||||
print(f" 内含 {len(copied)} 个状态文件(两库快照 + escrow + backups + .env)")
|
||||
print(" 恢复:新机器跑 setup.sh 后停服,执行")
|
||||
print(f" .venv/bin/python scripts/restore_migration_bundle.py {out}")
|
||||
print(" ⚠ 包等同最高机密:仅 scp/加密介质传输,验证后删除")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
"""MASTER_KEY 丢失恢复(一次性脚本,日常运行不接触)
|
||||
|
||||
用法:
|
||||
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py # 仅打印找回的 MASTER_KEY
|
||||
RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py --write # 备份 .env 后回写 MASTER_KEY
|
||||
|
||||
也支持 --key 传参(会残留 shell 历史,建议用环境变量)。
|
||||
恢复后重启服务即可:全部密文零迁移、照常解密。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.core import crypto
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
|
||||
DEFAULT_ENV = BASE_DIR / ".env"
|
||||
|
||||
|
||||
def _write_env(master_key: str, env_path: Path) -> Path | None:
|
||||
"""备份 .env 后回写 MASTER_KEY 行(不存在则追加),返回备份路径"""
|
||||
backup: Path | None = None
|
||||
if env_path.exists():
|
||||
backup = env_path.parent / (env_path.name + ".bak-pre-recover")
|
||||
shutil.copy2(env_path, backup)
|
||||
text = env_path.read_text(encoding="utf-8")
|
||||
if re.search(r"^MASTER_KEY=.*$", text, flags=re.MULTILINE):
|
||||
text = re.sub(
|
||||
r"^MASTER_KEY=.*$", f"MASTER_KEY={master_key}", text, count=1, flags=re.MULTILINE
|
||||
)
|
||||
else:
|
||||
text = text.rstrip("\n") + f"\nMASTER_KEY={master_key}\n"
|
||||
env_path.write_text(text, encoding="utf-8")
|
||||
else:
|
||||
env_path.write_text(f"MASTER_KEY={master_key}\n", encoding="utf-8")
|
||||
return backup
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="凭 RESTORE_KEY 找回 MASTER_KEY")
|
||||
parser.add_argument(
|
||||
"--key", help="RESTORE_KEY(建议改用环境变量 RESTORE_KEY,避免 shell 历史残留)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--write", action="store_true", help="找回后直接回写 .env(先备份为 .env.bak-pre-recover)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--escrow", default=str(DEFAULT_ESCROW),
|
||||
help="escrow 文件路径(默认 data/master_key.escrow)",
|
||||
)
|
||||
parser.add_argument("--env", default=str(DEFAULT_ENV), help=".env 路径(默认项目根 .env)")
|
||||
args = parser.parse_args()
|
||||
|
||||
restore_key = args.key or os.environ.get("RESTORE_KEY")
|
||||
if not restore_key:
|
||||
print("[恢复] 缺少 RESTORE_KEY:--key 传入或设置环境变量 RESTORE_KEY", file=sys.stderr)
|
||||
return 2
|
||||
escrow_path = Path(args.escrow)
|
||||
if not escrow_path.exists():
|
||||
print(f"[恢复] 未找到托管文件 {escrow_path}(尚未建立托管?)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
master_key = crypto.recover_master_key(
|
||||
restore_key.strip(), escrow_path.read_text(encoding="utf-8").strip()
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f"[恢复] 失败:{e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.write:
|
||||
backup = _write_env(master_key, Path(args.env))
|
||||
where = f"(原文件已备份至 {backup.name})" if backup else ""
|
||||
print(f"[恢复] MASTER_KEY 已找回并回写 {args.env}{where}")
|
||||
print("[恢复] 请重启服务使 key 生效:sudo systemctl restart vps-manager")
|
||||
else:
|
||||
print("[恢复] MASTER_KEY 已找回(未回写 .env):")
|
||||
print(f" MASTER_KEY={master_key}")
|
||||
print("[恢复] 确认无误后加 --write 自动回写 .env,或手动粘贴到 .env 的 MASTER_KEY= 行")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
"""迁移恢复:校验并解包迁移包到目标部署目录
|
||||
|
||||
在新机器运行(前提:已跑 deploy/setup.sh 完成代码/venv/units 部署):
|
||||
sudo systemctl stop vps-manager vps-manager-update.timer
|
||||
.venv/bin/python scripts/restore_migration_bundle.py <bundle.tar.gz> [--app-dir /opt/vps-manager]
|
||||
sudo systemctl start vps-manager
|
||||
|
||||
行为:
|
||||
- 按 manifest.sha256 逐文件校验,任一不匹配即拒绝恢复(防传输损坏/调包)
|
||||
- 目标机现有 data/ 与 .env 先备份为 *.pre-restore-<ts>(可回滚)
|
||||
- 解包 data/(两库 + escrow + backups)与 .env(chmod 600)
|
||||
- 打印后续手工步骤(serve/agent/CORS/源机 timer 下线,见 docs/migration-runbook.md)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="校验并恢复 vps-manager 迁移包")
|
||||
ap.add_argument("bundle", help="迁移包 tar.gz 路径")
|
||||
ap.add_argument("--app-dir", default="/opt/vps-manager", help="目标部署目录")
|
||||
args = ap.parse_args()
|
||||
|
||||
bundle = Path(args.bundle)
|
||||
app_dir = Path(args.app_dir)
|
||||
if not bundle.exists():
|
||||
print(f"[abort] 迁移包不存在:{bundle}", file=sys.stderr)
|
||||
return 2
|
||||
if not (app_dir / "app" / "main.py").exists():
|
||||
print("[abort] 目标目录不像已部署的 vps-manager(缺 app/main.py),请先跑 deploy/setup.sh",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
|
||||
work = Path(tempfile.mkdtemp(prefix="vps-restore-"))
|
||||
try:
|
||||
with tarfile.open(bundle) as tar:
|
||||
try:
|
||||
# Python >= 3.11.4/3.12:官方数据过滤器(防路径穿越/硬链接等)
|
||||
tar.extractall(work, filter="data")
|
||||
except TypeError:
|
||||
# 新机自带旧版 Python(如 3.10)无 filter 参数:手工校验成员路径
|
||||
for m in tar.getmembers():
|
||||
if m.name.startswith("/") or ".." in Path(m.name).parts:
|
||||
print(f"[abort] 包内路径异常,拒绝解包:{m.name}", file=sys.stderr)
|
||||
return 2
|
||||
tar.extractall(work)
|
||||
root = work / "bundle"
|
||||
manifest_sha = root / "manifest.sha256"
|
||||
if not manifest_sha.exists():
|
||||
print("[abort] 包内缺 manifest.sha256,拒绝恢复", file=sys.stderr)
|
||||
return 2
|
||||
print("[1/4] 校验包内文件…")
|
||||
for line in manifest_sha.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
digest, name = line.split(" ", 1)
|
||||
target = root / name
|
||||
if not target.exists():
|
||||
print(f"[abort] 包内缺文件:{name}", file=sys.stderr)
|
||||
return 2
|
||||
actual = sha256_file(target)
|
||||
if actual != digest:
|
||||
print(f"[abort] 校验和不匹配:{name}\n 期望 {digest}\n 实际 {actual}",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
print(f" ok {name}")
|
||||
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
print("[2/4] 备份目标机现有状态…")
|
||||
for src, tag in ((app_dir / "data", f"data.pre-restore-{ts}"),
|
||||
(app_dir / ".env", f".env.pre-restore-{ts}")):
|
||||
if src.exists():
|
||||
dst = app_dir / tag
|
||||
shutil.move(str(src), str(dst))
|
||||
print(f" {src} -> {dst}")
|
||||
|
||||
print("[3/4] 恢复 data/ 与 .env…")
|
||||
shutil.move(str(root / "data"), str(app_dir / "data"))
|
||||
shutil.move(str(root / ".env"), str(app_dir / ".env"))
|
||||
(app_dir / ".env").chmod(0o600)
|
||||
escrow = app_dir / "data" / "master_key.escrow"
|
||||
if escrow.exists():
|
||||
escrow.chmod(0o600)
|
||||
|
||||
print("[4/4] 恢复完成。后续手工步骤(详见 docs/migration-runbook.md):")
|
||||
print(" 1. systemctl start vps-manager && curl 127.0.0.1:8000/health 比对 version/commit")
|
||||
print(" 2. tailscale serve --bg --https=443 http://127.0.0.1:8000(设备名沿用旧名可保持 URL 不变)")
|
||||
print(" 3. .env 的 CORS_ORIGINS 加入新 HTTPS 域名(若 URL 变化)")
|
||||
print(" 4. 各被管 VPS 的 /etc/vps-agent.env:VPS_MANAGER_URL 指向新地址后 restart vps-agent")
|
||||
print(" 5. 确认新机数据无误后,源机 disable 全部 timer 并 stop 服务(防双写/双通知)")
|
||||
print(" 6. 删除本迁移包与源机上的包副本(等同最高机密)")
|
||||
return 0
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,120 @@
|
||||
"""建立 MASTER_KEY 密钥托管(Key Escrow,防 key 遗失)
|
||||
|
||||
用法:
|
||||
.venv/bin/python scripts/setup_key_escrow.py # 生成托管,终端显示 RESTORE_KEY
|
||||
.venv/bin/python scripts/setup_key_escrow.py --key <钥匙> # 回验已有托管是否可解
|
||||
.venv/bin/python scripts/setup_key_escrow.py --force # 重建(旧钥匙抄本随之作废)
|
||||
|
||||
- 生成随机 RESTORE_KEY(Fernet 格式),加密当前 MASTER_KEY 写入 data/master_key.escrow
|
||||
- RESTORE_KEY 仅在终端显示一次,请立即抄写到 2~3 个离线位置(密码管理器/纸质/可信家人)
|
||||
- escrow 随 data/ 备份流转(内容被离线钥匙加密,库泄露也无法还原),建议另复制一份到离线介质
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 确保能导入 app 包(脚本位于 scripts/ 子目录)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_ESCROW = BASE_DIR / "data" / "master_key.escrow"
|
||||
README_PATH = BASE_DIR / "data" / "master_key.escrow.README"
|
||||
|
||||
README_TEXT = """MASTER_KEY 密钥托管说明(Key Escrow)
|
||||
=====================================
|
||||
|
||||
data/master_key.escrow 是用 RESTORE_KEY 加密的 MASTER_KEY 副本,
|
||||
随 data/ 一起备份。其内容只有离线保管的 RESTORE_KEY 能解开。
|
||||
|
||||
MASTER_KEY 遗失时的恢复步骤:
|
||||
1. 找到离线保存的 RESTORE_KEY(44 字符,建立托管时终端显示过一次)
|
||||
2. 在 vps-manager 目录执行(仅打印找回结果,先人工确认):
|
||||
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py
|
||||
3. 确认无误后回写 .env(自动备份为 .env.bak-pre-recover):
|
||||
RESTORE_KEY=<你的钥匙> .venv/bin/python scripts/recover_master_key.py --write
|
||||
4. 重启服务:sudo systemctl restart vps-manager
|
||||
全部加密数据(密码/API Key/2FA secret)即可正常解密,零迁移。
|
||||
|
||||
注意:
|
||||
- RESTORE_KEY 只离线保存,绝不写入本文件、.env 或数据库
|
||||
- 重建托管(setup_key_escrow.py --force)后,旧 RESTORE_KEY 抄本即作废,需重新抄写
|
||||
- 建议把 escrow 文件另复制一份到离线介质,与 .env 分开存放
|
||||
"""
|
||||
|
||||
|
||||
def _write_readme() -> None:
|
||||
"""写恢复步骤说明(不含任何钥匙材料)"""
|
||||
README_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
README_PATH.write_text(README_TEXT, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="建立 MASTER_KEY 密钥托管(Key Escrow)")
|
||||
parser.add_argument("--key", help="已有 RESTORE_KEY:仅回验托管是否可解,不重建")
|
||||
parser.add_argument("--force", action="store_true", help="强制重建托管(旧 RESTORE_KEY 抄本随之作废)")
|
||||
parser.add_argument(
|
||||
"--escrow", default=str(DEFAULT_ESCROW),
|
||||
help="escrow 文件路径(默认 data/master_key.escrow)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
escrow_path = Path(args.escrow)
|
||||
|
||||
# 回验模式:--key 提供时只校验已有托管,不重建
|
||||
if args.key:
|
||||
if not escrow_path.exists():
|
||||
print(f"[托管] 未找到托管文件 {escrow_path}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
recovered = crypto.recover_master_key(
|
||||
args.key.strip(), escrow_path.read_text(encoding="utf-8").strip()
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f"[托管] 回验失败:{e}", file=sys.stderr)
|
||||
return 1
|
||||
ok = recovered == settings.MASTER_KEY
|
||||
print(
|
||||
f"[托管] 回验{'通过' if ok else '不通过'}:escrow 可解开,"
|
||||
f"还原的 MASTER_KEY 与 .env {'一致' if ok else '不一致(.env 已更换 key?)'}"
|
||||
)
|
||||
return 0 if ok else 1
|
||||
|
||||
if escrow_path.exists() and not args.force:
|
||||
print(
|
||||
f"[托管] {escrow_path} 已存在,未重建(重建加 --force;回验已有托管用 --key)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
restore_key = Fernet.generate_key().decode()
|
||||
escrow = crypto.build_escrow(restore_key)
|
||||
# 自检:写盘前立即回验,确保托管文件可用(避免生成废档)
|
||||
if crypto.recover_master_key(restore_key, escrow) != settings.MASTER_KEY:
|
||||
print("[托管] 自检失败,未写入文件", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
escrow_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
escrow_path.write_text(escrow + "\n", encoding="utf-8")
|
||||
escrow_path.chmod(0o600)
|
||||
if escrow_path == DEFAULT_ESCROW:
|
||||
_write_readme()
|
||||
|
||||
print("[托管] 已建立 MASTER_KEY 密钥托管:")
|
||||
print(f" escrow 文件:{escrow_path}(权限 600,随 data/ 备份流转)")
|
||||
print()
|
||||
print(" RESTORE_KEY(仅此一次显示,请立即抄写保存到 2~3 个离线位置):")
|
||||
print(f" {restore_key}")
|
||||
print()
|
||||
print(" 建议保存位置:密码管理器 / 纸质抄件(防火防潮)/ 可信家人")
|
||||
print(" 另建议把 escrow 文件复制一份到离线介质(与 .env 分开存放)")
|
||||
print(" 恢复命令:RESTORE_KEY=<钥匙> .venv/bin/python scripts/recover_master_key.py")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -28,11 +28,84 @@ window.VpsApi = (function () {
|
||||
};
|
||||
})();
|
||||
|
||||
/* 授权登录来源预设:key = 入库值(oauth_provider,统一英文 slug),label = 展示名,
|
||||
* group = 表单分组顺序。国内三方登录排在最前(支付宝 / 淘宝 / 微博 等),
|
||||
* 手填的自定义值由 store.oauthCustom 追加成候选,不需要动后端字典。 */
|
||||
const OAUTH_PRESETS = [
|
||||
{ key: 'alipay', label: '支付宝', group: '国内' },
|
||||
{ key: 'taobao', label: '淘宝', group: '国内' },
|
||||
{ key: 'wechat', label: '微信', group: '国内' },
|
||||
{ key: 'qq', label: 'QQ', group: '国内' },
|
||||
{ key: 'weibo', label: '微博', group: '国内' },
|
||||
{ key: 'douyin', label: '抖音', group: '国内' },
|
||||
{ key: 'baidu', label: '百度', group: '国内' },
|
||||
{ key: 'jd', label: '京东', group: '国内' },
|
||||
{ key: 'xiaohongshu', label: '小红书', group: '国内' },
|
||||
{ key: 'bilibili', label: 'B站', group: '国内' },
|
||||
{ key: 'meituan', label: '美团', group: '国内' },
|
||||
{ key: 'dingtalk', label: '钉钉', group: '国内' },
|
||||
{ key: 'feishu', label: '飞书', group: '国内' },
|
||||
{ key: 'huawei', label: '华为', group: '国内' },
|
||||
{ key: 'mi', label: '小米', group: '国内' },
|
||||
{ key: 'google', label: 'Google', group: '国际' },
|
||||
{ key: 'apple', label: 'Apple', group: '国际' },
|
||||
{ key: 'microsoft', label: 'Microsoft', group: '国际' },
|
||||
{ key: 'facebook', label: 'Facebook', group: '国际' },
|
||||
{ key: 'x', label: 'X (Twitter)', group: '国际' },
|
||||
{ key: 'yahoo', label: 'Yahoo', group: '国际' },
|
||||
{ key: 'line', label: 'LINE', group: '国际' },
|
||||
{ key: 'kakao', label: 'Kakao', group: '国际' },
|
||||
{ key: 'naver', label: 'NAVER', group: '国际' },
|
||||
{ key: 'amazon', label: 'Amazon', group: '国际' },
|
||||
{ key: 'paypal', label: 'PayPal', group: '国际' },
|
||||
{ key: 'discord', label: 'Discord', group: '国际' },
|
||||
{ key: 'telegram', label: 'Telegram', group: '国际' },
|
||||
{ key: 'reddit', label: 'Reddit', group: '国际' },
|
||||
{ key: 'linkedin', label: 'LinkedIn', group: '国际' },
|
||||
{ key: 'steam', label: 'Steam', group: '国际' },
|
||||
{ key: 'epic', label: 'Epic Games', group: '国际' },
|
||||
{ key: 'github', label: 'GitHub', group: '开发者' },
|
||||
{ key: 'gitlab', label: 'GitLab', group: '开发者' },
|
||||
{ key: 'bitbucket', label: 'Bitbucket', group: '开发者' },
|
||||
{ key: 'cloudflare', label: 'Cloudflare', group: '开发者' },
|
||||
{ key: 'huggingface', label: 'Hugging Face', group: '开发者' },
|
||||
];
|
||||
|
||||
window.VpsFmt = {
|
||||
TYPE_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
||||
STATUS_LABELS: { active: '使用中', expired: '已过期', stopped: '已停止', cancelled: '已注销', unknown: '未知' },
|
||||
CATEGORY_LABELS: { vps: 'VPS', domain: '域名', ai_agent: 'AI账号', cloudflare: 'Cloudflare', other: '其他' },
|
||||
// 平台可提供的服务(综合平台多标签):字段对应 Provider.services 逗号分隔列表
|
||||
SERVICES_LABELS: { vps: '云服务器', domain: '域名', ai_agent: 'AI服务', cloudflare: 'Cloudflare', ssl_cert: 'SSL证书', cdn: 'CDN', dns: 'DNS', other: '其他' },
|
||||
CYCLE_LABELS: { monthly: '月付', quarterly: '季付', yearly: '年付' },
|
||||
// 凭据库登录方式:字段对应 Credential.login_type
|
||||
LOGIN_TYPE_LABELS: { password: '密码登录', oauth: '授权登录', other: '其他' },
|
||||
/* 授权登录来源候选(表单 chips + datalist 建议)
|
||||
*
|
||||
* 清单在上面 OAUTH_PRESETS;这里只暴露给视图使用。
|
||||
*/
|
||||
OAUTH_PRESETS,
|
||||
// 短展示名:命中预设给中文,否则原样返回(自定义值不加工)
|
||||
oauthLabel(k) {
|
||||
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
|
||||
return p ? p.label : (k || '');
|
||||
},
|
||||
// 详情展示:「支付宝(alipay)」,自定义值只显示原文
|
||||
oauthDetail(k) {
|
||||
const p = OAUTH_PRESETS.find(x => x.key === (k || '').trim().toLowerCase());
|
||||
return p ? p.label + '(' + p.key + ')' : (k || '');
|
||||
},
|
||||
// 归一化:允许输入中文名或大小写不一致的 slug,落到规范 key 上,避免同来源存成多种写法
|
||||
normalizeOauth(raw) {
|
||||
const v = (raw || '').trim();
|
||||
if (!v) return '';
|
||||
const lower = v.toLowerCase();
|
||||
const p = OAUTH_PRESETS.find(x => x.key === lower || x.label.toLowerCase() === lower);
|
||||
return p ? p.key : v;
|
||||
},
|
||||
loginTypeBadge(t) {
|
||||
return { password: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', oauth: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', other: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
},
|
||||
typeBadge(t) {
|
||||
return { vps: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', domain: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', ai_agent: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', cloudflare: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', other: 'bg-slate-500/10 text-slate-600 dark:text-slate-400' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
},
|
||||
|
||||
+69
-1028
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,585 @@
|
||||
/* 资产编辑模态框 */
|
||||
const AssetModal = {
|
||||
template: `
|
||||
<div v-if="store.assetModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">{{ store.assetModal.editing ? '编辑资产' : '新增资产' }}</h3>
|
||||
<button @click="store.assetModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">名称 *</span>
|
||||
<input v-model="f.name" required 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">类型 *</span>
|
||||
<select v-model="f.asset_type" 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">
|
||||
<option v-for="(l,k) in Fmt.TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
|
||||
<select v-model="f.provider_id" @change="onProviderChange" 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">
|
||||
<option :value="null">未指定</option>
|
||||
<option v-for="p in providersForType" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">到期时间</span>
|
||||
<input type="date" v-model="f.expiry_date" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">续费周期</span>
|
||||
<select v-model="f.renewal_cycle" 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">
|
||||
<option value="">未设定</option><option v-for="(l,k) in Fmt.CYCLE_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">费用</span>
|
||||
<input type="number" step="0.01" v-model.number="f.cost" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">币种</span>
|
||||
<select v-model="f.currency" 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">
|
||||
<option>USD</option><option>CNY</option><option>JPY</option><option>EUR</option>
|
||||
</select></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">续费网址</span>
|
||||
<input v-model="f.renew_url" placeholder="https://…(去哪续费)" 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"></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">取消订阅网址</span>
|
||||
<input v-model="f.cancel_url" placeholder="https://…(去哪取消)" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">状态</span>
|
||||
<select v-model="f.status" 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">
|
||||
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">所属账号</span>
|
||||
<select v-model.number="f.account_id" 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">
|
||||
<option :value="null">无(在「平台」页管理账号)</option>
|
||||
<option v-for="a in store.accounts" :key="a.id" :value="a.id">{{ accountLabel(a) }}</option>
|
||||
</select></label>
|
||||
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
|
||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
|
||||
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset v-if="f.asset_type==='vps'" 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">VPS 详情</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="block"><span class="text-xs text-slate-400">公网 IP *</span><input v-model="f.vps_detail.ip_address" required 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">Tailscale IP</span><input v-model="f.vps_detail.tailscale_ip" 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><input v-model="f.vps_detail.region" 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><input v-model="f.vps_detail.os" 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">CPU核</span><input type="number" v-model.number="f.vps_detail.cpu_cores" 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">内存GB</span><input type="number" step="0.5" v-model.number="f.vps_detail.memory_gb" 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">磁盘GB</span><input type="number" v-model.number="f.vps_detail.disk_gb" 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">SSH端口</span><input type="number" v-model.number="f.vps_detail.ssh_port" 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">SSH用户</span><input v-model="f.vps_detail.ssh_user" placeholder="root" 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.vps_detail.login_method" 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="key">密钥</option><option value="password">密码</option><option value="other">其他</option></select></label>
|
||||
<label class="block"><span class="text-xs text-slate-400">SSH密钥</span><input v-model="f.vps_detail.ssh_key" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" 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><input v-model="f.vps_detail.password" type="password" :placeholder="store.assetModal.editing ? '留空不修改' : ''" 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.vps_detail.purpose" 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>
|
||||
|
||||
<fieldset v-if="f.asset_type==='domain'" 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">域名详情</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-400">域名 *</span><input v-model="f.domain_detail.domain_name" required 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><input v-model="f.domain_detail.registrar" 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">DNS托管</span><input v-model="f.domain_detail.dns_provider" 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">CF账号</span><input v-model="f.domain_detail.cloudflare_account" 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">绑定资产ID</span><input type="number" v-model.number="f.domain_detail.bind_asset_id" placeholder="关联VPS的ID" 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 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.domain_detail.is_using"> 仍在实际使用</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="f.asset_type==='ai_agent'" 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">AI 账号详情</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="block"><span class="text-xs text-slate-400">服务商 *</span><input v-model="f.ai_detail.provider" required 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><input v-model="f.ai_detail.plan" 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">API Key</span><input v-model="f.ai_detail.api_key" type="password" 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><input type="number" step="0.01" v-model.number="f.ai_detail.balance" 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><input type="number" step="0.01" v-model.number="f.ai_detail.monthly_limit" 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-1">
|
||||
<button type="button" @click="store.assetModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">{{ store.assetModal.editing ? '保存' : '创建' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.assetModal.form);
|
||||
const providersForType = Vue.computed(() => {
|
||||
const t = f.value ? f.value.asset_type : null;
|
||||
const map = { vps: 'vps', domain: 'domain', ai_agent: 'ai_agent', cloudflare: 'cloudflare' };
|
||||
const cat = map[t];
|
||||
if (!cat) return store.providers;
|
||||
// 综合平台按 services 过滤(如阿里云可同时选 vps/domain);services 为空时回退分类匹配
|
||||
return store.providers.filter(p => {
|
||||
if (p.services) return p.services.split(',').map(s => s.trim()).includes(cat);
|
||||
return p.category === cat || p.category === 'other';
|
||||
});
|
||||
});
|
||||
function onProviderChange() {
|
||||
const p = store.providers.find(x => x.id === f.value.provider_id);
|
||||
if (p) f.value.provider = p.slug;
|
||||
}
|
||||
// 账号候选显示:平台名 + 账号标识(同名账号靠平台前缀区分),说明优先
|
||||
function accountLabel(a) {
|
||||
const p = store.providers.find(x => x.slug === a.platform);
|
||||
const pname = p ? p.name : (a.platform || '未指定平台');
|
||||
if (a.remark) return pname + ' · ' + a.remark + '(' + a.name + ')';
|
||||
return pname + ' · ' + a.name;
|
||||
}
|
||||
return { store, Fmt, f, providersForType, onProviderChange, accountLabel, save: saveAsset };
|
||||
},
|
||||
};
|
||||
|
||||
/* 平台编辑模态框 */
|
||||
const ProviderModal = {
|
||||
template: `
|
||||
<div v-if="store.providerModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">{{ store.providerModal.editing ? '编辑平台' : '新增平台' }}</h3>
|
||||
<button @click="store.providerModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">标识 slug *</span><input v-model="f.slug" required placeholder="aliyun" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">名称 *</span><input v-model="f.name" required 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"></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">分类</span><select v-model="f.category" 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"><option v-for="(l,k) in Fmt.CATEGORY_LABELS" :key="k" :value="k">{{ l }}</option></select></label>
|
||||
<div class="col-span-2 block"><span class="text-xs text-slate-500">提供服务(可多选,综合平台全选)</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-1">
|
||||
<label v-for="(l,k) in Fmt.SERVICES_LABELS" :key="k" class="flex items-center gap-1 text-xs text-slate-600 dark:text-slate-300"><input type="checkbox" :checked="servicesSet.has(k)" @change="toggleService(k)"> {{ l }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="block"><span class="text-xs text-slate-500">SDK类型</span><select v-model="f.sdk_type" 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"><option value="">未指定</option><option v-for="o in sdkOptions" :key="o" :value="o">{{ o }}</option></select></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">官网</span><input v-model="f.website" 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"></label>
|
||||
<label class="col-span-2 block"><span class="text-xs text-slate-500">管理面板URL</span><input v-model="f.console_url" 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"></label>
|
||||
<div v-if="f.sdk_type" class="col-span-2 text-xs text-slate-400 bg-slate-50 dark:bg-slate-800/50 rounded-lg p-2">API 凭证请在平台账号中配置:保存后在平台卡片点「账号」→ 新增账号并填写 API 配置,即可测试/同步。</div>
|
||||
<label class="col-span-2 flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"><input type="checkbox" v-model="f.enabled"> 启用</label>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="store.providerModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.providerModal.form);
|
||||
const sdkOptions = Vue.ref([]);
|
||||
Vue.onMounted(async () => {
|
||||
try {
|
||||
const r = await Api.get('/providers/adapters');
|
||||
const opts = [];
|
||||
for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t);
|
||||
sdkOptions.value = opts;
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
// 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合
|
||||
const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean)));
|
||||
function toggleService(k) {
|
||||
const arr = (f.value.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const idx = arr.indexOf(k);
|
||||
if (idx >= 0) arr.splice(idx, 1); else arr.push(k);
|
||||
f.value.services = arr.join(',');
|
||||
}
|
||||
// 凭证已下沉到账号层,平台表单不再收集 API 凭证
|
||||
return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider };
|
||||
},
|
||||
};
|
||||
|
||||
/* 账号编辑模态框(z-[60]:可能叠加在账号查看弹窗 z-50 之上打开,需更高层级) */
|
||||
const AccountModal = {
|
||||
template: `
|
||||
<div v-if="store.accountModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">{{ store.accountModal.editing ? '编辑账号' : '新增账号' }}</h3>
|
||||
<button @click="store.accountModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||
<label class="block"><span class="text-xs text-slate-500">账号说明(这个账号是干嘛的)</span>
|
||||
<input v-model="f.remark" placeholder="如:XX公司代管账号" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">账号标识 *(邮箱/用户名)</span>
|
||||
<input v-model="f.name" required placeholder="如 you@example.com" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">所属平台</span>
|
||||
<select v-model="f.platform" 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">
|
||||
<option value="">未指定</option>
|
||||
<option v-for="p in store.providers" :key="p.id" :value="p.slug">{{ p.name }}</option>
|
||||
</select></label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">登录用户名/邮箱</span>
|
||||
<input v-model="f.login_user" placeholder="网站登录用" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
|
||||
<input v-model="f.login_password" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '可选'" 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"></label>
|
||||
</div>
|
||||
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
|
||||
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
|
||||
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
|
||||
<input v-model="apiFields[field]" type="password" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" 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>
|
||||
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="store.accountModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" :disabled="store.accountSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.accountSaving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.accountModal.form);
|
||||
const adapterMeta = Vue.ref([]);
|
||||
const apiFields = Vue.reactive({});
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ }
|
||||
});
|
||||
// 按账号所属平台的 sdk_type 动态渲染所需凭证字段
|
||||
const currentFields = Vue.computed(() => {
|
||||
const p = store.providers.find(x => x.slug === (f.value && f.value.platform));
|
||||
if (!p || !p.sdk_type) return [];
|
||||
const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type));
|
||||
return m ? (m.required_config || []) : [];
|
||||
});
|
||||
// 每次打开清空残留输入,避免串号
|
||||
Vue.watch(() => store.accountModal.show, (show) => {
|
||||
if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; }
|
||||
});
|
||||
function save() {
|
||||
const cfg = {};
|
||||
for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field];
|
||||
f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : '';
|
||||
saveAccount();
|
||||
}
|
||||
return { store, f, currentFields, apiFields, save };
|
||||
},
|
||||
};
|
||||
|
||||
/* 账号查看弹窗:平台 → 账号 → 资产 三层结构,展开账号看名下资产 */
|
||||
const AccountsViewModal = {
|
||||
template: `
|
||||
<div v-if="store.accountsModal.show" class="fixed inset-0 bg-black/40 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-lg md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center sticky top-0 bg-white dark:bg-slate-900">
|
||||
<h3 class="font-semibold text-sm">👤 {{ title }}({{ accountList.length }})</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="add" class="text-xs text-blue-600 dark:text-blue-400">+ 新增账号</button>
|
||||
<button @click="store.accountsModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<p v-if="!accountList.length" class="text-xs text-slate-400">暂无账号。一个平台可有多个账号(如代管/公司账号),添加后可在资产表单「所属账号」中选择。</p>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="g in groups" :key="g.platform">
|
||||
<div v-if="!singleProvider" class="text-xs font-medium text-slate-400 mb-1">{{ g.platform ? platformName(g.platform) : '未指定平台' }}</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-800 border border-slate-100 dark:border-slate-800 rounded-lg">
|
||||
<div v-for="a in g.accounts" :key="a.id">
|
||||
<div class="px-3 py-2 flex items-center gap-2">
|
||||
<button @click="toggle(a.id)" class="text-slate-400 text-xs w-4">{{ expanded[a.id] ? '▾' : '▸' }}</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm truncate">{{ a.remark || a.name }}
|
||||
<span v-if="a.has_login_password" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-slate-100 dark:bg-slate-800 text-slate-400" title="已配置登录凭证">🔑登录</span>
|
||||
<span v-if="a.has_api_config" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-500" title="已配置 API 凭证">⚙️API</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
|
||||
</div>
|
||||
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
|
||||
<button v-if="a.has_login_password" @click="copyAcctPwd(a)" class="text-xs text-slate-500 dark:text-slate-400" title="复制登录密码">复制</button>
|
||||
<button v-if="a.has_otp" @click="viewPwd(a)" class="text-xs text-blue-600 dark:text-blue-400" title="该账号已绑定 2FA,点 🔑 一并查看动态码">2FA</button>
|
||||
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
|
||||
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
|
||||
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-xs text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
<div v-if="expanded[a.id]" class="px-3 pb-2 pl-9 space-y-1">
|
||||
<p v-if="!assetsOf(a).length" class="text-xs text-slate-400">该账号下暂无资产(编辑资产时在「所属账号」选择 {{ a.name }} 即可关联)</p>
|
||||
<div v-for="asset in assetsOf(a)" :key="asset.id" class="text-xs flex items-center gap-2">
|
||||
<span class="px-1.5 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(asset.asset_type)">{{ Fmt.TYPE_LABELS[asset.asset_type] }}</span>
|
||||
<span class="truncate">{{ asset.name }}</span>
|
||||
<span v-if="asset.expiry_date" class="text-slate-400 shrink-0">{{ asset.expiry_date }} 到期</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 密码查看弹窗:点击账号的 🔑 后展示明文,几秒后自动隐藏 -->
|
||||
<div v-if="pwd.show" class="absolute inset-0 bg-black/50 flex items-center justify-center p-4" @click.self="pwd.show=false">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
|
||||
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.name }} 的登录凭证</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="pwd.login_user" class="flex justify-between items-center gap-2">
|
||||
<span class="text-slate-400 text-xs shrink-0">用户名</span>
|
||||
<span class="font-mono truncate">{{ pwd.login_user }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<span class="text-slate-400 text-xs shrink-0">密码</span>
|
||||
<span class="font-mono break-all">{{ pwd.password }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 账号绑定了 2FA:一并展示动态码(服务端生成,本地倒计时) -->
|
||||
<div v-if="pwd.otpId && otp.code" class="mt-3 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
|
||||
<span class="text-[10px] text-slate-500 shrink-0">2FA</span>
|
||||
<span class="font-mono text-xl tracking-widest text-blue-700 dark:text-blue-300">{{ otp.code }}</span>
|
||||
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
|
||||
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
|
||||
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
|
||||
</div>
|
||||
<p v-if="pwd.countdown" class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const expanded = Vue.reactive({});
|
||||
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
|
||||
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
|
||||
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
|
||||
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
|
||||
const title = Vue.computed(() => {
|
||||
const slug = store.accountsModal.providerSlug;
|
||||
if (!slug) return '全部账号';
|
||||
const p = store.providers.find(x => x.slug === slug);
|
||||
return (p ? p.name : slug) + ' 的账号';
|
||||
});
|
||||
const accountList = Vue.computed(() => {
|
||||
const slug = store.accountsModal.providerSlug;
|
||||
return slug ? store.accounts.filter(a => a.platform === slug) : store.accounts;
|
||||
});
|
||||
// 全部模式下按平台分组,保持 平台→账号→资产 层级
|
||||
const groups = Vue.computed(() => {
|
||||
const m = new Map();
|
||||
for (const a of accountList.value) {
|
||||
const key = a.platform || '';
|
||||
if (!m.has(key)) m.set(key, []);
|
||||
m.get(key).push(a);
|
||||
}
|
||||
return [...m.entries()].map(([platform, accounts]) => ({ platform, accounts }));
|
||||
});
|
||||
function platformName(slug) {
|
||||
const p = store.providers.find(x => x.slug === slug);
|
||||
return p ? p.name : slug;
|
||||
}
|
||||
function assetsOf(a) {
|
||||
return store.assets.filter(x => x.account_id === a.id);
|
||||
}
|
||||
function toggle(id) { expanded[id] = !expanded[id]; }
|
||||
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
|
||||
function providerOf(a) {
|
||||
return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform);
|
||||
}
|
||||
function canSync(a) {
|
||||
const p = providerOf(a);
|
||||
return !!(a.has_api_config && p && p.sdk_type);
|
||||
}
|
||||
async function testAcct(a) {
|
||||
try {
|
||||
const r = await Api.post('/accounts/' + a.id + '/test', {});
|
||||
alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message);
|
||||
} catch (e) { alert('测试失败:' + e.message); }
|
||||
}
|
||||
async function syncAcct(a) {
|
||||
try {
|
||||
const r = await Api.post('/accounts/' + a.id + '/sync', {});
|
||||
const lines = ['同步完成'];
|
||||
if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated);
|
||||
if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated);
|
||||
if (r.vps_error) lines.push('VPS 错误:' + r.vps_error);
|
||||
if (r.domains_error) lines.push('域名错误:' + r.domains_error);
|
||||
alert(lines.join('\n'));
|
||||
await Promise.all([loadAccounts(), loadAssets()]);
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
// 每次打开重置展开状态
|
||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||
});
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
||||
async function viewPwd(a) {
|
||||
try {
|
||||
const r = await Api.get('/accounts/' + a.id + '/password');
|
||||
pwd.name = a.remark || a.name;
|
||||
pwd.login_user = r.login_user || '';
|
||||
pwd.password = r.password;
|
||||
pwd.copied = false;
|
||||
pwd.show = true;
|
||||
if (pwd._timer) clearInterval(pwd._timer);
|
||||
if (a.has_otp && a.credential_id) {
|
||||
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
|
||||
pwd.otpId = a.credential_id;
|
||||
pwd.countdown = 0;
|
||||
fetchOtp(a.credential_id);
|
||||
} else {
|
||||
pwd.otpId = null;
|
||||
pwd.countdown = 8;
|
||||
pwd._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) closePwd();
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) { alert('获取密码失败:' + e.message); }
|
||||
}
|
||||
function closePwd() {
|
||||
pwd.show = false;
|
||||
pwd.password = '';
|
||||
if (pwd.otpId) { stopOtp(pwd.otpId); pwd.otpId = null; }
|
||||
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
|
||||
}
|
||||
function copyOtpCode() { if (otp.value.code) copyText(otp.value.code, '验证码'); }
|
||||
function copyPwd() {
|
||||
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(pwd.password).then(done).catch(() => fallbackCopy());
|
||||
} else { fallbackCopy(); }
|
||||
function fallbackCopy() {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = pwd.password; document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); done(); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
// 关闭弹窗时清定时器并停掉动态码刷新,避免泄漏
|
||||
Vue.watch(() => pwd.show, (v) => { if (!v) closePwd(); });
|
||||
return {
|
||||
store, Fmt, expanded, pwd, otp, singleProvider, title, accountList, groups,
|
||||
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
|
||||
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
|
||||
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
||||
edit: openAccountEdit, del: deleteAccount,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* 凭据编辑模态框(密码库)
|
||||
*
|
||||
* 粒度 = 站点 × 登录方式:同一邮箱注册多个站点就录多条,密码相同也各存一份。
|
||||
* 2FA 录入必须同时填当前动态码(服务端校验),防 secret 手误成废条目。
|
||||
*/
|
||||
const CredentialModal = {
|
||||
template: `
|
||||
<div v-if="store.credentialModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">{{ store.credentialModal.editing ? '编辑凭据' : '新增凭据' }}</h3>
|
||||
<button @click="store.credentialModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||
<label class="block"><span class="text-xs text-slate-500">站点/服务名 *</span>
|
||||
<input v-model="f.site" required placeholder="如 GitHub、某论坛、邮箱" 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"></label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">登录方式</span>
|
||||
<select v-model="f.login_type" 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">
|
||||
<option v-for="(l,k) in Fmt.LOGIN_TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
|
||||
<input v-model="f.username" placeholder="如 you@gmail.com" 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"></label>
|
||||
</div>
|
||||
<!-- 授权登录:只需记录来源,本站无独立密码;候选=预设+手填留存,点一下即填 -->
|
||||
<div v-if="f.login_type==='oauth'" class="space-y-1.5">
|
||||
<label class="block"><span class="text-xs text-slate-500">授权来源(点选或直接输入)</span>
|
||||
<div class="relative mt-1">
|
||||
<input v-model="f.oauth_provider" placeholder="如 支付宝 / alipay,也可以是任意自定义来源"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
|
||||
<button v-if="f.oauth_provider" type="button" @click="f.oauth_provider=''" title="清空"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 text-sm">✕</button>
|
||||
</div>
|
||||
</label>
|
||||
<!-- 候选自己渲染(不用 datalist):iOS Safari 对 datalist 支持不稳,且需要展示中文名 -->
|
||||
<div class="max-h-36 overflow-y-auto rounded-lg border border-slate-200 dark:border-slate-800 bg-slate-50/60 dark:bg-slate-800/30 p-2 space-y-1.5">
|
||||
<div v-for="g in oauthGroups" :key="g.name">
|
||||
<div class="text-[10px] text-slate-400 mb-0.5">{{ g.name }}</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button v-for="o in g.items" :key="o.key" type="button" @click="pickOauth(o.key)"
|
||||
class="text-[11px] pl-2 py-1 rounded-full border flex items-center gap-1"
|
||||
:class="f.oauth_provider===o.key ? 'border-emerald-500 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300'">
|
||||
<span>{{ o.label }}</span>
|
||||
<span v-if="o.label!==o.key" class="text-[9px] text-slate-400 pr-1">{{ o.key }}</span>
|
||||
<span v-if="o.custom" @click.stop="dropOauth(o.key)" title="从候选移除"
|
||||
class="px-1 text-slate-400 hover:text-red-500">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="oauthUnmatched" class="text-[11px] text-slate-400 leading-relaxed">
|
||||
「{{ f.oauth_provider }}」不在候选里,会按原样保存,并记入上面的自定义候选
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label v-else-if="f.login_type==='password'" class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
|
||||
<input v-model="f.password" type="password" autocomplete="new-password" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" 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"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
|
||||
<input v-model="f.url" placeholder="https://…(可选,便于直接跳转)" 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"></label>
|
||||
|
||||
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
|
||||
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FA(TOTP 动态码)</legend>
|
||||
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
|
||||
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
|
||||
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" 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 font-mono"></label>
|
||||
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
|
||||
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" 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 font-mono tracking-widest"></label>
|
||||
</fieldset>
|
||||
|
||||
<label class="block"><span class="text-xs text-slate-500">备注</span>
|
||||
<textarea v-model="f.note" rows="2" placeholder="如:备用邮箱注册、公司账号等" 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>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="store.credentialModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" :disabled="store.credentialSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.credentialSaving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.credentialModal.form);
|
||||
// 候选清单:手填留存值置顶(最近用过),其后按预设的国内/国际/开发者分组
|
||||
const oauthAll = Vue.computed(() => [
|
||||
...store.oauthCustom.map(k => ({ key: k, label: k, group: '自定义(我填过的)', custom: true })),
|
||||
...Fmt.OAUTH_PRESETS,
|
||||
]);
|
||||
function groupOptions(list) {
|
||||
const groups = [];
|
||||
for (const o of list) {
|
||||
let g = groups.find(x => x.name === o.group);
|
||||
if (!g) { g = { name: o.group, items: [] }; groups.push(g); }
|
||||
g.items.push(o);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
// 输入时同步过滤(key / 中文名都能搜);没命中就退回完整清单,避免逼用户先删字
|
||||
const oauthGroups = Vue.computed(() => {
|
||||
const all = oauthAll.value;
|
||||
const kw = ((f.value && f.value.oauth_provider) || '').trim().toLowerCase();
|
||||
if (!kw) return groupOptions(all);
|
||||
const hit = all.filter(o => o.key.toLowerCase().includes(kw) || o.label.toLowerCase().includes(kw));
|
||||
return hit.length ? groupOptions(hit) : groupOptions(all);
|
||||
});
|
||||
// 手填的新值给出明确反馈:会按原样入库并成为候选
|
||||
const oauthUnmatched = Vue.computed(() => {
|
||||
const key = Fmt.normalizeOauth((f.value && f.value.oauth_provider) || '');
|
||||
return !!key && !oauthAll.value.some(o => o.key === key);
|
||||
});
|
||||
function pickOauth(key) { if (f.value) f.value.oauth_provider = key; }
|
||||
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
|
||||
function save() {
|
||||
const form = f.value;
|
||||
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
|
||||
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
|
||||
return;
|
||||
}
|
||||
saveCredential();
|
||||
}
|
||||
return { store, Fmt, f, oauthGroups, oauthUnmatched, pickOauth, dropOauth: dropOauthProvider, save };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
/* 全局响应式状态与工具函数 */
|
||||
const { reactive, computed } = Vue;
|
||||
const Api = window.VpsApi;
|
||||
const Fmt = window.VpsFmt;
|
||||
|
||||
/* ---------------- 全局状态 ---------------- */
|
||||
|
||||
/* 手动填入的授权来源候选(本地留存)
|
||||
*
|
||||
* credentials 表只存条目本身,不维护来源字典;为了让「这次手填的值下次还能点」,
|
||||
* 把非预设值记在 localStorage:oauth_custom 是候选队列,oauth_ignored 是被用户
|
||||
* 划掉过的值(防止从凭据列表反推时又冒回来)。
|
||||
*/
|
||||
const OAUTH_CUSTOM_KEY = 'vps_oauth_custom';
|
||||
const OAUTH_IGNORED_KEY = 'vps_oauth_ignored';
|
||||
function _readOauthList(k) {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(k) || '[]');
|
||||
return Array.isArray(raw) ? raw.filter(x => typeof x === 'string' && x) : [];
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
function _writeOauthList(k, list) {
|
||||
try { localStorage.setItem(k, JSON.stringify(list.slice(0, 24))); } catch (e) { /* 隐私模式写入失败只影响候选展示 */ }
|
||||
}
|
||||
|
||||
const store = reactive({
|
||||
view: 'dashboard',
|
||||
assets: [],
|
||||
providers: [],
|
||||
accounts: [],
|
||||
subdomains: [],
|
||||
siteCerts: [],
|
||||
overview: {},
|
||||
expiring: [],
|
||||
serverMetrics: {},
|
||||
search: '',
|
||||
filterType: '',
|
||||
filterStatus: '',
|
||||
filterProvider: '', // 按平台筛选(slug),从平台页「关联 N 个资产」跳转而来
|
||||
assetTab: '', // 资产页 TAB:'' 全部 / vps / domain / cloudflare / subscription(付费资产) / ai_agent
|
||||
monitorTab: '', // 监控中心 TAB:同上
|
||||
dark: localStorage.getItem('vps_dark') === '1',
|
||||
loading: false,
|
||||
error: '',
|
||||
assetModal: { show: false, editing: null, form: null },
|
||||
providerModal: { show: false, editing: null, form: null },
|
||||
accountModal: { show: false, editing: null, form: null },
|
||||
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
|
||||
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
|
||||
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
|
||||
credentials: [],
|
||||
credentialModal: { show: false, editing: null, form: null },
|
||||
credentialSaving: false, // 保存锁,防双击重复提交
|
||||
vaultSearch: '',
|
||||
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
|
||||
oauthCustom: _readOauthList(OAUTH_CUSTOM_KEY), // 手填来源候选(预设之外)
|
||||
oauthIgnored: _readOauthList(OAUTH_IGNORED_KEY), // 已划掉的手填来源
|
||||
});
|
||||
|
||||
function applyDark() {
|
||||
document.documentElement.classList.toggle('dark', store.dark);
|
||||
localStorage.setItem('vps_dark', store.dark ? '1' : '0');
|
||||
}
|
||||
|
||||
/* ---------------- 表单工厂 ---------------- */
|
||||
function emptyAssetForm() {
|
||||
return {
|
||||
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
|
||||
account_id: null, expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
|
||||
status: 'active', is_archived: false, remark: '',
|
||||
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 },
|
||||
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: '' },
|
||||
};
|
||||
}
|
||||
|
||||
function buildAssetPayload(f) {
|
||||
const p = {
|
||||
name: f.name, asset_type: f.asset_type, provider: f.provider,
|
||||
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
|
||||
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
|
||||
account_id: f.account_id || null, expiry_date: f.expiry_date || null,
|
||||
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
|
||||
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
|
||||
};
|
||||
if (f.asset_type === 'vps') {
|
||||
const v = f.vps_detail;
|
||||
p.vps_detail = { ip_address: v.ip_address, tailscale_ip: v.tailscale_ip || null, region: v.region || null, os: v.os || null, cpu_cores: Number(v.cpu_cores) || 1, memory_gb: Number(v.memory_gb) || 1, disk_gb: Number(v.disk_gb) || 20, bandwidth_gb: v.bandwidth_gb ? Number(v.bandwidth_gb) : null, ssh_port: Number(v.ssh_port) || 22, panel_url: v.panel_url || null, ssh_user: v.ssh_user || null, login_method: v.login_method || 'key', ssh_key: v.ssh_key || null, password: v.password || null, purpose: v.purpose || null };
|
||||
} else if (f.asset_type === 'domain') {
|
||||
const d = f.domain_detail;
|
||||
p.domain_detail = { domain_name: d.domain_name, registrar: d.registrar || null, dns_provider: d.dns_provider || null, cloudflare_account: d.cloudflare_account || null, is_using: !!d.is_using, redirect_target: d.redirect_target || null, bind_asset_id: d.bind_asset_id || null };
|
||||
} else if (f.asset_type === 'ai_agent') {
|
||||
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 };
|
||||
} 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;
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function loadAssets() {
|
||||
const params = new URLSearchParams();
|
||||
if (store.filterType) params.set('asset_type', store.filterType);
|
||||
if (store.filterStatus) params.set('status', store.filterStatus);
|
||||
if (store.filterProvider) params.set('provider', store.filterProvider);
|
||||
if (store.search) params.set('q', store.search);
|
||||
params.set('sort', 'expiry_date'); params.set('order', 'asc');
|
||||
store.assets = await Api.get('/assets?' + params.toString());
|
||||
}
|
||||
async function loadProviders() { store.providers = await Api.get('/providers'); }
|
||||
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
|
||||
async function loadCredentials() {
|
||||
const params = new URLSearchParams();
|
||||
if (store.vaultSearch) params.set('q', store.vaultSearch);
|
||||
if (store.vaultOtpOnly) params.set('has_otp', 'true');
|
||||
store.credentials = await Api.get('/credentials?' + params.toString());
|
||||
syncOauthCustom();
|
||||
}
|
||||
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
|
||||
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
|
||||
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
|
||||
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
|
||||
async function loadAll() {
|
||||
store.loading = true; store.error = '';
|
||||
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
|
||||
catch (e) { store.error = e.message; } finally { store.loading = false; }
|
||||
}
|
||||
|
||||
/* ---------------- 资产操作 ---------------- */
|
||||
function openAssetCreate(presetType) {
|
||||
const form = emptyAssetForm();
|
||||
if (presetType) form.asset_type = presetType;
|
||||
store.assetModal = { show: true, editing: null, form };
|
||||
}
|
||||
function openAICreate(provider) {
|
||||
const form = emptyAssetForm();
|
||||
form.asset_type = 'ai_agent';
|
||||
if (provider) {
|
||||
form.provider = provider;
|
||||
form.ai_detail.provider = provider;
|
||||
const p = store.providers.find(x => x.slug === provider);
|
||||
if (p) form.provider_id = p.id;
|
||||
}
|
||||
store.assetModal = { show: true, editing: null, form };
|
||||
}
|
||||
function openAssetEdit(a) {
|
||||
const form = emptyAssetForm();
|
||||
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account_id', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
|
||||
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.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 = '';
|
||||
store.assetModal = { show: true, editing: a.id, form };
|
||||
}
|
||||
async function saveAsset() {
|
||||
store.error = '';
|
||||
const f = store.assetModal.form;
|
||||
try {
|
||||
const payload = buildAssetPayload(f);
|
||||
if (store.assetModal.editing) await Api.put('/assets/' + store.assetModal.editing, payload);
|
||||
else await Api.post('/assets', payload);
|
||||
store.assetModal.show = false;
|
||||
await loadAll();
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
async function deleteAsset(a) {
|
||||
if (!confirm('确认删除资产「' + a.name + '」?此操作不可恢复。')) return;
|
||||
store.error = '';
|
||||
try { await Api.del('/assets/' + a.id); await loadAll(); }
|
||||
catch (e) { store.error = e.message; }
|
||||
}
|
||||
|
||||
/* ---------------- 平台操作 ---------------- */
|
||||
function openProviderCreate() {
|
||||
store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } };
|
||||
}
|
||||
function openProviderEdit(p) {
|
||||
store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } };
|
||||
}
|
||||
async function saveProvider() {
|
||||
store.error = '';
|
||||
const f = store.providerModal.form;
|
||||
// 凭证已下沉到账号层,平台不再携带 api_config
|
||||
const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null };
|
||||
try {
|
||||
if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload);
|
||||
else await Api.post('/providers', payload);
|
||||
store.providerModal.show = false;
|
||||
await loadProviders();
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
async function seedProviders() {
|
||||
store.error = '';
|
||||
try { const r = await Api.post('/providers/seed', {}); await loadProviders(); alert('已初始化预设平台,新增 ' + r.added + ' 个'); }
|
||||
catch (e) { store.error = e.message; }
|
||||
}
|
||||
async function deleteProvider(p) {
|
||||
if (!confirm('确认删除平台「' + p.name + '」?')) return;
|
||||
try { await Api.del('/providers/' + p.id); await loadProviders(); }
|
||||
catch (e) { store.error = e.message; }
|
||||
}
|
||||
|
||||
/* ---------------- 账号操作 ---------------- */
|
||||
function openAccountsView(providerSlug) {
|
||||
store.accountsModal = { show: true, providerSlug: providerSlug || null };
|
||||
}
|
||||
function openAccountCreate(platform) {
|
||||
store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } };
|
||||
}
|
||||
function openAccountEdit(a) {
|
||||
// login_password / api_config 留空 = 不修改原凭证
|
||||
store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } };
|
||||
}
|
||||
async function saveAccount() {
|
||||
// 提交锁:双击/网络慢时重复点击会发出两次请求,第二次必然重名 400
|
||||
if (store.accountSaving) return;
|
||||
store.error = '';
|
||||
store.accountSaving = true;
|
||||
const f = store.accountModal.form;
|
||||
const payload = {
|
||||
name: f.name, platform: f.platform || null, remark: f.remark || null,
|
||||
login_user: f.login_user || null,
|
||||
login_password: f.login_password || null,
|
||||
api_config: f.api_config || null,
|
||||
};
|
||||
try {
|
||||
if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload);
|
||||
else await Api.post('/accounts', payload);
|
||||
store.accountModal.show = false;
|
||||
// 重命名会同步更新资产端引用,需一并刷新资产
|
||||
await Promise.all([loadAccounts(), loadAssets()]);
|
||||
} catch (e) { store.error = e.message; }
|
||||
finally { store.accountSaving = false; }
|
||||
}
|
||||
async function deleteAccount(a) {
|
||||
let msg = '确认删除账号「' + a.name + '」?';
|
||||
if (a.asset_count) msg += '\n有 ' + a.asset_count + ' 个资产引用该账号(资产中已填的账号名不受影响)。';
|
||||
if (!confirm(msg)) return;
|
||||
store.error = '';
|
||||
try { await Api.del('/accounts/' + a.id); await loadAccounts(); }
|
||||
catch (e) { store.error = e.message; }
|
||||
}
|
||||
|
||||
/* ---------------- 凭据库(密码 + 2FA) ---------------- */
|
||||
// 手填来源记入候选:预设内的值不记(清单已在 api.js),并撤销先前的「划掉」
|
||||
function rememberOauthProvider(raw) {
|
||||
const key = Fmt.normalizeOauth(raw);
|
||||
if (!key || Fmt.OAUTH_PRESETS.some(p => p.key === key)) return;
|
||||
store.oauthIgnored = store.oauthIgnored.filter(x => x !== key);
|
||||
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
|
||||
store.oauthCustom = [key, ...store.oauthCustom.filter(x => x !== key)];
|
||||
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
|
||||
}
|
||||
// 划掉一个候选(错别字等):同时进 ignore 名单,避免从凭据列表反推时再冒回来
|
||||
function dropOauthProvider(raw) {
|
||||
const key = Fmt.normalizeOauth(raw);
|
||||
if (!key) return;
|
||||
store.oauthCustom = store.oauthCustom.filter(x => x !== key);
|
||||
_writeOauthList(OAUTH_CUSTOM_KEY, store.oauthCustom);
|
||||
if (!store.oauthIgnored.includes(key)) {
|
||||
store.oauthIgnored = [...store.oauthIgnored, key];
|
||||
_writeOauthList(OAUTH_IGNORED_KEY, store.oauthIgnored);
|
||||
}
|
||||
}
|
||||
// 候选跟服务端的值对账:换设备/别人录过的来源也能直接点(忽略名单优先)
|
||||
function syncOauthCustom() {
|
||||
const used = [];
|
||||
for (const c of store.credentials) {
|
||||
const key = Fmt.normalizeOauth(c.oauth_provider);
|
||||
if (!key || store.oauthIgnored.includes(key)) continue;
|
||||
if (Fmt.OAUTH_PRESETS.some(p => p.key === key)) continue;
|
||||
if (!used.includes(key)) used.push(key);
|
||||
}
|
||||
const merged = [...store.oauthCustom, ...used.filter(k => !store.oauthCustom.includes(k))];
|
||||
if (merged.length !== store.oauthCustom.length || merged.some((k, i) => k !== store.oauthCustom[i])) {
|
||||
store.oauthCustom = merged;
|
||||
_writeOauthList(OAUTH_CUSTOM_KEY, merged);
|
||||
}
|
||||
}
|
||||
function emptyCredentialForm() {
|
||||
return {
|
||||
site: '', username: '', login_type: 'password', oauth_provider: '',
|
||||
password: '', url: '', note: '', otp_secret: '', otp_code: '',
|
||||
};
|
||||
}
|
||||
function openCredentialCreate(preset) {
|
||||
const form = emptyCredentialForm();
|
||||
if (preset) Object.assign(form, preset);
|
||||
store.credentialModal = { show: true, editing: null, form };
|
||||
}
|
||||
function openCredentialEdit(c) {
|
||||
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
|
||||
store.credentialModal = {
|
||||
show: true, editing: c.id,
|
||||
form: {
|
||||
site: c.site, username: c.username || '', login_type: c.login_type,
|
||||
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
|
||||
note: c.note || '', otp_secret: '', otp_code: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
async function saveCredential() {
|
||||
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
|
||||
if (store.credentialSaving) return;
|
||||
store.error = '';
|
||||
store.credentialSaving = true;
|
||||
const f = store.credentialModal.form;
|
||||
const editing = store.credentialModal.editing;
|
||||
// 授权来源允许手填(中文/英文均可),归一化后回写表单,保证候选高亮与入库值一致
|
||||
const oauthKey = f.login_type === 'oauth' ? Fmt.normalizeOauth(f.oauth_provider) : '';
|
||||
f.oauth_provider = oauthKey;
|
||||
const payload = {
|
||||
site: f.site, username: f.username || null, login_type: f.login_type,
|
||||
oauth_provider: oauthKey || null, url: f.url || null, note: f.note || null,
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
// 密码留空 = 不修改(后端 None 语义),填了才提交
|
||||
if (f.password) payload.password = f.password;
|
||||
await Api.put('/credentials/' + editing, payload);
|
||||
// 2FA 走独立接口(secret + 当前动态码服务端校验)
|
||||
if (f.otp_secret) {
|
||||
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
|
||||
}
|
||||
} else {
|
||||
payload.password = f.password || null;
|
||||
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
|
||||
await Api.post('/credentials', payload);
|
||||
}
|
||||
store.credentialModal.show = false;
|
||||
if (oauthKey) rememberOauthProvider(oauthKey);
|
||||
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
|
||||
await Promise.all([loadCredentials(), loadAccounts()]);
|
||||
} catch (e) { store.error = e.message; }
|
||||
finally { store.credentialSaving = false; }
|
||||
}
|
||||
async function deleteCredential(c) {
|
||||
const label = c.site + (c.username ? ' · ' + c.username : '');
|
||||
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
|
||||
store.error = '';
|
||||
try {
|
||||
await Api.del('/credentials/' + c.id);
|
||||
await Promise.all([loadCredentials(), loadAccounts()]);
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
async function unbindCredentialOtp(c) {
|
||||
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
|
||||
store.error = '';
|
||||
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
|
||||
catch (e) { store.error = e.message; }
|
||||
}
|
||||
|
||||
/* ---------------- 复制与轻提示(toast) ---------------- */
|
||||
const toast = Vue.reactive({ show: false, text: '', _timer: null });
|
||||
function showToast(text) {
|
||||
toast.text = text;
|
||||
toast.show = true;
|
||||
if (toast._timer) clearTimeout(toast._timer);
|
||||
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
|
||||
}
|
||||
function copyText(text, label) {
|
||||
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
|
||||
} else { _fallbackCopy(text, done); }
|
||||
}
|
||||
function _fallbackCopy(text, done) {
|
||||
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
async function copyCredentialPassword(c) {
|
||||
store.error = '';
|
||||
try {
|
||||
const r = await Api.get('/credentials/' + c.id + '/password');
|
||||
copyText(r.password, '密码');
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
async function copyAccountPassword(a) {
|
||||
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
|
||||
store.error = '';
|
||||
try {
|
||||
const r = await Api.get('/accounts/' + a.id + '/password');
|
||||
copyText(r.password, '密码');
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
|
||||
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
|
||||
// otpState[credential_id] = { code, expiresIn, loading, timer };secret 永不下发到前端
|
||||
const otpState = Vue.reactive({});
|
||||
async function fetchOtp(id) {
|
||||
// 先经 Proxy 创建占位对象,再取回响应式代理。
|
||||
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
|
||||
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
|
||||
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
|
||||
const s = otpState[id];
|
||||
s.loading = true;
|
||||
try {
|
||||
const r = await Api.get('/credentials/' + id + '/otp');
|
||||
s.code = r.code;
|
||||
s.expiresIn = r.expires_in;
|
||||
if (s.timer) clearInterval(s.timer);
|
||||
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
|
||||
s.timer = setInterval(() => {
|
||||
s.expiresIn--;
|
||||
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
|
||||
}, 1000);
|
||||
} catch (e) { store.error = e.message; }
|
||||
finally { s.loading = false; }
|
||||
}
|
||||
function stopOtp(id) {
|
||||
const s = otpState[id];
|
||||
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
|
||||
}
|
||||
function stopAllOtp() {
|
||||
for (const id of Object.keys(otpState)) stopOtp(Number(id));
|
||||
}
|
||||
|
||||
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */
|
||||
function openAssetsByProvider(p) {
|
||||
store.filterProvider = p.slug;
|
||||
store.assetTab = '';
|
||||
store.filterType = '';
|
||||
if (window.location.hash !== '#assets') window.location.hash = '#assets';
|
||||
loadAssets();
|
||||
}
|
||||
function clearProviderFilter() {
|
||||
store.filterProvider = '';
|
||||
loadAssets();
|
||||
}
|
||||
|
||||
/* ---------------- 服务器监控 ---------------- */
|
||||
async function viewServer(a) {
|
||||
try {
|
||||
const [metrics, info, checks, security] = await Promise.all([
|
||||
Api.get('/monitor/' + a.id + '/metrics?limit=200'),
|
||||
Api.get('/monitor/' + a.id + '/info'),
|
||||
Api.get('/monitor/' + a.id + '/security'),
|
||||
Api.get('/monitor/' + a.id + '/security-score'),
|
||||
]);
|
||||
store.serverMetrics = { asset: a, metrics, info, checks, security };
|
||||
} catch (e) { store.serverMetrics = { asset: a, metrics: [], info: null, checks: [], security: null }; }
|
||||
// 通过 hash 路由切换视图,保证刷新/分享链接后能回到 servers 视图
|
||||
if (window.location.hash !== '#servers') {
|
||||
window.location.hash = '#servers';
|
||||
} else {
|
||||
store.view = 'servers';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 设置 ---------------- */
|
||||
const settings = reactive({ apiKey: Api.getApiKey(), saved: false });
|
||||
function saveSettings() { Api.setApiKey(settings.apiKey); settings.saved = true; setTimeout(() => settings.saved = false, 2000); }
|
||||
|
||||
function normalizedMonthly(cost, cycle) {
|
||||
if (!cost) return 0;
|
||||
if (cycle === 'yearly') return cost / 12;
|
||||
if (cycle === 'quarterly') return cost / 3;
|
||||
return cost;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* AI 账号视图 */
|
||||
const AIAccountsView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center flex-wrap gap-2">
|
||||
<div class="text-sm text-slate-500">共 {{ totalAccounts }} 个账号 · 总余额 {{ totalBalance }}</div>
|
||||
<button @click="addAny" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 新增账号</button>
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.provider" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-3 flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{{ g.provider }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400">{{ g.accounts.length }} 个账号</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<span class="text-slate-500">余额合计 <b class="text-slate-800 dark:text-slate-200">{{ g.totalBalance }}</b></span>
|
||||
<button @click="add(g.provider)" class="text-blue-600 dark:text-blue-400 hover:underline">+ 添加账号</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in g.accounts" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium">{{ a.name }}</span>
|
||||
<span v-if="a.ai_detail && a.ai_detail.plan" class="text-xs text-slate-400 ml-2">{{ a.ai_detail.plan }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs shrink-0">
|
||||
<span :class="balanceClass(a.ai_detail && a.ai_detail.balance)">余额 {{ fmtBal(a.ai_detail && a.ai_detail.balance) }}{{ a.ai_detail && a.ai_detail.balance !== null && a.ai_detail.balance !== undefined ? ' ' + a.ai_detail.currency : '' }}</span>
|
||||
<span v-if="a.ai_detail && a.ai_detail.monthly_usage !== null && a.ai_detail.monthly_usage !== undefined" class="text-slate-500">用量 {{ a.ai_detail.monthly_usage }}/{{ a.ai_detail.monthly_limit ?? '∞' }}</span>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!groups.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无 AI 账号,点右上角「新增账号」添加(同一平台可添加多个)</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const aiList = () => store.assets.filter(a => a.asset_type === 'ai_agent');
|
||||
const groups = Vue.computed(() => {
|
||||
const map = {};
|
||||
for (const a of aiList()) {
|
||||
const prov = (a.ai_detail && a.ai_detail.provider) || a.provider || '其他';
|
||||
if (!map[prov]) map[prov] = { provider: prov, accounts: [], totalBalance: 0 };
|
||||
map[prov].accounts.push(a);
|
||||
const bal = a.ai_detail && a.ai_detail.balance;
|
||||
if (bal) map[prov].totalBalance += Number(bal);
|
||||
}
|
||||
return Object.values(map).map(g => ({ ...g, totalBalance: Math.round(g.totalBalance * 100) / 100 })).sort((x, y) => y.totalBalance - x.totalBalance);
|
||||
});
|
||||
const totalAccounts = Vue.computed(() => aiList().length);
|
||||
const totalBalance = Vue.computed(() => Math.round(aiList().reduce((s, a) => s + (a.ai_detail && a.ai_detail.balance ? Number(a.ai_detail.balance) : 0), 0) * 100) / 100);
|
||||
function balanceClass(b) {
|
||||
if (b === null || b === undefined) return 'text-slate-400';
|
||||
if (Number(b) < 10) return 'text-red-600 dark:text-red-400 font-semibold';
|
||||
if (Number(b) < 50) return 'text-amber-600 dark:text-amber-400';
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
}
|
||||
function fmtBal(b) { return (b === null || b === undefined) ? '—' : b; }
|
||||
return { store, groups, totalAccounts, totalBalance, balanceClass, fmtBal, add: openAICreate, addAny: () => openAICreate(''), edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/* 资产视图:TAB 容器(全部/VPS/域名/Cloudflare/订阅/AI账号)
|
||||
* 注意:专有视图(DomainsView 等)在后续脚本才定义,必须在 setup 运行时求值,
|
||||
* 不能在组件定义处直接引用(会因加载顺序抛 ReferenceError)。
|
||||
*/
|
||||
const AssetsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 类型 TAB -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
|
||||
class="text-sm px-3 py-1.5 rounded-full transition"
|
||||
:class="store.assetTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 平台筛选标签(从平台页跳转而来,点 ✕ 取消) -->
|
||||
<div v-if="store.filterProvider" class="flex items-center">
|
||||
<span class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
🏢 平台:{{ providerName(store.filterProvider) }}
|
||||
<button @click="clearProvider" class="font-bold hover:opacity-70" title="取消平台筛选">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 通用列表(全部 / VPS) -->
|
||||
<template v-if="store.assetTab==='' || store.assetTab==='vps'">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<select v-model="store.filterStatus" @change="reload" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<option value="">全部状态</option>
|
||||
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select>
|
||||
<button v-if="store.assetTab===''" @click="syncAllAI" class="text-sm px-3 py-1.5 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20">同步AI余额</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ list.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<!-- 桌面表格 -->
|
||||
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 font-medium">名称</th>
|
||||
<th class="px-4 py-2.5 font-medium">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">平台</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期</th>
|
||||
<th class="px-4 py-2.5 font-medium">费用</th>
|
||||
<th class="px-4 py-2.5 font-medium">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!list.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无资产</td></tr>
|
||||
<tr v-for="a in list" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30" :class="{ 'opacity-50': a.is_archived }">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null && a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400 hover:underline mr-3 text-xs">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 移动卡片 -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<div v-if="!list.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无资产</div>
|
||||
<div v-for="a in list" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3" :class="{ 'opacity-50': a.is_archived }">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">{{ a.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '无到期' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined"> ({{ a.days_to_expiry }}天)</span></span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full" :class="Fmt.statusBadge(a.status)">{{ Fmt.STATUS_LABELS[a.status] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button v-if="a.asset_type==='vps'" @click="viewServer(a)" class="text-emerald-600 dark:text-emerald-400">监控</button>
|
||||
<button v-if="a.asset_type==='ai_agent'" @click="refreshBalance(a)" class="text-violet-600 dark:text-violet-400">刷新余额</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 专有视图(域名/Cloudflare/订阅/AI账号) -->
|
||||
<component v-else :is="tabView"></component>
|
||||
</div>`,
|
||||
setup() {
|
||||
// tip 为 TAB 悬浮说明:让用户一眼明白每个分类装的是什么
|
||||
const tabs = [
|
||||
{ key: '', label: '全部', tip: '所有类型的资产' },
|
||||
{ key: 'vps', label: 'VPS', tip: '云服务器(可查看资源监控)' },
|
||||
{ key: 'domain', label: '域名', tip: '域名与解析' },
|
||||
{ key: 'cloudflare', label: 'Cloudflare', tip: 'Cloudflare 账号下的资源' },
|
||||
{ key: 'subscription', label: '付费资产', tip: '有费用或到期日、需要续费的项目(跨类型汇总)' },
|
||||
{ key: 'ai_agent', label: 'AI账号', tip: 'ChatGPT 等 AI 服务账号与余额' },
|
||||
];
|
||||
const tabView = Vue.computed(() => {
|
||||
const t = store.assetTab;
|
||||
if (t === 'domain') return DomainsView;
|
||||
if (t === 'cloudflare') return CloudflareView;
|
||||
if (t === 'subscription') return SubscriptionsView;
|
||||
if (t === 'ai_agent') return AIAccountsView;
|
||||
return null;
|
||||
});
|
||||
// 通用列表:全部显示所有,VPS 前端过滤类型
|
||||
const list = Vue.computed(() => {
|
||||
if (store.assetTab === 'vps') return store.assets.filter(a => a.asset_type === 'vps');
|
||||
return store.assets;
|
||||
});
|
||||
function switchTab(k) {
|
||||
store.assetTab = k;
|
||||
// 专有视图需要全量数据;若此前按类型过滤过则清空重载
|
||||
if (k !== '' && k !== 'vps' && store.filterType) { store.filterType = ''; loadAssets(); }
|
||||
}
|
||||
// 平台筛选标签显示名:filterProvider 存 slug,回退显示原值
|
||||
function providerName(slug) {
|
||||
const p = store.providers.find(x => x.slug === slug);
|
||||
return p ? p.name : slug;
|
||||
}
|
||||
async function refreshBalance(a) {
|
||||
if (!confirm('刷新「' + a.name + '」的余额?')) return;
|
||||
try {
|
||||
const r = await Api.post('/assets/' + a.id + '/refresh-balance', {});
|
||||
alert('余额已更新:' + r.balance + ' ' + r.currency);
|
||||
await loadAssets();
|
||||
} catch (e) { alert('刷新失败:' + e.message); }
|
||||
}
|
||||
async function syncAllAI() {
|
||||
if (!confirm('同步所有 AI 账号的余额?')) return;
|
||||
try {
|
||||
const r = await Api.post('/sync/ai-balances', {});
|
||||
alert('同步完成:成功 ' + r.success + ',失败 ' + r.failed + (r.errors && r.errors.length ? '\n' + r.errors.join('\n') : ''));
|
||||
await loadAssets();
|
||||
} catch (e) { alert('同步失败:' + e.message); }
|
||||
}
|
||||
return { store, Fmt, tabs, tabView, list, switchTab, providerName, reload: loadAssets, clearProvider: clearProviderFilter, edit: openAssetEdit, del: deleteAsset, viewServer, refreshBalance, syncAllAI };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Cloudflare 子资产视图 */
|
||||
const SUB_TYPE_LABELS = { zone: '解析', worker: 'Worker', r2: 'R2', tunnel: 'Tunnel', mail: 'Mail', dns_record: 'DNS记录', other: '其他' };
|
||||
|
||||
const CloudflareView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div v-if="!groups.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
|
||||
暂无 Cloudflare 子资产(新增资产时选 Cloudflare 类型即可)
|
||||
</div>
|
||||
<div v-for="g in groups" :key="g.account" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<div class="font-semibold text-sm">☁️ {{ g.account || '未指定账号' }}</div>
|
||||
<span class="text-xs text-slate-400">{{ g.items.length }} 个子资产</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in g.items" :key="a.id" class="flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 text-sm">
|
||||
<div class="min-w-0 flex items-center gap-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="subTypeBadge(a.cloudflare_detail.sub_type)">{{ SUB_TYPE_LABELS[a.cloudflare_detail.sub_type] || a.cloudflare_detail.sub_type }}</span>
|
||||
<span class="font-medium truncate">{{ a.cloudflare_detail.sub_name || a.cloudflare_detail.zone_name || a.name }}</span>
|
||||
<span v-if="a.cloudflare_detail.zone_name && a.cloudflare_detail.sub_type !== 'zone'" class="text-xs text-slate-400">@{{ a.cloudflare_detail.zone_name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs shrink-0">
|
||||
<span v-if="a.cloudflare_detail.status" class="text-slate-500">{{ a.cloudflare_detail.status }}</span>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const groups = Vue.computed(() => {
|
||||
const cfAssets = store.assets.filter(a => a.asset_type === 'cloudflare' && a.cloudflare_detail);
|
||||
const map = {};
|
||||
for (const a of cfAssets) {
|
||||
const acc = a.cloudflare_detail.account_email || '';
|
||||
if (!map[acc]) map[acc] = { account: a.cloudflare_detail.account_email, items: [] };
|
||||
map[acc].items.push(a);
|
||||
}
|
||||
return Object.values(map);
|
||||
});
|
||||
function subTypeBadge(t) {
|
||||
return { zone: 'bg-blue-500/10 text-blue-600 dark:text-blue-400', worker: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', r2: 'bg-purple-500/10 text-purple-600 dark:text-purple-400', tunnel: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', mail: 'bg-pink-500/10 text-pink-600 dark:text-pink-400', dns_record: 'bg-slate-500/10 text-slate-600', other: 'bg-slate-500/10 text-slate-500' }[t] || 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, groups, subTypeBadge, SUB_TYPE_LABELS, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/* 总览视图 */
|
||||
const DashboardView = {
|
||||
template: `
|
||||
<div class="space-y-5">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
|
||||
<div class="text-xs text-slate-500">总资产</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ store.overview.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-red-200 dark:border-red-900/50">
|
||||
<div class="text-xs text-slate-500">30天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ store.overview.expiring_30 || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-800">
|
||||
<div class="text-xs text-slate-500">本年支出</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ store.overview.year_cost || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl p-4 border border-amber-200 dark:border-amber-900/50">
|
||||
<div class="text-xs text-slate-500">异常状态</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ store.overview.abnormal_count || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4" v-if="store.expiring.length">
|
||||
<h2 class="text-sm font-semibold mb-3">续费倒计时(30天内)</h2>
|
||||
<div class="space-y-2">
|
||||
<div v-for="a in store.expiring" :key="a.id" class="flex justify-between items-center px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<div class="min-w-0">
|
||||
<span class="font-medium text-sm truncate">{{ a.name }}</span>
|
||||
<span class="text-xs text-slate-400 ml-2">{{ a.provider_name || a.provider }}</span>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.expiryBadge(a.days_to_expiry)">{{ a.days_to_expiry }}天 · {{ a.cost }}{{ a.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h2 class="text-sm font-semibold mb-3">按类型分布</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="(n, t) in store.overview.by_type" :key="t" class="text-xs px-3 py-1.5 rounded-lg" :class="Fmt.typeBadge(t)">{{ Fmt.TYPE_LABELS[t] }} {{ n }}</span>
|
||||
<span v-if="!store.overview.by_type || !Object.keys(store.overview.by_type).length" class="text-xs text-slate-400">暂无数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() { return { store, Fmt }; },
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/* 域名视图:域名列表 + 子域名管理 + 站点 SSL 状态
|
||||
* 交互:每行可展开「子域名」面板;子域名可添加/编辑/删除、一键探测 SSL 证书;
|
||||
* 域名行显示该域名下最近的证书到期徽章。
|
||||
*/
|
||||
const DomainsView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🌐 域名资产下管理子域名与站点 SSL 证书:展开任意域名添加子域名(如 www/api),点「探测SSL」即可监控该站点证书到期情况。
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-x-auto">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 font-medium">域名</th>
|
||||
<th class="px-4 py-2.5 font-medium">注册商</th>
|
||||
<th class="px-4 py-2.5 font-medium">DNS</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期</th>
|
||||
<th class="px-4 py-2.5 font-medium">站点SSL</th>
|
||||
<th class="px-4 py-2.5 font-medium">使用中</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!domains.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无域名资产</td></tr>
|
||||
<template v-for="a in domains" :key="a.id">
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.domain_detail.domain_name }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.registrar || '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.domain_detail.dns_provider || '—' }}</td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="sslByAsset[a.id]" class="text-xs px-2 py-0.5 rounded-full whitespace-nowrap" :class="certBadge(sslByAsset[a.id].days_to_expiry)" :title="sslByAsset[a.id].issuer || ''">
|
||||
{{ sslByAsset[a.id].hostname }} · {{ certText(sslByAsset[a.id]) }}
|
||||
</span>
|
||||
<span v-else class="text-xs text-slate-400">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="a.domain_detail.is_using ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-slate-500/10 text-slate-400'">{{ a.domain_detail.is_using ? '使用' : '闲置' }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button @click="toggle(a)" class="text-indigo-600 dark:text-indigo-400 hover:underline mr-3 text-xs">{{ expanded[a.id] ? '收起' : '子域名' }}</button>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline text-xs">编辑/绑定</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- 子域名展开区 -->
|
||||
<tr v-if="expanded[a.id]">
|
||||
<td colspan="7" class="px-4 py-3 bg-slate-50/60 dark:bg-slate-800/20">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300">子域名({{ domainName(a) }})</span>
|
||||
<button @click="resetForm(a)" class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white">+ 新增子域名</button>
|
||||
<button @click="probeSsl(domainName(a), a)" class="text-xs px-2 py-1 rounded-lg border border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400">🔒 探测根域名SSL</button>
|
||||
<span class="ml-auto text-xs text-slate-400">共 {{ (subsByAsset[a.id]||[]).length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div v-if="(subsByAsset[a.id]||[]).length" class="space-y-1 mb-2">
|
||||
<div v-for="s in subsByAsset[a.id]||[]" :key="s.id" class="flex flex-wrap items-center gap-2 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 px-3 py-2 text-xs">
|
||||
<span class="font-mono font-medium text-slate-700 dark:text-slate-200">{{ fullHost(s, a) }}</span>
|
||||
<span v-if="s.record_type" class="px-1.5 py-0.5 rounded bg-slate-500/10 text-slate-500">{{ s.record_type }}</span>
|
||||
<span v-if="s.record_value" class="text-slate-400 truncate max-w-[180px]" :title="s.record_value">{{ s.record_value }}</span>
|
||||
<span :class="s.is_active ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ s.is_active ? '启用' : '停用' }}</span>
|
||||
<span v-if="s.note" class="text-slate-400 truncate max-w-[120px]" :title="s.note">{{ s.note }}</span>
|
||||
<span class="ml-auto flex gap-3">
|
||||
<button @click="probeSsl(fullHost(s, a), a)" class="text-violet-600 dark:text-violet-400 hover:underline">🔒 探测SSL</button>
|
||||
<button @click="startEdit(a, s)" class="text-blue-600 dark:text-blue-400 hover:underline">编辑</button>
|
||||
<button @click="delSub(s)" class="text-red-600 dark:text-red-400 hover:underline">删除</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-400 mb-2">暂无子域名,点「+ 新增子域名」开始管理</div>
|
||||
|
||||
<!-- 新增/编辑表单 -->
|
||||
<form v-if="forms[a.id]" @submit.prevent="saveSub(a)" class="flex flex-wrap gap-2 items-end bg-white dark:bg-slate-900 rounded-lg border border-blue-200 dark:border-blue-900/50 px-3 py-2">
|
||||
<label class="text-xs text-slate-500">主机名
|
||||
<input v-model="forms[a.id].host" placeholder="www / api(@=根域名)" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-28 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">类型
|
||||
<select v-model="forms[a.id].record_type" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 mt-0.5">
|
||||
<option v-for="t in ['A','CNAME','AAAA','MX','TXT']" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex-1 min-w-[130px]">记录值
|
||||
<input v-model="forms[a.id].record_value" placeholder="IP 或目标域名" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-full mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500">备注
|
||||
<input v-model="forms[a.id].note" class="block text-sm px-2 py-1 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 w-24 mt-0.5">
|
||||
</label>
|
||||
<label class="text-xs text-slate-500 flex items-center gap-1 pb-1.5"><input type="checkbox" v-model="forms[a.id].is_active" class="accent-blue-600">启用</label>
|
||||
<button type="submit" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white">{{ forms[a.id].editing ? '保存' : '添加' }}</button>
|
||||
<button type="button" @click="resetForm(a)" class="text-sm px-2 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-500">取消</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const domains = Vue.computed(() => store.assets.filter(a => a.asset_type === 'domain' && a.domain_detail));
|
||||
const expanded = Vue.reactive({}); // asset_id -> 是否展开子域名面板
|
||||
const forms = Vue.reactive({}); // asset_id -> 新增/编辑表单
|
||||
|
||||
const subsByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const s of store.subdomains) { (m[s.asset_id] = m[s.asset_id] || []).push(s); }
|
||||
return m;
|
||||
});
|
||||
// 每个域名资产下最近到期的一条证书(用于主行徽章)
|
||||
const sslByAsset = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const c of store.siteCerts) {
|
||||
if (!c.asset_id) continue;
|
||||
if (!m[c.asset_id] || (c.days_to_expiry ?? 9999) < (m[c.asset_id].days_to_expiry ?? 9999)) m[c.asset_id] = c;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function domainName(a) { return a.domain_detail.domain_name; }
|
||||
function fullHost(s, a) { return s.host === '@' ? domainName(a) : s.host + '.' + domainName(a); }
|
||||
function toggle(a) {
|
||||
expanded[a.id] = !expanded[a.id];
|
||||
if (expanded[a.id] && !forms[a.id]) resetForm(a);
|
||||
}
|
||||
function resetForm(a) {
|
||||
forms[a.id] = { host: '', record_type: 'A', record_value: '', note: '', is_active: true, editing: null };
|
||||
}
|
||||
function startEdit(a, s) {
|
||||
expanded[a.id] = true;
|
||||
forms[a.id] = { host: s.host, record_type: s.record_type || 'A', record_value: s.record_value || '', note: s.note || '', is_active: !!s.is_active, editing: s.id };
|
||||
}
|
||||
async function saveSub(a) {
|
||||
const f = forms[a.id];
|
||||
if (!f.host.trim()) { alert('请填写主机名'); return; }
|
||||
const payload = { host: f.host.trim(), record_type: f.record_type, record_value: f.record_value, note: f.note, is_active: !!f.is_active };
|
||||
try {
|
||||
if (f.editing) await Api.put('/subdomains/' + f.editing, payload);
|
||||
else await Api.post('/subdomains', { ...payload, asset_id: a.id });
|
||||
await loadSubdomains();
|
||||
resetForm(a);
|
||||
} catch (e) { alert('保存失败:' + e.message); }
|
||||
}
|
||||
async function delSub(s) {
|
||||
if (!confirm('删除子域名「' + s.host + '」?')) return;
|
||||
try { await Api.del('/subdomains/' + s.id); await loadSubdomains(); }
|
||||
catch (e) { alert('删除失败:' + e.message); }
|
||||
}
|
||||
async function probeSsl(defaultHost, a) {
|
||||
const target = prompt('探测 SSL 证书的站点(留空取消):', defaultHost);
|
||||
if (!target) return;
|
||||
try {
|
||||
await Api.post('/site-certs', { hostname: target, asset_id: a.id });
|
||||
await loadSiteCerts();
|
||||
alert('已探测:' + target);
|
||||
} catch (e) { alert('探测失败:' + e.message); }
|
||||
}
|
||||
function certText(c) {
|
||||
const d = c.days_to_expiry;
|
||||
if (d === null || d === undefined) return '未知';
|
||||
if (d < 0) return '已过期' + Math.abs(d) + '天';
|
||||
if (d === 0) return '今天到期';
|
||||
return d + '天';
|
||||
}
|
||||
function certBadge(d) {
|
||||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
return 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, Fmt, domains, subsByAsset, sslByAsset, expanded, forms, domainName, fullHost, toggle, resetForm, startEdit, saveSub, delSub, probeSsl, certText, certBadge, edit: openAssetEdit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
/* 监控中心视图:默认到期倒计时,可按类别筛选 */
|
||||
const MonitorView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- 说明条:让用户明白这个页面是干嘛的 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>⏳ 所有设置了到期日的资产按剩余天数倒计时排序,越靠前越紧急</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-red-500/10 text-red-600 dark:text-red-400">红=7天内</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400">橙=30天内</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">绿=90天内</span>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计(资产) -->
|
||||
<div v-if="store.monitorTab!=='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">30 天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ stats.soon30 }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">7 天内到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ stats.soon7 }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">已过期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-slate-700 dark:text-slate-200">{{ stats.expired }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">有到期日的资产</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计(SSL 证书) -->
|
||||
<div v-if="store.monitorTab==='ssl'" class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">监控站点</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ sslStats.total }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">证书正常</div>
|
||||
<div class="text-2xl font-bold mt-1 text-emerald-600 dark:text-emerald-400">{{ sslStats.valid }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">即将到期</div>
|
||||
<div class="text-2xl font-bold mt-1 text-amber-600 dark:text-amber-400">{{ sslStats.expiring }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">已过期 / 失败</div>
|
||||
<div class="text-2xl font-bold mt-1 text-red-600 dark:text-red-400">{{ sslStats.expired + sslStats.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 类别 TAB -->
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button v-for="t in tabs" :key="t.key" @click="switchTab(t.key)" :title="t.tip"
|
||||
class="text-sm px-3 py-1.5 rounded-full transition"
|
||||
:class="store.monitorTab===t.key ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800'">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 资产表格(非 SSL TAB) -->
|
||||
<template v-if="store.monitorTab!=='ssl'">
|
||||
<!-- 桌面表格 -->
|
||||
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 font-medium">名称</th>
|
||||
<th class="px-4 py-2.5 font-medium">类型</th>
|
||||
<th class="px-4 py-2.5 font-medium">平台</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期日</th>
|
||||
<th class="px-4 py-2.5 font-medium">倒计时</th>
|
||||
<th class="px-4 py-2.5 font-medium">费用</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!items.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">{{ emptyText }}</td></tr>
|
||||
<tr v-for="a in items" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5">{{ a.expiry_date }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400 hover:underline mr-3 text-xs">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 移动卡片 -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<div v-if="!items.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">{{ emptyText }}</div>
|
||||
<div v-for="a in items" :key="a.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm truncate">{{ a.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ a.provider_name || a.provider }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(a.days_to_expiry)">{{ countdownText(a.days_to_expiry) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs">
|
||||
<span class="text-slate-500">{{ a.expiry_date }}</span>
|
||||
<span class="text-slate-500">{{ a.cost }}{{ a.currency }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(a.asset_type)">{{ Fmt.TYPE_LABELS[a.asset_type] }}</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400">续费</a>
|
||||
<button @click="edit(a)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(a)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- SSL 证书表格 -->
|
||||
<template v-if="store.monitorTab==='ssl'">
|
||||
<div class="hidden md:block bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 font-medium">站点</th>
|
||||
<th class="px-4 py-2.5 font-medium">所属资产</th>
|
||||
<th class="px-4 py-2.5 font-medium">签发机构</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期日</th>
|
||||
<th class="px-4 py-2.5 font-medium">倒计时</th>
|
||||
<th class="px-4 py-2.5 font-medium">状态</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!sslList.length"><td colspan="7" class="px-4 py-10 text-center text-slate-400">暂无证书监控(在「资产 → 域名」的子域名面板点「探测SSL」,或稍后支持直接添加)</td></tr>
|
||||
<tr v-for="c in sslList" :key="c.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-mono font-medium">{{ c.hostname }}<span v-if="c.port!==443" class="text-xs text-slate-400">:{{ c.port }}</span></td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ c.asset_name || '—' }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500 max-w-[220px] truncate" :title="c.issuer || ''">{{ c.issuer || '—' }}</td>
|
||||
<td class="px-4 py-2.5">{{ c.valid_to || '—' }}</td>
|
||||
<td class="px-4 py-2.5"><span class="text-xs font-semibold px-2 py-0.5 rounded-full" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span></td>
|
||||
<td class="px-4 py-2.5">
|
||||
<span v-if="c.status==='error'" class="text-xs text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||||
<span v-else class="text-xs text-slate-500" :title="c.error || ''">{{ c.last_checked_at ? '已检查 ' + c.last_checked_at.slice(5,16).replace('T',' ') : '未检查' }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">重测</button>
|
||||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400 hover:underline text-xs">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- SSL 移动卡片 -->
|
||||
<div class="md:hidden space-y-2">
|
||||
<div v-if="!sslList.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">暂无证书监控</div>
|
||||
<div v-for="c in sslList" :key="c.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono font-medium text-sm truncate">{{ c.hostname }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ c.asset_name || '独立站点' }}</div>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="countdownBadge(c.days_to_expiry)">{{ countdownText(c.days_to_expiry) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-500">
|
||||
<span>{{ c.valid_to || '—' }}</span>
|
||||
<span v-if="c.status==='error'" class="text-red-600 dark:text-red-400" :title="c.error || ''">探测失败</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs">
|
||||
<button @click="recheck(c)" class="text-emerald-600 dark:text-emerald-400">重测</button>
|
||||
<button @click="delCert(c)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>`,
|
||||
setup() {
|
||||
const tabs = [
|
||||
{ key: '', label: '全部', tip: '所有有到期日的资产' },
|
||||
{ key: 'vps', label: 'VPS', tip: '仅看云服务器' },
|
||||
{ key: 'domain', label: '域名', tip: '仅看域名' },
|
||||
{ key: 'cloudflare', label: 'Cloudflare', tip: '仅看 Cloudflare 资源' },
|
||||
{ key: 'subscription', label: '付费资产', tip: '仅看有费用、需要续费的项目' },
|
||||
{ key: 'ai_agent', label: 'AI账号', tip: '仅看 AI 服务账号' },
|
||||
{ key: 'ssl', label: 'SSL证书', tip: '站点 HTTPS 证书到期倒计时(独立于资产)' },
|
||||
];
|
||||
const hasExpiry = a => a.expiry_date && a.status !== 'cancelled';
|
||||
const items = Vue.computed(() => {
|
||||
if (store.monitorTab === 'ssl') return [];
|
||||
let list = store.assets.filter(hasExpiry);
|
||||
const t = store.monitorTab;
|
||||
if (t === 'subscription') list = list.filter(a => a.cost > 0);
|
||||
else if (t) list = list.filter(a => a.asset_type === t);
|
||||
return list.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999));
|
||||
});
|
||||
// SSL 证书:按剩余天数倒计时排序
|
||||
const sslList = Vue.computed(() =>
|
||||
[...store.siteCerts].sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999))
|
||||
);
|
||||
const sslStats = Vue.computed(() => {
|
||||
const s = { total: store.siteCerts.length, valid: 0, expiring: 0, expired: 0, error: 0 };
|
||||
for (const c of store.siteCerts) if (s[c.status] !== undefined) s[c.status]++;
|
||||
return s;
|
||||
});
|
||||
async function recheck(c) {
|
||||
try { await Api.post('/site-certs/' + c.id + '/check', {}); await loadSiteCerts(); }
|
||||
catch (e) { alert('重测失败:' + e.message); }
|
||||
}
|
||||
async function delCert(c) {
|
||||
if (!confirm('删除证书监控「' + c.hostname + '」?')) return;
|
||||
try { await Api.del('/site-certs/' + c.id); await loadSiteCerts(); }
|
||||
catch (e) { alert('删除失败:' + e.message); }
|
||||
}
|
||||
const total = Vue.computed(() => items.value.length);
|
||||
const stats = Vue.computed(() => {
|
||||
const s = { soon30: 0, soon7: 0, expired: 0 };
|
||||
for (const a of store.assets.filter(hasExpiry)) {
|
||||
const d = a.days_to_expiry;
|
||||
if (d === null || d === undefined) continue;
|
||||
if (d < 0) s.expired++;
|
||||
else if (d <= 7) { s.soon7++; s.soon30++; }
|
||||
else if (d <= 30) s.soon30++;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const emptyText = Vue.computed(() => store.monitorTab ? '该类别暂无到期资产' : '暂无到期资产(资产设置到期日即可出现在这里)');
|
||||
function switchTab(k) { store.monitorTab = k; }
|
||||
function countdownText(d) {
|
||||
if (d === null || d === undefined) return '—';
|
||||
if (d < 0) return '已过期 ' + Math.abs(d) + ' 天';
|
||||
if (d === 0) return '今天到期';
|
||||
return d + ' 天';
|
||||
}
|
||||
function countdownBadge(d) {
|
||||
if (d === null || d === undefined) return 'bg-slate-500/10 text-slate-500';
|
||||
if (d < 0 || d === 0 || d <= 7) return 'bg-red-500/10 text-red-600 dark:text-red-400';
|
||||
if (d <= 30) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400';
|
||||
if (d <= 90) return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400';
|
||||
return 'bg-slate-500/10 text-slate-500';
|
||||
}
|
||||
return { store, Fmt, tabs, items, total, stats, sslList, sslStats, emptyText, switchTab, countdownText, countdownBadge, recheck, delCert, edit: openAssetEdit, del: deleteAsset };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/* 平台管理视图 */
|
||||
const ProvidersView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<!-- 说明条:让用户明白平台是什么 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button @click="seed" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">初始化预设平台</button>
|
||||
<button @click="create" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">+ 自定义平台</button>
|
||||
<button @click="manageAccounts" class="text-sm px-3 py-1.5 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20">👤 账号管理</button>
|
||||
<button @click="toggleShowAll" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800">{{ showAll ? '只显示在用' : '显示全部平台(' + store.providers.length + ')' }}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div v-for="p in visibleProviders" :key="p.id" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ p.name }}</div>
|
||||
<div class="text-xs text-slate-400 mt-0.5">{{ p.slug }}</div>
|
||||
</div>
|
||||
<div v-if="serviceList(p).length" class="flex flex-wrap gap-1 justify-end max-w-[60%]">
|
||||
<span v-for="s in serviceList(p)" :key="s" class="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400">{{ Fmt.SERVICES_LABELS[s] || s }}</span>
|
||||
</div>
|
||||
<span v-else class="text-xs px-2 py-0.5 rounded-full" :class="Fmt.typeBadge(p.category)">{{ Fmt.CATEGORY_LABELS[p.category] }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-2 space-y-1">
|
||||
<div v-if="p.sdk_type">API 对接: {{ p.sdk_type }}</div>
|
||||
<div v-if="p.console_url"><a :href="p.console_url" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">管理面板 ↗</a></div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<button @click="goAssets(p)" :disabled="!countOf(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400 disabled:hover:text-slate-500" :title="countOf(p) ? '查看该平台下的资产' : ''">📎 关联 {{ countOf(p) }} 个资产</button>
|
||||
<button @click="openAccounts(p)" class="text-slate-500 hover:text-blue-600 dark:hover:text-blue-400">👤 账号 {{ accountsCountOf(p) }}</button>
|
||||
<span :class="p.enabled ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'">{{ p.enabled ? '启用' : '停用' }}</span>
|
||||
</div>
|
||||
<div v-if="p.last_synced_at" class="text-slate-400">最后同步:{{ fmtTime(p.last_synced_at) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-4 mt-3 pt-2 border-t border-slate-100 dark:border-slate-800 text-xs flex-wrap">
|
||||
<button @click="edit(p)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button @click="del(p)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!showAll && hiddenCount" @click="toggleShowAll" class="w-full text-center text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 py-2">
|
||||
已隐藏 {{ hiddenCount }} 个未使用平台(无资产/账号/API 配置)· 点击显示全部
|
||||
</button>
|
||||
</div>`,
|
||||
setup() {
|
||||
// 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗)
|
||||
// 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug
|
||||
const assetCounts = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of store.assets) {
|
||||
if (a.provider_id) m['id:' + a.provider_id] = (m['id:' + a.provider_id] || 0) + 1;
|
||||
else if (a.provider) m['p:' + a.provider] = (m['p:' + a.provider] || 0) + 1;
|
||||
}
|
||||
return m;
|
||||
});
|
||||
function countOf(p) {
|
||||
return (assetCounts.value['id:' + p.id] || 0) + (assetCounts.value['p:' + p.slug] || 0) + (assetCounts.value['p:' + p.name] || 0);
|
||||
}
|
||||
// 平台名下账号数(账号按 platform slug 归属平台)
|
||||
function accountsCountOf(p) {
|
||||
return store.accounts.filter(a => a.platform === p.slug).length;
|
||||
}
|
||||
// 在用判定:有资产/有账号/已配置 API 任一即算在用
|
||||
function isUsed(p) {
|
||||
return countOf(p) > 0 || accountsCountOf(p) > 0 || p.has_api_config;
|
||||
}
|
||||
// 默认隐藏未使用平台,避免预设平台撑满屏幕;偏好持久化到 localStorage
|
||||
const showAll = Vue.ref(localStorage.getItem('vps_show_all_providers') === '1');
|
||||
function toggleShowAll() {
|
||||
showAll.value = !showAll.value;
|
||||
localStorage.setItem('vps_show_all_providers', showAll.value ? '1' : '0');
|
||||
}
|
||||
const visibleProviders = Vue.computed(() => showAll.value ? store.providers : store.providers.filter(isUsed));
|
||||
const hiddenCount = Vue.computed(() => store.providers.length - visibleProviders.value.length);
|
||||
function serviceList(p) {
|
||||
return (p.services || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/* 服务器监控视图 */
|
||||
const ServersView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<div v-if="!m.asset" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-8 text-center text-slate-400 text-sm">
|
||||
请在「资产」页点击 VPS 的「监控」查看服务器资源与安全状态
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="goBack" class="text-sm text-slate-500 hover:text-slate-700">← 返回</button>
|
||||
<h2 class="font-semibold">{{ m.asset.name }}</h2>
|
||||
</div>
|
||||
<div v-if="m.info" class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">CPU</div>
|
||||
<div class="text-xl font-bold mt-1">{{ latest ? latest.cpu_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.cpu_cores }} 核</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">内存</div>
|
||||
<div class="text-xl font-bold mt-1">{{ latest ? latest.mem_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.mem_total_gb }} GB</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">磁盘</div>
|
||||
<div class="text-xl font-bold mt-1">{{ latest ? latest.disk_pct : '—' }}%</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.disk_total_gb }} GB</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">负载/在线</div>
|
||||
<div class="text-xl font-bold mt-1">{{ latest && latest.load_1m !== null ? latest.load_1m : '—' }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">{{ m.info.hostname }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="m.info" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4 text-sm">
|
||||
<h3 class="font-semibold mb-2">服务器信息</h3>
|
||||
<div class="grid grid-cols-2 gap-y-1 text-xs text-slate-500">
|
||||
<div>系统:{{ m.info.os }}</div><div>内核:{{ m.info.kernel }}</div>
|
||||
<div>公网 IP:{{ m.info.public_ip || '—' }}</div><div>状态:{{ m.info.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="m.metrics && m.metrics.length > 1" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">资源趋势(最近 {{ m.metrics.length }} 次采样)</h3>
|
||||
<canvas id="metricsChart" height="90"></canvas>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-semibold text-sm">安全检查</h3>
|
||||
<div v-if="m.security && m.security.score !== null && m.security.score !== undefined" class="flex items-center gap-2">
|
||||
<span class="text-xs text-slate-500">安全评分</span>
|
||||
<span class="text-lg font-bold" :class="scoreClass(m.security.level)">{{ m.security.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="c in m.checks" :key="c.id" class="flex items-start gap-2 text-sm">
|
||||
<span class="text-xs px-2 py-0.5 rounded-full shrink-0" :class="Fmt.checkBadge(c.status)">{{ c.status }}</span>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-xs">{{ c.check_item }}</div>
|
||||
<div class="text-xs text-slate-500">{{ c.detail }}</div>
|
||||
<div v-if="c.suggestion" class="text-xs text-amber-600 dark:text-amber-400">建议:{{ c.suggestion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!m.checks.length" class="text-xs text-slate-400">暂无安全检查数据(需 Agent 上报)</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>`,
|
||||
setup() {
|
||||
const m = Vue.computed(() => store.serverMetrics);
|
||||
const latest = Vue.computed(() => (m.value.metrics && m.value.metrics.length) ? m.value.metrics[0] : null);
|
||||
let chartInstance = null;
|
||||
function renderChart() {
|
||||
const canvas = document.getElementById('metricsChart');
|
||||
if (!canvas || !window.Chart) return;
|
||||
if (!m.value.metrics || m.value.metrics.length < 2) return;
|
||||
if (chartInstance) { chartInstance.destroy(); chartInstance = null; }
|
||||
const data = [...m.value.metrics].reverse();
|
||||
const labels = data.map(p => new Date(p.ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }));
|
||||
chartInstance = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'CPU %', data: data.map(p => p.cpu_pct), borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
{ label: '内存 %', data: data.map(p => p.mem_pct), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
{ label: '磁盘 %', data: data.map(p => p.disk_pct), borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.1)', tension: 0.3, pointRadius: 0 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
|
||||
plugins: { legend: { position: 'top' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch(m, () => { Vue.nextTick(renderChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderChart); });
|
||||
Vue.onUnmounted(() => { if (chartInstance) { chartInstance.destroy(); chartInstance = null; } });
|
||||
function scoreClass(level) {
|
||||
return { good: 'text-emerald-600 dark:text-emerald-400', warning: 'text-amber-600 dark:text-amber-400', risk: 'text-red-600 dark:text-red-400', unknown: 'text-slate-400' }[level] || 'text-slate-400';
|
||||
}
|
||||
function goBack() { window.location.hash = '#assets'; }
|
||||
return { store, Fmt, m, latest, scoreClass, goBack };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/* 设置视图 */
|
||||
const SettingsView = {
|
||||
template: `
|
||||
<div class="max-w-lg space-y-4">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-3">API Key(写操作鉴权)</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">若服务端 .env 配置了 API_KEY,请在此填写相同的值,前端写操作会自动携带。</p>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="settings.apiKey" type="password" placeholder="留空表示服务端未启用鉴权" class="flex-1 text-sm px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<button @click="save" class="text-sm px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">{{ settings.saved ? '已保存' : '保存' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">通知与续费提醒</h3>
|
||||
<div class="text-xs text-slate-500 mb-3">已配置渠道:
|
||||
<span v-if="notifyChannels.length" class="text-emerald-600 dark:text-emerald-400">{{ notifyChannels.join('、') }}</span>
|
||||
<span v-else class="text-slate-400">未配置(在 .env 设置 TELEGRAM_*/SMTP_*)</span>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button @click="testNotify" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">测试通知</button>
|
||||
<button @click="checkRenewals" class="text-sm px-3 py-1.5 rounded-lg bg-blue-600 text-white hover:bg-blue-700">检查续费并提醒</button>
|
||||
</div>
|
||||
<div v-if="notifyResult" class="mt-3 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50 whitespace-pre-wrap break-words">{{ notifyResult }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">外观</h3>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" v-model="store.dark" @change="applyDark"> 深色模式
|
||||
</label>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">数据管理(迁移/备份)</h3>
|
||||
<p class="text-xs text-slate-500 mb-2">导出所有资产为 JSON,可导入到新部署的实例,实现数据迁移。</p>
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<button @click="exportData" class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800">导出数据</button>
|
||||
<label class="text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer">导入数据<input type="file" accept=".json" @change="onImportFile" class="hidden"></label>
|
||||
</div>
|
||||
<div v-if="dataResult" class="mt-2 text-xs px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-800/50">{{ dataResult }}</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold text-sm mb-2">版本信息</h3>
|
||||
<div class="text-xs text-slate-500">当前运行版本:<span class="text-slate-700 dark:text-slate-300 font-mono">v{{ version }} ({{ commit }})</span></div>
|
||||
<p class="text-xs text-slate-400 mt-1">与本地 git rev-parse --short HEAD 比对,可确认线上是否为最新代码。</p>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const notifyChannels = Vue.ref([]);
|
||||
const notifyResult = Vue.ref('');
|
||||
const dataResult = Vue.ref('');
|
||||
const version = Api.CFG.version || 'dev';
|
||||
const commit = Api.CFG.commit || 'unknown';
|
||||
Vue.onMounted(async () => {
|
||||
try { const r = await Api.get('/notify/channels'); notifyChannels.value = r.channels || []; } catch (e) { /* ignore */ }
|
||||
});
|
||||
async function testNotify() {
|
||||
notifyResult.value = '发送中…';
|
||||
try {
|
||||
const r = await Api.post('/notify/test', {});
|
||||
notifyResult.value = r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join('\n');
|
||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||
}
|
||||
async function checkRenewals() {
|
||||
notifyResult.value = '检查中…';
|
||||
try {
|
||||
const r = await Api.post('/notify/renewals?send=true', {});
|
||||
let text = '即将到期 ' + r.expiring_count + ' 项(阈值 ' + r.threshold_days + ' 天)';
|
||||
if (r.expiring.length) text += '\n' + r.expiring.map(i => '• ' + i.name + ' [' + i.provider + '] ' + i.days + ' 天后').join('\n');
|
||||
if (r.notifications && r.notifications.length) text += '\n\n通知:' + r.notifications.map(n => '[' + n.channel + '] ' + (n.ok ? '成功' : '失败:' + n.message)).join(',');
|
||||
notifyResult.value = text;
|
||||
} catch (e) { notifyResult.value = '失败:' + e.message; }
|
||||
}
|
||||
async function exportData() {
|
||||
dataResult.value = '导出中…';
|
||||
try {
|
||||
const r = await Api.get('/assets/export');
|
||||
const blob = new Blob([JSON.stringify(r, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'vps-manager-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
dataResult.value = '导出成功:' + r.count + ' 个资产';
|
||||
} catch (e) { dataResult.value = '导出失败:' + e.message; }
|
||||
}
|
||||
function onImportFile(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
dataResult.value = '导入中…';
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
const r = await Api.post('/assets/import', data);
|
||||
dataResult.value = '导入完成:新增 ' + r.created + ',更新 ' + r.updated + (r.errors && r.errors.length ? ',错误 ' + r.errors.length : '');
|
||||
await loadAssets();
|
||||
} catch (err) { dataResult.value = '导入失败:' + err.message; }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
return { store, settings, save: saveSettings, applyDark, notifyChannels, notifyResult, testNotify, checkRenewals, dataResult, exportData, onImportFile, version, commit };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/* 订阅视图 */
|
||||
const SubscriptionsView = {
|
||||
template: `
|
||||
<div class="space-y-4">
|
||||
<!-- 说明条:让用户明白这个视图是什么 -->
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
💸 这里是「付费资产」汇总:自动收集所有设置了费用或到期日的资产(跨 VPS / 域名 / AI 账号),统一看每个月花多少钱、哪些该续费。
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">付费资产数</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ subs.length }}</div>
|
||||
</div>
|
||||
<div v-for="(v, cur) in monthlyByCurrency" :key="cur" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<div class="text-xs text-slate-500">月度支出({{ cur }})</div>
|
||||
<div class="text-2xl font-bold mt-1">{{ v.toFixed(2) }}</div>
|
||||
<div class="text-xs text-slate-400 mt-1">年度约 {{ (v*12).toFixed(2) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subs.length" class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 p-4">
|
||||
<h3 class="font-semibold mb-2 text-sm">按平台月度支出(换算为月均)</h3>
|
||||
<canvas id="billingChart" height="80"></canvas>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 dark:bg-slate-800/50 text-slate-500 text-left text-xs">
|
||||
<tr>
|
||||
<th class="px-4 py-2.5 font-medium">项目</th>
|
||||
<th class="px-4 py-2.5 font-medium">平台</th>
|
||||
<th class="px-4 py-2.5 font-medium">费用</th>
|
||||
<th class="px-4 py-2.5 font-medium">到期</th>
|
||||
<th class="px-4 py-2.5 font-medium text-right">管理</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
<tr v-if="!subs.length"><td colspan="5" class="px-4 py-10 text-center text-slate-400">暂无付费资产(给资产设置费用和到期日即可出现)</td></tr>
|
||||
<tr v-for="a in subs" :key="a.id" class="hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||
<td class="px-4 py-2.5 font-medium">{{ a.name }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-500">{{ a.provider_name || a.provider }}</td>
|
||||
<td class="px-4 py-2.5 text-slate-600 dark:text-slate-300">{{ a.cost }} {{ a.currency }}<span v-if="a.renewal_cycle" class="text-xs text-slate-400 ml-1">/{{ Fmt.CYCLE_LABELS[a.renewal_cycle] || a.renewal_cycle }}</span></td>
|
||||
<td class="px-4 py-2.5" :class="Fmt.expiryText(a.days_to_expiry)">{{ a.expiry_date || '—' }}<span v-if="a.days_to_expiry!==null&&a.days_to_expiry!==undefined" class="text-xs ml-1">({{ a.days_to_expiry }}天)</span></td>
|
||||
<td class="px-4 py-2.5 text-right whitespace-nowrap">
|
||||
<a v-if="a.renew_url" :href="a.renew_url" target="_blank" class="text-emerald-600 dark:text-emerald-400 hover:underline mr-3 text-xs">续费</a>
|
||||
<a v-if="a.cancel_url" :href="a.cancel_url" target="_blank" class="text-red-600 dark:text-red-400 hover:underline text-xs">取消</a>
|
||||
<span v-if="!a.renew_url && !a.cancel_url" class="text-xs text-slate-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const subs = Vue.computed(() => store.assets
|
||||
.filter(a => (a.cost > 0 || a.expiry_date) && a.status !== 'cancelled')
|
||||
.sort((x, y) => (x.days_to_expiry ?? 9999) - (y.days_to_expiry ?? 9999)));
|
||||
const monthlyByCurrency = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of subs.value) {
|
||||
const cur = a.currency || 'USD';
|
||||
m[cur] = (m[cur] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const byProviderMonthly = Vue.computed(() => {
|
||||
const m = {};
|
||||
for (const a of subs.value) {
|
||||
const prov = a.provider_name || a.provider;
|
||||
m[prov] = (m[prov] || 0) + normalizedMonthly(a.cost, a.renewal_cycle);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
let billingChart = null;
|
||||
function renderBillingChart() {
|
||||
const canvas = document.getElementById('billingChart');
|
||||
if (!canvas || !window.Chart) return;
|
||||
const entries = Object.entries(byProviderMonthly.value).sort((x, y) => y[1] - x[1]);
|
||||
if (!entries.length) return;
|
||||
if (billingChart) { billingChart.destroy(); billingChart = null; }
|
||||
billingChart = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: entries.map(e => e[0]),
|
||||
datasets: [{ label: '月度支出', data: entries.map(e => e[1].toFixed(2)), backgroundColor: '#6366f1', borderRadius: 6 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: { y: { beginAtZero: true } },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vue.watch([subs, byProviderMonthly], () => { Vue.nextTick(renderBillingChart); }, { deep: true });
|
||||
Vue.onMounted(() => { Vue.nextTick(renderBillingChart); });
|
||||
Vue.onUnmounted(() => { if (billingChart) { billingChart.destroy(); billingChart = null; } });
|
||||
return { store, Fmt, subs, monthlyByCurrency, byProviderMonthly };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
/* 凭据库视图:登录密码 + 2FA 动态码
|
||||
*
|
||||
* 数据来自 credentials 表(登录凭据唯一事实源):既包含平台账号迁移/联动过来的
|
||||
* 条目,也包含自行录入的普通网站。列表以「取用快」为先:搜索、一键复制、
|
||||
* 展开看动态码;移动端单列卡片,桌面端同布局(避免横向溢出)。
|
||||
*/
|
||||
const VaultView = {
|
||||
template: `
|
||||
<div class="space-y-3">
|
||||
<div class="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 rounded-lg px-3 py-2">
|
||||
🔐 凭据库 = 各站点的登录方式(用户名 / 密码 / 2FA / 授权登录)。平台账号的登录密码也统一存在这里,只维护一份,不会两头改。
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<input v-model="store.vaultSearch" @input="reload" placeholder="搜索站点/用户名/备注…"
|
||||
class="flex-1 min-w-0 text-sm px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900">
|
||||
<button @click="toggleOtpFilter" class="text-sm px-3 py-1.5 rounded-lg border whitespace-nowrap shrink-0"
|
||||
:class="store.vaultOtpOnly ? 'border-blue-500 bg-blue-500/10 text-blue-600 dark:text-blue-400' : 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-400'">🛡 2FA</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!store.credentials.length" class="text-xs text-slate-400 py-8 text-center">
|
||||
{{ store.vaultSearch || store.vaultOtpOnly ? '没有匹配的凭据' : '暂无凭据。点右上「+ 新增」录入网站账号;平台账号填过登录密码的会自动出现在这里。' }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="c in store.credentials" :key="c.id"
|
||||
class="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<!-- 主行 -->
|
||||
<div class="px-3 py-2.5 flex items-center gap-2">
|
||||
<button @click="toggle(c)" class="text-slate-400 text-xs w-4 shrink-0">{{ expanded[c.id] ? '▾' : '▸' }}</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">
|
||||
{{ c.site }}
|
||||
<span v-if="c.has_otp" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 align-middle">2FA</span>
|
||||
<span v-if="c.duplicate" class="text-[10px] px-1 py-0.5 ml-1 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 align-middle"
|
||||
title="同站点同用户名已有多条,确认是否重复录入">重复?</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 truncate">
|
||||
<span v-if="c.username">{{ c.username }}</span><span v-else class="italic">未填用户名</span>
|
||||
<span v-if="c.login_type==='oauth'" class="ml-1 text-emerald-600 dark:text-emerald-400">· {{ Fmt.oauthLabel(c.oauth_provider) || '授权' }} 登录</span>
|
||||
<span v-else-if="c.login_type==='other'" class="ml-1">· 其他登录方式</span>
|
||||
<span v-if="c.account_name" class="ml-1">· 平台账号</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="c.has_otp" @click="toggle(c, true)"
|
||||
class="text-xs px-2 py-1 rounded-lg bg-blue-600 text-white shrink-0">验证码</button>
|
||||
<button v-if="c.has_password" @click="copyPwd(c)"
|
||||
class="text-xs px-2 py-1 rounded-lg border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 shrink-0">复制</button>
|
||||
</div>
|
||||
|
||||
<!-- 展开详情 -->
|
||||
<div v-if="expanded[c.id]" class="px-3 pb-3 pt-2 border-t border-slate-100 dark:border-slate-800 space-y-2">
|
||||
<!-- 2FA 动态码:服务端生成,本地倒计时,归零自动刷新 -->
|
||||
<div v-if="c.has_otp" class="flex items-center gap-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
|
||||
<div class="font-mono text-2xl tracking-widest text-blue-700 dark:text-blue-300 shrink-0">{{ otpOf(c).code || '······' }}</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">{{ otpOf(c).expiresIn }}s 后刷新</div>
|
||||
<div class="h-1 bg-slate-200 dark:bg-slate-700 rounded mt-1 overflow-hidden">
|
||||
<div class="h-full bg-blue-500 transition-all duration-1000 ease-linear"
|
||||
:style="{ width: Math.max(0, otpOf(c).expiresIn) / 30 * 100 + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="copyOtp(c)" class="text-xs px-2 py-1 rounded-lg border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-slate-500 space-y-1">
|
||||
<div v-if="c.url"><a :href="c.url" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline break-all">登录页 ↗</a></div>
|
||||
<div v-if="c.note" class="whitespace-pre-wrap break-words">{{ c.note }}</div>
|
||||
<div v-if="c.login_type==='oauth'" class="text-slate-400">授权登录({{ Fmt.oauthDetail(c.oauth_provider) || '未记录来源' }}),本站无独立密码</div>
|
||||
<div v-if="c.account_name" class="text-slate-400">关联平台账号:{{ c.account_name }}</div>
|
||||
<div class="text-slate-300 dark:text-slate-600">更新于 {{ fmtTime(c.updated_at) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 text-xs flex-wrap">
|
||||
<button v-if="c.has_password" @click="reveal(c)" class="text-amber-600 dark:text-amber-400">查看密码</button>
|
||||
<button @click="edit(c)" class="text-blue-600 dark:text-blue-400">编辑</button>
|
||||
<button v-if="c.has_otp" @click="unbindOtp(c)" class="text-slate-500 dark:text-slate-400">解绑 2FA</button>
|
||||
<button @click="del(c)" class="text-red-600 dark:text-red-400">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码明文弹窗:8 秒自动关闭,减少明文暴露时间 -->
|
||||
<div v-if="pwd.show" class="fixed inset-0 bg-black/50 z-[70] flex items-center justify-center p-4" @click.self="closePwd">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl w-full max-w-xs p-5 shadow-xl">
|
||||
<h4 class="font-semibold text-sm mb-3">🔑 {{ pwd.site }}</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="pwd.username" class="flex justify-between items-center gap-2">
|
||||
<span class="text-slate-400 text-xs shrink-0">用户名</span>
|
||||
<span class="font-mono truncate">{{ pwd.username }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<span class="text-slate-400 text-xs shrink-0">密码</span>
|
||||
<span class="font-mono break-all">{{ pwd.password }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="copyText(pwd.password, '密码')" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">复制密码</button>
|
||||
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const expanded = Vue.reactive({});
|
||||
const pwd = Vue.reactive({ show: false, site: '', username: '', password: '', countdown: 0, _timer: null });
|
||||
|
||||
function otpOf(c) {
|
||||
return otpState[c.id] || { code: '', expiresIn: 0, loading: false };
|
||||
}
|
||||
// 展开即拉动态码并开始倒计时;收起时停掉定时器
|
||||
function toggle(c, forceOpen) {
|
||||
const open = forceOpen ? true : !expanded[c.id];
|
||||
expanded[c.id] = open;
|
||||
if (!c.has_otp) return;
|
||||
if (open && !otpState[c.id]) fetchOtp(c.id);
|
||||
if (!open) stopOtp(c.id);
|
||||
}
|
||||
async function reveal(c) {
|
||||
store.error = '';
|
||||
try {
|
||||
const r = await Api.get('/credentials/' + c.id + '/password');
|
||||
pwd.site = c.site;
|
||||
pwd.username = r.username || '';
|
||||
pwd.password = r.password;
|
||||
pwd.show = true;
|
||||
if (pwd._timer) clearInterval(pwd._timer);
|
||||
pwd.countdown = 8;
|
||||
pwd._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) closePwd();
|
||||
}, 1000);
|
||||
} catch (e) { store.error = e.message; }
|
||||
}
|
||||
function closePwd() {
|
||||
pwd.show = false;
|
||||
pwd.password = '';
|
||||
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
|
||||
}
|
||||
function copyOtp(c) {
|
||||
const s = otpState[c.id];
|
||||
if (s && s.code) copyText(s.code, '验证码');
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// 搜索防抖:300ms 内连续输入只发一次请求
|
||||
let searchTimer = null;
|
||||
function debouncedReload() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { loadCredentials(); }, 300);
|
||||
}
|
||||
function toggleOtpFilter() {
|
||||
store.vaultOtpOnly = !store.vaultOtpOnly;
|
||||
loadCredentials();
|
||||
}
|
||||
// 离开视图时停掉全部倒计时,避免后台定时器泄漏
|
||||
Vue.onUnmounted(() => { stopAllOtp(); closePwd(); });
|
||||
return {
|
||||
store, Fmt, expanded, pwd, otpOf, toggle, reveal, closePwd, copyOtp, fmtTime,
|
||||
reload: debouncedReload, toggleOtpFilter, copyText,
|
||||
copyPwd: copyCredentialPassword, edit: openCredentialEdit,
|
||||
del: deleteCredential, unbindOtp: unbindCredentialOtp,
|
||||
};
|
||||
},
|
||||
};
|
||||
+23
-6
@@ -1,12 +1,13 @@
|
||||
/* vps-manager Service Worker
|
||||
* 缓存策略:
|
||||
* - 预缓存核心静态资源,安装即可离线打开
|
||||
* - 静态资源与页面:stale-while-revalidate(先返回缓存秒开,后台自动拉取最新版本)
|
||||
* - 页面导航(HTML):network-first,回源失败才用缓存。保证刷新即拿到最新版本号
|
||||
* - 静态资源(?v= 版本号):stale-while-revalidate(先返回缓存秒开,后台自动拉取最新)
|
||||
* —— 版本号变了 URL 就变,缓存必 miss,天然回源新文件
|
||||
* - /api/* 数据请求:始终走网络,保证数据实时
|
||||
* 更新机制:业务代码更新后,后台自动同步到缓存,下次访问生效;
|
||||
* 若需强制刷新缓存,递增 CACHE_NAME 即可清理旧缓存。
|
||||
* 更新机制:sw.js 由服务端 no-cache 提供,改动后导航时立即被浏览器发现;
|
||||
* install 后 skipWaiting + clients.claim 立即接管,无需手动清缓存。
|
||||
*/
|
||||
const CACHE_NAME = 'vps-manager-v1';
|
||||
const CACHE_NAME = 'vps-manager-v3';
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
@@ -48,10 +49,26 @@ self.addEventListener('fetch', (event) => {
|
||||
// 数据接口走网络,不缓存
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// 页面导航:network-first,保证每次刷新都拿到最新 HTML(含最新版本号)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源:stale-while-revalidate(版本号控制缓存失效)
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(async (cache) => {
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request)
|
||||
// no-store:绕开浏览器 HTTP 磁盘缓存,保证回源拿到最新版本
|
||||
const network = fetch(request, { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (response && response.ok) cache.put(request, response.clone());
|
||||
return response;
|
||||
|
||||
@@ -247,7 +247,8 @@ def test_cloudflare_pagination_result_info():
|
||||
adapter = CloudflareAdapter({"api_token": "x"})
|
||||
page1 = {"result": [{"id": "z1", "name": "a.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 1}}
|
||||
page2 = {"result": [{"id": "z2", "name": "b.com", "status": "active"}], "result_info": {"total_pages": 2, "page": 2}}
|
||||
with patch.object(CloudflareAdapter, "_get", side_effect=[page1, page2]):
|
||||
with patch.object(CloudflareAdapter, "_registrar_expiry_map", return_value={}), \
|
||||
patch.object(CloudflareAdapter, "_get", side_effect=[page1, page2]):
|
||||
result = adapter.list_domains()
|
||||
assert len(result) == 2
|
||||
assert {d.domain_name for d in result} == {"a.com", "b.com"}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""凭据中心化迁移测试(accounts.login_password → credentials)
|
||||
|
||||
模拟旧库(accounts 无 credential_id 列)→ 跑迁移 → 断言:
|
||||
密文原样搬入、site 取平台显示名、账号关联回填、原字段置空、重跑幂等、
|
||||
无密码账号不产生空条目。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_credential_migration.py -v
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
import app.database as db
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
from app.models.asset import AIAccount, Account, Asset, CloudflareDetail, DomainDetail, VPSDetail
|
||||
from app.models.credential import Credential, LoginType
|
||||
from app.models.provider import Provider
|
||||
from app.models.ssl import SiteCert, Subdomain
|
||||
|
||||
# 旧结构 accounts 表:无 credential_id 列(迁移应自动补列)
|
||||
OLD_ACCOUNTS_DDL = """
|
||||
CREATE TABLE accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR NOT NULL,
|
||||
platform VARCHAR,
|
||||
remark VARCHAR,
|
||||
login_user VARCHAR,
|
||||
login_password_encrypted VARCHAR,
|
||||
api_config_encrypted VARCHAR,
|
||||
last_synced_at DATETIME,
|
||||
created_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def legacy_engine(tmp_path, monkeypatch):
|
||||
"""旧结构测试库:新表齐备,但 accounts 缺 credential_id 列"""
|
||||
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
|
||||
crypto._get_fernet.cache_clear() # 避免复用其它测试缓存的 Fernet 实例
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'test.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
# 除 Account 外按当前模型建表(含新 credentials 表;accounts 用旧 DDL)
|
||||
SQLModel.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Provider.__table__, Asset.__table__, VPSDetail.__table__, DomainDetail.__table__,
|
||||
AIAccount.__table__, CloudflareDetail.__table__, Subdomain.__table__,
|
||||
SiteCert.__table__, Credential.__table__,
|
||||
],
|
||||
)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(sa.text(OLD_ACCOUNTS_DDL))
|
||||
# 迁移函数引用模块级 assets_engine → 指向测试库
|
||||
monkeypatch.setattr(db, "assets_engine", engine)
|
||||
yield engine
|
||||
crypto._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _seed(engine, pwd_aliyun: str, pwd_forum: str) -> None:
|
||||
"""造迁移前现场:平台账号(有密码)/ 无平台账号(有密码)/ 无密码账号"""
|
||||
with Session(engine) as s:
|
||||
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
|
||||
s.commit()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
|
||||
"VALUES ('me@gmail.com', 'aliyun', 'me@gmail.com', :pwd, :now)"
|
||||
),
|
||||
{"pwd": pwd_aliyun, "now": now},
|
||||
)
|
||||
# platform 为空串:site 应回退 '未分类';无 login_user:username 回退 name
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO accounts (name, platform, login_user, login_password_encrypted, created_at) "
|
||||
"VALUES ('forum-user', '', NULL, :pwd, :now)"
|
||||
),
|
||||
{"pwd": pwd_forum, "now": now},
|
||||
)
|
||||
# 无密码账号:不应产生凭据条目
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO accounts (name, platform, created_at) VALUES ('no-pwd', 'vultr', :now)"
|
||||
),
|
||||
{"now": now},
|
||||
)
|
||||
|
||||
|
||||
def test_migration_moves_passwords_to_vault(legacy_engine):
|
||||
engine = legacy_engine
|
||||
pwd_aliyun = crypto.encrypt("p@ss-阿里云")
|
||||
pwd_forum = crypto.encrypt("forum-pass-中文")
|
||||
_seed(engine, pwd_aliyun, pwd_forum)
|
||||
|
||||
db._migrate_assets_db()
|
||||
|
||||
with Session(engine) as s:
|
||||
creds = s.exec(select(Credential).order_by(Credential.id)).all()
|
||||
# 仅两条有密码的账号被迁移,无密码账号不产生空条目
|
||||
assert len(creds) == 2
|
||||
|
||||
c1, c2 = creds
|
||||
# 密文原样搬入(不重新加密,避免中间态明文暴露),仍可解密还原
|
||||
assert c1.password_encrypted == pwd_aliyun
|
||||
assert crypto.decrypt(c1.password_encrypted) == "p@ss-阿里云"
|
||||
# site 取平台显示名(join providers),username 取 login_user
|
||||
assert c1.site == "阿里云"
|
||||
assert c1.username == "me@gmail.com"
|
||||
assert c1.login_type == LoginType.PASSWORD
|
||||
# 无 provider 匹配 + platform 空 → site 回退 '未分类';username 回退 name
|
||||
assert c2.password_encrypted == pwd_forum
|
||||
assert c2.site == "未分类"
|
||||
assert c2.username == "forum-user"
|
||||
|
||||
# 账号侧:credential_id 回填、原密码字段置空(唯一事实源,防双份漂移)
|
||||
accs = {a.name: a for a in s.exec(select(Account)).all()}
|
||||
assert accs["me@gmail.com"].credential_id == c1.id
|
||||
assert accs["me@gmail.com"].login_password_encrypted is None
|
||||
assert accs["forum-user"].credential_id == c2.id
|
||||
assert accs["forum-user"].login_password_encrypted is None
|
||||
assert accs["no-pwd"].credential_id is None
|
||||
|
||||
|
||||
def test_migration_is_idempotent(legacy_engine):
|
||||
"""重跑迁移不得重复建条目(幂等条件:credential_id 已非空则跳过)"""
|
||||
engine = legacy_engine
|
||||
_seed(engine, crypto.encrypt("pw1"), crypto.encrypt("pw2"))
|
||||
|
||||
db._migrate_assets_db()
|
||||
db._migrate_assets_db()
|
||||
db._migrate_assets_db()
|
||||
|
||||
with Session(engine) as s:
|
||||
assert len(s.exec(select(Credential)).all()) == 2
|
||||
|
||||
|
||||
def test_migration_adds_credential_id_column(legacy_engine):
|
||||
"""旧库缺 credential_id 列时迁移应自动补列"""
|
||||
engine = legacy_engine
|
||||
with engine.connect() as conn:
|
||||
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
|
||||
assert "credential_id" not in cols # 前置:确实是旧结构
|
||||
|
||||
db._migrate_assets_db()
|
||||
|
||||
with engine.connect() as conn:
|
||||
cols = {c["name"] for c in sa.inspect(conn).get_columns("accounts")}
|
||||
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
|
||||
assert "credential_id" in cols
|
||||
|
||||
db._migrate_indexes()
|
||||
with engine.connect() as conn:
|
||||
indexes = {ix["name"] for ix in sa.inspect(conn).get_indexes("accounts")}
|
||||
assert "ix_accounts_credential_id" in indexes
|
||||
@@ -0,0 +1,351 @@
|
||||
"""凭据服务(密码库 + 2FA)与账号联动测试
|
||||
|
||||
覆盖:创建/更新/删除语义、密码加解密、oauth 条目、2FA 绑定强制校验
|
||||
(错码拒绝)、动态码生成、搜索过滤、重复标记,以及账号侧密码重定向
|
||||
(唯一事实源)与生命周期解耦(删账号不删凭据)。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_credential_service.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import HTTPException
|
||||
from sqlmodel import Session, SQLModel, create_engine, select
|
||||
|
||||
from app.core import crypto, totp
|
||||
from app.core.config import settings
|
||||
from app.models.asset import Account
|
||||
from app.models.credential import Credential, LoginType
|
||||
from app.models.provider import Provider
|
||||
from app.schemas.account import AccountCreate, AccountUpdate
|
||||
from app.schemas.credential import CredentialCreate, CredentialUpdate, OtpBindRequest
|
||||
from app.services import account_service, credential_service
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(tmp_path, monkeypatch):
|
||||
"""独立测试库(新结构全表)+ 预置一个平台供 site 显示名映射"""
|
||||
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
|
||||
crypto._get_fernet.cache_clear()
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'svc.db'}", connect_args={"check_same_thread": False}
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
with Session(engine) as s:
|
||||
s.add(Provider(slug="aliyun", name="阿里云", category="vps"))
|
||||
s.commit()
|
||||
yield s
|
||||
crypto._get_fernet.cache_clear()
|
||||
|
||||
|
||||
def _otp_pair():
|
||||
"""生成一对可用的 (secret, 当前动态码)"""
|
||||
secret = totp.random_secret()
|
||||
return secret, totp.totp_at(secret)[0]
|
||||
|
||||
|
||||
# ----------------------------- 创建 / 读取 ----------------------------- #
|
||||
def test_create_with_password(session):
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="s3cret-中文")
|
||||
)
|
||||
assert read.has_password is True
|
||||
assert read.has_otp is False
|
||||
# Read 永不返回明文/密文
|
||||
dumped = read.model_dump()
|
||||
assert "password" not in dumped and "password_encrypted" not in dumped
|
||||
|
||||
revealed = credential_service.reveal_password(session, read.id)
|
||||
assert revealed["password"] == "s3cret-中文"
|
||||
assert revealed["username"] == "me@gmail.com"
|
||||
# 库里确实是密文
|
||||
row = session.get(Credential, read.id)
|
||||
assert row.password_encrypted != "s3cret-中文"
|
||||
assert crypto.decrypt(row.password_encrypted) == "s3cret-中文"
|
||||
|
||||
|
||||
def test_create_oauth_entry(session):
|
||||
"""授权登录条目:无密码也是正常记录,可检索可回忆"""
|
||||
read = credential_service.create_credential(
|
||||
session,
|
||||
CredentialCreate(
|
||||
site="某论坛", username="me@gmail.com",
|
||||
login_type=LoginType.OAUTH, oauth_provider="google",
|
||||
),
|
||||
)
|
||||
assert read.login_type == LoginType.OAUTH
|
||||
assert read.oauth_provider == "google"
|
||||
assert read.has_password is False
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.reveal_password(session, read.id)
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_create_requires_site(session):
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.create_credential(session, CredentialCreate(site=" "))
|
||||
assert e.value.status_code == 400
|
||||
|
||||
|
||||
# ----------------------------- 2FA 绑定校验 ----------------------------- #
|
||||
def test_create_with_otp_requires_code(session):
|
||||
"""绑定 2FA 必须同时提供当前动态码(防 secret 手误成废条目)"""
|
||||
secret, _ = _otp_pair()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=secret)
|
||||
)
|
||||
assert e.value.status_code == 400 and "动态码" in e.value.detail
|
||||
|
||||
|
||||
def test_create_with_otp_wrong_code_rejected(session):
|
||||
secret, _ = _otp_pair()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=secret, otp_code="000000")
|
||||
)
|
||||
assert e.value.status_code == 400 and "不匹配" in e.value.detail
|
||||
# 校验失败不得落库
|
||||
assert session.exec(select(Credential)).first() is None
|
||||
|
||||
|
||||
def test_create_with_otp_valid_code(session):
|
||||
secret, code = _otp_pair()
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", username="me@gmail.com", otp_secret=secret, otp_code=code)
|
||||
)
|
||||
assert read.has_otp is True
|
||||
otp = credential_service.current_otp(session, read.id)
|
||||
assert len(otp["code"]) == 6 and otp["code"].isdigit()
|
||||
assert 1 <= otp["expires_in"] <= 30
|
||||
# secret 加密存储,且与录入值等价(能算出同样的码)
|
||||
row = session.get(Credential, read.id)
|
||||
assert row.otp_secret_encrypted != secret
|
||||
assert totp.verify(crypto.decrypt(row.otp_secret_encrypted), otp["code"])
|
||||
|
||||
|
||||
def test_create_with_otpauth_uri(session):
|
||||
"""支持直接粘贴 otpauth:// 链接录入(自动提取 secret)"""
|
||||
secret, code = _otp_pair()
|
||||
uri = f"otpauth://totp/GitHub:me%40gmail.com?secret={secret}&issuer=GitHub"
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", otp_secret=uri, otp_code=code)
|
||||
)
|
||||
assert read.has_otp is True
|
||||
row = session.get(Credential, read.id)
|
||||
assert crypto.decrypt(row.otp_secret_encrypted) == secret # 只存 secret,不存整条 URI
|
||||
|
||||
|
||||
def test_create_with_invalid_secret_rejected(session):
|
||||
secret, code = _otp_pair()
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="x", otp_secret="otpauth://hotp/a?secret=AA", otp_code=code)
|
||||
)
|
||||
assert e.value.status_code == 400 and "secret 无效" in e.value.detail
|
||||
|
||||
|
||||
def test_current_otp_unbound_404(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
with pytest.raises(HTTPException) as e:
|
||||
credential_service.current_otp(session, read.id)
|
||||
assert e.value.status_code == 404
|
||||
|
||||
|
||||
def test_bind_and_unbind_otp(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub"))
|
||||
secret, code = _otp_pair()
|
||||
updated = credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code=code))
|
||||
assert updated.has_otp is True
|
||||
# 错码解绑不了也绑不上
|
||||
with pytest.raises(HTTPException):
|
||||
credential_service.bind_otp(session, read.id, OtpBindRequest(secret=secret, code="111111"))
|
||||
unbound = credential_service.unbind_otp(session, read.id)
|
||||
assert unbound.has_otp is False
|
||||
|
||||
|
||||
# ----------------------------- 更新 / 删除语义 ----------------------------- #
|
||||
def test_update_password_semantics(session):
|
||||
"""None=不改,''=清除,非空=重加密(与账号凭证惯例一致)"""
|
||||
read = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", password="old")
|
||||
)
|
||||
# None:不修改
|
||||
r = credential_service.update_credential(session, read.id, CredentialUpdate(note="记一笔"))
|
||||
assert r.note == "记一笔"
|
||||
assert credential_service.reveal_password(session, read.id)["password"] == "old"
|
||||
# 非空:重加密
|
||||
credential_service.update_credential(session, read.id, CredentialUpdate(password="new-中文"))
|
||||
assert credential_service.reveal_password(session, read.id)["password"] == "new-中文"
|
||||
# 空串:清除
|
||||
r = credential_service.update_credential(session, read.id, CredentialUpdate(password=""))
|
||||
assert r.has_password is False
|
||||
with pytest.raises(HTTPException):
|
||||
credential_service.reveal_password(session, read.id)
|
||||
|
||||
|
||||
def test_update_fields(session):
|
||||
read = credential_service.create_credential(session, CredentialCreate(site="GitHub", username="a@b.c"))
|
||||
r = credential_service.update_credential(
|
||||
session, read.id,
|
||||
CredentialUpdate(site="GitLab", username="", login_type=LoginType.OTHER, url="https://x", note=None),
|
||||
)
|
||||
assert r.site == "GitLab"
|
||||
assert r.username is None # 空串归一化为 None
|
||||
assert r.login_type == LoginType.OTHER
|
||||
assert r.url == "https://x"
|
||||
|
||||
|
||||
def test_delete_unlinks_account(session):
|
||||
"""删除凭据:账号保留,仅解除关联(生命周期解耦)"""
|
||||
acc = account_service.create_account(
|
||||
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
|
||||
)
|
||||
cred_id = acc.credential_id
|
||||
assert cred_id is not None
|
||||
|
||||
credential_service.delete_credential(session, cred_id)
|
||||
|
||||
refreshed = session.get(Account, acc.id)
|
||||
assert refreshed is not None # 账号还在
|
||||
assert refreshed.credential_id is None # 关联已解除
|
||||
assert session.get(Credential, cred_id) is None
|
||||
|
||||
|
||||
# ----------------------------- 列表 / 搜索 / 重复标记 ----------------------------- #
|
||||
def test_list_search_and_filters(session):
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", username="me@gmail.com", password="p1")
|
||||
)
|
||||
secret, code = _otp_pair()
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="阿里云", username="me@gmail.com", password="p2",
|
||||
otp_secret=secret, otp_code=code)
|
||||
)
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="某论坛", login_type=LoginType.OAUTH, oauth_provider="google")
|
||||
)
|
||||
|
||||
all_creds = credential_service.list_credentials(session)
|
||||
assert len(all_creds) == 3
|
||||
|
||||
# 搜索命中 site / username / note
|
||||
assert len(credential_service.list_credentials(session, q="git")) == 1
|
||||
assert len(credential_service.list_credentials(session, q="me@gmail.com")) == 2
|
||||
# 登录方式过滤
|
||||
assert len(credential_service.list_credentials(session, login_type="oauth")) == 1
|
||||
# 2FA 过滤
|
||||
assert len(credential_service.list_credentials(session, has_otp=True)) == 1
|
||||
assert len(credential_service.list_credentials(session, has_otp=False)) == 2
|
||||
|
||||
|
||||
def test_duplicate_flag(session):
|
||||
"""同 (site, username) 多条时全部标记 duplicate(录入提示用)"""
|
||||
credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", username="me@gmail.com")
|
||||
)
|
||||
second = credential_service.create_credential(
|
||||
session, CredentialCreate(site="github", username="ME@gmail.com") # 大小写/空白归一化
|
||||
)
|
||||
assert second.duplicate is True
|
||||
rows = credential_service.list_credentials(session)
|
||||
assert all(r.duplicate for r in rows)
|
||||
# 同站不同用户名不算重复
|
||||
third = credential_service.create_credential(
|
||||
session, CredentialCreate(site="GitHub", username="other@gmail.com")
|
||||
)
|
||||
assert third.duplicate is False
|
||||
|
||||
|
||||
# ----------------------------- 账号联动(唯一事实源) ----------------------------- #
|
||||
def test_account_create_redirects_password(session):
|
||||
"""账号录入密码 → 自动建凭据并关联;账号表不再存密码密文"""
|
||||
acc = account_service.create_account(
|
||||
session,
|
||||
AccountCreate(name="me@gmail.com", platform="aliyun",
|
||||
login_user="me@gmail.com", login_password="pw-中文"),
|
||||
)
|
||||
assert acc.credential_id is not None
|
||||
assert acc.has_login_password is True
|
||||
assert acc.has_otp is False
|
||||
|
||||
row = session.get(Account, acc.id)
|
||||
assert row.login_password_encrypted is None # 唯一事实源在凭据表
|
||||
cred = session.get(Credential, acc.credential_id)
|
||||
assert cred.site == "阿里云" # 平台 slug → 显示名
|
||||
assert cred.username == "me@gmail.com"
|
||||
assert crypto.decrypt(cred.password_encrypted) == "pw-中文"
|
||||
|
||||
# 账号查看密码接口仍可用(重定向到凭据)
|
||||
revealed = account_service.reveal_password(session, acc.id)
|
||||
assert revealed["password"] == "pw-中文"
|
||||
assert revealed["login_user"] == "me@gmail.com"
|
||||
|
||||
|
||||
def test_account_create_without_password_no_credential(session):
|
||||
"""无密码账号不产生空凭据条目"""
|
||||
acc = account_service.create_account(session, AccountCreate(name="x@y.z", platform="aliyun"))
|
||||
assert acc.credential_id is None
|
||||
assert session.exec(select(Credential)).first() is None
|
||||
|
||||
|
||||
def test_account_update_syncs_credential(session):
|
||||
"""改名/换平台/改密码都同步到关联凭据"""
|
||||
acc = account_service.create_account(
|
||||
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw1")
|
||||
)
|
||||
# 改密码
|
||||
r = account_service.update_account(session, acc.id, AccountUpdate(login_password="pw2"))
|
||||
assert r.has_login_password is True
|
||||
assert account_service.reveal_password(session, acc.id)["password"] == "pw2"
|
||||
# 换平台 → site 同步(无匹配 provider 时回退 slug 原文)
|
||||
r = account_service.update_account(session, acc.id, AccountUpdate(platform="vultr"))
|
||||
cred = session.get(Credential, r.credential_id)
|
||||
assert cred.site == "vultr"
|
||||
# 清密码(空串)→ 凭据保留但无密码
|
||||
r = account_service.update_account(session, acc.id, AccountUpdate(login_password=""))
|
||||
assert r.has_login_password is False
|
||||
assert session.get(Credential, r.credential_id) is not None
|
||||
|
||||
|
||||
def test_account_otp_visible_in_read(session):
|
||||
"""账号 Read 暴露 has_otp,供前端在账号侧展示「获取验证码」"""
|
||||
acc = account_service.create_account(
|
||||
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
|
||||
)
|
||||
secret, code = _otp_pair()
|
||||
credential_service.bind_otp(session, acc.credential_id, OtpBindRequest(secret=secret, code=code))
|
||||
listed = {a.id: a for a in account_service.list_accounts(session)}
|
||||
assert listed[acc.id].has_otp is True
|
||||
assert listed[acc.id].has_login_password is True
|
||||
# 账号侧取动态码(走凭据)
|
||||
otp = credential_service.current_otp(session, acc.credential_id)
|
||||
assert totp.verify(secret, otp["code"])
|
||||
|
||||
|
||||
def test_account_delete_keeps_credential(session):
|
||||
"""删除账号不删凭据(密码库独立留存,仍可查/可复制)"""
|
||||
acc = account_service.create_account(
|
||||
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
|
||||
)
|
||||
cred_id = acc.credential_id
|
||||
account_service.delete_account(session, acc.id)
|
||||
|
||||
cred = session.get(Credential, cred_id)
|
||||
assert cred is not None
|
||||
assert crypto.decrypt(cred.password_encrypted) == "pw"
|
||||
# 凭据列表不再标记「来自平台账号」
|
||||
rows = credential_service.list_credentials(session)
|
||||
assert rows[0].account_id is None and rows[0].account_name is None
|
||||
|
||||
|
||||
def test_credential_list_marks_source_account(session):
|
||||
"""凭据列表反向标注来源账号(密码库中区分「平台账号」与游离条目)"""
|
||||
acc = account_service.create_account(
|
||||
session, AccountCreate(name="me@gmail.com", platform="aliyun", login_password="pw")
|
||||
)
|
||||
credential_service.create_credential(session, CredentialCreate(site="某论坛", username="a@b.c"))
|
||||
rows = {r.site: r for r in credential_service.list_credentials(session)}
|
||||
assert rows["阿里云"].account_id == acc.id
|
||||
assert rows["阿里云"].account_name == "me@gmail.com"
|
||||
assert rows["某论坛"].account_id is None
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Key Escrow(MASTER_KEY 密钥托管)测试
|
||||
|
||||
覆盖两层:
|
||||
- crypto 层:build_escrow/recover_master_key 往返、错误钥匙拒绝、格式校验
|
||||
- CLI 层:setup → 模拟 .env 丢失 key → recover --write 找回回写(§3.3 丢失演练)
|
||||
|
||||
运行:.venv/bin/pytest tests/test_escrow.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SETUP_SCRIPT = BASE_DIR / "scripts" / "setup_key_escrow.py"
|
||||
RECOVER_SCRIPT = BASE_DIR / "scripts" / "recover_master_key.py"
|
||||
|
||||
|
||||
# ----------------------------- crypto 层 ----------------------------- #
|
||||
def test_build_recover_roundtrip(monkeypatch):
|
||||
"""托管往返:build_escrow 后用同一把钥匙能还原 MASTER_KEY"""
|
||||
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
|
||||
restore_key = Fernet.generate_key().decode()
|
||||
escrow = crypto.build_escrow(restore_key)
|
||||
assert escrow.startswith(crypto.ESCROW_PREFIX)
|
||||
assert crypto.recover_master_key(restore_key, escrow) == settings.MASTER_KEY
|
||||
|
||||
|
||||
def test_recover_wrong_key_rejected(monkeypatch):
|
||||
"""错误恢复钥匙必须抛 ValueError(不能静默返回错误 key)"""
|
||||
monkeypatch.setattr(settings, "MASTER_KEY", Fernet.generate_key().decode())
|
||||
escrow = crypto.build_escrow(Fernet.generate_key().decode())
|
||||
with pytest.raises(ValueError):
|
||||
crypto.recover_master_key(Fernet.generate_key().decode(), escrow)
|
||||
|
||||
|
||||
def test_recover_invalid_prefix():
|
||||
"""缺 v1: 前缀视为格式无效"""
|
||||
with pytest.raises(ValueError):
|
||||
crypto.recover_master_key(Fernet.generate_key().decode(), "garbage-no-prefix")
|
||||
|
||||
|
||||
def test_build_escrow_requires_master_key(monkeypatch):
|
||||
"""MASTER_KEY 未配置时拒绝建立托管(避免生成解不开真实数据的废档)"""
|
||||
monkeypatch.setattr(settings, "MASTER_KEY", "")
|
||||
with pytest.raises(RuntimeError):
|
||||
crypto.build_escrow(Fernet.generate_key().decode())
|
||||
|
||||
|
||||
# ----------------------------- CLI 端到端 ----------------------------- #
|
||||
def _run_script(script: Path, env_extra: dict, args: list) -> subprocess.CompletedProcess:
|
||||
"""以子进程运行脚本(环境变量覆盖 .env:python-dotenv 不覆盖已存在变量)"""
|
||||
env = {**os.environ, **env_extra}
|
||||
return subprocess.run(
|
||||
[sys.executable, str(script), *args], capture_output=True, text=True, env=env
|
||||
)
|
||||
|
||||
|
||||
# Fernet key = base64url(32 字节) = 43 个 [A-Za-z0-9_-] 字符 + '='
|
||||
_FERNET_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{43}=$")
|
||||
|
||||
|
||||
def _extract_restore_key(stdout: str) -> str:
|
||||
"""从 setup 输出提取 RESTORE_KEY(44 字符 Fernet key,独立一行)"""
|
||||
for line in stdout.splitlines():
|
||||
s = line.strip()
|
||||
if _FERNET_KEY_RE.match(s):
|
||||
return s
|
||||
raise AssertionError("setup 输出未包含 RESTORE_KEY:\n" + stdout)
|
||||
|
||||
|
||||
def test_cli_loss_drill(tmp_path):
|
||||
"""丢失演练:托管建立 → .env 中 key 变为错误值 → recover --write 找回真实 key"""
|
||||
master_key = Fernet.generate_key().decode()
|
||||
escrow_path = tmp_path / "master_key.escrow"
|
||||
env_path = tmp_path / ".env"
|
||||
# 模拟丢失现场:.env 里是错误的 key,其它配置需原样保留
|
||||
env_path.write_text("API_KEY=abc\nMASTER_KEY=lost-wrong-key\n", encoding="utf-8")
|
||||
|
||||
# 1. 建立托管(MASTER_KEY 环境变量 = 正确 key)
|
||||
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
|
||||
assert r.returncode == 0, r.stderr
|
||||
assert escrow_path.exists()
|
||||
assert (escrow_path.stat().st_mode & 0o777) == 0o600
|
||||
restore_key = _extract_restore_key(r.stdout)
|
||||
|
||||
# 2. 回验模式:--key 校验托管可解且与当前 MASTER_KEY 一致
|
||||
r = _run_script(
|
||||
SETUP_SCRIPT, {"MASTER_KEY": master_key},
|
||||
["--key", restore_key, "--escrow", str(escrow_path)],
|
||||
)
|
||||
assert r.returncode == 0 and "通过" in r.stdout
|
||||
|
||||
# 3. 恢复:即使运行时环境里的 MASTER_KEY 是错的,也能凭 RESTORE_KEY 找回并回写 .env
|
||||
r = _run_script(
|
||||
RECOVER_SCRIPT, {"MASTER_KEY": "irrelevant", "RESTORE_KEY": restore_key},
|
||||
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
text = env_path.read_text(encoding="utf-8")
|
||||
assert f"MASTER_KEY={master_key}" in text
|
||||
assert "API_KEY=abc" in text # 其它配置行不动
|
||||
assert "lost-wrong-key" not in text
|
||||
assert (tmp_path / ".env.bak-pre-recover").exists() # 回写前已备份
|
||||
|
||||
# 4. 错误钥匙恢复必须失败(退出码 1,.env 不被污染)
|
||||
r = _run_script(
|
||||
RECOVER_SCRIPT, {"RESTORE_KEY": Fernet.generate_key().decode()},
|
||||
["--escrow", str(escrow_path), "--env", str(env_path), "--write"],
|
||||
)
|
||||
assert r.returncode == 1 and "失败" in (r.stdout + r.stderr)
|
||||
assert f"MASTER_KEY={master_key}" in env_path.read_text(encoding="utf-8")
|
||||
|
||||
# 5. 幂等:托管已存在时默认不重建
|
||||
r = _run_script(SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path)])
|
||||
assert r.returncode == 2 and "已存在" in r.stderr
|
||||
|
||||
# 6. --force 重建:新钥匙生效,旧钥匙作废
|
||||
r = _run_script(
|
||||
SETUP_SCRIPT, {"MASTER_KEY": master_key}, ["--escrow", str(escrow_path), "--force"]
|
||||
)
|
||||
assert r.returncode == 0
|
||||
new_key = _extract_restore_key(r.stdout)
|
||||
assert new_key != restore_key
|
||||
r = _run_script(
|
||||
RECOVER_SCRIPT, {"RESTORE_KEY": new_key}, ["--escrow", str(escrow_path)]
|
||||
)
|
||||
assert r.returncode == 0 and f"MASTER_KEY={master_key}" in r.stdout
|
||||
r = _run_script(
|
||||
RECOVER_SCRIPT, {"RESTORE_KEY": restore_key}, ["--escrow", str(escrow_path)]
|
||||
)
|
||||
assert r.returncode == 1 # 旧钥匙已作废
|
||||
@@ -0,0 +1,112 @@
|
||||
"""TOTP(RFC 6238)自实现测试
|
||||
|
||||
官方向量锚定正确性(RFC 6238 附录 B:SHA1、20 字节 ASCII secret
|
||||
"12345678901234567890"),覆盖:码生成、剩余秒数、录入校验窗口、
|
||||
otpauth URI 解析/重建、随机 secret 可用性。
|
||||
|
||||
运行:.venv/bin/pytest tests/test_totp.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core import totp
|
||||
|
||||
# RFC 6238 附录 B 官方向量 secret 的 base32 形式
|
||||
RFC_SECRET_B32 = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|
||||
# (时刻 T, 官方 8 位码);本实现为 6 位 = 8 位码取模 10^6 补零
|
||||
RFC_VECTORS = [
|
||||
(59, "94287082"),
|
||||
(1111111109, "07081804"),
|
||||
(1111111111, "14050471"),
|
||||
(1234567890, "89005924"),
|
||||
(2000000000, "69279037"),
|
||||
(20000000000, "65353130"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ts,code8", RFC_VECTORS)
|
||||
def test_rfc6238_official_vectors(ts, code8):
|
||||
"""逐条对齐 RFC 6238 附录 B 官方向量(6 位截断)"""
|
||||
code6, _ = totp.totp_at(RFC_SECRET_B32, ts=ts)
|
||||
assert code6 == str(int(code8) % 10**6).zfill(6)
|
||||
|
||||
|
||||
def test_expires_in():
|
||||
"""剩余秒数:T=59 窗口已过 29s 剩 1s;T=60 新窗口剩 30s"""
|
||||
_, left = totp.totp_at(RFC_SECRET_B32, ts=59)
|
||||
assert left == 1
|
||||
_, left = totp.totp_at(RFC_SECRET_B32, ts=60)
|
||||
assert left == 30
|
||||
|
||||
|
||||
def test_verify_window_tolerance():
|
||||
"""录入校验容忍 ±1 步进时钟偏差,隔两个窗口必须失败"""
|
||||
code, _ = totp.totp_at(RFC_SECRET_B32, ts=59) # counter=1
|
||||
assert totp.verify(RFC_SECRET_B32, code, ts=59)
|
||||
assert totp.verify(RFC_SECRET_B32, code, ts=61) # counter=2,窗口含 1
|
||||
assert totp.verify(RFC_SECRET_B32, code, ts=80) # counter=2
|
||||
assert not totp.verify(RFC_SECRET_B32, code, ts=150) # counter=5,超出窗口
|
||||
|
||||
|
||||
def test_verify_rejects_bad_input():
|
||||
assert not totp.verify(RFC_SECRET_B32, "12345") # 位数不对
|
||||
assert not totp.verify(RFC_SECRET_B32, "abcdef") # 非数字
|
||||
assert not totp.verify(RFC_SECRET_B32, "000000", ts=59) # 错误码
|
||||
assert not totp.verify(RFC_SECRET_B32, "", ts=59)
|
||||
|
||||
|
||||
def test_b32decode_tolerant():
|
||||
"""小写/空白/缺填充都应正确解码"""
|
||||
raw = totp.b32decode(RFC_SECRET_B32)
|
||||
assert totp.b32decode(RFC_SECRET_B32.lower()) == raw
|
||||
assert totp.b32decode("gezdg nbvgy 3tqoj qgezd gnbvg y3tqo jq") == raw
|
||||
assert totp.b32decode(RFC_SECRET_B32.rstrip("=")) == raw
|
||||
|
||||
|
||||
def test_parse_otpauth_uri_full():
|
||||
uri = (
|
||||
"otpauth://totp/GitHub:me@example.com"
|
||||
"?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1&period=30&digits=6"
|
||||
)
|
||||
p = totp.parse_otpauth_uri(uri)
|
||||
assert p["secret"] == "JBSWY3DPEHPK3PXP"
|
||||
assert p["issuer"] == "GitHub"
|
||||
assert p["account"] == "me@example.com"
|
||||
|
||||
|
||||
def test_parse_otpauth_uri_label_only():
|
||||
"""无 issuer 参数时从 Label 前缀提取,account 需 URL 解码"""
|
||||
p = totp.parse_otpauth_uri("otpauth://totp/Google:me%40gmail.com?secret=JBSWY3DPEHPK3PXP")
|
||||
assert p["issuer"] == "Google"
|
||||
assert p["account"] == "me@gmail.com"
|
||||
|
||||
|
||||
def test_parse_otpauth_uri_rejects():
|
||||
with pytest.raises(ValueError):
|
||||
totp.parse_otpauth_uri("https://example.com") # 非 otpauth 协议
|
||||
with pytest.raises(ValueError):
|
||||
totp.parse_otpauth_uri("otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP") # 非 TOTP
|
||||
with pytest.raises(ValueError):
|
||||
totp.parse_otpauth_uri("otpauth://totp/x?issuer=y") # 缺 secret
|
||||
with pytest.raises(ValueError):
|
||||
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&digits=8") # 非 6 位
|
||||
with pytest.raises(ValueError):
|
||||
totp.parse_otpauth_uri("otpauth://totp/x?secret=JBSWY3DPEHPK3PXP&period=60") # 非 30s
|
||||
|
||||
|
||||
def test_build_otpauth_uri_roundtrip():
|
||||
"""重建的 URI 可被解析回同样的 secret/issuer/account(换机导出场景)"""
|
||||
uri = totp.build_otpauth_uri("JBSWY3DPEHPK3PXP", "GitHub", "me@example.com")
|
||||
p = totp.parse_otpauth_uri(uri)
|
||||
assert p["secret"] == "JBSWY3DPEHPK3PXP"
|
||||
assert p["issuer"] == "GitHub"
|
||||
assert p["account"] == "me@example.com"
|
||||
|
||||
|
||||
def test_random_secret_usable():
|
||||
"""随机 secret 生成的码应能通过自身校验(录入闭环)"""
|
||||
s = totp.random_secret()
|
||||
code, left = totp.totp_at(s)
|
||||
assert len(code) == 6 and code.isdigit()
|
||||
assert 1 <= left <= 30
|
||||
assert totp.verify(s, code)
|
||||
Reference in New Issue
Block a user