- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
441 lines
32 KiB
Markdown
441 lines
32 KiB
Markdown
# AGENTS.md — 明缘云道场 · 后端(FastAPI)
|
||
|
||
> 本仓库是**后端仓库**,本地目录 `wechat_backend/`,**代码内包名是 `server/` / `server_prd/`**
|
||
> (本地目录名 ≠ 包名,详见工作区根目录 `../AGENTS.md` 第 2 节,不要"修正"这个差异)。
|
||
> 本文件只讲后端工程事实。前端见 `../wechat-clock-mini/AGENTS.md`。
|
||
>
|
||
> ⚠️ 同目录的 `README.md` 已有多处漂移(写着"四 tab"、"Bearer JWT"、`pytest tests`),
|
||
> **以本文件为准**。
|
||
>
|
||
> 最后更新:2026-09-22
|
||
|
||
---
|
||
|
||
## 1. 技术栈与入口
|
||
|
||
- 单 FastAPI 服务,Python **3.9**(服务器 `pyenv39`),依赖见 `requirements.txt`
|
||
(fastapi / uvicorn / pymysql / PyJWT / httpx / DBUtils / python-multipart)。
|
||
- 入口:`main.py` → `app = FastAPI(title="明缘进阶场 API")`,9 个 router 全部挂载在 **`/api/v1`** 下。
|
||
- 健康检查:`GET /api/v1/health` → `{"ok": true}`。
|
||
|
||
> 🐛 **已知 bug**:`main.py` 的 `if __name__ == "__main__"` 里写的是
|
||
> `uvicorn.run("server_prd.main:app", ...)`,直接 `python main.py` 会 ImportError。
|
||
> 本地跑请用 `uvicorn server.main:app --port 8000`(需把目录名视作 `server`),或参考 `start.sh`。
|
||
|
||
---
|
||
|
||
## 2. 目录结构
|
||
|
||
```
|
||
wechat_backend/
|
||
├── main.py # FastAPI 入口,注册全部 router
|
||
├── config.py # 全部配置项,从 .env 读取(load_dotenv override=True)
|
||
├── db.py # PyMySQL + DBUtils PooledDB 连接池,双库
|
||
├── security.py # token 生成 / 手机号 HMAC
|
||
├── wechat.py # 微信 code2session 封装
|
||
├── user.py # 用户查询辅助
|
||
├── routes/ # 9 个业务路由
|
||
├── migrations/ # 001~005 SQL 迁移脚本(手工执行)
|
||
├── start.sh / stop.sh # 测试环境(8000,/home/clock_server/clock_test)
|
||
├── start_prd.sh / stop_prd.sh # 生产环境(8001,/home/clock_server)
|
||
├── index.html # H5 侧静态页
|
||
├── .env # ⚠️ 真实密钥,曾被 git 跟踪,勿提交
|
||
└── media/ # 上传/反馈等媒体目录
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 路由清单(全部前缀 `/api/v1`)
|
||
|
||
| 模块 | 端点 | 鉴权 |
|
||
|---|---|---|
|
||
| `auth` | `POST /auth/wechat-login`、`POST /auth/bind-phone`、**`POST /auth/password-login`**(手机号+密码)、**`POST /auth/set-password`**(运营设/重置密码,`X-Admin-Key` 鉴权)、`GET /auth/verify`、`POST /auth/logout` | 登录接口免鉴权 |
|
||
| `checkin` | `GET /checkin/weeks`、`GET /checkin/week`、`GET /checkin/groups`、`POST /checkin`、`GET /checkin/list`、`GET /checkin/group` | 需登录 |
|
||
| `plan` | `GET /plan/periods`、`GET /plan/status`、`GET /plan/groups`、`GET /plan/group`(小组成员 + 学号 + 打卡内容)、**`GET /plan/records`**(周计划打卡名单,仅老师)、`POST /plan/checkin`、`GET /plan/history`、`POST /plan/upload`、**`POST /plan/upload-signature`**(小程序直传 OSS 的表单签名,2026-09-22 新增)、`GET/DELETE /plan/file/{sid}/{plan_type}/{period}/{filename}` | **全部需登录**(`GET /file` 原先无鉴权,2026-09-22 才补上)。凡返回附件的响应都**新增 `attachment_urls`**(OSS 签名地址,与 `attachments` 等长同序),详见 §9.0。图片**只存在 OSS**,无磁盘兜底 |
|
||
| `profile` | `GET /profile`、`POST /profile/avatar` | 需登录。`wechat_avatar` 存规范路径 `avatars/{uid}/avatar.ext`,另有展示用 `avatar_url` |
|
||
| `pages` | `GET /pages/home` | 需登录(首页按后端返回顺序渲染) |
|
||
| `survey` | `GET /survey/tasks`、`GET /survey/responses`、`POST /survey/response`、`PUT /survey/response/{rid}`、`POST /survey/task` | 基本无鉴权 |
|
||
| `feedback` | `GET /feedback`、`POST /feedback`、`GET /feedback/file/{name}`、`POST /feedback/resolve` | **`GET /feedback` 无鉴权(风险)** |
|
||
| `course_vote` | `POST /course-vote`、`GET /course-votes` | 需登录 |
|
||
| `yijing` | `GET /yijing/catalog`、`GET /yijing/progress`、`POST /yijing/progress`、`POST /yijing/quiz` | 部分需登录 |
|
||
| `webview` | `GET /webview/redirect` | URL token 换 Cookie |
|
||
|
||
---
|
||
|
||
## 4. 鉴权机制(**不是 JWT,别看名字**)
|
||
|
||
- 配置项叫 `JWT_SECRET` / `JWT_TTL_SECONDS`,但**实际不使用 JWT**:登录成功后把
|
||
64 位 hex token 写入 **`auth_sessions` 表**,受保护接口查表校验。
|
||
- 双通道:
|
||
- **小程序** → `Authorization: Bearer <token>`(token 存在 `wx.getStorageSync('token')`)
|
||
- **H5(web-view 内)** → Cookie:由 `/api/v1/webview/redirect?token=xxx` 换成 HttpOnly Cookie
|
||
- ⚠️ 那个 `auth_token` Cookie **未带 `domain`**,是 host-only(挂在 `m.muguaclub.cn`),
|
||
**跨不到 `www` 子域**。`webview/redirect` 里的 host 白名单靠 `host.endswith(".muguaclub.cn")`
|
||
会把 `www` 放行,看起来"能用",实际 cookie 无效。若将来课程详情要显示登录态,
|
||
得先给这个 Cookie 补 `domain=".muguaclub.cn"`。
|
||
- 登录**有两条路**(第二条 2026-09-22 新增):
|
||
1. **微信**:`POST /auth/wechat-login` 的 `code` 必须来自 `wx.login()`,**curl 构造不出来**。
|
||
未匹配 openid 时返回 `need_phone=true`,走 `POST /auth/bind-phone` 绑手机号。
|
||
2. **手机号 + 密码**:`POST /auth/password-login`。**curl 可用** —— 这是它相对微信登录的
|
||
最大价值:微信那条路没法自动登录,调试/压测做不了,且小程序**审核员**没有学员手机号、
|
||
永远登不进去(本项目提审最大风险点)。密码哈希存 `auth_users.password_hash`,
|
||
统一用纯 SHA-256(`hashlib.sha256(p.encode()).hexdigest()`,**无盐**),
|
||
与遗留鉴权代码 `routes/auth_api.py` / `routes/auth_app.py` 的 `hash_pwd()` 完全一致;
|
||
刻意不引入 bcrypt / passlib。**未设置密码(`password_hash` 为 NULL)时,默认密码 = 手机号后6位**,
|
||
便于学员免初始设置直接登录。详见 `../03.开发文档/接口交接-账号密码登录.md`。
|
||
- **密码由运营设置**:`POST /auth/set-password`,**单个用户**更新密码,`ADMIN_KEY` 鉴权,
|
||
密钥从**请求头 `X-Admin-Key`** 传入(**刻意不走 query** —— query 会被 nginx 写进 access log)。
|
||
**需先验证老密码 `old_password`**(`password_hash` 为空时默认 = 手机号后6位)才更新;
|
||
**系统没有注册流程**,手机号匹配不到 `auth_users` 就报错(404)、
|
||
**不会新建账号**;会**直接覆盖**已有密码("重置"语义)。
|
||
- ⚠️ 密码登录**不校验 `status`**,与微信登录保持一致 —— `暂停` 学员仍可登录。
|
||
- ⚠️ **两条登录路径的 401 文案都必须是「手机号或密码错误」**,刻意不区分"账号不存在/密码错",
|
||
否则该接口会变成「哪些手机号是有效学员」的枚举器。**未设密码的账号用默认密码「手机号后6位」也能登录**;
|
||
要排查"谁还在用默认密码"请查库
|
||
(`SELECT COUNT(*) FROM auth_users WHERE password_hash IS NULL`)或看日志(日志里有区分)。
|
||
- 角色:**只区分 `teacher` 与「非 teacher」**。`student/member/leader/admin` 一律当学员处理,
|
||
「组长」只是前端展示标签,后端不据此做权限控制。
|
||
- 手机号:用 HMAC-SHA256(`PHONE_HMAC_KEY`)加密后存 `phone_encrypted`,**不可逆**,用于匹配学员记录。
|
||
|
||
---
|
||
|
||
## 5. 数据库
|
||
|
||
- 主库 `mys_info`(测试 `mys_info_test`),问卷库 `myxhxc`(`SURVEY_DB`)。
|
||
- 连接池:PyMySQL + DBUtils `PooledDB`(见 `db.py`),**不要绕过连接池自己开连接**。
|
||
- 核心表:
|
||
|
||
| 表 | 说明 |
|
||
|---|---|
|
||
| `auth_users` | 用户主表,含 `group`(如「01组」)、`student_id`、`status`(`正常` / `暂停`)、`phone_encrypted`、`wechat_openid`、`role`、**`password_hash`**(2026-09-22 加,NULL = 未设置密码,登录时用默认密码「手机号后6位」) |
|
||
| `checkin_records_old` | 打卡记录。**表名保留 `_old` 后缀是历史原因,不是废弃表**;唯一键 `(user_id, class_date)` |
|
||
| `plan_checkins` | 计划打卡,唯一键 `(user_id, plan_type, period_key)` |
|
||
| `auth_sessions` | 登录会话(token) |
|
||
|
||
- 迁移脚本**必须手工执行**,没有自动迁移框架:`mysql <db> < migrations/00X_xxx.sql`。
|
||
001 openid 字段 / 002 索引 / 003、004 把 checkin 与 plan 从 `student_id` 改为 `user_id` 并重写附件路径 /
|
||
**006 新增 `auth_users.password_hash`**(账号密码登录用,⚠️ **不可重复执行**,重复 `ADD COLUMN` 会报错)。
|
||
- ⚠️ **编号 005 在仓库里不存在。** 本文档(本节与 §8.1)一直引用
|
||
`005_class_date_sunday.sql`(上课日周六→周日、`class_date` 整体 +1 天),
|
||
但 `migrations/` 目录下**没有这个文件** —— 很可能只在服务器上执行过、文件从未提交。
|
||
**不要把"仓库里有 001~006"当成迁移全集**;重装数据库或迁移环境前须到服务器核实这一步是否已生效。
|
||
- ⚠️ **006 未执行时的表现是静默的**:`SELECT *` 取不到 `password_hash` 列,
|
||
于是**所有密码登录都返回 401「手机号或密码错误」**(不是 500),极易误判成"密码设错了"。
|
||
- ⚠️ `auth_users.status` 没有对应的 migration,是既有字段;
|
||
`/plan/records` 是第一处用它做过滤的接口。
|
||
|
||
---
|
||
|
||
## 6. 部署环境(完全隔离的两套)
|
||
|
||
| 项 | 测试 | 生产 |
|
||
|---|---|---|
|
||
| 代码目录 | `/home/clock_server/server_test/` ⚠️ 见下 | `/home/clock_server/server_prd/` |
|
||
| 虚拟环境 | `/home/clock_server/pyenv39_test/` | `/home/clock_server/pyenv39/` |
|
||
| ASGI 目标 | `server_test.main:app` ⚠️ 见下 | `server_prd.main:app` |
|
||
| 端口 | **8000** | **8001** |
|
||
| 数据库 | `mys_info_test` | 生产库 |
|
||
| 日志 | `logs/test/` | `logs/prd/` |
|
||
| 启动脚本 | `start_test.sh` / `stop_test.sh`(由 `deploy_test/` 生成) | `start_prd.sh` |
|
||
|
||
> ⚠️ **仓库内对测试环境路径存在两种互相矛盾的说法,发布前必须到服务器实地确认:**
|
||
>
|
||
> | 来源 | 代码目录 | ASGI 目标 |
|
||
> |---|---|---|
|
||
> | `deploy_test/deploy_test.sh` + `README_test.md`(较新,脚本自动生成 `start_test.sh`) | `/home/clock_server/server_test/` | `server_test.main:app` |
|
||
> | `wechat_backend/start.sh`(仓库内脚本,用新 venv `pyenv39_test`) | `/home/clock_server/clock_test/` | `server.main:app`(即目录下有 `server/` 子包) |
|
||
>
|
||
> 两者只能有一个是当前生效的。**不要凭文档直接发布**,先 `ls /home/clock_server/` + `ps aux | grep uvicorn` 确认。
|
||
|
||
- 发布方式:`git pull` 更新代码 → 跑对应 `start_*.sh`(脚本会先清 `__pycache__` 再 nohup 拉起)。
|
||
- 同步测试代码可用 `deploy_test/update_test_code.sh`(**保留 `server_test/.env`** 不被覆盖)。
|
||
- 外网入口:测试 `https://test.muguaclub.cn/clock`,生产 `https://m.muguaclub.cn/clock`(nginx 反代到 8000/8001)。
|
||
- ⚠️ `README.md` 写了 `python -m pytest tests -q`,但**仓库中并不存在 `tests/` 目录**,
|
||
`requirements.txt` 里的 `pytest` 目前无实际测试可跑 —— 属文档漂移,不要以为有测试网。
|
||
|
||
---
|
||
|
||
## 7. 配置项(`config.py`,全部来自 `.env`)
|
||
|
||
`WECHAT_APPID` / `WECHAT_SECRET` / `JWT_SECRET` / `ADMIN_KEY` / `MYSQL_HOST` / `MYSQL_PORT` /
|
||
`MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DB` / `SURVEY_DB` / `PHONE_HMAC_KEY` / `H5_BASE_URL` /
|
||
`FEEDBACK_DIR` / `COURSE_VOTES_FILE` /
|
||
`OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` / `OSS_ENDPOINT` / `OSS_BUCKET` /
|
||
`OSS_PUBLIC_ENDPOINT` / `OSS_URL_EXPIRE` / `OSS_POST_EXPIRE`
|
||
|
||
> **2026-09-22 新增 OSS 七项**,模板见 `.env.example`。
|
||
> ⚠️ **同日收口:`PLAN_ATTACH_DIR` / `STATIC_ROOT` / `AVATAR_DIR` 三个磁盘路径配置项已删除**
|
||
> —— 图片只存 OSS,磁盘读写路径整体拆掉了。`.env` 里留着它们无害,但已无人读取。
|
||
> 注意 `FEEDBACK_DIR` 还在用:**反馈模块的截图仍走磁盘**,它不在 OSS 化范围内。
|
||
>
|
||
> 缺 OSS 四项(AK/SK/Endpoint/Bucket)任意一项时 `oss_store.is_enabled()` 为 False,
|
||
> 此时**不再降级到磁盘,而是明确失败**:`POST /profile/avatar` 返回 **503**,
|
||
> `/plan/upload-signature` 返回 `enabled=false`(前端提示「上传服务不可用」),
|
||
> 读接口的 `attachment_urls` / `avatar_url` 全为空串(前端不渲染那张图)。
|
||
> **所以「代码先上线、变量后补」会把功能打挂** —— 变量必须先配好。这是刻意的:
|
||
> 磁盘文件没有任何读路径能取到,悄悄写下去等于丢。
|
||
>
|
||
> 两个时间参数的默认值(都可在 `.env` 覆盖):
|
||
> - `OSS_URL_EXPIRE` = **432000(5 天)**。取这么长不是为了好看,是为了配合小程序的
|
||
> 签名 URL 本地缓存:**URL 稳定,微信 `<image>` 的「按 URL 缓存图片」才命中**,
|
||
> 否则每次现签的 URL 都不同,微信会当新图**反复重新下载整张图**。
|
||
> ⚠️ 代价是泄漏窗口大(拿到 URL 的人 5 天内都能看图),不可接受就调小 ——
|
||
> 前端缓存会跟着签名有效期自然失效,**不用改代码**。
|
||
> - `OSS_POST_EXPIRE` = **600(10 分钟)**,只用于直传的表单签名;窗口开太长等于放大可写时间。
|
||
|
||
> ⚠️ `.env` 中存在**真实生产密钥**。任何情况下不要把 `.env` 内容写进文档、软著材料、日志或提交。
|
||
> ⚠️ `requirements.txt` 里**漏了 `python-dotenv`**,而 `config.py` 依赖它。
|
||
|
||
---
|
||
|
||
## 8. 核心业务规则(改时间相关代码前必读)
|
||
|
||
这几条是**最容易改错、一改就全盘错**的地方,请务必对照 `routes/checkin.py` 与 `routes/plan.py` 确认。
|
||
|
||
### 8.1 上课日 = 每周周日
|
||
|
||
`routes/checkin.py` 中 `CLASS_WEEKDAY = 6`(Python `date.weekday()`:周一=0 … 周日=6)。
|
||
历史数据曾按周六计算,迁移 `005_class_date_sunday.sql` 已把 `class_date` 整体后移 1 天。
|
||
|
||
### 8.2 打卡窗口
|
||
|
||
上课日所在**自然周(周一~周日)** 与当前周相同或为未来周时才允许打卡 / 修改。
|
||
|
||
### 8.3 `period_key` 格式
|
||
|
||
| 类型 | 格式 | 示例 |
|
||
|---|---|---|
|
||
| 周 | `YYYY-Www`(ISO 周,与自然周一~周日一致) | `2026-W37` |
|
||
| 月 | `YYYY-MM` | `2026-09` |
|
||
| 年 | `YYYY` | `2026` |
|
||
|
||
### 8.4 填写时间窗口
|
||
|
||
| 类型 | 窗口 |
|
||
|---|---|
|
||
| 周计划 `weekly_window` | 目标周的**上一周周五 00:00** ~ 目标周**周四 23:59:59** |
|
||
| 月计划 `monthly_window` | **上月最后 5 天 00:00** ~ 本月**第 10 天 23:59:59**(共约 15 天) |
|
||
| 年计划 `yearly_window` | **上一年 12-01 00:00** ~ 本年 **01-31 23:59:59** |
|
||
|
||
### 8.5 默认周期与列表终点(易错)
|
||
|
||
- 默认填写周期 = **窗口正在开放的周期**;都未开放时取**最近将要开放的**周期
|
||
(`default_weekly_period` / `default_monthly_period` / `default_yearly_period`,由 `get_period_key` 分发)。
|
||
- 副作用:月计划在每月 11 日~25 日左右存在**空档期**(如 9/11~9/25 默认跳到 2026-10);
|
||
年计划在 2~11 月默认跳到下一年。**这是设计如此,不要"修"**。
|
||
- **`generate_period_list` 的三种类型终点都必须取 `default_*_period`**(而非"今天所在周期"),
|
||
否则默认周期会落在列表外,前端选不到、被误判为「窗口已过」。
|
||
- `GET /plan/periods` 返回:`periods` / `current_period` / `period_labels`
|
||
(周=本周 / 下周,月=本月 / 下月,年=今年 / 明年;周计划另有 `current_week`)。
|
||
|
||
### 8.6 写入校验
|
||
|
||
- `POST /checkin` 与 `POST /plan/checkin` 统一校验「格式合法 + **必须等于默认周期** + 窗口已开放」;
|
||
月周期会归一化为 `YYYY-MM` 后入库。
|
||
- 计划附件存储路径:`attachments/{user_id}/{plan_type}/{period_key}/{filename}`。
|
||
- ⚠️ 前端**不做任何业务日期计算**,周期一律以后端 `current_period` / `current_week` 为准。
|
||
|
||
### 8.7 周计划打卡名单 `GET /plan/records`
|
||
|
||
**仅老师可查**(非 `teacher` 一律 403),固定查询周计划,用于看某个周期里谁打了卡。
|
||
|
||
| 参数 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `period_key` | 当前应填周 | ISO 周如 `2026-W38`;不传取 `get_period_key("weekly")`(周五~周日为下一周) |
|
||
|
||
- 实现:`auth_users` **LEFT JOIN** `plan_checkins`(`plan_type='weekly'` + `period_key`),
|
||
一条 SQL 取回全员(含未打卡),无分页。
|
||
- **过滤条件:`WHERE u.\`status\` = '正常'`**(2026-09-15 加)——`auth_users.status` 取值
|
||
`正常` / `暂停`(`VARCHAR(20) DEFAULT '正常'`),**暂停学员不出现在名单里**。
|
||
- 判定口径:该周在 `plan_checkins` 中存在记录 = 已打卡(与 `/groups` 统计口径一致)。
|
||
注意 `/groups` 与 `/group` **尚未**加 status 条件,小组统计数与本名单人数可能不一致。
|
||
- 排序:`ORDER BY u.\`group\`, u.student_id`。
|
||
- 返回:`{"period_key": "...", "list": [{user_id, student_id, name, group_name, checked}]}`
|
||
- ⚠️ 反直觉:**老师本人也在名单里**。
|
||
- ⚠️ `parse_iso_week` **不校验周数**,传 `W99` 会返回 200 + 空名单(不报错)。
|
||
- 与既有接口分工:`/groups` 给**小组维度统计数**,`/group` 给**单个小组**的成员名单,
|
||
`/records` 给**跨小组的扁平打卡名单**(字段最少)。
|
||
- 接口交接文档见 `03.开发文档/接口交接-周计划打卡名单-GET-plan-records.md`。
|
||
|
||
### 8.8 小组成员详情 `GET /plan/group`(老师详情页 / 学员小组 tab 共用)
|
||
|
||
- 参数:`type`(必传)、`group_name`(**必传**,为空 400、组不存在 404)、`period_key`(可选)。
|
||
- 实现:先查该组成员(`auth_users WHERE group=%s ORDER BY id`),再用一条
|
||
`IN (...)` 查询批量取该周期记录(避免 N+1)。
|
||
- 返回每项:`user_id` / `student_id` / `name` / `filled` / `record`;
|
||
`record`(未提交为 `null`)= `status` / `checked_at` / **`content`** / **`attachments`**(已解析成数组)。
|
||
**2026-09-16 加** `student_id`、`content`、`attachments` 三项,供小程序详情页展示学号与打卡内容。
|
||
- 排序:**未提交在前**,再按 `user_id`(Python 侧重排,不是 SQL 的 `ORDER BY`)。
|
||
- ⚠️ **该接口不校验权限、不校验周期归属、未加 `status` 过滤**:任何登录用户传任意
|
||
`group_name` 都能查到别组名单与打卡内容(`/records` 已收紧,此接口未动)。
|
||
别误以为"学员看不到"是后端挡住了 —— 挡住它的是前端的死代码 `openMyGroup`(见前端文档)。
|
||
|
||
---
|
||
|
||
## 9. OSS 与离线脚本
|
||
|
||
### 9.0 媒体文件已接 OSS(**2026-09-22 起,业务已在用**)
|
||
|
||
`oss_store.py`(本仓库根目录)+ `config.py` 的 OSS 七项。桶 `mingyuan-oss` 是**私有读**,
|
||
取图必须走签名 URL。**键空间是 1:1 的**,所以迁移没动数据库:
|
||
|
||
| 层 | 形式 |
|
||
|---|---|
|
||
| OSS 对象 key | `plan/{user_id}/{plan_type}/{period_key}/{filename}`(实测线上 1422 个,恰好 4 段) |
|
||
| DB `plan_checkins.attachments` | `attachments/{同上 4 段}` |
|
||
| 头像 | DB `avatars/{uid}/avatar.ext` ↔ OSS `avatars/{uid}/avatar.ext` |
|
||
|
||
> **⚠️ OSS 是唯一图片存储(2026-09-22 收口)。** 原先还有一层「服务器磁盘
|
||
> `/home/static/plan/…`(nginx 直读)」,**连同全部磁盘读写代码一起删除了** ——
|
||
> 别再加回来。唯一保留的"旧痕迹"是 `profile.avatar_key()` 里剥 `/clock/static/` 前缀的
|
||
> 兼容逻辑:**那不是磁盘逻辑**,库里仍有旧格式存量值,剥完就是 OSS 对象 key。
|
||
|
||
- 读:读接口**并排返回** `attachment_urls`(签名 URL)。⚠️ **`attachments` 绝不换成 URL** ——
|
||
小程序把它原样回提交给 `POST /plan/checkin`,一换就把带 `Signature` 的地址写进库了。
|
||
- 写:`POST /profile/avatar` 写 OSS。**未配 OSS 一律 503、写失败 500/400,
|
||
都不落磁盘**(磁盘文件没有任何读路径能取到,写下去等于丢)。
|
||
(计划打卡附件**只走直传**,中转 `POST /plan/upload` 已于 2026-09-22 删除。)
|
||
- 删:`DELETE /plan/file/...` **只删 OSS 对象**(幂等,不需要先查存在性)。
|
||
- ⚠️ **`GET /plan/file/...` 原先完全无鉴权**(可枚举下载任意学员的图),已补 `get_current_user`;
|
||
磁盘分支已拆,现在**一律 302 到签名地址**(只转地址,字节不过服务器),未配 OSS 返 404。
|
||
- ⚠️ `_with_scheme()` 给没写协议的 endpoint 补 `https://`:**oss2 在没协议时默认按 http 处理**,
|
||
而微信小程序只接受 https,会静默裂图(本地验证实际抓到过这条)。
|
||
- ⚠️ **`OSS_PUBLIC_ENDPOINT`(自定义域名)必须配**:`*.aliyuncs.com` 加不进小程序
|
||
`downloadFile` 合法域名白名单,且默认域名看图会强制下载。签名按该 host 计算,不可事后替换。
|
||
- ⚠️ 读路径**没有逐图回退**(不做 HEAD 探测,也没磁盘可退),所以"OSS 上缺哪张就是空白"。
|
||
**补传完整性是上线硬前提**,上线前跑 `scripts/oss_upload/check_oss_coverage.py` 核对。
|
||
- **直传**(2026-09-22 追加):小程序先用 `POST /plan/upload-signature` 取一次 PostObject
|
||
表单签名,再直接 `wx.uploadFile` 到 OSS —— **图片字节不过服务器,省掉入口+出口双份带宽**。
|
||
签名是**目录级**的(`plan/{uid}/{type}/{period}/`):锁得住目录、**锁不住扩展名**,
|
||
所以扩展名白名单实际由 `_is_allowed_attachment` 在 `/plan/checkin` 落库时兜住,**别删**。
|
||
取不到签名(后端异常 / 未配 OSS)前端直接报错提示,**没有中转兜底**(`POST /plan/upload`
|
||
已于 2026-09-22 删除,图片只走直传)。
|
||
只加了**一个**签名接口、**没有回执接口** —— 真正入库是用户保存时 `/plan/checkin` 干的,
|
||
直传无需再通知后端一次。
|
||
签名用 **V1**(已在真实桶上实测通过),V1 表单签名串里不含 Host,
|
||
所以绑自定义域名后同一份签名依然有效。
|
||
- 接口契约与运维清单见 `03.开发文档/接口交接-媒体文件OSS化.md`。
|
||
|
||
### 9.1 OSS 上传脚本(`scripts/oss_upload/`,**是 §9.0 的补传工具**)
|
||
|
||
> **2026-09-17:已从 `wechat_backend/oss_demo/` 迁到 `scripts/oss_upload/`,并裁剪到只剩「上传本地文件」。**
|
||
> `scripts/` **不属于任何 git 仓库**,所以 OSS 的 AK/SK 现在完全不在后端仓库里了
|
||
> (原先写在 `oss_demo/oss_client.py` 里,而后端仓库没有 `.gitignore`,是个风险点)。
|
||
> ⚠️ 注意现在有**两份独立的 OSS 密钥配置**:后端走 `.env`,这个脚本走它自己的
|
||
> `oss_client.py`,**轮换时要一起换**。
|
||
|
||
| 文件 | 用途 |
|
||
|---|---|
|
||
| `upload_local_file.py` | **把本地文件批量上传到 OSS**。改顶部 `LOCAL_SOURCE` / `OSS_PREFIX` 两行后直接运行 |
|
||
| `check_oss_coverage.py` | **核查 DB 引用的图片在 OSS 上是否都在**(2026-09-22 新增)。喂它一个由 SQL 导出的路径清单,输出缺失/孤儿清单,退出码 0=全覆盖。**去掉磁盘兜底后,这就是上线硬前提的检查工具** |
|
||
| `oss_client.py` | 精简版封装(116 行):配置写在代码里 + `get_bucket()` / `signed_get_url()` + 配置门禁 |
|
||
|
||
- 上传用 `bucket.put_object_from_file(key, local)`:逐文件流式读、流式发,**不压缩不打包**,
|
||
本地目录结构原样成为 object key;跳过 `.` 开头的隐藏文件与目录。
|
||
- 退出码:`0` 全部成功 / `1` 有文件失败(单个失败不中断整批)/ `2` 配置或路径有问题,没开跑。
|
||
- ⚠️ 同名 key 是**静默覆盖**(`put_object` 的语义),重跑一次就是覆盖上一次。
|
||
- ⚠️ Bucket `mingyuan-oss`(`oss-cn-shanghai`)是**私有读**:裸地址 GET 返回 403,
|
||
取图必须用 `signed_get_url()`(脚本末尾打印的就是签名 URL)。签名 URL 有效期上限 **7 天**(604800 秒)。
|
||
- ⚠️ 该 Bucket 用 **V4 签名**(`OSS_SIGN_VERSION = "v4"`,此时 `OSS_REGION` 必填)。
|
||
- 占位值守卫:`is_enabled()` 会把 `_PLACEHOLDER_VALUES` 里的值(如 `your-bucket-name`)
|
||
按「没填」处理 —— 否则默认值是**真值**,只填 AK/SK 忘填 Bucket 会被误判成已配置。
|
||
- **与业务的关系:离线工具,不参与运行时。** 后端读写 OSS 走的是自己根目录的 `oss_store.py`,
|
||
与这里没有代码依赖;两者的唯一联系是**对象 key 前缀约定** —— `LOCAL_SOURCE` 指向
|
||
`/home/static`、`OSS_PREFIX` 留空,落出来的 key 才与线上 `plan/...` / `avatars/...` 对齐。
|
||
- 迁移时删掉的:PostObject 表单直传签名(V1/V4)、`routes_oss_demo.py`、`demo_cli.py`、
|
||
`web_upload_test.html`、`samples/`、`.env`/`config.py` 读取链路。
|
||
本地假 OSS 服务收进 skill:`~/.workbuddy/skills/aliyun-oss-postobject-upload/scripts/mock_oss_server.py`。
|
||
- 通用工程知识(PostObject 签名算法、`oss2` 无签名 API、小程序 uploadFile 域名白名单、私有桶读路径)
|
||
见 skill `aliyun-oss-postobject-upload`。
|
||
|
||
### 9.2 小鹅通打卡数据导入(`scripts/xiaoe_import/`)
|
||
|
||
> **2026-09-17:已去重精简。** 两个脚本的 `load_config` / `get_connection` / 日志 / 常量原本各写了一份,
|
||
> 现抽到 `common.py`;并删掉了无人引用的 `user_map.json`(内容就是个 `{}`)与 `user_map.example.json`。
|
||
|
||
历史打卡数据的回流链路,**两步走**,都是改顶部配置区后直接 `python xxx.py`,不带命令行参数:
|
||
|
||
| 脚本 | 方向 |
|
||
|---|---|
|
||
| `xiaoe_import.py` | 小鹅通导出 Excel → 源表 `xiaoe_checkin_records` |
|
||
| `sync_to_plan_checkin.py` | 源表 → 下载图片 → 系统表 `plan_checkins` |
|
||
| `common.py` | 共用的配置读取 / 连接 / 日志 / `ALLOWED_IMAGE_EXT` / `MAX_FILE_SIZE` |
|
||
|
||
- 配置只在 `config.ini`(含**真实数据库密码**,不要外传、不要进 git / 软著材料);
|
||
`config.example.ini` 是模板。
|
||
- ⚠️ `attach_dir` 只是个**暂存目录**(脚本把图下载到本地)。2026-09-22 起后端不再读磁盘,
|
||
所以它**不再需要指向后端目录**;但导入完必须把这些文件传到 OSS,否则库里记了路径、
|
||
读出来是空白。建议仍指向 `/home/static`,这样接着跑 `upload_local_file.py` 一次就能对齐线上布局。
|
||
- ⚠️ **两处历史坑(已修,别改回去)**:① `xiaoe_import.py` 原本读 `import.table_name`,而配置里写的是
|
||
`source_table` → 一直静默走 fallback 默认值(值刚好一样所以没暴露);② README 的配置示例键名
|
||
与代码不一致。现在统一走 `source_table` / `target_table`。
|
||
- Excel 列号写死在 `COL_MAP`(列号从 1 起):1 用户ID / 3 备注名 / 4 分组 / 5 发布时间 /
|
||
6 内容 / 8 动态类型 / 11 任务名。内容里 `图片:https://...` 会被抽成 `attachments` JSON 数组。
|
||
- ⚠️ Excel **中间空行会被写成空记录**(已加 `if not any(row): continue` 守卫)。
|
||
- 幂等性:第一步 `ON DUPLICATE KEY UPDATE`(`skip_duplicate`)、第二步 `plan_checkins` 也是 upsert;
|
||
图片「已存在且非空即跳过」→ **断点续跑安全,整条链路可重复执行**。
|
||
- 附件落盘路径与 `routes/plan.py` 的对象 key **逐段一致**(只是前缀不同):
|
||
`<attach_dir>/<user_id>/<plan_type>/<period_key>/<文件名>`
|
||
↔ OSS `plan/<user_id>/<plan_type>/<period_key>/<文件名>`。
|
||
⚠️ 所以导入完**必须补传 OSS**:后端已经不读磁盘了,只落盘等于图看不到。
|
||
- `user_id` 的映射是**人工在源表里补的**,没有自动化代码;为空的记录第二步直接跳过。
|
||
- 注意 `dynamic_type LIKE '%打卡%'` 只捞打卡动态;`period_key` 由 `task_name`(`第X周(MM.DD~MM.DD)`)
|
||
推断,推不出来退回发布时间所在 ISO 周。
|
||
|
||
---
|
||
|
||
## 10. 已知风险清单(改动前请评估,不要无意中扩大)
|
||
|
||
| 级别 | 位置 | 问题 |
|
||
|---|---|---|
|
||
| 高 | OSS 覆盖完整性 | **读路径没有逐图回退、也没有磁盘兜底**,OSS 上缺哪张就是空白。2026-09-22 实测桶里只有 `2026-W30 / W37 / W38 / W39 / 2026-09 / 2026` 六个周期(1422 个 `plan/` 对象)与 29 个头像。**上线前必须跑 `scripts/oss_upload/check_oss_coverage.py` 核对 DB 引用**,退出码 0 才切前端 |
|
||
| 高 | nginx `/clock/static/plan/` `/clock/static/avatars/` | 磁盘上的老图仍被 nginx **无鉴权**直接吐出去,绕过 `get_current_user`。磁盘读路径已拆,**关掉这两条静态路由不影响任何功能**,但要手动关 |
|
||
| 中 | 签名 URL 有效期 | 默认 `OSS_URL_EXPIRE=432000`(5 天),上限 7 天。页面停留超过它之后再新渲染的图会失效(已加载的不受影响);前端缓存会提前 1 小时自动换新 |
|
||
| 中 | OSS 密钥有两份 | 后端读 `.env`,`scripts/oss_upload/oss_client.py` 里另写死一份(该目录不属任何 git 仓库)。**轮换时必须一起换**,否则补传与线上读写会有一边失效 |
|
||
| 中 | 未配 OSS = 功能直接挂 | 2026-09-22 收口后**没有磁盘降级了**:缺 OSS 环境变量时上传返回 503、图片全空白。所以环境变量是上线前置,不是可选项 |
|
||
| ~~高~~ | ~~`routes/plan.py` `POST /plan/upload`~~ | **2026-09-22 已修复**:原先完全不校验 `period_key`(可 `../../` 穿越),现非法值回落到当前周期。`GET /plan/file` 的无鉴权问题同日一并修复 |
|
||
| 高 | `routes/feedback.py` `GET /feedback` | **无任何鉴权**,任何人可读取全部反馈(含联系方式) |
|
||
| 高 | `wechat_backend/.env` | 曾纳入 git 且含真实密钥,需确认是否已从历史中清除 |
|
||
| 中 | `routes/auth.py` `POST /auth/password-login` | **无失败次数限制**(按需求确认不做防爆破)。手机号+密码可在线暴力尝试,接口一旦暴露到公网即有撞库风险。另:`set-password` 依赖的 `ADMIN_KEY` 是长期固定密钥,无轮换机制 |
|
||
| 中 | `plan.py` `_validate_month_key` | 仍接受 `2026-9`、`2026-09 ` 等非规范格式(写入侧已归一化;读接口 `_normalize_period` 仍按原样返回) |
|
||
| 中 | 全局时区 | 后端**无 TZ 配置**,`date.today()` 依赖服务器系统时区,直接影响周期边界。需确认生产机为 `Asia/Shanghai` |
|
||
| 中 | 本仓库 | `.gitignore` 已于 2026-09-22 补上(忽略 `.env` / `__pycache__` / `plan_attachments`)。但 **`.env` 仍在 git 索引里且是 staged 状态**,忽略规则对它无效,需手动 `git rm --cached .env` 一次(只取消跟踪,不删本地文件) |
|
||
| 低 | `main.py` | `__main__` 块指向 `server_prd.main:app`,直接运行会失败 |
|
||
|
||
---
|
||
|
||
## 11. 工作约定
|
||
|
||
1. **改后端业务规则先读 `routes/checkin.py` / `routes/plan.py` 全文再动手**,第 8 节只是索引,不是完整实现。
|
||
2. **数据库变更必须新增迁移脚本** `migrations/00X_xxx.sql`,写在注释里说明目标库、背景、幂等性、备份建议
|
||
(参照 `005_class_date_sunday.sql` 的写法),并**手工执行**。
|
||
3. **不要提交密钥**:`.env`、`JWT_SECRET`、`ADMIN_KEY`、`WECHAT_SECRET`、`MYSQL_PASSWORD`。
|
||
4. **不要绕过连接池**自己开数据库连接。
|
||
5. 新增或改动**面向前端的接口**时,照 `03.开发文档/接口交接-周计划打卡名单-GET-plan-records.md`
|
||
的结构同步维护一份交接文档。
|
||
6. 测试路径(`server_test` vs `clock_test`)两说并存,**发布前先上服务器实测确认**,不要凭文档直接发。
|
||
|
||
---
|
||
|
||
## 12. 常用命令速查
|
||
|
||
```bash
|
||
# ── 本地开发(注意 cwd 需让 server 包可被导入,参见 start.sh)
|
||
uvicorn server.main:app --host 0.0.0.0 --port 8000 --reload
|
||
curl http://127.0.0.1:8000/api/v1/health
|
||
|
||
# ── 测试环境
|
||
cd /home/clock_server && ./start_test.sh # 启动(8000)
|
||
cd /home/clock_server && ./stop_test.sh # 停止
|
||
tail -f /home/clock_server/logs/test/server.err.log
|
||
|
||
# ── 生产环境
|
||
cd /home/clock_server && ./start_prd.sh # 启动(8001)
|
||
|
||
# ── 数据库迁移(手工,注意目标库)
|
||
mysql mys_info_test < wechat_backend/migrations/005_class_date_sunday.sql
|
||
```
|
||
|
||
> 本地验证后端单个接口时,**不要连生产库**(生产库只在内网)。用 skill
|
||
> `fastapi-endpoint-local-verify` 的做法:打桩 db 层 + `dependency_overrides` 覆盖鉴权 + TestClient。
|