Files
wechat_backend/oss_store.py
王宏豪 faa114503f feat(api): 修改oss
- 替换开发环境API基础URL为本地服务地址
- 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题
- 添加缓存清理功能,包括过期清理和按需删除特定缓存项
- 集成阿里云OSS上传功能,支持直传减少服务器带宽占用
- 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化
- 移除过时的OSS上传示例文件oss-upload.js
- 更新课程页面实现跳转第三方小程序功能
- 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑
- 优化图片上传流程,增加压缩处理和批量上传支持
- 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存
- 添加后端环境变量配置模板和代理文档说明
2026-10-10 10:27:00 +08:00

236 lines
10 KiB
Python
Raw Permalink 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.
# -*- 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()
def unavailable_reason():
"""返回「OSS 不可用」的可读原因;可用时返回空串。
**为什么要单独一个函数**:`is_enabled()` 把两种完全不同的故障揉成同一个 `False` ——
① **没装 oss2**(`pip install -r requirements.txt` 没在这个 venv 里跑过)
② **没配 .env**(部署时 `.env` 没带上 / 被 `git pull` 覆盖 / 值还是占位符)
而这两种的修法毫无关系:一个是装包,一个是配环境变量。
线上表现却**完全一样**(上传 503 + 图片全空白),而且**一句话日志都没有** ——
2026-10-09 就因为这个把排查方向带到错的地方去了。
所以:启动时把它的返回值打进日志(见 `main.py`),排查时先问它。
"""
if oss2 is None:
return "未安装 oss2(用该 venv 的解释器跑 pip install -r requirements.txt)"
missing = missing_config()
if missing:
return "缺少或仍是占位值的配置项:" + " / ".join(missing)
return ""
_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,
},
}