diff --git a/app/api/routers/rbac_admin.py b/app/api/routers/rbac_admin.py index 98789a9..abb69b4 100644 --- a/app/api/routers/rbac_admin.py +++ b/app/api/routers/rbac_admin.py @@ -57,7 +57,12 @@ async def list_users( _role: dict = Depends(require_roles("operator")), _perm: dict = Depends(require_permission("menu:admin_user_mgmt")), ): - return await db.users.list() + from ...infrastructure.oss import resolve_url + items = await db.users.list() + for u in items: # 出口规范:头像对象路径统一生成 CDN 直链 + u["avatar"] = resolve_url(u.get("avatar", "")) + u["company_avatar"] = resolve_url(u.get("company_avatar", "")) + return items @router.post("/users/{user_id}/role", summary="分配角色") diff --git a/app/config.py b/app/config.py index a886ed1..e9b4e39 100644 --- a/app/config.py +++ b/app/config.py @@ -57,6 +57,11 @@ OSS_ENDPOINT = os.environ.get("OSS_ENDPOINT", "") OSS_ACCESS_KEY = os.environ.get("OSS_ACCESS_KEY", "") OSS_SECRET_KEY = os.environ.get("OSS_SECRET_KEY", "") OSS_BUCKET = os.environ.get("OSS_BUCKET", "opc-files") +OSS_REGION = os.environ.get("OSS_REGION", "cn-heyuan") +# CDN 加速域名(如 https://opcfiles.pinesound.cn);配置后下载统一走 CDN,空则退回预签名直链 +OSS_CDN_BASE_URL = os.environ.get("OSS_CDN_BASE_URL", "").rstrip("/") +# CDN URL 鉴权密钥(鉴权方式C;须与阿里云 CDN 控制台主密钥一致),空则生成不带鉴权的 CDN 链接 +OSS_CDN_AUTH_KEY = os.environ.get("OSS_CDN_AUTH_KEY", "") USERS_FILE = DATA_DIR / "users.json" diff --git a/app/infrastructure/oss.py b/app/infrastructure/oss.py index 23872f4..926f4c7 100644 --- a/app/infrastructure/oss.py +++ b/app/infrastructure/oss.py @@ -1,63 +1,221 @@ # -*- coding: utf-8 -*- -"""基础设施层 · OSS 对象存储异步封装(aioboto3,兼容阿里云 OSS / MinIO)。 +"""基础设施层 · OSS 对象存储统一封装(aioboto3,兼容阿里云 OSS / MinIO / S3)。 -未配置 OSS 时降级为本地上传目录(uploads/),保证无 OSS 也可用。 +全项目所有文件上传/下载的唯一入口,供各端口(培训/园区/运营端/平台)调用: + - upload() 服务端中转上传(表单 multipart 走后端) + - presigned_put() 生成临时上传链接(前端直传,绕过后端带宽) + - presigned_get() 生成临时下载链接 + - object_url() 对象的稳定相对路径 /oss/(入库用;经下载路由跳转预签名 URL) + +未配置 OSS_ENDPOINT 时降级本地 serverdata/uploads,保证开发环境无 OSS 也可用。 """ from __future__ import annotations -import os -from pathlib import Path from typing import Optional from .. import config class OSS: - """对象存储:上传文件 / 生成访问 URL。配置了 OSS_ENDPOINT 才启用,否则落本地上传目录。""" + """对象存储:上传 / 预签名上下行 URL。配置了 OSS_ENDPOINT 才启用,否则落本地上传目录。""" def __init__(self) -> None: self._client: Optional[object] = None self.enabled = bool(config.OSS_ENDPOINT) self.bucket = config.OSS_BUCKET - self.local_dir = Path(__file__).resolve().parent.parent.parent / "uploads" + self.local_dir = config.SERVERDATA_DIR / "uploads" self.local_dir.mkdir(parents=True, exist_ok=True) async def _get_client(self): if self._client is None: import aioboto3 + from botocore.config import Config session = aioboto3.Session( aws_access_key_id=config.OSS_ACCESS_KEY, aws_secret_access_key=config.OSS_SECRET_KEY, ) - self._client = session.client( + # aioboto3 的 client 是异步上下文管理器,需 enter 后长期复用; + # 阿里云 OSS 要求 virtual-hosted style;不支持 PutObject CRC32 trailer(botocore 新版默认) + self._client = await session.client( "s3", endpoint_url=config.OSS_ENDPOINT, - region_name="oss-cn-hangzhou", - ) + region_name=config.OSS_REGION or "cn-heyuan", + config=Config( + s3={ + "addressing_style": "virtual", + "payload_signing_enabled": True, + }, + signature_version="s3v4", + request_checksum_calculation="when_required", + response_checksum_validation="when_required", + ), + ).__aenter__() return self._client + def clean_key(self, key: str) -> str: + """规整对象 key:去首尾斜杠、拒目录穿越。""" + key = (key or "").strip().strip("/") + if not key or ".." in key.split("/"): + raise ValueError(f"非法对象 key:{key!r}") + return key + + # ---- 上传 ---- + async def upload(self, key: str, data: bytes, content_type: str = "application/octet-stream") -> str: - """上传对象,返回可访问的相对 URL。""" + """服务端上传对象,成功返回稳定相对路径 /oss/(入库/回传客户端统一用这个)。""" + key = self.clean_key(key) if self.enabled: client = await self._get_client() await client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type) return f"/oss/{key}" - # 本地降级 dest = self.local_dir / key dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) return f"/uploads/{key}" - async def presigned_url(self, key: str, expires: int = 3600) -> str: - """生成临时访问 URL(OSS 启用时)。""" + # ---- 下载 / 访问 ---- + + def object_url(self, key: str) -> str: + """对象的稳定相对路径(下载经 GET /oss/ 跳转预签名 URL,桶私有也可读)。""" + return f"/oss/{self.clean_key(key)}" + + async def presigned_get(self, key: str, expires: int = 3600) -> str: + """临时下载链接(预签名 GET,走 OSS 源站)。""" + key = self.clean_key(key) + if not self.enabled: + return f"/uploads/{key}" + client = await self._get_client() + return await client.generate_presigned_url( + "get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=expires + ) + + async def download_url(self, key: str, expires: int = 3600) -> str: + """统一下载链接:配置了 CDN 加速域名则走 CDN(带鉴权),否则退回预签名直链。""" + if config.OSS_CDN_BASE_URL: + return self.cdn_url(key) + return await self.presigned_get(key, expires) + + # ---- CDN URL 鉴权(阿里云「鉴权方式A」)---- + + # 「永久有效」实现:鉴权时间戳取 32 位最大值(2038-01),叠加控制台有效期内长期可访问 + _CDN_TS_MAX = "7FFFFFFF" # 方式A 要求大写 hex + + def _cdn_sign(self, key: str, ts_hex: str) -> str: + """鉴权方式A:md5(密钥 + URI + timestamp),URI 以 / 开头,时间戳大写 hex。""" + import hashlib + + uri = f"/{key}" + return hashlib.md5(f"{config.OSS_CDN_AUTH_KEY}{uri}{ts_hex}".encode()).hexdigest() + + def cdn_url(self, key: str) -> str: + """CDN 鉴权直链(方式A):{CDN}/{md5签名}/{时间戳}/{key};未配密钥则裸链接。""" + key = self.clean_key(key) + base = config.OSS_CDN_BASE_URL.rstrip("/") + if not config.OSS_CDN_AUTH_KEY: + return f"{base}/{key}" + ts_hex = self._CDN_TS_MAX + return f"{base}/{self._cdn_sign(key, ts_hex)}/{ts_hex}/{key}" + + def direct_url(self, key: str) -> str: + """直链(不经服务端代理,直接返回给前端): + CDN 加速域名(带鉴权)> OSS 公网直链 > 本地 /uploads。""" + key = self.clean_key(key) + if config.OSS_CDN_BASE_URL: + return self.cdn_url(key) if self.enabled: - client = await self._get_client() - return await client.generate_presigned_url( - "get_object", Params={"Bucket": self.bucket, "Key": key}, ExpiresIn=expires - ) + return self.public_url(key) return f"/uploads/{key}" - @staticmethod - def is_configured() -> bool: - return bool(config.OSS_ENDPOINT) + async def presigned_put(self, key: str, expires: int = 600, content_type: str = "application/octet-stream") -> str: + """临时上传链接(预签名 PUT,供前端直传)。""" + key = self.clean_key(key) + if not self.enabled: + raise RuntimeError("OSS 未配置,无法生成直传链接") + client = await self._get_client() + return await client.generate_presigned_url( + "put_object", + Params={"Bucket": self.bucket, "Key": key, "ContentType": content_type}, + ExpiresIn=expires, + ) + + def public_url(self, key: str) -> str: + """对象的公网直链(仅公共读桶可直接访问;私有桶 403)。""" + endpoint = (config.OSS_ENDPOINT or "").rstrip("/") + return f"{endpoint}/{self.bucket}/{self.clean_key(key)}" + + +# 模块级单例:全项目统一通过 `from ..infrastructure.oss import oss` 使用 +oss = OSS() + + +def resolve_url(u: str | None) -> str: + """资源 URL 统一出口(读取时把对象路径生成为可访问 URL): + 1) 对象路径(/oss/、/uploads/)补全为站点公开绝对 URL; + 2) 本站 CDN 裸链(历史入库的无鉴权链接)自动重签为鉴权直链; + 3) 其余完整 URL 原样返回。""" + import os as _os + + if not u: + return "" + u = str(u).strip() + base = _os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/") + if not (u.startswith("http://") or u.startswith("https://")): + if u.startswith("/oss/"): + # OSS 对象路径 → 直接生成 CDN 鉴权直链(未启用 OSS 时回退站点路径) + if oss.enabled and config.OSS_CDN_BASE_URL: + try: + return oss.cdn_url(u[len("/oss/"):]) + except ValueError: + return u + return base + u + return base + ("" if u.startswith("/") else "/") + u + # 历史 CDN 裸链(无鉴权段)→ 提取 key 重签(鉴权上线前的存量数据) + cdn = config.OSS_CDN_BASE_URL + if cdn and config.OSS_CDN_AUTH_KEY and u.startswith(f"{cdn}/") and "/7FFFFFFF/" not in u: + key = u[len(cdn) + 1:].split("?")[0] + try: + return oss.cdn_url(key) + except ValueError: + return u + return u + + +# 本站资源 URL 前缀(用于入库归一化:完整 URL → 对象路径) +_OSS_PUBLIC_PREFIXES = ( + lambda: (config.OSS_CDN_BASE_URL or "") + "/", + lambda: (config.OSS_ENDPOINT or "").rstrip("/") + "/" + config.OSS_BUCKET + "/", +) + + +def to_object_path(u: str | None) -> str: + """入库归一化(写入规范):本站资源完整 URL → 对象相对路径(/oss/、/uploads/)。 + + 换 OSS/CDN 供应商时只改本层配置,数据库无需迁移。 + 外部 URL(如微信头像)与本站已有相对路径原样保留。 + """ + import os as _os + + if not u: + return "" + u = str(u).strip() + if not (u.startswith("http://") or u.startswith("https://")): + return u # 已是路径/外链原样 + for prefix_fn in _OSS_PUBLIC_PREFIXES: + prefix = prefix_fn() + if prefix and u.startswith(prefix): + rest = u[len(prefix):].split("?")[0].strip("/") + parts = rest.split("/") + # CDN 鉴权方式A:{sign}/{timestamp}/{key}——剥掉前两段 + if len(parts) >= 3 and parts[1].upper() == "7FFFFFFF": + rest = "/".join(parts[2:]) + if ".." in rest.split("/"): + return u + return f"/oss/{rest}" + # 站点自身域名下的 /oss、/uploads 路径 → 还原为相对路径 + base = _os.environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/") + if u.startswith(base + "/"): + rest = u[len(base) + 1:].split("?")[0] + if rest.startswith(("oss/", "uploads/")): + return "/" + rest + return u # 外部 URL 原样(如微信头像) diff --git a/app/services/media_upload.py b/app/services/media_upload.py index 0e15acc..48ac111 100644 --- a/app/services/media_upload.py +++ b/app/services/media_upload.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -"""媒体上传(图片/pdf/doc 等)—— OSS 优先(OSS_ENDPOINT 配置后走桶),否则落 serverdata/uploads。 +"""媒体上传(图片/pdf/doc 等)—— 统一走基础设施层 OSS(infrastructure.oss.oss)。 -统一返回契约:绝对 URL。编辑器图片/视频/封面、C端资料上传共用。 +OSS 已配置时上传到桶并返回 /oss/ 的绝对 URL(下载经 /oss 路由跳预签名); +未配置时降级本地 serverdata/uploads。编辑器图片/视频/封面、C端资料上传共用。 """ from __future__ import annotations @@ -12,12 +13,11 @@ from pathlib import Path from fastapi import HTTPException, UploadFile from .. import config -# server-data 上传目录(server-core 根下 serverdata/uploads,本地降级用) -SERVER_CORE_DIR = Path(__file__).resolve().parents[2] -UPLOAD_DIR = SERVER_CORE_DIR / "serverdata" / "uploads" +# server-data 上传目录(本地降级用,与 infrastructure.oss 同一目录) +UPLOAD_DIR = config.SERVERDATA_DIR / "uploads" UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -# 站点公开基础地址(把 /uploads 相对路径补全为完整 HTTPS) +# 站点公开基础地址(把 /oss、/uploads 相对路径补全为完整 HTTPS) PUBLIC_BASE = __import__("os").environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/") ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf", ".doc", ".docx"} @@ -33,23 +33,19 @@ _MIME = { def abs_url(u: str | None) -> str: - """相对路径补全为绝对 URL;已是完整 URL 或空则原样返回。""" - if not u: - return u or "" - u = str(u).strip() - if u.startswith("http://") or u.startswith("https://"): - return u - return PUBLIC_BASE + u + """相对路径补全为绝对 URL;已是完整 URL 或空则原样返回(委托基础设施层 resolve_url)。""" + from ..infrastructure.oss import resolve_url + return resolve_url(u) -def _oss_url(key: str) -> str: - """OSS 对象 -> 可访问绝对 URL(公共读桶 endpoint/bucket/key)。""" - endpoint = (config.OSS_ENDPOINT or "").rstrip("/") - return f"{endpoint}/{config.OSS_BUCKET}/{key}" +async def save_media(file: UploadFile, dir: str = "misc") -> str: + """保存上传文件(OSS 优先,本地降级);返回 CDN/OSS 直链绝对 URL;不合法抛 400/413。 + OSS 路径规范:<业务目录>//<时间戳>_<随机>. + 业务目录:avatar 头像 / park-admission 入驻认证资料 / news 资讯 / event 活动封面 / park 大屏媒体 / misc 兜底 + """ + from ..infrastructure.oss import oss -async def save_media(file: UploadFile) -> str: - """保存上传文件:OSS 优先,否则本地 uploads;返回绝对 URL;不合法抛 400/413。""" name = (file.filename or "").rsplit("/", 1)[-1] ext = ("." + name.split(".")[-1].lower()) if "." in name else "" if ext not in ALLOWED_EXT: @@ -57,13 +53,13 @@ async def save_media(file: UploadFile) -> str: payload = await file.read() if len(payload) > MAX_SIZE: raise HTTPException(status_code=413, detail="文件超过 20MB 限制") - fname = f"up_{int(time.time())}_{secrets.token_hex(2)}{ext}" + key = build_key(dir, ext) + await oss.upload(key, payload, content_type=_MIME.get(ext, "application/octet-stream")) + # 直接返回 CDN/OSS 直链,不经服务端代理 + return abs_url(oss.direct_url(key)) - if config.OSS_ENDPOINT and config.OSS_SECRET_KEY and config.OSS_ACCESS_KEY: - from ..infrastructure.oss import OSS - path = await OSS().upload(fname, payload, content_type=_MIME.get(ext, "application/octet-stream")) - # upload 返回 /oss/ 或 /uploads/——OSS 桶走 endpoint/bucket/key - return _oss_url(fname) - with open(UPLOAD_DIR / fname, "wb") as f: - f.write(payload) - return abs_url(f"/uploads/{fname}") + +def build_key(dir: str, ext: str) -> str: + """按规范生成对象 key:<业务目录>//<时间戳>_<随机>.。""" + d = "".join(c for c in (dir or "misc").lower() if c.isalnum() or c in "-_") or "misc" + return f"{d}/{time.strftime('%Y%m')}/{int(time.time())}_{secrets.token_hex(4)}{ext}" diff --git a/dispatcher.py b/dispatcher.py index 9d716c8..285097a 100644 --- a/dispatcher.py +++ b/dispatcher.py @@ -18,7 +18,7 @@ from app.training.main import app as training_app logger = logging.getLogger(__name__) -ROUTE_TRAINING_PREFIXES = ("/api", "/uploads", "/SpXvScDiDT.txt") +ROUTE_TRAINING_PREFIXES = ("/api", "/uploads", "/oss", "/SpXvScDiDT.txt") ROUTE_PARK_PREFIX = "/park"