diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd0cc1d --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env b/.env index f5dbdd0..19ecdbd 100644 --- a/.env +++ b/.env @@ -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 @@ -14,4 +18,25 @@ FEEDBACK_DIR=/home/introduction/feedback COURSE_VOTES_FILE=/home/introduction/test/course_votes.json PLAN_ATTACH_DIR=/home/static/plan STATIC_ROOT=/home/static -AVATAR_DIR=/home/static/avatars \ No newline at end of file +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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1cd129 --- /dev/null +++ b/Dockerfile @@ -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}"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..f9eeec6 --- /dev/null +++ b/docker-compose.test.yml @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..35712d6 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/main.py b/main.py index 344bf80..f5a91d4 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/oss_store.py b/oss_store.py index 11821d5..faeec61 100644 --- a/oss_store.py +++ b/oss_store.py @@ -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 = {} diff --git a/requirements.txt b/requirements.txt index aa5ca8f..1ed79ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/routes/auth.py b/routes/auth.py index 53237f1..2e0e262 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -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` 的默认密码逻辑一致)。 - **直接覆盖已有密码**("重置"就是这个语义),没有二次确认。