feat(api): 重构图片地址管理与上传机制

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

214
oss_store.py Normal file
View File

@@ -0,0 +1,214 @@
# -*- 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,
},
}