feat(api): 重构图片地址管理与上传机制
- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
This commit is contained in:
45
.env.example
Normal file
45
.env.example
Normal 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
440
AGENTS.md
Normal 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。
|
||||||
@@ -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 迁移(一次性):
|
||||||
|
|
||||||
|
|||||||
30
config.py
30
config.py
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
2
main.py
2
main.py
@@ -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
214
oss_store.py
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
115
routes/auth.py
115
routes/auth.py
@@ -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)}
|
||||||
|
|||||||
228
routes/plan.py
228
routes/plan.py
@@ -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():
|
||||||
|
return RedirectResponse(oss_store.signed_url(key), status_code=302)
|
||||||
raise HTTPException(status_code=404, detail="not found")
|
raise HTTPException(status_code=404, detail="not found")
|
||||||
ext = os.path.splitext(safe_name)[1].lstrip(".").lower()
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
29
security.py
29
security.py
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user