Files
live-hub-py/web/backend/services/file_storage.py
T
yml2213 df8c63b829 feat(huya): 外部 CK 协作台账(S1–S6)+ 图片上传能力
协作台账:新表 huya_external_orders 替代 Excel 多人协同表(设计见
docs/设计-虎牙外部CK协作台账.md),一条 CK 一行,所有人共享、改动留痕。

- 前端 pages/HuyaCollabPage.tsx + 菜单「虎牙 → 外部单协作」/huya/collab:
  内联编辑(带乐观锁 version)、图片缩略图、详情抽屉(字段表单 + 两个图片槽位 +
  操作时间线)、粘贴导入弹窗(带 TSV 预览)、批量/单条验证 CK、页头统计条、筛选
- 两套状态机:业务 pending→bound→recharged→scripted→done(旁路 void)、
  凭据 unverified/valid/invalid/disabled;唯一写入口在 services/huya_collab_service.py,
  字段级审计 + 整数 version 乐观锁(不用 updated_at:DATETIME 只到秒)
- CK 只读探针 check_user_bind_game_account(真机判据:响应非 None 且 status == 200),
  成功即覆盖写绑定快照 bind_game_account/bind_game_role/bind_game_channel(迁移 0056),
  失败只改凭据状态、不动业务状态;人工只能停用/恢复(POST /{id}/ck/status)
- 贴/换/重建 CK、TSV 批量导入(SAVEPOINT 单行隔离,部分失败不整体失败)、
  权限 huya:collab:view/edit/manage(删除只给运营)
- 孤儿图清理脚本 scripts/cleanup_orphan_file_assets.py(默认 --dry-run)

图片上传(协作台账截图用):OSS/本地双后端 + 缩略图变体 + /api/files 代理回读,
权限 file:upload / file:view。

验证:pytest 880 passed / 6 skipped;ruff format 全绿、ruff check 仅剩既有告警;
前端 tsc -b / eslint / vite build 通过;接口端到端冒烟 29 项全绿(TestClient,探针用假客户端);
探针真机冒烟 3 例(有效 CK=True、别人 uid=False、裸 cookie=False)。
2026-10-16 19:38:59 +08:00

435 lines
15 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.
"""对象存储适配层:本地目录后端 + S3 兼容对象存储(阿里云 OSS / MinIO)。
约定对齐两个参考实现(order_site 的 ``apps/backend/src/services/file-storage/*``、
hfb_sys 的 ``backend/internal/modules/file/storage.go``):
- **服务端代理读写**:对外只暴露 ``/api/files/object?key=...``,不下发 OSS 直链,
也不发签名链接 —— 签名链接会被业务方原样存进库里,过期后到处裂图;
- **内网 endpoint**:ECS 与桶同地域时用 ``oss-<region>-internal.aliyuncs.com``,
免公网流量费且延迟低;
- **寻址风格**:阿里云 OSS 必须 virtual-host style,自建 MinIO 走 path-style;
- **S3 校验头**:OSS 的 S3 兼容层不认 botocore 默认的 CRC32 请求校验头,需要关掉。
boto3 只在真正走 OSS 时才导入:本地开发/测试用 ``local`` 后端,没装 boto3 也能跑。
"""
from __future__ import annotations
import hashlib
import io
import os
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, BinaryIO, Protocol
from urllib.parse import quote, urlsplit
from loguru import logger
PROJECT_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_LOCAL_ROOT = PROJECT_ROOT / "data" / "file_storage"
MAX_UPLOAD_SIZE_MB_DEFAULT = 10
class FileStorageError(RuntimeError):
"""对象存储不可用(配置缺失、依赖缺失或远端拒绝)。"""
class StorageUnavailableError(FileStorageError):
"""存储后端没有配置好或连不上,属于「暂时不可用」而非「文件不存在」。"""
@dataclass(frozen=True)
class StorageSettings:
"""从环境变量读到的存储配置。"""
mode: str
endpoint: str
bucket: str
access_key_id: str
secret_access_key: str
region: str
local_root: Path
max_upload_size_mb: int
@property
def is_remote(self) -> bool:
return self.mode == "oss"
@dataclass(frozen=True)
class StoredObject:
"""读取到的对象:调用方负责关闭 ``reader``。"""
reader: BinaryIO
content_type: str
size: int
etag: str = ""
last_modified: datetime | None = None
class ObjectStorage(Protocol):
"""存储后端接口。缺 key 一律抛 ``FileNotFoundError``。"""
def put_object(
self,
*,
key: str,
data: bytes,
content_type: str,
metadata: dict[str, str] | None = None,
) -> None: ...
def get_object(self, key: str) -> StoredObject: ...
def delete_object(self, key: str) -> None: ...
def ensure_ready(self) -> None: ...
def _env_int(name: str, default: int) -> int:
"""读取正整数环境变量,非法值回退到默认值。"""
try:
value = int(os.getenv(name, str(default)))
except ValueError:
return default
return value if value > 0 else default
def load_storage_settings() -> StorageSettings:
"""读取 ``OSS_*`` 环境变量。未知 mode 一律回退到 local。
变量名沿用仓库既有约定(compose 早已透传、生产 ``.env`` 里也已写入):
``OSS_ENDPOINT`` 用内网端点,``OSS_BUCKET`` 是本项目自己的桶。
"""
raw_mode = (os.getenv("OSS_MODE", "") or "").strip().lower()
mode = raw_mode if raw_mode in ("local", "oss") else "local"
endpoint = (os.getenv("OSS_ENDPOINT", "") or "").strip()
local_root = (os.getenv("OSS_LOCAL_ROOT", "") or "").strip()
region = (os.getenv("OSS_REGION", "") or "").strip() or _derive_region(endpoint)
return StorageSettings(
mode=mode,
endpoint=endpoint,
bucket=(os.getenv("OSS_BUCKET", "") or "").strip(),
access_key_id=(os.getenv("OSS_ACCESS_KEY_ID", "") or "").strip(),
secret_access_key=(os.getenv("OSS_ACCESS_KEY_SECRET", "") or "").strip(),
region=region,
local_root=Path(local_root) if local_root else DEFAULT_LOCAL_ROOT,
max_upload_size_mb=_env_int(
"OSS_MAX_UPLOAD_SIZE_MB", MAX_UPLOAD_SIZE_MB_DEFAULT
),
)
def _derive_region(endpoint: str) -> str:
"""从 endpoint 反推 region。
``oss-cn-hangzhou-internal.aliyuncs.com`` → ``oss-cn-hangzhou``。
生产 ``.env`` 只要求配 endpoint/bucket/AK 三项,但 sigv4 需要 region,
所以没显式配 ``OSS_REGION`` 时按端点推导,省一个必填项、也避免填错。
"""
if not endpoint:
return ""
candidate = endpoint if "//" in endpoint else f"//{endpoint}"
host = urlsplit(candidate).hostname or ""
label = host.split(".")[0]
if label.startswith("oss-") and label.endswith("-internal"):
return label[: -len("-internal")]
return label if label.startswith("oss-") else ""
def max_upload_size_bytes(settings: StorageSettings | None = None) -> int:
"""上传大小上限(字节)。"""
resolved = settings or load_storage_settings()
return max(1, resolved.max_upload_size_mb) * 1024 * 1024
def normalize_object_key(raw_key: Any) -> str:
"""归一化对象 key 并挡住路径穿越。
与 order_site 的 ``normalizeObjectKey`` 同一套判据:去掉前导斜杠,
出现 ``..`` 或反斜杠直接拒绝。
"""
key = str(raw_key or "").strip().lstrip("/")
if not key or ".." in key or "\\" in key:
raise ValueError("文件 key 不正确")
return key
class LocalObjectStorage:
"""本地目录后端:仅用于开发与测试,生产走 OSS。
内容类型按扩展名推断(本地模式下原图与变体的扩展名都由上传流程按 MIME 生成,
因此推断是可靠的);etag 用内容 md5,便于回读接口做 304。
"""
def __init__(self, root: Path):
self._root = Path(root)
@property
def root(self) -> Path:
return self._root
def _path_for(self, key: str) -> Path:
return self._root / normalize_object_key(key)
def ensure_ready(self) -> None:
self._root.mkdir(parents=True, exist_ok=True)
def put_object(
self,
*,
key: str,
data: bytes,
content_type: str,
metadata: dict[str, str] | None = None,
) -> None:
del content_type, metadata # 本地后端不保存对象元数据
path = self._path_for(key)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
def get_object(self, key: str) -> StoredObject:
path = self._path_for(key)
try:
data = path.read_bytes()
except FileNotFoundError as exc:
raise FileNotFoundError(key) from exc
return StoredObject(
reader=io.BytesIO(data),
content_type=_guess_content_type(path.name),
size=len(data),
etag=f'"{hashlib.md5(data).hexdigest()}"',
last_modified=datetime.fromtimestamp(path.stat().st_mtime, UTC),
)
def delete_object(self, key: str) -> None:
path = self._path_for(key)
try:
path.unlink()
except FileNotFoundError:
return
class S3ObjectStorage:
"""S3 兼容后端(阿里云 OSS / MinIO)。boto3 延迟导入。"""
def __init__(self, settings: StorageSettings):
self._settings = settings
self._client: Any = None
self._bucket_ready = False
self._lock = threading.Lock()
@property
def is_aliyun(self) -> bool:
return "aliyuncs.com" in self._settings.endpoint.lower()
def _build_client(self) -> Any:
settings = self._settings
if not (settings.endpoint and settings.bucket):
raise StorageUnavailableError(
"对象存储未配置:需要同时设置 OSS_ENDPOINT 与 OSS_BUCKET"
)
try:
import boto3
except ImportError as exc: # pragma: no cover - 取决于部署环境
raise StorageUnavailableError(
"缺少 boto3 依赖,无法使用 OSS 存储(执行 uv sync 安装后重启服务)"
) from exc
from botocore.config import Config as BotoConfig
addons: dict[str, Any] = {}
try:
# 新版 botocore 才支持这两个开关;旧版直接省略。
addons = {
"request_checksum_calculation": "when_required",
"response_checksum_validation": "when_required",
}
config = BotoConfig(
signature_version="s3v4",
s3={"addressing_style": "virtual" if self.is_aliyun else "path"},
retries={"max_attempts": 3, "mode": "standard"},
connect_timeout=_env_int("OSS_CONNECT_TIMEOUT", 5),
read_timeout=_env_int("OSS_READ_TIMEOUT", 30),
max_pool_connections=_env_int("OSS_MAX_POOL_CONNECTIONS", 20),
**addons,
)
except TypeError: # pragma: no cover - 仅旧版 botocore 走到
config = BotoConfig(
signature_version="s3v4",
s3={"addressing_style": "virtual" if self.is_aliyun else "path"},
)
return boto3.client(
"s3",
endpoint_url=_normalize_endpoint_url(settings.endpoint, self.is_aliyun),
aws_access_key_id=settings.access_key_id or None,
aws_secret_access_key=settings.secret_access_key or None,
region_name=settings.region or None,
config=config,
)
def _get_client(self) -> Any:
if self._client is None:
with self._lock:
if self._client is None:
self._client = self._build_client()
return self._client
def ensure_ready(self) -> None:
"""确保桶可用。
阿里云 OSS 的桶由控制台预先创建 —— HeadBucket 在 RAM 权限收紧时会 403
甚至挂起,所以直接跳过;自建 MinIO 才做「不存在就建」。
"""
if self._bucket_ready or self.is_aliyun:
self._bucket_ready = True
return
client = self._get_client()
with self._lock:
if self._bucket_ready:
return
try:
client.head_bucket(Bucket=self._settings.bucket)
except Exception as exc: # 兼容层错误码不规范,只能按形状兜底
if not _is_not_found(exc):
raise
client.create_bucket(Bucket=self._settings.bucket)
self._bucket_ready = True
def put_object(
self,
*,
key: str,
data: bytes,
content_type: str,
metadata: dict[str, str] | None = None,
) -> None:
self.ensure_ready()
try:
self._get_client().put_object(
Bucket=self._settings.bucket,
Key=normalize_object_key(key),
Body=data,
ContentType=content_type or "application/octet-stream",
**({"Metadata": _sanitize_metadata(metadata)} if metadata else {}),
)
except Exception as exc: # 边界统一转成存储异常
raise StorageUnavailableError(f"对象写入失败: {exc}") from exc
def get_object(self, key: str) -> StoredObject:
self.ensure_ready()
try:
output = self._get_client().get_object(
Bucket=self._settings.bucket, Key=normalize_object_key(key)
)
except Exception as exc: # 边界统一转成存储异常
if _is_not_found(exc):
raise FileNotFoundError(key) from exc
raise StorageUnavailableError(f"对象读取失败: {exc}") from exc
body = output.get("Body")
if body is None:
raise FileNotFoundError(key)
return StoredObject(
reader=body,
content_type=str(output.get("ContentType") or "application/octet-stream"),
size=int(output.get("ContentLength") or 0),
etag=str(output.get("ETag") or ""),
last_modified=output.get("LastModified"),
)
def delete_object(self, key: str) -> None:
self.ensure_ready()
try:
self._get_client().delete_object(
Bucket=self._settings.bucket, Key=normalize_object_key(key)
)
except Exception as exc: # noqa: BLE001 删除失败不该打断业务异常链
logger.warning(f"对象删除失败 key={key}: {exc}")
_storage: ObjectStorage | None = None
_storage_lock = threading.Lock()
def _build_storage(settings: StorageSettings) -> ObjectStorage:
"""按配置构造后端:oss → S3 兼容,其余 → 本地目录。"""
if settings.is_remote:
return S3ObjectStorage(settings)
return LocalObjectStorage(settings.local_root)
def get_object_storage(settings: StorageSettings | None = None) -> ObjectStorage:
"""返回当前配置对应的存储后端;显式传 settings 时不走单例(测试用)。"""
global _storage
if settings is not None:
return _build_storage(settings)
if _storage is None:
with _storage_lock:
if _storage is None:
_storage = _build_storage(load_storage_settings())
return _storage
def reset_object_storage_cache() -> None:
"""清空单例 —— 测试里切换 ``STORAGE_*`` 环境变量后调用。"""
global _storage
with _storage_lock:
_storage = None
def _guess_content_type(filename: str) -> str:
lowered = filename.lower()
if lowered.endswith((".jpg", ".jpeg")):
return "image/jpeg"
if lowered.endswith(".png"):
return "image/png"
if lowered.endswith(".webp"):
return "image/webp"
if lowered.endswith(".pdf"):
return "application/pdf"
return "application/octet-stream"
def _normalize_endpoint_url(endpoint: str, is_aliyun: bool) -> str:
"""补全 scheme:阿里云走 https,自建 MinIO 走 http。"""
candidate = endpoint.strip()
if not candidate:
raise StorageUnavailableError("对象存储 endpoint 未配置")
if not candidate.startswith(("http://", "https://")):
candidate = f"{'https' if is_aliyun else 'http'}://{candidate}"
parsed = urlsplit(candidate)
if not parsed.hostname:
raise StorageUnavailableError(f"对象存储 endpoint 不合法: {endpoint}")
return candidate.rstrip("/")
def _sanitize_metadata(metadata: dict[str, str] | None) -> dict[str, str]:
"""元数据会作为 HTTP header 下发,值统一转义为 ASCII(中文文件名否则会 400)。"""
if not metadata:
return {}
sanitized: dict[str, str] = {}
for raw_key, raw_value in metadata.items():
key = str(raw_key or "").strip()
if not key:
continue
sanitized[key] = quote(str(raw_value or ""), safe="")
return sanitized
def _is_not_found(error: Exception) -> bool:
"""把各兼容层形状不一的不存在错误统一识别出来。"""
code = getattr(error, "response", None)
if isinstance(code, dict):
error_code = str((code.get("Error") or {}).get("Code") or "")
status = (code.get("ResponseMetadata") or {}).get("HTTPStatusCode")
if error_code in ("404", "NoSuchKey", "NoSuchBucket", "NotFound"):
return True
if status == 404:
return True
return getattr(error, "name", "") in ("NotFound", "NoSuchKey")