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

441 lines
32 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。