Files
vps-manager/app/services/account_service.py
T
gouki f09c0f6e4b fix(review): 处理全量审查剩余项(M4-M8/M11/M12 + 21 条 low)
后端
- asset_service: delete_asset 先清 subdomains/site_certs 子表(SQLite 不强制
  FK 且 assets.id 无 AUTOINCREMENT,删最大 id 后新建会复用 rowid 导致孤儿误挂);
  metrics.db 清理改 best-effort,不再让已成功的删除返回 500
- provider_service: slug 唯一约束竞态补 IntegrityError 兜底(与 account_service 一致)
- account_service/sync_service: raise HTTPException 补 from e,保留原始异常链
- sync_service: 删死代码 _load_config;新增 BalanceUnsupported,平台无余额接口时
  计入 skipped 而不是静默当作失败;到期日写库前归一
- ai: 删掉 OpenAI 早已下线的 billing 余额接口实现,能力探测如实报「不支持」
- adapters/base: _overridden 改走 MRO,中间层基类实现的能力不再被误判为未实现
- core/timeutils: 删死代码 utcnow_iso(注释声称 Z 后缀,实际输出 +00:00)
- main: lifespan 退出时 await 被 cancel 的清理任务,避免 pending task 被销毁
- ssl_service: 写 site_certs/subdomains 前校验 asset_id 存在,杜绝悬空引用;
  到期阈值贯穿 _judge_status/_apply_probe/check_one/check_all_site_certs
- routers/credentials、accounts: 明文密码 GET 补 Cache-Control: no-store
- routers/stats: /stats/expiring 的 Query 描述与实际语义对齐(不设下界,含已过期)

Agent
- collector: 解析 sshd_config 的 Include(含 sshd_config.d/*.conf,按 sshd 语义
  「首次出现的值生效」),未显式配置的关键字按编译默认值判定,消除假 pass;
  防火墙检测改用 iptables -S 统计 -A 规则行并读 INPUT 策略(-L -n 恒有链头输出)
- reporter: 4xx(除 429)不再重试直接退出,429 优先按 Retry-After 退避

安全与部署
- core/security: require_agent_key 改为对外 fail-closed——tailnet/本机放行,
  外部来源未配 AGENT_KEY 时一律 401(此前全放行,而服务默认监听 0.0.0.0,
  公网上任何人可伪造监控指标与安全检查结果)
- main: 启动时把「未配 API_KEY/AGENT_KEY 则外部来源不可用」打进日志
- run.sh: HOST/PORT 可用环境变量覆盖,并说明默认 0.0.0.0 的权衡
- deploy/setup.sh: 新装时自动生成随机 API_KEY/AGENT_KEY 并提示同步到各 VPS;
  注册 vps-cert-check.timer
- deploy/vps-cert-check.{service,timer}: 新增每日 09:30 的站点证书到期探测
  (此前 check_site_certs.py 没有任何 unit,证书提醒不会自动跑)
- deploy/update.sh: sync_units 对 timer 用 enable --now(新增 timer 只 restart
  不会建开机自启软链),主服务 unit 变更后额外 restart;pip 安装失败不再被吞
- scripts/check_site_certs.py: threshold 参数真正生效并打印
- scripts/make_migration_bundle.py: 缺 assets.db 直接 abort;umask 077 + chmod 600
  (包内含 .env 的 MASTER_KEY 与 escrow,不能默认 644 落在 /tmp)
- scripts/restore_migration_bundle.py: 校验之外补必备文件预检,缺文件在触碰
  目标机状态之前就 abort

前端
- store: 拆分列表口径 assets 与统计口径 allAssets(无筛选时同源,不重复请求);
  删除已无消费者的 expiring/loadExpiring(每次 loadAll 白发一个请求)
- dashboard/monitor/providers/subscriptions/modals: 统计与计数改用 allAssets,
  资产页的筛选(含统计卡片点击跳转带的筛选)不再让其他视图的数字缩水
- modals: 账号同步后补 loadOverview;settings: 导入后改 loadAll
- api: countdownText 支持 unit/empty 选项,monitor/domains 各自的实现改为委托,
  assets/domains/subscriptions 的到期列不再直出「(-3天)」
- servers: cpu/mem/disk 百分比统一判空,采样缺项不再渲染「null%」
- assets: 同步 AI 余额的结果提示区分 skipped(平台无余额接口)与 failed
- modals: 修正凭据库头注释——2FA 动态码是选填 + 可事后补验,与实现一致

测试
- 新增 tests/test_agent_collector.py(8 条)、tests/test_security.py(11 条)
- 全量 113 passed;改动 JS 均通过 node --check,deploy 脚本通过 bash -n
- 浏览器实测:筛选态下总览/监控中心/平台页/付费资产的统计仍为全量口径,
  到期文案与 null 百分比渲染正确,console 零报错
2026-09-23 10:15:52 +00:00

190 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""平台账号业务逻辑
账号与资产的关系: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.otp_verified = bool(cred and cred.otp_verified_at)
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 as e:
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
) from e
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 as e:
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{account.name}",
) from e
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}