feat(api): 修改oss

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

29
.dockerignore Normal file
View File

@@ -0,0 +1,29 @@
# 密钥与运行时产物 —— 绝不进镜像
.env
.env.*
!.env.example
# git
.git
.gitignore
# Python
__pycache__/
*.py[cod]
.venv/
venv/
.pytest_cache/
# 运行时产物 / 媒体(feedback 走挂载卷,不走镜像)
*.log
media/
plan_attachments/
# 文档(不进运行时镜像)
*.md
# 编辑器 / 系统
.idea/
.vscode/
.DS_Store
Thumbs.db

31
.env
View File

@@ -2,11 +2,15 @@ WECHAT_APPID=wx0f576e94afa52e1a
WECHAT_SECRET=c91063c6b923a6ddba326368488b88c2
JWT_SECRET=6e6b877bb67669a68d2c1f0c1e1192340293595d2443a3469b4a28c0a68be9db
ADMIN_KEY=870b5219532c1a77a405872f555c7f5cdeec23093740d6793f4e988ebcc70bfc
MYSQL_HOST=127.0.0.1
MYSQL_HOST=47.116.118.42
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=MingYuan20251112.!@#
MYSQL_USER=db_clock_user
MYSQL_PASSWORD=CLOCK_user@MY101&&%%
MYSQL_DB=mys_info_test
#MYSQL_HOST=127.0.0.1
#MYSQL_PORT=3306
#MYSQL_USER=root
#MYSQL_PASSWORD=MingYuan20251112.!@#
SURVEY_DB=myxhxc
PHONE_HMAC_KEY=MingYuan2026AuthKey
H5_BASE_URL=https://m.muguaclub.cn
@@ -15,3 +19,24 @@ COURSE_VOTES_FILE=/home/introduction/test/course_votes.json
PLAN_ATTACH_DIR=/home/static/plan
STATIC_ROOT=/home/static
AVATAR_DIR=/home/static/avatars
# ── 阿里云 OSS(打卡附件 + 头像)────────────────────────────
# 建议用只授权该 Bucket 的 RAM 子账号,不要用主账号 AK。
# 这四项缺任意一项,后端就整体按「OSS 未启用」处理:上传落磁盘、读接口返回相对路径。
# ⚠️ 值不要加引号、不要在同一行写注释(python-dotenv 会把这些当值的一部分解析)。
OSS_ACCESS_KEY_ID=LTAI5t7SA4oSowCAiXBUmmgS
OSS_ACCESS_KEY_SECRET=qmHZaZqCvlLdQ5CbxNepxa6omx2r9b
OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
OSS_BUCKET=mingyuan-oss
OSS_REGION=cn-shanghai
# (可选)对外访问域名;仅签名 URL 用,此项保留备查
OSS_PUBLIC_BASE_URL=mingyuan-1837720343614130.oss-cn-shanghai.oss-accesspoint.aliyuncs.com
# 绑定的自定义域名(例:https://oss.muguaclub.cn)。**必须填**,否则签名 URL 落在
# *.aliyuncs.com 上,而那个域名加不进小程序「downloadFile 合法域名」白名单。
# ⚠️ 没有绑自定义域名时必须留空 —— 若填成裸 endpoint(oss-cn-shanghai.aliyuncs.com),
# public_host() 会直接拿它当上传域名,拼不出 bucket 前缀,直传 OSS 会 404。
OSS_PUBLIC_ENDPOINT=
# 签名 URL 有效期(秒),上限 604800(7 天)
OSS_URL_EXPIRE=604800

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ============================================================
# 明缘云道场 · FastAPI 后端 Docker 镜像
#
# 基础镜像用 Python 3.11(3.9 已 EOL,3.11 是当前主流 LTS)。
# ⚠️ requirements.txt 全是「>=」下限约束:3.11 会解析到**最新版** fastapi/uvicorn/pydantic,
# 与线上 3.9(pyenv39)装的旧版(fastapi 0.128.x / uvicorn 0.39.x)不同 ——
# 切换前需用 3.11 重新做一遍接口验证(见部署方案 §3.1)。
# ============================================================
FROM python:3.11-slim
# 时区:业务用 date.today() 算「上课日/周期边界」,必须是 Asia/Shanghai。
# 项目本身无 TZ 配置,全靠系统时区,容器默认 UTC 会把周期窗口整体错位。
ENV TZ=Asia/Shanghai \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# 换国内 apt 镜像源:构建机访问 deb.debian.org 很慢(apt-get update 要拉 ~10MB+ 索引,
# 官方源可能 4~5 分钟)。换成清华源后首次构建可降到几十秒。
# 同时兼容 deb822(debian.sources)与旧版 sources.list;构建机在海外可删掉换源这段。
RUN set -eux; \
for f in /etc/apt/sources.list.d/debian.sources /etc/apt/sources.list; do \
[ -f "$f" ] && sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' "$f"; \
done; \
apt-get update \
&& apt-get install -y --no-install-recommends tzdata curl \
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
&& echo ${TZ} > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /srv
# 先装依赖,利用构建缓存:requirements.txt 没变就不会重装。
# crcmod(oss2 的依赖)是 C 扩展 sdist,镜像里没装编译器,它会自动退回
# 纯 Python 实现 —— 属预期行为,只是 CRC 校验慢一点,不影响功能。
COPY requirements.txt /srv/requirements.txt
# pip 同样走国内镜像:构建机访问 pypi.org 也慢(本轮实测 10~22 kB/s),
# 换清华 PyPI 后这步可从 4 分钟降到几十秒。若清华缺包,可换成
# https://mirrors.aliyun.com/pypi/simple/
RUN pip install -r /srv/requirements.txt \
-i https://pypi.tuna.tsinghua.edu.cn/simple
# 代码放 /srv/wechat_backend/ —— main.py 已用相对导入(from . import ...),
# 包名与目录名彻底解耦,这里叫什么都能正常启动(uvicorn wechat_backend.main:app)。
COPY . /srv/wechat_backend/
# 端口由 PORT 环境变量决定(测试 8000 / 生产 8001)
EXPOSE 8000 8001
# 健康检查走应用自带 /api/v1/health(无鉴权、无 DB 依赖,只证进程存活)
# curl -f:非 2xx 直接返回非零退出码;${PORT:-8000} 由容器内 shell 展开
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT:-8000}/api/v1/health" >/dev/null || exit 1
# exec 让 uvicorn 成为 PID 1,能接收 SIGTERM 优雅退出。
# --workers 默认 1(贴近现状);状态全在 DB/OSS、无内存态,多进程安全,需要时可调大。
CMD ["sh", "-c", "exec uvicorn wechat_backend.main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WORKERS:-1}"]

29
docker-compose.test.yml Normal file
View File

@@ -0,0 +1,29 @@
# ============================================================
# 明缘云道场 · 后端 测试环境(端口 8000)
# 与生产完全隔离:独立容器名、独立端口、独立 .env.test(测试库 mys_info_test)
# 用法:
# docker compose -f docker-compose.test.yml build
# docker compose -f docker-compose.test.yml up -d
# ============================================================
services:
clock-api:
build: .
image: clock-api:test
container_name: clock-api-test
network_mode: host
restart: unless-stopped
env_file:
- .env.test # 测试环境变量(MYSQL_DB=mys_info_test 等)
environment:
- TZ=Asia/Shanghai
- PORT=8000
volumes:
# 反馈/投票如需与生产隔离,请把挂载点改到独立目录,并同步改 .env.test 里的
# FEEDBACK_DIR / COURSE_VOTES_FILE。否则与生产共享这两份数据。
- /home/introduction/feedback:/home/introduction/feedback
- /home/introduction/test/course_votes.json:/home/introduction/test/course_votes.json
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
# ============================================================
# 明缘云道场 · 后端 生产环境(端口 8001)
#
# 用法(在 wechat_backend/ 目录,服务器上已放好 .env):
# docker compose build && docker compose up -d
# docker compose logs -f
# docker compose down
# ============================================================
services:
clock-api:
build: .
image: clock-api:prd
container_name: clock-api-prd
# Linux 服务器用 host 网络:MySQL 走 localhost/内网IP 直连,
# 端口 8001 直接监听宿主机,nginx 反代到 127.0.0.1:8001 无需改动。
# (这是最贴近现状、迁移最小的选择;若要走 bridge,见部署方案 §网络模式)
network_mode: host
restart: unless-stopped
env_file:
- .env # 宿主机上的 .env(运行时注入,绝不进镜像)
environment:
- TZ=Asia/Shanghai
- PORT=8001 # 生产端口
# - WORKERS=4 # 并发上来后再打开(状态全在 DB/OSS,多进程安全)
volumes:
# 反馈截图 + 课程投票仍走本地磁盘(不在 OSS 化范围内),必须挂出持久化,
# 否则容器重建数据丢失。容器内路径与 .env 的 FEEDBACK_DIR / COURSE_VOTES_FILE 一致。
- /home/introduction/feedback:/home/introduction/feedback
- /home/introduction/test/course_votes.json:/home/introduction/test/course_votes.json
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

32
main.py
View File

@@ -1,11 +1,11 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
import logging
from fastapi import FastAPI
from wechat_backend.routes import auth, checkin, course_vote, feedback, plan, profile, survey, webview, yijing
from . import config, oss_store
from .routes import auth, checkin, course_vote, feedback, plan, profile, survey, webview, yijing
logger = logging.getLogger(__name__)
app = FastAPI(title="明缘进阶场 API")
app.include_router(auth.router)
@@ -19,12 +19,28 @@ app.include_router(yijing.router)
app.include_router(webview.router)
# ── 启动自检:把 OSS 状态打进 stderr ────────────────────────────────────────
# 为什么必须显式打这一条:OSS 坏掉时**完全没有别的信号** —— 上传接口返 503、
# 图片地址全是空串,而 oss_store 里一处日志都没有;「没装 oss2」和「.env 没配」
# 还表现成一个样。2026-10-09 排查「部署后 OSS 不能用」时就是卡在这里。
# 这里刻意用 **error** 级:项目没有 basicConfig,root logger 未配置时只有
# WARNING 以上才会经 lastResort 落到 stderr(即 start_prd.sh 的 server.err.log)。
_oss_reason = oss_store.unavailable_reason()
if _oss_reason:
logger.error("对象存储(OSS) 未启用:%s"
" —— 上传接口会返 503、图片地址全为空串。"
"排查:用该 venv 跑 check_oss.py", _oss_reason)
else:
logger.info("对象存储(OSS) 已启用:bucket=%s endpoint=%s public_host=%s",
config.OSS_BUCKET, config.OSS_ENDPOINT, oss_store.public_host())
@app.get("/api/v1/health")
def health():
return {"ok": True}
if __name__ == "__main__":
import uvicorn
uvicorn.run("wechat_backend.main:app", host="127.0.0.1", port=8000, reload=True)
# 启动方式:相对导入(from . import ...)下不能 `python main.py` 直接运行
# (顶层会报 `attempted relative import`),必须通过 uvicorn 以包方式加载:
# uvicorn wechat_backend.main:app --host 0.0.0.0 --port 8000

View File

@@ -64,10 +64,31 @@ def missing_config():
def is_enabled():
"""OSS 是否可用。False 时调用方必须退回本地磁盘,不能抛异常。"""
"""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 = {}

View File

@@ -1,9 +1,30 @@
# 直接依赖。全部是「>= 下限」约束 —— 注意服务器跑在 **Python 3.9**,
# pip 会自动退到「声明支持 3.9 的最高版本」:fastapi / uvicorn / python-multipart / pytest
# 的最新版都已要求 >=3.10,所以服务器上装到的会比开发机旧一档(这是 pip 的正常行为,不是错误)。
fastapi>=0.110
# 代码里有 8 个文件直接 `from pydantic import BaseModel`。
# 它本来是 fastapi 的传递依赖(装了 fastapi 就会有),所以一直没写进来;
# 2026-10-09 补上 —— 「直接 import 的就要直接声明」,否则 fastapi 哪天换掉它就会静默炸。
# 下限定 2.0:现有各环境实测都是 2.x,且 fastapi>=0.110 接受 <3。
pydantic>=2.0
uvicorn[standard]>=0.29
pymysql>=1.1
# ⚠️ Python 3.9 上 pytest / python-multipart 也会退版本,属正常。
# ⚠️ PyJWT:2026-10-09 全仓库扫描确认**没有任何地方 `import jwt`** ——
# 鉴权走的是 `auth_sessions` 里的 64 位 hex token,`config.JWT_*` 只是历史命名。
# 但 `python-multipart` 不一样:虽然我们也不 import 它,FastAPI 处理
# `File(...)` / `Form(...)` 时**运行时需要它**,删了上传接口就 500,必须留着。
PyJWT>=2.8
httpx>=0.27
python-multipart>=0.0.9
pytest>=8.0
DBUtils>=3.0
# 2026-10-09 补记:`config.py` 一直在用 `from dotenv import load_dotenv`,
# 但这份清单里从来没有它 —— 服务器上是手工装的,没有记录。
# 换机器 / 重建 venv 时就会 ImportError,所以必须写进来。
python-dotenv>=1.0
# 打卡附件 / 头像写 OSS(2026-09-22 新增)。
# 纯 Python 包,但 PyPI 上只有 sdist(没有 wheel)→ 会走源码安装,不需要编译器。
# 它依赖的 `crcmod` 是 C 扩展,好在 setup.py **编译失败时会退回纯 Python 实现**,
# 所以没有编译器的机器也能装上(只是 CRC 校验慢一点)。
oss2>=2.17

View File

@@ -177,8 +177,9 @@ def _check_password_input(phone, password):
@router.post("/set-password")
def set_password(req: SetPasswordRequest, request: Request):
"""
def set_password(req: SetPasswordRequest):
"""更改单个学员登录密码(2026-09-22 新增,无运营密钥鉴权,靠老密码验证)。
- **改密码需先验证老密码**:`old_password` 正确才更新;`password_hash` 为空时
默认老密码 = 手机号后6位(与 `password-login` 的默认密码逻辑一致)。
- **直接覆盖已有密码**("重置"就是这个语义),没有二次确认。