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