- 替换开发环境API基础URL为本地服务地址 - 实现签名URL本地缓存机制,解决微信按URL缓存图片导致重复下载问题 - 添加缓存清理功能,包括过期清理和按需删除特定缓存项 - 集成阿里云OSS上传功能,支持直传减少服务器带宽占用 - 优化头像地址处理逻辑,统一使用签名缓存避免地址频繁变化 - 移除过时的OSS上传示例文件oss-upload.js - 更新课程页面实现跳转第三方小程序功能 - 调整打卡记录图片地址获取方式,统一使用新的签名缓存逻辑 - 优化图片上传流程,增加压缩处理和批量上传支持 - 完善认证模块中的缓存清理逻辑,切换账号时清除相关图片缓存 - 添加后端环境变量配置模板和代理文档说明
32 KiB
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_tokenCookie 未带domain,是 host-only(挂在m.muguaclub.cn), 跨不到www子域。webview/redirect里的 host 白名单靠host.endswith(".muguaclub.cn")会把www放行,看起来"能用",实际 cookie 无效。若将来课程详情要显示登录态, 得先给这个 Cookie 补domain=".muguaclub.cn"。 - 登录有两条路(第二条 2026-09-22 新增):
- 微信:
POST /auth/wechat-login的code必须来自wx.login(),curl 构造不出来。 未匹配 openid 时返回need_phone=true,走POST /auth/bind-phone绑手机号。 - 手机号 + 密码:
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:appwechat_backend/start.sh(仓库内脚本,用新 venvpyenv39_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/119/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_usersLEFT JOINplan_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 域名白名单、私有桶读路径) 见 skillaliyun-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://...会被抽成attachmentsJSON 数组。 - ⚠️ 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>/<文件名>↔ OSSplan/<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. 工作约定
- 改后端业务规则先读
routes/checkin.py/routes/plan.py全文再动手,第 8 节只是索引,不是完整实现。 - 数据库变更必须新增迁移脚本
migrations/00X_xxx.sql,写在注释里说明目标库、背景、幂等性、备份建议 (参照005_class_date_sunday.sql的写法),并手工执行。 - 不要提交密钥:
.env、JWT_SECRET、ADMIN_KEY、WECHAT_SECRET、MYSQL_PASSWORD。 - 不要绕过连接池自己开数据库连接。
- 新增或改动面向前端的接口时,照
03.开发文档/接口交接-周计划打卡名单-GET-plan-records.md的结构同步维护一份交接文档。 - 测试路径(
server_testvsclock_test)两说并存,发布前先上服务器实测确认,不要凭文档直接发。
12. 常用命令速查
# ── 本地开发(注意 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。