# 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 存在 `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 < 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 稳定,微信 `` 的「按 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 **逐段一致**(只是前缀不同): `////<文件名>` ↔ OSS `plan////<文件名>`。 ⚠️ 所以导入完**必须补传 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。