# -*- coding: utf-8 -*- """阿里云 OSS 存取封装(打卡附件 / 头像共用)。 对外只有 5 个函数:`is_enabled` / `missing_config` / `put_bytes` / `delete` / `signed_url`。 三条设计要点,都不是随手写的: 1. **惰性导入 oss2**。开发机、测试机、甚至刚拉下来的服务器上可能还没装 oss2; 如果模块顶层 `import oss2` 失败,整个 FastAPI 服务就起不来了。 所以这里 try/except,`is_enabled()` 会把「没装 oss2」也算作「未启用」。 2. **配置不全一律当「未启用」**,不做半吊子尝试 —— 避免用假配置去打真实 OSS, 也避免出现"部分文件上了云、部分还在盘上"这种没法查的状态。 3. **AK/SK 只从环境变量取**(见 config.py),仓库里不留密钥。 存储路径约定(**和磁盘、数据库逐段一致,只差前缀**,这是整个方案能不动数据的前提): 对象 key plan/{user_id}/{plan_type}/{period_key}/{filename} 服务器磁盘 /home/static/plan/{user_id}/{plan_type}/{period_key}/{filename} 数据库 attachments attachments/{user_id}/{plan_type}/{period_key}/{filename} 所以 `attachments/` 前缀换成 `plan/`,就是对象 key。头像同理: 数据库 `avatars/{uid}/avatar.jpg` ↔ 对象 key `avatars/{uid}/avatar.jpg`。 """ import base64 import hashlib import hmac import json import logging import os from datetime import datetime, timedelta, timezone from . import config logger = logging.getLogger(__name__) try: import oss2 except ImportError: # pragma: no cover —— 取决于部署环境是否装了依赖 oss2 = None # OSS 签名 URL 的硬上限:7 天。超过这个值 OSS 直接报 0002-00000232,不会给你签。 MAX_EXPIRE = 7 * 24 * 3600 # 这些值出现在配置里说明「还没真配」,要和空值一样当成未配置。 # 否则默认值(比如 your-bucket-name)是真值,只填了 AK/SK 也会被误判成"已配置"。 _PLACEHOLDER_VALUES = { "", "your-access-key-id", "your-access-key-secret", "your-bucket-name", "your-bucket", "xxx", "xxxx", "todo", "changeme", "placeholder", "ltaixxxxxxxx", "oss-cn-hangzhou.aliyuncs.com.example", } def _ok(value): """配置项是否是一个真实可用的值(非空、非占位符)。""" text = (value or "").strip() return text.lower() not in _PLACEHOLDER_VALUES def missing_config(): """返回缺失/占位的配置项名字列表,空列表表示配置齐全。""" required = ("OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_ENDPOINT", "OSS_BUCKET") return [name for name in required if not _ok(getattr(config, name, ""))] def is_enabled(): """OSS 是否可用。False 时调用方必须退回本地磁盘,不能抛异常。""" return oss2 is not None and not missing_config() _buckets = {} def _with_scheme(endpoint): """给没写协议的 endpoint 补上 `https://`。 ⚠️ 这一步不能省:oss2 在没写协议时默认按 **http** 处理,签出来的就是 http 链接。 而微信小程序只接受 https,图片会直接加载失败 —— 而且失败得很隐蔽, 因为签名串本身完全合法,只是协议不对(2026-09-22 本地验证实际抓到这一条)。 显式写了 `http://` 的地址(例如本地假 OSS 服务)不会被改动。 """ text = (endpoint or "").strip().rstrip("/") if text and not text.startswith(("http://", "https://")): text = "https://" + text return text def _endpoint(public=False): if public and _ok(config.OSS_PUBLIC_ENDPOINT): return _with_scheme(config.OSS_PUBLIC_ENDPOINT), True return _with_scheme(config.OSS_ENDPOINT), False def _bucket(public=False): """取得(并缓存)Bucket 对象。 `public=True` 时用配置的自定义域名建一个 CNAME 型 Bucket,**只用来算签名**。 这点很关键:V1 签名的 CanonicalizedResource 带 bucket 名、V4 签名的 CanonicalRequest 带 Host,所以「用哪个域名签」必须和「实际用哪个域名访问」一致,否则 403。 建 Bucket 不产生任何网络请求,因此这里不会有性能开销。 """ endpoint, is_cname = _endpoint(public) cache_key = (endpoint, is_cname) bucket = _buckets.get(cache_key) if bucket is None: auth = oss2.Auth(config.OSS_ACCESS_KEY_ID.strip(), config.OSS_ACCESS_KEY_SECRET.strip()) bucket = oss2.Bucket(auth, endpoint, config.OSS_BUCKET.strip(), is_cname=is_cname) _buckets[cache_key] = bucket return bucket def put_bytes(key, data, content_type=None): """上传一段字节到指定 key。同名 key 是覆盖语义。""" headers = {"Content-Type": content_type} if content_type else None return _bucket().put_object(key, data, headers=headers) def delete(key): """删除对象。**不存在的 key 也算成功**(OSS delete 是幂等的),调用方无需先查存在性。""" return _bucket().delete_object(key) def signed_url(key, expires=None): """生成签名 URL(默认取 config.OSS_URL_EXPIRE)。 私有桶必须靠它才能取到图;签名是**纯本地计算**,不发网络请求,可以放心循环里批量签。 `slash_safe=True` 让 key 里的 `/` 不被转义成 %2F,保持目录层级可读。 """ seconds = int(expires or config.OSS_URL_EXPIRE) seconds = max(60, min(seconds, MAX_EXPIRE)) return _bucket(public=True).sign_url("GET", key, seconds, slash_safe=True) def guess_content_type(filename): """按扩展名给出 Content-Type,用于上传时写对媒体类型。""" ext = os.path.splitext(filename or "")[1].lstrip(".").lower() return { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp", }.get(ext) # PostObject 单文件大小上限(字节)。默认 10MB,与 plan 上传接口一致, # 调用方可用 `max_size` 覆盖成更小的值。 MAX_POST_SIZE = 10 * 1024 * 1024 def public_host(): """直传要用的上传域名(含协议,不带路径)。 **优先用自定义域名**:OSS 默认域名加不进小程序的「uploadFile 合法域名」 (`*.aliyuncs.com` 的备案主体是阿里云、子域名不继承主体资质),真机上是 `url not in domain list` 直接失败。没绑自定义域名时退回默认域名 —— 此时开发者工具能传通、真机不行,属于预期行为,不是 bug。 """ if _ok(config.OSS_PUBLIC_ENDPOINT): return _with_scheme(config.OSS_PUBLIC_ENDPOINT) endpoint = _with_scheme(config.OSS_ENDPOINT) scheme, _, rest = endpoint.partition("://") bucket = config.OSS_BUCKET.strip() if rest.startswith(bucket + "."): return endpoint return "{}://{}.{}".format(scheme, bucket, rest) def post_form(key_prefix, expire=None, max_size=None): """生成 PostObject 表单签名,供小程序「直传」(图片字节不经过后端)。 直传省掉的是服务器的**入口+出口双份带宽**(中转模式下字节要先收进来再发出去); 后端只签发这一小段 policy,成本可忽略。 两个关键设计,都别改成"更直接"的写法: 1. **只签「目录前缀」,不签完整 key。** 小程序上传前会先压缩大图, 而 `wx.compressImage` 会换掉文件名(临时路径),签完整 key 就必须提前 知道最终文件名,做不到「一次签名、多张复用」。锁前缀后一份 policy 可以覆盖本次所有图片。代价是前端能在该目录内自行命名 —— 由调用方 把前缀锁到 `plan/{user_id}/{app_type}/{period}/` 来兜住。 2. **前缀必须锁到用户自己的目录**,绝不能是 `plan/`。这是防越权覆盖 他人附件的唯一防线:锁到 `plan/` 等于把所有人的目录都开放给前端了。 关于签名版本:2026-09-22 已在真实 `mingyuan-oss` 上**实测 V1 表单签名可用** (POST 返回 204、`object_exists=True`、Content-Type 正确),所以不引入 V4。 另一个好处是 **V1 表单签名的签名串里不含 Host** —— 同一份签名 POST 到默认 域名或自定义域名都有效,以后绑自定义域名不需要重新设计签名。 """ seconds = int(expire or config.OSS_POST_EXPIRE) seconds = max(30, min(seconds, MAX_EXPIRE)) limit = int(max_size or MAX_POST_SIZE) expiration = (datetime.now(timezone.utc) + timedelta(seconds=seconds)) \ .strftime("%Y-%m-%dT%H:%M:%S.000Z") policy = { "expiration": expiration, "conditions": [ ["eq", "$bucket", config.OSS_BUCKET.strip()], ["content-length-range", 0, limit], ["starts-with", "$key", key_prefix], ], } policy_b64 = base64.b64encode( json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii") signature = base64.b64encode(hmac.new( config.OSS_ACCESS_KEY_SECRET.strip().encode("utf-8"), policy_b64.encode("ascii"), hashlib.sha1).digest()).decode("ascii") return { "host": public_host(), "expires_in": seconds, "key_prefix": key_prefix, "form_data": { "policy": policy_b64, "OSSAccessKeyId": config.OSS_ACCESS_KEY_ID.strip(), "signature": signature, }, }