feat(api): 重构图片地址管理与上传机制

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

45
.env.example Normal file
View File

@@ -0,0 +1,45 @@
# 环境变量模板:复制成 .env 再填真值。
# ⚠️ .env 里有真实密钥,**不要提交进 git**(本仓库历史上 .env 被跟踪过,务必先配 .gitignore)。
# 本文件只能放占位值。
# ── 微信小程序 ──────────────────────────────────────────────
WECHAT_APPID=your-wechat-appid
WECHAT_SECRET=your-wechat-secret
# ── 会话 / 管理口令 ────────────────────────────────────────
# JWT_SECRET 是历史命名,实际不签发 JWT;会话 token 存在 auth_sessions 表。
JWT_SECRET=replace-with-random-string
JWT_TTL_SECONDS=7776000
# 管理密钥:同时守护 POST /feedback/resolve 与 POST /auth/set-password,务必轮换过再上线。
ADMIN_KEY=replace-with-random-string
PHONE_HMAC_KEY=replace-with-random-string
# ── MySQL ─────────────────────────────────────────────────
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=clock_service
MYSQL_PASSWORD=replace-me
MYSQL_DB=mys_info_v3
SURVEY_DB=myxhxc
# ── 静态资源目录(本地磁盘,OSS 切完之后仍作为回退)────────────
PLAN_ATTACH_DIR=/home/static/plan
STATIC_ROOT=/home/static
AVATAR_DIR=/home/static/avatars
FEEDBACK_DIR=/home/introduction/feedback
COURSE_VOTES_FILE=/home/introduction/test/course_votes.json
# ── 阿里云 OSS(打卡附件 + 头像)────────────────────────────
# 建议用只授权该 Bucket 的 RAM 子账号,不要用主账号 AK。
# 这四项缺任意一项,后端就整体按「OSS 未启用」处理:上传落磁盘、读接口返回相对路径。
OSS_ACCESS_KEY_ID=your-access-key-id
OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
OSS_BUCKET=mingyuan-oss
# 绑定的自定义域名(例:https://oss.muguaclub.cn)。**必须填**,否则签名 URL 落在
# *.aliyuncs.com 上,而那个域名加不进小程序「downloadFile 合法域名」白名单。
OSS_PUBLIC_ENDPOINT=
# 签名 URL 有效期(秒),上限 604800(7 天)
OSS_URL_EXPIRE=7200

440
AGENTS.md Normal file
View File

@@ -0,0 +1,440 @@
# 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。

View File

@@ -19,7 +19,11 @@
- `SURVEY_DB`:问卷库 `myxhxc` - `SURVEY_DB`:问卷库 `myxhxc`
- `PHONE_HMAC_KEY`:手机号 HMAC 密钥,必须与既有 `phone_encrypted` 生成一致(默认 `MingYuan2026AuthKey`) - `PHONE_HMAC_KEY`:手机号 HMAC 密钥,必须与既有 `phone_encrypted` 生成一致(默认 `MingYuan2026AuthKey`)
- `H5_BASE_URL`:H5 模块基础地址(默认 `https://muguaclub.cn`) - `H5_BASE_URL`:H5 模块基础地址(默认 `https://muguaclub.cn`)
- `FEEDBACK_DIR` / `COURSE_VOTES_FILE` / `PLAN_ATTACH_DIR`:文件存储路径 - `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,没有磁盘兜底**,这四项缺任意一项上传会直接 503
- 注:原 `PLAN_ATTACH_DIR` / `AVATAR_DIR` / `STATIC_ROOT` 已随磁盘读路径删除
3. 执行 wechat_openid 迁移(一次性): 3. 执行 wechat_openid 迁移(一次性):

View File

@@ -32,9 +32,31 @@ H5_BASE_URL = _env("H5_BASE_URL", "http://127.0.0.1:8001").rstrip("/")
FEEDBACK_DIR = _env("FEEDBACK_DIR", "/home/introduction/feedback") FEEDBACK_DIR = _env("FEEDBACK_DIR", "/home/introduction/feedback")
COURSE_VOTES_FILE = _env("COURSE_VOTES_FILE", "/home/introduction/test/course_votes.json") COURSE_VOTES_FILE = _env("COURSE_VOTES_FILE", "/home/introduction/test/course_votes.json")
PLAN_ATTACH_DIR = _env("PLAN_ATTACH_DIR", "/home/static/plan")
# nginx 静态资源根目录:HEAD/登录后图片统一由 nginx 直接读取 # ── 阿里云 OSS:打卡附件 / 头像的对象存储(2026-09-22 新增)─────────────
STATIC_ROOT = _env("STATIC_ROOT", "/home/static").rstrip("/") # **图片的唯一存储**。2026-09-22 收口后已无本地磁盘兜底:`PLAN_ATTACH_DIR` /
AVATAR_DIR = _env("AVATAR_DIR", "/home/static/avatars").rstrip("/") # `AVATAR_DIR` / `STATIC_ROOT` 三个磁盘路径配置项随磁盘读路径一起删掉了。
# 未配置时 oss_store.is_enabled() 为 False:上传接口返回 503、读接口的
# attachment_urls 全为空串(前端干脆不显示图),**不会**退化成读写磁盘。
# 注意反馈模块(FEEDBACK_DIR)的截图仍走磁盘,它不在本次 OSS 化范围内。
# AK/SK 只从环境变量取,**不写进仓库**(.env 历史上有被 git 跟踪过,务必配 .gitignore)。
OSS_ACCESS_KEY_ID = _env("OSS_ACCESS_KEY_ID")
OSS_ACCESS_KEY_SECRET = _env("OSS_ACCESS_KEY_SECRET")
OSS_ENDPOINT = _env("OSS_ENDPOINT", "oss-cn-shanghai.aliyuncs.com")
OSS_BUCKET = _env("OSS_BUCKET", "mingyuan-oss")
# 绑了自定义域名就填这里(例:https://oss.muguaclub.cn)。
# 为什么必要:小程序 <image> 只能加载「downloadFile 合法域名」里的域名,
# *.aliyuncs.com 加不进白名单;且 OSS 默认域名访问图片会强制下载、浏览器无法直接预览。
# 签名 URL 会用这个 host 计算签名(V4 的签名包含 Host),所以它必须和实际访问的域名一致。
OSS_PUBLIC_ENDPOINT = _env("OSS_PUBLIC_ENDPOINT")
# 签名 URL 有效期(秒)。OSS 硬上限 604800(7 天),超了报 0002-00000232。
# 默认 5 天(432000):小程序端会把这个 URL 缓存在本地,**URL 稳定**才能让微信
# `<image>` 的「按 URL 缓存图片」命中 —— 后端每次现签的话 Expires 每次都不同,
# 微信会当作新图**反复重新下载整个文件**。
# ⚠️ 代价是泄漏窗口变长:拿到 URL 的人在有效期内都能看图,而这个桶里是学员打卡照。
# 若敏感度优先于省流量,把它调小即可 —— 前端缓存会随签名有效期自然失效,不必改代码。
OSS_URL_EXPIRE = int(_env("OSS_URL_EXPIRE", "432000"))
# PostObject 表单直传签名的有效期(秒)。默认 10 分钟 —— 从「取到签名」到「传完」
# 通常只要几秒,窗口开太长等于把可写时间放大。
OSS_POST_EXPIRE = int(_env("OSS_POST_EXPIRE", "600"))

View File

@@ -26,5 +26,5 @@ def health():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("server_prd.main:app", host="127.0.0.1", port=8000, reload=True) uvicorn.run("wechat_backend.main:app", host="127.0.0.1", port=8000, reload=True)

214
oss_store.py Normal file
View File

@@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""阿里云 OSS 存取封装(打卡附件 / 头像共用)。
对外只有 5 个函数:`is_enabled` / `missing_config` / `put_bytes` / `delete` / `signed_url`。
三条设计要点,都不是随手写的:
1. **惰性导入 oss2**。开发机、测试机、甚至刚拉下来的服务器上可能还没装 oss2;
如果模块顶层 `import oss2` 失败,整个 FastAPI 服务就起不来了。
所以这里 try/except,`is_enabled()` 会把「没装 oss2」也算作「未启用」。
2. **配置不全一律当「未启用」**,不做半吊子尝试 —— 避免用假配置去打真实 OSS,
也避免出现"部分文件上了云、部分还在盘上"这种没法查的状态。
3. **AK/SK 只从环境变量取**(见 config.py),仓库里不留密钥。
存储路径约定(**和磁盘、数据库逐段一致,只差前缀**,这是整个方案能不动数据的前提):
对象 key plan/{user_id}/{plan_type}/{period_key}/{filename}
服务器磁盘 /home/static/plan/{user_id}/{plan_type}/{period_key}/{filename}
数据库 attachments attachments/{user_id}/{plan_type}/{period_key}/{filename}
所以 `attachments/` 前缀换成 `plan/`,就是对象 key。头像同理:
数据库 `avatars/{uid}/avatar.jpg` ↔ 对象 key `avatars/{uid}/avatar.jpg`。
"""
import base64
import hashlib
import hmac
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from . import config
logger = logging.getLogger(__name__)
try:
import oss2
except ImportError: # pragma: no cover —— 取决于部署环境是否装了依赖
oss2 = None
# OSS 签名 URL 的硬上限:7 天。超过这个值 OSS 直接报 0002-00000232,不会给你签。
MAX_EXPIRE = 7 * 24 * 3600
# 这些值出现在配置里说明「还没真配」,要和空值一样当成未配置。
# 否则默认值(比如 your-bucket-name)是真值,只填了 AK/SK 也会被误判成"已配置"。
_PLACEHOLDER_VALUES = {
"", "your-access-key-id", "your-access-key-secret", "your-bucket-name",
"your-bucket", "xxx", "xxxx", "todo", "changeme", "placeholder",
"ltaixxxxxxxx", "oss-cn-hangzhou.aliyuncs.com.example",
}
def _ok(value):
"""配置项是否是一个真实可用的值(非空、非占位符)。"""
text = (value or "").strip()
return text.lower() not in _PLACEHOLDER_VALUES
def missing_config():
"""返回缺失/占位的配置项名字列表,空列表表示配置齐全。"""
required = ("OSS_ACCESS_KEY_ID", "OSS_ACCESS_KEY_SECRET", "OSS_ENDPOINT", "OSS_BUCKET")
return [name for name in required if not _ok(getattr(config, name, ""))]
def is_enabled():
"""OSS 是否可用。False 时调用方必须退回本地磁盘,不能抛异常。"""
return oss2 is not None and not missing_config()
_buckets = {}
def _with_scheme(endpoint):
"""给没写协议的 endpoint 补上 `https://`。
⚠️ 这一步不能省:oss2 在没写协议时默认按 **http** 处理,签出来的就是 http 链接。
而微信小程序只接受 https,图片会直接加载失败 —— 而且失败得很隐蔽,
因为签名串本身完全合法,只是协议不对(2026-09-22 本地验证实际抓到这一条)。
显式写了 `http://` 的地址(例如本地假 OSS 服务)不会被改动。
"""
text = (endpoint or "").strip().rstrip("/")
if text and not text.startswith(("http://", "https://")):
text = "https://" + text
return text
def _endpoint(public=False):
if public and _ok(config.OSS_PUBLIC_ENDPOINT):
return _with_scheme(config.OSS_PUBLIC_ENDPOINT), True
return _with_scheme(config.OSS_ENDPOINT), False
def _bucket(public=False):
"""取得(并缓存)Bucket 对象。
`public=True` 时用配置的自定义域名建一个 CNAME 型 Bucket,**只用来算签名**。
这点很关键:V1 签名的 CanonicalizedResource 带 bucket 名、V4 签名的 CanonicalRequest
带 Host,所以「用哪个域名签」必须和「实际用哪个域名访问」一致,否则 403。
建 Bucket 不产生任何网络请求,因此这里不会有性能开销。
"""
endpoint, is_cname = _endpoint(public)
cache_key = (endpoint, is_cname)
bucket = _buckets.get(cache_key)
if bucket is None:
auth = oss2.Auth(config.OSS_ACCESS_KEY_ID.strip(), config.OSS_ACCESS_KEY_SECRET.strip())
bucket = oss2.Bucket(auth, endpoint, config.OSS_BUCKET.strip(), is_cname=is_cname)
_buckets[cache_key] = bucket
return bucket
def put_bytes(key, data, content_type=None):
"""上传一段字节到指定 key。同名 key 是覆盖语义。"""
headers = {"Content-Type": content_type} if content_type else None
return _bucket().put_object(key, data, headers=headers)
def delete(key):
"""删除对象。**不存在的 key 也算成功**(OSS delete 是幂等的),调用方无需先查存在性。"""
return _bucket().delete_object(key)
def signed_url(key, expires=None):
"""生成签名 URL(默认取 config.OSS_URL_EXPIRE)。
私有桶必须靠它才能取到图;签名是**纯本地计算**,不发网络请求,可以放心循环里批量签。
`slash_safe=True` 让 key 里的 `/` 不被转义成 %2F,保持目录层级可读。
"""
seconds = int(expires or config.OSS_URL_EXPIRE)
seconds = max(60, min(seconds, MAX_EXPIRE))
return _bucket(public=True).sign_url("GET", key, seconds, slash_safe=True)
def guess_content_type(filename):
"""按扩展名给出 Content-Type,用于上传时写对媒体类型。"""
ext = os.path.splitext(filename or "")[1].lstrip(".").lower()
return {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "webp": "image/webp", "bmp": "image/bmp",
}.get(ext)
# PostObject 单文件大小上限(字节)。默认 10MB,与 plan 上传接口一致,
# 调用方可用 `max_size` 覆盖成更小的值。
MAX_POST_SIZE = 10 * 1024 * 1024
def public_host():
"""直传要用的上传域名(含协议,不带路径)。
**优先用自定义域名**:OSS 默认域名加不进小程序的「uploadFile 合法域名」
(`*.aliyuncs.com` 的备案主体是阿里云、子域名不继承主体资质),真机上是
`url not in domain list` 直接失败。没绑自定义域名时退回默认域名 ——
此时开发者工具能传通、真机不行,属于预期行为,不是 bug。
"""
if _ok(config.OSS_PUBLIC_ENDPOINT):
return _with_scheme(config.OSS_PUBLIC_ENDPOINT)
endpoint = _with_scheme(config.OSS_ENDPOINT)
scheme, _, rest = endpoint.partition("://")
bucket = config.OSS_BUCKET.strip()
if rest.startswith(bucket + "."):
return endpoint
return "{}://{}.{}".format(scheme, bucket, rest)
def post_form(key_prefix, expire=None, max_size=None):
"""生成 PostObject 表单签名,供小程序「直传」(图片字节不经过后端)。
直传省掉的是服务器的**入口+出口双份带宽**(中转模式下字节要先收进来再发出去);
后端只签发这一小段 policy,成本可忽略。
两个关键设计,都别改成"更直接"的写法:
1. **只签「目录前缀」,不签完整 key。** 小程序上传前会先压缩大图,
而 `wx.compressImage` 会换掉文件名(临时路径),签完整 key 就必须提前
知道最终文件名,做不到「一次签名、多张复用」。锁前缀后一份 policy
可以覆盖本次所有图片。代价是前端能在该目录内自行命名 —— 由调用方
把前缀锁到 `plan/{user_id}/{app_type}/{period}/` 来兜住。
2. **前缀必须锁到用户自己的目录**,绝不能是 `plan/`。这是防越权覆盖
他人附件的唯一防线:锁到 `plan/` 等于把所有人的目录都开放给前端了。
关于签名版本:2026-09-22 已在真实 `mingyuan-oss` 上**实测 V1 表单签名可用**
(POST 返回 204、`object_exists=True`、Content-Type 正确),所以不引入 V4。
另一个好处是 **V1 表单签名的签名串里不含 Host** —— 同一份签名 POST 到默认
域名或自定义域名都有效,以后绑自定义域名不需要重新设计签名。
"""
seconds = int(expire or config.OSS_POST_EXPIRE)
seconds = max(30, min(seconds, MAX_EXPIRE))
limit = int(max_size or MAX_POST_SIZE)
expiration = (datetime.now(timezone.utc) + timedelta(seconds=seconds)) \
.strftime("%Y-%m-%dT%H:%M:%S.000Z")
policy = {
"expiration": expiration,
"conditions": [
["eq", "$bucket", config.OSS_BUCKET.strip()],
["content-length-range", 0, limit],
["starts-with", "$key", key_prefix],
],
}
policy_b64 = base64.b64encode(
json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(hmac.new(
config.OSS_ACCESS_KEY_SECRET.strip().encode("utf-8"),
policy_b64.encode("ascii"), hashlib.sha1).digest()).decode("ascii")
return {
"host": public_host(),
"expires_in": seconds,
"key_prefix": key_prefix,
"form_data": {
"policy": policy_b64,
"OSSAccessKeyId": config.OSS_ACCESS_KEY_ID.strip(),
"signature": signature,
},
}

View File

@@ -6,3 +6,4 @@ httpx>=0.27
python-multipart>=0.0.9 python-multipart>=0.0.9
pytest>=8.0 pytest>=8.0
DBUtils>=3.0 DBUtils>=3.0
oss2>=2.17

View File

@@ -22,6 +22,28 @@ class BindPhoneRequest(BaseModel):
phone_code: str phone_code: str
class PasswordLoginRequest(BaseModel):
phone: str
password: str
class SetPasswordRequest(BaseModel):
"""运营更改单个学员登录密码的入参(需验证老密码)。
- `old_password`:老密码(`password_hash` 为空时默认 = 手机号后6位)。
- `new_password`:新密码。
"""
phone: str
old_password: str
new_password: str
# 密码长度约束(设置密码时校验;登录时不校验,避免历史弱密码登不进来)
PASSWORD_MIN_LEN = 8
PASSWORD_MAX_LEN = 64
def get_current_user(request: Request): def get_current_user(request: Request):
token = "" token = ""
@@ -100,6 +122,99 @@ def bind_phone(req: BindPhoneRequest):
return {"success": True, "is_new": True, "token": token, **user_payload(user)} return {"success": True, "is_new": True, "token": token, **user_payload(user)}
@router.post("/password-login")
def password_login(req: PasswordLoginRequest):
"""手机号 + 密码登录(与微信登录并列的第二条登录路径,2026-09-22 新增)。
- 查询靠 `phone_encrypted` 的 HMAC 比对,**库里不存明文手机号**,
做法与 `bind-phone` 完全一致(同一个 `encrypt_phone`)。
- 错误文案统一成「手机号或密码错误」,**刻意不区分**「账号不存在」与「密码不对」——
否则这个接口就成了「哪些手机号是有效学员」的枚举器。
- 响应结构与 `/wechat-login` 一致,前端两条登录路径可共用后续处理。
- **未设置密码(`password_hash` 为空)时,默认密码 = 手机号后6位**,便于学员免初始设置直接登录。
- ⚠️ 与微信登录保持一致,**不校验 `status`**:`暂停` 学员仍然能登录。
"""
phone = (req.phone or "").strip()
password = req.password or ""
if not phone or not password:
raise HTTPException(status_code=400, detail="手机号与密码均不能为空")
user = db.fetch_one(
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
(security.encrypt_phone(phone),),
)
if not user:
logger.info("密码登录失败:手机号未匹配到账号")
raise HTTPException(status_code=401, detail="手机号或密码错误")
stored = user.get("password_hash")
used_default = False
if not stored:
# 未设置密码的账号,默认密码 = 手机号后6位(便于学员免初始设置直接登录)。
stored = security.hash_password(phone[-6:])
used_default = True
if not security.verify_password(password, stored):
# 日志里区分「默认密码也不对」与「密码错」,方便运营排查;对外文案保持一致。
# 典型情况:006 迁移还没执行 → password_hash 列取不到 → 这里走默认密码分支。
reason = "默认密码(手机号后6位)不正确" if used_default else "密码不正确"
logger.info("密码登录失败:user_id=%s %s", user["id"], reason)
raise HTTPException(status_code=401, detail="手机号或密码错误")
token = _record_session(user["id"])
return {"success": True, "token": token, **user_payload(user)}
def _check_password_input(phone, password):
"""校验一条待设置的账号密码,返回错误原因;空串表示通过。"""
if not phone:
return "手机号不能为空"
if not password:
return "密码不能为空"
if len(password) < PASSWORD_MIN_LEN or len(password) > PASSWORD_MAX_LEN:
return "密码长度需为 %d~%d 位" % (PASSWORD_MIN_LEN, PASSWORD_MAX_LEN)
if password == phone:
return "密码不能与手机号相同"
return ""
@router.post("/set-password")
def set_password(req: SetPasswordRequest, request: Request):
"""
- **改密码需先验证老密码**:`old_password` 正确才更新;`password_hash` 为空时
默认老密码 = 手机号后6位(与 `password-login` 的默认密码逻辑一致)。
- **直接覆盖已有密码**("重置"就是这个语义),没有二次确认。
- 手机号必须能匹配到 `auth_users`(用 `encrypt_phone` 比对);
匹配不到即报错,**不会新建账号** —— 本系统没有注册流程,用户由运营导入。
"""
phone = (req.phone or "").strip()
old_password = req.old_password or ""
new_password = req.new_password or ""
err = _check_password_input(phone, new_password)
if err:
raise HTTPException(status_code=400, detail=err)
user = db.fetch_one(
"SELECT * FROM auth_users WHERE phone_encrypted=%s",
(security.encrypt_phone(phone),),
)
if not user:
raise HTTPException(status_code=404, detail="手机号未匹配到学员账号")
# 验证老密码:password_hash 为空时,默认老密码 = 手机号后6位。
stored = user.get("password_hash")
if not stored:
stored = security.hash_password(phone[-6:])
if not security.verify_password(old_password, stored):
raise HTTPException(status_code=403, detail="老密码不正确")
db.execute(
"UPDATE auth_users SET password_hash=%s WHERE id=%s",
(security.hash_password(new_password), user["id"]),
)
logger.info("设置登录密码:user_id=%s", user["id"])
return {"success": True, "user_id": user["id"]}
@router.get("/verify") @router.get("/verify")
def verify(user=Depends(get_current_user)): def verify(user=Depends(get_current_user)):
return {"success": True, **user_payload(user)} return {"success": True, **user_payload(user)}

View File

@@ -4,11 +4,11 @@ import os
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from typing import List from typing import List
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from .. import config, db from .. import db, oss_store
from .auth import get_current_user from .auth import get_current_user
@@ -24,16 +24,8 @@ MAX_CONTENT_LEN = 1000
MAX_FILE_SIZE = 10 * 1024 * 1024 MAX_FILE_SIZE = 10 * 1024 * 1024
MAX_FILES = 4 MAX_FILES = 4
ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"} ALLOWED_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
ATTACH_DIR = config.PLAN_ATTACH_DIR
SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at" SELECT_COLS = "id,user_id,plan_type,period_key,status,checked_at,content,attachments,created_at,updated_at"
CONTENT_TYPES = {
"pdf": "application/pdf", "png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp",
"bmp": "image/bmp", "doc": "application/msword",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}
def iso_week_key(day): def iso_week_key(day):
iso = day.isocalendar() iso = day.isocalendar()
@@ -282,7 +274,56 @@ def _parse_attachments(raw):
return raw or [] return raw or []
ATTACH_PREFIX = "attachments/"
OSS_PLAN_PREFIX = "plan/"
def oss_key(rel_path):
"""规范相对路径 -> OSS 对象 key。
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` 对应的对象 key 是
`plan/{user_id}/{plan_type}/{period_key}/{filename}` —— 只差一个前缀,
所以从磁盘换成 OSS 时**数据库里的相对路径一个字都不用改**。
外链(http/https)不是本桶的对象,返回空串。
"""
if not isinstance(rel_path, str) or not rel_path.startswith(ATTACH_PREFIX):
return ""
tail = rel_path[len(ATTACH_PREFIX):].strip("/")
if not tail or ".." in tail:
return ""
return OSS_PLAN_PREFIX + tail
def media_url(rel_path):
"""把规范相对路径换成可直接喂给 `<image>` 的地址。
- 外链(http/https):原样返回。库里确实存在外链图片(小鹅通导入那批),必须放行。
- OSS 未启用 / key 非法:空串。前端**已经没有静态地址可退**,那张图就是不显示 ——
这是刻意的:图片只存在于 OSS,退回 nginx 静态地址只会得到一张 404 的裂图。
- 其余:OSS 签名 URL(纯本地计算,不发网络请求,可放心循环批量签)。
"""
if not isinstance(rel_path, str) or not rel_path.strip():
return ""
text = rel_path.strip()
if text.startswith(("http://", "https://")):
return text
key = oss_key(text)
if not key or not oss_store.is_enabled():
return ""
return oss_store.signed_url(key)
def media_urls(paths):
"""批量签名,**长度与顺序和入参严格一致**,签不出来的位置留空串。
保持等长是有意的:前端按同一个下标去取 `attachment_urls[i]`,
一旦长度不等就会出现"缩略图和正文错位"这种很难查的问题。
"""
return [media_url(p) for p in (paths or [])]
def fmt_rec(rec): def fmt_rec(rec):
files = _parse_attachments(rec.get("attachments"))
return { return {
"id": rec["id"], "id": rec["id"],
"user_id": rec["user_id"], "user_id": rec["user_id"],
@@ -291,7 +332,13 @@ def fmt_rec(rec):
"status": rec.get("status", DEFAULT_STATUS), "status": rec.get("status", DEFAULT_STATUS),
"checked_at": fmt_dt(rec.get("checked_at")), "checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content", ""), "content": rec.get("content", ""),
"attachments": _parse_attachments(rec.get("attachments")), # attachments 是**规范值**,会被 /plan/checkin 原样回写进库,格式不许变。
"attachments": files,
# 展示专用的签名 URL,与 attachments 一一对应。
# 为什么另开一个字段而不是直接把 attachments 换成 URL:小程序把
# this.data.attachments 原样提交回 /plan/checkin,一旦这里返回签名 URL,
# 用户保存一次打卡就会把带 Expires/Signature 的地址写进数据库。
"attachment_urls": media_urls(files),
"created_at": fmt_dt(rec.get("created_at")), "created_at": fmt_dt(rec.get("created_at")),
"updated_at": fmt_dt(rec.get("updated_at")), "updated_at": fmt_dt(rec.get("updated_at")),
} }
@@ -539,17 +586,22 @@ def group(type: str, group_name: str, period_key: str = "", user=Depends(get_cur
result = [] result = []
for m in members: for m in members:
rec = rec_map.get(m["user_id"]) rec = rec_map.get(m["user_id"])
record = None
if rec:
files = _parse_attachments(rec.get("attachments"))
record = {
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content") or "",
"attachments": files,
"attachment_urls": media_urls(files),
}
result.append({ result.append({
"user_id": m["user_id"], "user_id": m["user_id"],
"student_id": m.get("student_id") or "", "student_id": m.get("student_id") or "",
"name": m["name"], "name": m["name"],
"filled": rec is not None, "filled": rec is not None,
"record": { "record": record,
"status": rec["status"],
"checked_at": fmt_dt(rec.get("checked_at")),
"content": rec.get("content") or "",
"attachments": _parse_attachments(rec.get("attachments")),
} if rec else None,
}) })
result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"])) result.sort(key=lambda item: (0 if not item["filled"] else 1, item["user_id"]))
return { return {
@@ -647,7 +699,9 @@ def checkin(req: PlanCheckinRequest, user=Depends(get_current_user)):
content = req.content.strip() content = req.content.strip()
if len(content) > MAX_CONTENT_LEN: if len(content) > MAX_CONTENT_LEN:
content = content[:MAX_CONTENT_LEN] content = content[:MAX_CONTENT_LEN]
attachments = [a for a in req.attachments if isinstance(a, str) and a.strip()][:MAX_FILES] # 落库前的最后一道闸:直传模式下客户端能往自己目录塞任意后缀的文件,
# 签名只能锁目录、锁不住扩展名,所以必须在这里再筛一遍。
attachments = [a for a in (req.attachments or []) if _is_allowed_attachment(a)][:MAX_FILES]
attachments_json = json.dumps(attachments, ensure_ascii=False) attachments_json = json.dumps(attachments, ensure_ascii=False)
now = datetime.now() now = datetime.now()
db.execute( db.execute(
@@ -684,59 +738,113 @@ def history(type: str, page: int = 1, size: int = 10, user=Depends(get_current_u
return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size} return {"list": [fmt_rec(r) for r in rows], "total": total, "page": page, "size": size}
@router.post("/upload") def _safe_period_key(value):
def upload( """period_key 会直接拼进 OSS 对象 key,必须挡住路径穿越。
plan_type: str = Form(...),
period_key: str = Form(""), 传 `../../..` 就能把文件写到预期目录之外 —— 需求文档 V3.0 列为待修项。
files: List[UploadFile] = File(...), 非法值一律当作"没传",回落到当前周期,而不是报错中断(保持旧调用方兼容)。
user=Depends(get_current_user), """
): text = (value or "").strip()
plan_type = plan_type.strip() if not text or "/" in text or "\\" in text or ".." in text:
return ""
return text
def _is_allowed_attachment(rel_path):
"""落库前的最后一道闸:只收「规范相对路径 + 白名单扩展名」。
⚠️ 这道校验在直传模式下**不能省**。PostObject 的 policy 用 `starts-with`
锁住了目录,但**锁不住扩展名** —— 一个被改过的客户端可以往自己的目录里
POST 任意后缀的文件(比如 .html)。所以真正决定「什么能进 attachments
字段」的地方是这里,而不是前端、也不是签名接口。
"""
if not isinstance(rel_path, str):
return False
text = rel_path.strip()
if not text:
return False
if text.startswith(("http://", "https://")):
return True # 库里确实存在外链图(小鹅通导入那批),必须放行
if not oss_key(text):
return False # 非 attachments/ 前缀、含 `..` 的都在这里被挡掉
return os.path.splitext(text)[1].lower() in ALLOWED_IMAGE_EXT
class UploadSignatureRequest(BaseModel):
plan_type: str = ""
period_key: str = ""
@router.post("/upload-signature")
def upload_signature(req: UploadSignatureRequest, user=Depends(get_current_user)):
"""给「小程序直传 OSS」签一份 PostObject 表单签名。
直传后图片字节只走「小程序 → OSS」,服务器带宽为零;后端只签发这一小段 policy。
安全要点(改前先读 `oss_store.post_form` 的注释):
1. **key 前缀由服务端用 session 里的 user_id 拼,前端无法指定。**
若让前端传 key,它就能写到 `plan/{别人的 uid}/...` 覆盖他人附件。
2. 返回的是**目录级**签名(`plan/{uid}/{type}/{period}/`),前端在该目录内
自行命名。原因是小程序会先压缩再上传,而压缩会换掉文件名 ——
签完整 key 就必须提前知道最终文件名,做不到「一次签名、多张复用」。
3. 扩展名**不靠签名兜**,而靠 `/plan/checkin` 落库时再校验一次
(`_is_allowed_attachment`)。所以那道校验不能删。
未配置 OSS 时返回 `enabled=false`,前端据此提示「上传服务不可用」——
这不是错误分支,而是设计好的提示路径(没有中转兜底,图片只走直传)。
"""
plan_type = (req.plan_type or "").strip()
if plan_type not in PLAN_TYPES: if plan_type not in PLAN_TYPES:
raise HTTPException(status_code=400, detail="无效的计划类型") raise HTTPException(status_code=400, detail="无效的计划类型")
if len(files) > MAX_FILES: if not oss_store.is_enabled():
raise HTTPException(status_code=400, detail="最多上传4个文件") return {"success": True, "enabled": False}
period_key = period_key.strip() or get_period_key(plan_type) period_key = _safe_period_key(req.period_key) or get_period_key(plan_type)
save_dir = os.path.join(ATTACH_DIR, str(user["id"]), plan_type, period_key) tail = str(user["id"]) + "/" + plan_type + "/" + period_key + "/"
os.makedirs(save_dir, exist_ok=True) form = oss_store.post_form(OSS_PLAN_PREFIX + tail, max_size=MAX_FILE_SIZE)
saved = [] return {
for f in files: "success": True,
data = f.file.read() "enabled": True,
if len(data) > MAX_FILE_SIZE: "rel_prefix": ATTACH_PREFIX + tail,
continue "key_prefix": form["key_prefix"],
safe_name = os.path.basename(f.filename or "") "host": form["host"],
if not safe_name: "expires_in": form["expires_in"],
continue "form_data": form["form_data"],
ext = os.path.splitext(safe_name)[1].lower() }
if ext not in ALLOWED_IMAGE_EXT:
continue
save_path = os.path.join(save_dir, safe_name)
with open(save_path, "wb") as fh:
fh.write(data)
rel_path = "attachments/" + str(user["id"]) + "/" + plan_type + "/" + period_key + "/" + safe_name
saved.append(rel_path)
if not saved:
raise HTTPException(status_code=400, detail="文件保存失败")
return {"success": True, "attachments": saved}
@router.get("/file/{sid}/{plan_type}/{period}/{filename}") @router.get("/file/{sid}/{plan_type}/{period}/{filename}")
def get_file(sid: str, plan_type: str, period: str, filename: str): def get_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
"""旧版取图接口:302 到 OSS 签名地址。
⚠️ 2026-09-22 之前这个接口**没有任何鉴权**(紧邻的 DELETE 反而有),
只要按 {sid}/{plan_type}/{period}/{filename} 猜路径就能下载任意学员的打卡图,
需求文档 V3.0 把它列为高风险项。同日补上了登录校验。
图片现在只在 OSS 上,所以这里不再读磁盘,直接 302 —— 只是转个地址,
图片字节不经过服务器。小程序取图实际不走这个接口,而是用读接口并排返回的
`attachment_urls`;保留它只为兼容已经发出去的老地址。未配 OSS 时返回 404。
"""
safe_name = os.path.basename(filename) safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name) key = oss_key("attachments/" + sid + "/" + plan_type + "/" + period + "/" + safe_name)
if not os.path.exists(file_path): if key and oss_store.is_enabled():
raise HTTPException(status_code=404, detail="not found") return RedirectResponse(oss_store.signed_url(key), status_code=302)
ext = os.path.splitext(safe_name)[1].lstrip(".").lower() raise HTTPException(status_code=404, detail="not found")
media_type = CONTENT_TYPES.get(ext, "application/octet-stream")
return FileResponse(file_path, media_type=media_type)
@router.delete("/file/{sid}/{plan_type}/{period}/{filename}") @router.delete("/file/{sid}/{plan_type}/{period}/{filename}")
def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)): def delete_file(sid: str, plan_type: str, period: str, filename: str, user=Depends(get_current_user)):
"""删除附件对象。只能删自己的。
OSS 的 delete 是幂等的(删不存在的 key 也算成功),所以不需要先查存在性。
"""
if str(sid) != str(user["id"]): if str(sid) != str(user["id"]):
raise HTTPException(status_code=403, detail="无权限") raise HTTPException(status_code=403, detail="无权限")
safe_name = os.path.basename(filename) safe_name = os.path.basename(filename)
file_path = os.path.join(ATTACH_DIR, sid, plan_type, period, safe_name) key = oss_key("attachments/" + sid + "/" + plan_type + "/" + period + "/" + safe_name)
if os.path.exists(file_path): if key and oss_store.is_enabled():
os.remove(file_path) try:
oss_store.delete(key)
except Exception:
logger.exception("删除 OSS 对象失败: %s", key)
return {"success": True} return {"success": True}

View File

@@ -1,16 +1,54 @@
import logging
import os import os
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from .. import config, db from .. import db, oss_store
from ..user import user_payload from ..user import user_payload
from .auth import get_current_user from .auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/profile", tags=["profile"]) router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
MAX_AVATAR_SIZE = 4 * 1024 * 1024 MAX_AVATAR_SIZE = 4 * 1024 * 1024
ALLOWED_AVATAR_EXT = {".png", ".jpg", ".jpeg", ".webp"} ALLOWED_AVATAR_EXT = {".png", ".jpg", ".jpeg", ".webp"}
LEGACY_AVATAR_PREFIX = "/clock/static/"
def avatar_key(path):
"""头像路径 -> OSS 对象 key。
兼容两种格式,因为老数据还在用旧的那种:
- `avatars/{uid}/avatar.jpg` 新格式(2026-09-22 起),等于对象 key
- `/clock/static/avatars/{uid}/avatar.jpg` 旧格式,原本是 nginx 静态路径
⚠️ 旧格式**不能删**:库里仍有这种值,而它对应的文件现在也在 OSS 上,
对象 key 就是剥掉 `/clock/static/` 之后的那串 —— 剥离这一步是它唯一的读法。
两种格式指向同一个对象。不是头像路径则返回空串。
"""
text = (path or "").strip()
if text.startswith(LEGACY_AVATAR_PREFIX):
text = text[len(LEGACY_AVATAR_PREFIX):]
text = text.lstrip("/")
if not text.startswith("avatars/") or ".." in text:
return ""
return text
def avatar_url(path):
"""头像规范路径 -> 可直接喂给 `<image>` 的签名地址(OSS 未启用时为空串)。"""
text = (path or "").strip()
if not text:
return ""
if text.startswith(("http://", "https://")):
return text
key = avatar_key(text)
if not key or not oss_store.is_enabled():
return ""
return oss_store.signed_url(key)
def _profile(user): def _profile(user):
payload = user_payload(user) payload = user_payload(user)
@@ -22,7 +60,9 @@ def _profile(user):
"group": payload["group"], "group": payload["group"],
"role": payload["role"], "role": payload["role"],
"class_groups": payload["class_groups"], "class_groups": payload["class_groups"],
# wechat_avatar 保持规范路径不变(会写回库),avatar_url 是展示专用的签名地址
"wechat_avatar": user.get("wechat_avatar", ""), "wechat_avatar": user.get("wechat_avatar", ""),
"avatar_url": avatar_url(user.get("wechat_avatar", "")),
"login_type": user.get("login_type", ""), "login_type": user.get("login_type", ""),
} }
@@ -43,23 +83,32 @@ def upload_avatar(file: UploadFile = File(...), user=Depends(get_current_user)):
if ext not in ALLOWED_AVATAR_EXT: if ext not in ALLOWED_AVATAR_EXT:
raise HTTPException(status_code=400, detail="不支持的文件类型") raise HTTPException(status_code=400, detail="不支持的文件类型")
avatar_base = config.AVATAR_DIR
uid = str(user["id"]) uid = str(user["id"])
save_dir = os.path.join(avatar_base, uid)
os.makedirs(save_dir, exist_ok=True)
for old_name in os.listdir(save_dir):
if old_name.startswith("avatar.") and old_name != "avatar" + ext:
try:
os.remove(os.path.join(save_dir, old_name))
except OSError:
pass
filename = "avatar" + ext filename = "avatar" + ext
file_path = os.path.join(save_dir, filename) # 新格式:直接就是 OSS 对象 key,去掉 /clock/static 这层 nginx 前缀
with open(file_path, "wb") as f: url_path = "avatars/" + uid + "/" + filename
f.write(data)
url_path = "/clock/static/avatars/" + uid + "/" + filename if not oss_store.is_enabled():
# OSS 是现在唯一的头像存储,没有磁盘兜底了。
# 未配置就明确报错,而不是悄悄写本地 —— 本地文件取不到,写了等于丢。
raise HTTPException(status_code=503, detail="对象存储未配置,无法上传头像")
try:
oss_store.put_bytes(url_path, data, content_type=oss_store.guess_content_type(filename))
except Exception:
# 写失败就明确报错让用户重试,**不落磁盘**:磁盘上的文件已经没有任何读路径
# 能取到(前端取头像走的是 OSS 签名 URL),落盘只是留下一份永远没人读的副本。
logger.exception("头像上传到 OSS 失败: %s", url_path)
raise HTTPException(status_code=500, detail="头像保存失败,请重试")
# 换扩展名时清掉同目录下其它扩展名的旧头像,否则 avatar.jpg / avatar.png 会并存
for other in ALLOWED_AVATAR_EXT:
other_name = "avatar" + other
if other_name != filename:
try:
oss_store.delete("avatars/" + uid + "/" + other_name)
except Exception:
logger.debug("清理旧头像对象失败: %s", other_name, exc_info=True)
db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"])) db.execute("UPDATE auth_users SET wechat_avatar=%s WHERE id=%s", (url_path, user["id"]))
return {"success": True, "wechat_avatar": url_path} return {"success": True, "wechat_avatar": url_path, "avatar_url": avatar_url(url_path)}

View File

@@ -1,4 +1,3 @@
import logging
import uuid import uuid
import hmac import hmac
import hashlib import hashlib
@@ -17,3 +16,31 @@ def encrypt_phone(phone):
def create_session_token(): def create_session_token():
"""生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。""" """生成写入 auth_sessions 的会话 token(长度适合 token VARCHAR(64))。"""
return uuid.uuid4().hex + uuid.uuid4().hex[:16] return uuid.uuid4().hex + uuid.uuid4().hex[:16]
# ── 密码哈希(手机号+密码登录用,2026-09-22 新增)─────────────────────
# 统一用纯 SHA-256:hashlib.sha256(p.encode()).hexdigest(),
# 与遗留鉴权代码 routes/auth_api.py / routes/auth_app.py 的 hash_pwd() 完全一致。
# 刻意不引入 bcrypt / passlib / PBKDF2,也不加盐(保持与存量数据格式一致)。
# 存储格式:64 位十六进制 hex 串。
def hash_password(password):
"""把明文密码变成可入库的哈希串(纯 SHA-256 hex,无盐)。"""
if not password:
raise ValueError("password 不能为空")
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password, stored):
"""校验明文密码是否匹配存储串(恒时比较,防时序侧信道)。
⚠️ stored 为 None / 空串一律返回 False,**绝不把异常抛给调用方**。
典型场景:006 迁移还没执行时 SELECT * 取不到 password_hash 列,这里是 None。
"""
if not password or not stored:
return False
return hmac.compare_digest(
hashlib.sha256(password.encode()).hexdigest(),
stored,
)