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

32 KiB
Raw Blame History

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/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_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. 常用命令速查

# ── 本地开发(注意 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。