Files
android-client/docs/需求规格说明书-V4.0-小程序与后端.md

658 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 明缘云道场小程序 + 后端 API · 需求规格说明书 V4.0
> 基于 `miniprogram/`(微信小程序前端)和 `server/`(FastAPI 后端)实际代码反向整理。
> 与 V2.0/V3.0(涵盖 Web 主站 + 学员网站)平行存在;本文档 **仅覆盖这两个子系统**。
>
> · 适用范围:`miniprogram/`、`server/`、`server/migrations/`
> · 反向工程日期:2026-09-10
> · 反向基线:`git` 工作树(HEAD = 当前)
---
## 目录
1. 文档信息
2. 项目概述
3. 用户角色与权限
4. 术语表
5. 小程序前端(miniprogram/)
6. 后端 API(server/)
7. 数据模型
8. 部署与配置
9. 安全与合规
10. 非功能性需求
11. 错误约定
12. 已知问题与 TODO
---
## 1. 文档信息
| 项 | 内容 |
| ----- | --------------------------------------------------- |
| 软件名称 | 明缘云道场小程序(前端)+ 明缘进阶场 API(后端) |
| 版本 | V1.0 / V4.0 文档 |
| 平台 | 前端:微信小程序 · 后端:Linux + Python 3.9 + FastAPI |
| 关键依赖 | 微信开发者工具 · PyMySQL + DBUtils · httpx · python-dotenv |
| 客户端入口 | 微信内搜索 / 扫码进入「明缘云道场」 |
| 服务端入口 | uvicorn `server.main:app` 监听 `:8000` |
| 文档目的 | 把代码事实定型为可验收的需求规格 |
---
## 2. 项目概述
### 2.1 子系统定位
| 子系统 | 代码路径 | 角色 |
| ------ | -------------- | ---------------------------------------------- |
| 小程序前端 | `miniprogram/` | 用户交互入口(打卡 / 计划 / 个人),本地无状态,所有数据走后端 API |
| 后端 API | `server/` | 鉴权、业务逻辑、MySQL 读写、文件存储、WebView 桥接 |
| 微信生态 | 微信开放接口 | `wx.login` 换取 openid、`getuserphonenumber` 绑手机号 |
### 2.2 业务目标
为「明缘进阶场」体系的成员提供基于微信的 **轻量化学习管理工具**,核心是:
1. **每周打卡**(周日上课日)
2. **周 / 月 / 年计划** 三级管理
3. **小组统计**(学员互看,管理员全览)
4. **个人主页**(资料、头像、登录方式)
5. 内嵌 **H5 复用既有 Web 复盘系统**(通过 WebView + cookie 桥接)
### 2.3 不在范围内(明确)
- 不在前端代码内的子系统(Web 主站 `xinhuo/19qi/18qi/`、学员站)——见 V3.0
- 不做支付、不做 IM、不做推送(小程序代码中无相关痕迹)
- 不做服务端渲染 SSR
---
## 3. 用户角色与权限
### 3.1 角色矩阵
代码侧只识别两种角色(`auth_users.role` 字段):
| 角色键 | 中文 | 判定方式 | 用例 |
| -------------------------------------------------------- | -------- | ------------------------------------------------ | -------------------- |
| `teacher` | 管理员 / 老师 | `(user.role or '').strip().lower() == 'teacher'` | 看所有小组、看未分组人数、看全部打卡统计 |
| 其他(含 `student`、`member`、`leader`、`group_leader`、`admin`) | 成员 / 组长 | 非 teacher 走学员分支 | 只看自己所在小组的数据 |
> 注:`ROLE_LABELS`(`pages/profile/profile.js`)将 `student/member → 成员`、`teacher/admin → 管理员`、`leader/group_leader → 组长`,但后端权限只认 `teacher`,其他角色后端一律视作学员。
### 3.2 权限矩阵
| 接口前缀 | 学员 | 老师 |
| ------------------------------------ | ---------------- | ----------------- |
| `/api/v1/auth/*` | ✅ 自助 | ✅ 同左 |
| `/api/v1/profile` | ✅ 自己 | ✅ 自己 |
| `/api/v1/profile/avatar` | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/weeks` | ✅ | ✅ |
| `/api/v1/checkin/groups` | ✅(仅看自己小组) | ✅(看全部 + 未分组数) |
| `/api/v1/checkin/group` | 仅自己小组(传他组 → 403) | 任意小组(不传看自己) |
| `/api/v1/checkin` POST | ✅ 自己 | ✅ 自己 |
| `/api/v1/checkin/list` | ✅ 自己历史 | ✅ 自己历史 |
| `/api/v1/plan/*` GET | ✅ 看自己 / 自己小组 | ✅ 看全部 + 未分组数 |
| `/api/v1/plan` POST `/checkin` | ✅ 自己当前周期 | ✅ 自己当前周期(限当前周期) |
| `/api/v1/plan/file/{sid}/...` DELETE | 仅自己 sid(403) | 仅自己 sid(403) |
| `/api/v1/webview/redirect` | 凭 token | 凭 token(所有权限同 H5) |
| `/api/v1/survey/*` | ✅ | ✅(无鉴权) |
| `/api/v1/feedback` GET | ⚠ 无鉴权(已知问题) | ⚠ 无鉴权(已知问题) |
| `/api/v1/course-vote` | ✅ 自己投 | ✅ 自己投 |
| `/api/v1/yijing/*` | ✅ | ✅ |
---
## 4. 术语表
| 术语 | 含义 |
| --------------------- | --------------------------------------------------------------------------------- |
| openid | 微信用户唯一标识,由 `code_to_openid(code)` 换取 |
| token / session token | 64 位 hex,会话标识,写入 `auth_sessions` 表(不是 JWT,名字沿用老 JWT 配置) |
| class_date | 上课日 = 每周周日(周一 ~ 周日组成的自然周) |
| period_key | 计划周期键:周=YYYY-Www、月=YYYY-MM、年=YYYY |
| 小组(group) | `auth_users.group` 字段,如「01组」 |
| display_name | 形如「01组张三」(仅当 group 形如 `NN组` 时拼接) |
| webview 桥接 | 通过 `/api/v1/webview/redirect?token=...&target=...` 把 URL 上的 token 换成 cookie 再 302 |
---
## 5. 小程序前端(miniprogram/)
### 5.1 全局结构
```
miniprogram/
├── app.js App 入口:onLaunch 调 ensureLogin → /profile
├── app.json 7 个页面 + 4 tabBar + 导航主题色
├── app.wxss 全局基础样式(背景 #FDFBF7、主色 #C67B30)
├── project.config.json
├── project.private.config.json
├── sitemap.json
├── assets/ tabBar 图标(home/checkin/plan/profile × 2)
├── utils/
│ ├── api.js request / planFileUrl / 环境切换 / 401 跳登录
│ └── auth.js ensureLogin 单例 + loginWithPhone + token 存取
└── pages/
├── home/ 首页(已登录 → 内嵌 H5;游客 → 原生介绍)
├── checkin/ 打卡(老师总览+详情 / 学员小组+个人)
├── plan/ 计划总览(周/月/年)
├── plan-submit/ 计划提交(文本 + 至多 4 张图)
├── profile/ 个人主页(资料 / 头像 / 退出)
├── webview/ 通用 WebView(域名白名单)
└── privacy/ 隐私政策 + 用户协议(静态展示)
```
### 5.2 主题色与视觉规范
| token | 值 | 用途 |
| ----- | ------------------------------ | ------------- |
| 主背景 | `#FDFBF7` | 全站冷色米白底 |
| 顶栏 | `#2C1810` 渐变 → `#6B3F24` | Hero 卡背景 |
| 主交互色 | `#C67B30` | 主按钮、未选中状态、强调色 |
| 成功 | `#2E7D32`(+ 浅底 `#E8F4EB`) | 「正常」打卡、已完成 |
| 警示 | `#C0392B`(+ 浅底 `#FDEAEA`) | 「不参加讨论」、错误页 |
| 信息 | `#3A7BD5` | 「请假」 |
| 文本主 | `#2C1810` | 标题 |
| 文本辅 | `#8B7A6A` / `#999` | 描述、时间戳 |
| 圆角 | `12rpx / 16rpx / 24rpx` | 卡片、按钮、Hero |
| 字号 | 22/24/26/28/30/32/34/40/48 rpx | 时间戳到主标题 |
### 5.3 API 客户端(utils/api.js)
#### 5.3.1 环境切换(多域名)
```js
getAccountInfoSync().miniProgram.envVersion
├── 'develop' → baseUrl = https://test.muguaclub.cn/clock
├── 'trial' → baseUrl = https://test.muguaclub.cn/clock
└── 'release' → baseUrl = https://m.muguaclub.cn/clock
```
- 三个环境对应 `STATIC_BASE_URL` + `H5_BASE_URL` 同源
#### 5.3.2 request 行为约定
| 入参 | 默认 | 说明 |
| -------- | ------ | --------------------------------------------------------- |
| `url` | 必填 | 必须以 `/api/v1` 开头 |
| `method` | `GET` | 影响幂等重试 |
| `data` | `{}` | 自动 JSON |
| `auth` | `true` | true 时自动从 storage 取 `token` 加 `Authorization: Bearer xxx` |
行为:
- **401** → 删 token + `goProfile()` 切到「我的」页(带 1.2s 防连点锁)
- **幂等方法(GET / DELETE)** 失败自动 800ms 后重试一次;命中 `domain not` 错误不重试
- **非 2xx** → reject 原 data(`{error/detail/message}`)
#### 5.3.3 planFileUrl 路径约定
`attachments/{user_id}/{plan_type}/{period_key}/{filename}` →
`STATIC_BASE_URL + /clock/static/plan/<encoded/path>`
> 前 4 段缺失则返回空串,强制调用方重新加载。
### 5.4 登录态(utils/auth.js)
#### 5.4.1 单例 + 指数退避
- `loginPromise` 全局单例,解决 `App.onLaunch` 与首页 `onShow` 同时调 `ensureLogin` 的竞态
- `wxLogin` 内部失败重试 3 次(首次 600ms → 800ms → 1000ms)
- 失败兜底:`wechatLogin` 整个流程失败再重试 1 次(500ms 后)
- 任何情况下 `loginPromise` 完成后清空 → 让下一次重新发起(避免冷启动偶发失败后卡死)
#### 5.4.2 双入口登录
| 入口 | 触发 |
| ------ | ------------------------------------------------------------------------------------------------------------------- |
| 微信授权登录 | `ensureLogin()` → `/api/v1/auth/wechat-login` 用 code 换 token;后端返回 `need_phone:true` 时 resolve(null),前端引导到「我的」页绑定手机号 |
| 手机号登录 | `loginWithPhone(phoneCode)` → `/api/v1/auth/bind-phone` 必须同时传 `code` 和 `phone_code`;绑定成功后写 token 并返回 is_new:true |
### 5.5 页面清单
#### 5.5.1 home 首页
- **登录态判断**:`isLoggedIn = !!getToken()`
- **已登录** → `<web-view src="/api/v1/webview/redirect?token=...&target=...">`,key 自增强制重建(解决切 tab 白屏)
- **未登录** → 原生介绍页(Hero 头图 + 4 个模块卡 + 4 个产品介绍卡 + 引导登录按钮)
- 提供分享:`onShareAppMessage` / `onShareTimeline`
#### 5.5.2 checkin 打卡(核心)
**老师视图**
- `view = 'overview'`:所有小组打卡进度 + 自己的打卡状态
- `view = 'detail'`:点开某小组看每个成员是否打卡 / 状态
**学员视图**
- 顶部打卡卡:5 个状态按钮(正常/迟到/请假/不参加讨论/早退),单选切换
- `tab = 'group'`:本组成员列表
- `tab = 'personal'`:个人打卡历史(分页,每页 20 条)
**通用交互**
- 周选择器:`/api/v1/checkin/weeks` 返 2022 年到当前的所有上课周六
- 下拉刷新 + 上拉加载更多(个人历史 tab)
- 周计划 / 月计划 / 年计划的周期切换(plan 页面公用同一套 picker)
**打卡规则(来自 `routes/checkin.py`)**
- 状态:`正常 / 迟到 / 请假 / 不参加讨论 / 早退`
- 默认状态 `正常`
- 备注最大 500 字符(仅非「正常」时允许)
- 打卡窗口:上课日所在自然周(周一 ~ 周日)与当前周相同 / 未来周可打卡
#### 5.5.3 plan 计划(核心)
- 类型切换 tab:**周计划 / 月计划 / 年计划**
- 卡片:
- 自己的状态(已提交/待提交 + 当前周期)
- 「提交 / 修改」按钮,`canSubmit=false` 时 disabled
- 窗口提示(如「周四截止,已过提交时间」)
- 老师视图:所有小组列表
- 学员视图:tab = 小组(自己成员)/ 个人(历史最多 20 条)
- 周期 key:
- 周 = `YYYY-Www`(ISO 周)
- 月 = `YYYY-MM`
- 年 = `YYYY`
**提交时间窗(`routes/plan.py`)**
- 周:上一周五 00:00 ~ 本周四 23:59:59
- 月 / 年:仅当前周期可提交,历史 / 未来都禁止
#### 5.5.4 plan-submit 提交页
| 控件 | 限制 |
| -------- | ------------------------------------------------------------------ |
| textarea | maxlength 1000、disabled=!canSubmit |
| 图片 | 最多 4 张、允许后缀 `jpg/jpeg/png/gif/webp/bmp`、>1MB 自动逐级压缩 80→60→45 |
| 删除 | 调 `/api/v1/plan/file/{sid}/{plan_type}/{period}/{filename}` DELETE |
| 提交 | 文本或图片必有一项;已有记录弹覆盖确认 |
**上传协议**:`wx.uploadFile`,`formData = { plan_type, period_key }`,`name='files'`,**支持 multipart 多文件**
#### 5.5.5 profile 个人主页
**视图状态机**:`view ∈ { loading, profile, phoneLogin, loadError }`
- 已登录 + 有 token → 显示完整资料 + 头像 + 班级分组列表 + 退出登录
- 未登录 / need_phone → 显示手机号登录卡(强依赖隐私协议勾选)
- chooseAvatar:调 `/api/v1/profile/avatar` 单文件上传,avatar 文件名统一为 `avatar.{ext}`,旧头像自动清理
- 退出登录:调 `/api/v1/auth/logout`(尽力而为)+ 清本地 token + 清隐私同意标记 + reLaunch 回首页
#### 5.5.6 webview 通用 web-view
- 入参:`url`(必须 URL 编码)
- **域名白名单**(`isAllowedUrl`):仅允许 `m.muguaclub.cn` 和 `muguaclub.cn` 及其子域;非法 URL 渲染错误页
#### 5.5.7 privacy 隐私 / 协议页
- 两个静态文档:隐私政策 8 节、用户协议 8 节
- 通过 query `type` 切换:`privacy` / `agreement`
- 最后更新日期占位:**2026 年 9 月**
### 5.6 通用 UI 特性
- **骨架屏**:列表 / 表单 / 详情都有 `sk-*` 占位
- **错误页**:圆形「!」 + 失败文案 + 「重新加载」按钮
- **空态**:emoji + 简短说明
- **分享**:所有页面都实现 `onShareAppMessage` + `onShareTimeline`
---
## 6. 后端 API(server/)
### 6.1 工程骨架
```
server/
├── __init__.py
├── main.py FastAPI() + 挂 9 个 router + /api/v1/health
├── config.py 从 .env 读所有密钥 / 路径
├── db.py PyMySQL + DBUtils PooledDB,单例 per db
├── security.py encrypt_phone(hmac-sha256) + create_session_token
├── wechat.py code_to_openid + get_phone_number + _get_access_token
├── user.py make_display_name + get_class_groups + user_payload
├── routes/
│ ├── __init__.py
│ ├── auth.py /api/v1/auth/*
│ ├── checkin.py /api/v1/checkin/*
│ ├── plan.py /api/v1/plan/*
│ ├── profile.py /api/v1/profile*
│ ├── webview.py /api/v1/webview/redirect
│ ├── survey.py /api/v1/survey/* (无鉴权)
│ ├── feedback.py /api/v1/feedback/* (含仅 ADMIN_KEY 的管理接口)
│ ├── course_vote.py /api/v1/course-vote*
│ └── yijing.py /api/v1/yijing/* (从老 Flask 服务迁移)
└── migrations/
├── 001_add_wechat_openid_column.sql
├── 002_plan_perf_index.sql
├── 003_checkin_records_user_id.sql
└── 004_plan_checkins_user_id.sql
```
> ⚠ 已知 bug(`main.py:29`):`uvicorn.run("server_prd.main:app", ...)` 引用不存在的包,启动会 ImportError。
### 6.2 全局运行时依赖
| 依赖 | 作用 |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| FastAPI | Web 框架 |
| uvicorn | ASGI 运行(默认 :8000,host 127.0.0.1,dev 模式 reload) |
| PyMySQL | MySQL 驱动 |
| DBUtils `PooledDB` | 连接池:`DB_POOL_MIN_CACHED=1 / MAX_CACHED=5 / MAX_CONNECTIONS=10 / MAX_USAGE=100`、ping=1、connect_timeout=5s、read_timeout=5s |
| httpx | 调用 `api.weixin.qq.com`(jscode2session / access_token / 手机号解密) |
| python-dotenv | `load_dotenv(dotenv_path, override=True)` |
| pydantic | 请求体 / 响应模型 |
### 6.3 配置项(config.py)
| 键 | 默认 | 用途 |
| ---------------------------------- | ------------------------------------------- | -------------------------------------------- |
| `WECHAT_APPID` / `WECHAT_SECRET` | 空 | 微信 API 凭据 |
| `JWT_SECRET` * | 空 | **代码里没有真正用上 JWT**,命名沿用历史,实际为 `auth_sessions` |
| `JWT_ALGORITHM` | `HS256` | 同上,遗留 |
| `JWT_TTL_SECONDS` | `90*24*3600`(90 天) | 会话有效期 |
| `ADMIN_KEY` | 空 | `/api/v1/feedback/resolve` 管理密钥 |
| `MYSQL_HOST/PORT/USER/PASSWORD/DB` | `localhost/3306/root/''/mys_info_v3` | 主业务库 |
| `SURVEY_DB` | `myxhxc` | 问卷库(独立库,独立连接池) |
| `PHONE_HMAC_KEY` | `MingYuan2026AuthKey` | 手机号 HMAC-SHA256 盐 |
| `H5_BASE_URL` | `http://127.0.0.1:8001` | WebView 跳转上游(H5 主站) |
| `FEEDBACK_DIR` | `/home/introduction/feedback` | 用户反馈 JSON + 图片目录 |
| `COURSE_VOTES_FILE` | `/home/introduction/test/course_votes.json` | 课程投票持久化文件 |
| `PLAN_ATTACH_DIR` | `/home/static/plan` | 计划附件根目录 |
| `STATIC_ROOT` / `AVATAR_DIR` | `/home/static[/avatars]` | 头像静态文件目录(nginx 直读) |
| `DB_POOL_*` | 上表 | 连接池参数(仅从 os.environ 读,不走 config) |
> 注:`config.py` 用 `os.environ.get(name, default)`,但 `db.py` 用 `os.environ.get("DB_POOL_*")` 直接读。
### 6.4 数据库连接池(db.py)
- 多库支持:`_get_pool(database)` 按 db 名缓存 pool,**双数据库**(主业务 + survey)
- `fetch_one / fetch_all / execute` 三个 API,借 / 还连接(`close()` 让连接回池)
- `close_all()` 用于测试 / reload hook
### 6.5 鉴权(`get_current_user`)
判定顺序:
1. `Authorization: Bearer xxx`(小程序 API 默认)
2. `Cookie: auth_token=xxx`(H5 走 webview 桥接后自动带)
3. 都无 → 401
查表:
- `auth_sessions WHERE token=? AND expires_at>NOW()` 拿到 `user_id`
- 再 `SELECT * FROM auth_users WHERE id=?`
### 6.6 接口约定
#### 6.6.1 /api/v1/auth
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | --------------- | --- | ------------------------------------------------------------------------------------------------ |
| POST | `/wechat-login` | 否 | `code` → openid → 查 user;命中即发 token,未命中返 `need_phone:true` |
| POST | `/bind-phone` | 否 | `code, phone_code` → 拿手机号 → HMAC 查 user;命中即 update `wechat_openid` + 发 token;首次绑定返 `is_new:true` |
| GET | `/verify` | 是 | 返回 `user_payload` |
| POST | `/logout` | 是 | 删除 session + 清 user.wechat_openid + 删 cookie |
#### 6.6.2 /api/v1/profile
| 方法 | 路径 | 说明 |
| ---- | ----------------- | ------------------------------------------------------------------------------------------------------------- |
| GET | `/profile` | user_payload + `phone_display`(注意:**原文里 user 表无 phone_display 字段**,8.2 已知问题) + `wechat_avatar` + `login_type` |
| POST | `/profile/avatar` | multipart 单文件,4MB 上限,存 `avatar.{ext}`,旧头像自动删;URL `=/clock/static/avatars/{uid}/avatar{ext}` |
#### 6.6.3 /api/v1/checkin
| 方法 | 路径 | 说明 |
| ---- | ----------------------------------------- | ---------------------------------- |
| GET | `/weeks` | 2022 年至当前的所有上课周六列表 + current_week |
| GET | `/week` | **已弃用**,仅供旧客户端 |
| GET | `/groups?class_date=YYYY-MM-DD` | 老师→全部小组 + 未分组数;学员→自己小组 + my_group |
| GET | `/group?class_date=&group_name=` | 单组成员 + 各自打卡状态;学员传他组→403 |
| POST | `/checkin` `{status, class_date?, note?}` | upsert(按 user_id + class_date 唯一键) |
| GET | `/list?page=&size=` | 个人历史分页,默认 10,上限 50 |
#### 6.6.4 /api/v1/plan
| 方法 | 路径 | 说明 |
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- |
| GET | `/periods?type=` | 周/月/年周期列表 + current_period |
| GET | `/status?type=&period_key?=` | 看本期是否已提交 + can_submit + message |
| GET | `/groups?type=&period_key?=` | 老师看全部 + 未分组;学员看自己小组 |
| GET | `/group?type=&group_name=&period_key?=` | 单组成员列表 + 各自 filled |
| POST | `/checkin` `{plan_type, period_key?, status?, content?, attachments[]?}` | upsert(按 user_id + plan_type + period_key) |
| GET | `/history?type=&page=&size=` | 个人历史分页 |
| POST | `/upload` multipart `{plan_type, period_key, files[]}` | 至多 4 文件、每文件 ≤10MB、白名单扩展名、保存路径 `attachments/{user_id}/{plan_type}/{period_key}/{filename}` |
| GET | `/file/{sid}/{plan_type}/{period}/{filename}` | 直读静态文件 |
| DELETE | `/file/{sid}/...` | 校验 sid == user.id,否则 403 |
#### 6.6.5 /api/v1/webview
| 方法 | 路径 | 鉴权 | 说明 |
| --- | -------------------------- | -------------- | ------------------------------------------- |
| GET | `/redirect?token=&target=` | 否(凭 URL token) | 校验 token → set HttpOnly cookie → 302 target |
安全要点:
- `target` 仅允许相对路径(`/xxx`)或白名单域 `m.muguaclub.cn` 及其子域
- cookie:`HttpOnly`、`Secure(scheme=https)`、`SameSite=Lax`、`MaxAge=JWT_TTL_SECONDS`
#### 6.6.6 /api/v1/survey(独立库 `myxhxc`,无鉴权)
| 方法 | 路径 | 说明 |
| ---- | ------------------------------------------ | ------------------- |
| GET | `/tasks` | 状态为 active 的所有 task |
| GET | `/responses?task_id=` | 某 task 的所有 group 回答 |
| POST | `/response` `{task_id, group_id, answers}` | upsert |
| PUT | `/response/{rid}` `{answers}` | 更新已有 |
| POST | `/task` | 占位,功能预留 |
#### 6.6.7 /api/v1/feedback
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------ | --------- | --------------------------------------------- |
| GET | `""` | ❌ 无 | 读所有 json 文件,按 unsolved/solved 倒序 |
| POST | `""` `{name?, description?, image?}` | 否 | 写一个 4 位 hex id 的 JSON,可附 data:image base64 图片 |
| GET | `/file/{name}` | 否 | 直读图片 |
| POST | `/resolve?id=&token=` | ADMIN_KEY | 标记为 solved |
> ⚠ 已知问题:`/feedback` GET 当前无鉴权,任何人能看所有反馈(**安全风险**)
#### 6.6.8 /api/v1/course_vote
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------ | --- | ------------------------------ |
| POST | `/course-vote` `{votes[3-6], rating?, comment?}` | 是 | 写入 `COURSE_VOTES_FILE` JSON 文件 |
| GET | `/course-votes` | 是 | 聚合(总票数 / 评论均分)+ 我的投票 |
> 用 JSON 文件存,无数据库,便于迁移自老 Flask。
#### 6.6.9 /api/v1/yijing
| 方法 | 路径 | 鉴权 | 说明 |
| ---- | ------------------------------------------------------- | --- | ---------------------------------------------------------------- |
| GET | `/catalog` | 否 | 八卦 + 六十四卦元数据 |
| GET | `/progress` | 是 | 5 个阶段(1..5)的学习进度 |
| POST | `/progress` `{stage[1-5], item_type, item_id, status?}` | 是 | upsert |
| POST | `/quiz` `{stage, item_type, item_id, correct, answer?}` | 是 | 记一次尝试,对应进度 score++;score ≥ 2 → mastered;写入 yijing_reviews 用于复习排期 |
### 6.7 后端非业务模块
| 模块 | 用途 |
| ------------------------------ | ----------------------- |
| `start.sh` / `stop.sh` | 启停脚本(开发环境) |
| `start_prd.sh` / `stop_prd.sh` | 生产环境启停(pid 文件管理) |
| `index.html` | 部署目录下的 health 静态页 |
| `requirements.txt` | 依赖清单 |
| `.env` | **曾被 git 跟踪,含密钥**(已知问题) |
| `uvicorn.log` | 运行日志 |
---
## 7. 数据模型
> 由 `auth_users / auth_sessions / classes / groups / class_groups / group_history / checkin_records_old / plan_checkins / yijing_trigrams / yijing_hexagrams / yijing_progress / yijing_attempts / yijing_reviews / survey_tasks / survey_responses` 等表名 / SQL 反推;具体 DDL 不在 `server/` 内。
### 7.1 主业务库(mys_info_v3)
| 表 | 主键 | 关键字段 | 用途 |
| --------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `auth_users` | `id` | `student_id, name, phone_encrypted(HMAC-SHA256), wechat_openid(唯一键), wechat_avatar, login_type, role, \`group\`` | 用户账号 + 资料 |
| `auth_sessions` | `id` | `user_id, token(唯一), expires_at` | 会话(90 天) |
| `classes` | `id` | `code, name` | 班级(期/班次) |
| `groups` | `id` | `group_code, sort` | 小组 |
| `class_groups` | (复合) | `class_id, group_id, user_id, role_in_group` | 班级 × 小组 × 学员 多对多 |
| `group_history` | (复合) | `user_id, class_id, old_group_id, changed_at` | 学员所在小组变迁 |
| `checkin_records_old` | (复合) `uk_checkin_user_date(user_id, class_date)` | `user_id, class_date, checked_at, status, note` | **打卡记录**(注:表名保留 `_old` 后缀,迁移按 user_id,详见 migration 003) |
| `plan_checkins` | (复合) `uk_plan_user_period(user_id, plan_type, period_key)` | `user_id, plan_type, period_key, status(已完成/未完成), checked_at, content, attachments(JSON), created_at, updated_at` + 索引 `idx_plan_period_user` | 计划记录 |
| `yijing_trigrams` | `id` | `name, symbol, nature, de, family, direction, xiantian, houtian, binary_code, class_xiang, summary` | 八卦(教材化元数据) |
| `yijing_hexagrams` | `id` | `name, full_name, symbol, upper_trigram, lower_trigram, binary_code, judgment, gua_explanation, bagong, gong_wuxing` | 六十四卦 |
| `yijing_progress` | (复合 stage, item_type, item_id) | `user_id, stage, item_type, item_id, status(learning/mastered), score, attempts, correct` | 学习进度 |
| `yijing_attempts` | `id` | `user_id, stage, item_type, item_id, correct, answer` | 尝试明细 |
| `yijing_reviews` | (复合) | `user_id, item_type, item_id, due_at` | 复习到期时间 |
### 7.2 问卷库(myxhxc)
| 表 | 关键字段 |
| ------------------ | ---------------------------------- |
| `survey_tasks` | `task_no, status(active), ...` |
| `survey_responses` | `task_id, group_id, answers(JSON)` |
### 7.3 文件存储
| 路径 | 用途 |
| ---------------------------------------------------------------------- | -------------- |
| `/home/static/avatars/{uid}/avatar{ext}` | 个人头像(nginx 直读) |
| `/home/static/plan/attachments/{user_id}/{plan_type}/{period_key}/...` | 计划附件 |
| `/home/introduction/feedback/*.json` + `images/*.png` | 反馈 JSON + 图片 |
| `/home/introduction/test/course_votes.json` | 课程投票 JSON |
### 7.4 迁移状态(migrations/)
- **001**:给 `auth_users` 加 `wechat_openid` 唯一键(绑微信前提)
- **002**:给 `plan_checkins` 加 `(plan_type, period_key, student_id)` 索引(性能,已被 004 替换)
- **003**:`checkin_records_old` 改按 `user_id` 关联,加唯一键 `(user_id, class_date)`
- **004**:`plan_checkins` 改按 `user_id` 关联,加 `(user_id, plan_type, period_key)` 唯一键 + 替换索引;附件相对路径从 student_id 改写为 user_id
---
## 8. 部署与配置
### 8.1 启动命令(开发)
```bash
pip install -r requirements.txt
# 配置 server/.env:WECHAT_APPID / WECHAT_SECRET / MYSQL_* / PHONE_HMAC_KEY / ADMIN_KEY / H5_BASE_URL
python -m uvicorn server.main:app --reload --port 8000
```
### 8.2 启动命令(生产)
```bash
# 启动
bash server/start.sh
# 停止
bash server/stop.sh
```
### 8.3 nginx 转发示例(推断)
```
# /api/v1/* → :8000 (uvicorn)
# /clock/static/* → /home/static/ (nginx 直读)
# /clock/api/v1/webview/* → :8000 (cookie 桥接)
# / → upstream muguaclub.cn / 8001 (H5 主站)
```
### 8.4 域名与端口
| 角色 | 域名 | 后端端口 |
| --- | ------------------- | -------------------------- |
| 正式 | `m.muguaclub.cn` | 8000 (FastAPI) + 8001 (H5) |
| 测试 | `test.muguaclub.cn` | 同上 |
---
## 9. 安全与合规
### 9.1 鉴权
- 单 token 体系:所有受保护接口统一用 `get_current_user`(`Authorization` 或 cookie)
- 退出登录:删 session + 解绑 openid(下次需重新授权手机号)
- WebView 桥接:URL 上短期 token → cookie,再 302 跳转同站域(防开放重定向)
### 9.2 传输安全
- 头像直链强制 https(`secure=request.url.scheme == "https"`)
- Cookie `HttpOnly + SameSite=Lax`
- OpenAPI 全 HTTPS(微信 API 仅 https)
### 9.3 数据安全
- 手机号 HMAC-SHA256 存 `phone_encrypted`,原号不出库
- 反馈、计划内容、明文入库(小程序端有 maxlength 1000 / 500 限制)
### 9.4 隐私合规
- 「我的」页强制要求勾选隐私政策 + 用户协议才能调 `getPhoneNumber`
- 隐私协议 8 节;用户协议 8 节;最后更新 2026 年 9 月(占位)
- 客服邮箱 `[此处填写客服邮箱]` 占位需补充
---
## 10. 非功能性需求
### 10.1 性能
| 维度 | 要求 |
| ------- | ----------------------------------- |
| 首屏冷启动 | 幂等请求(GET/DELETE)网络错误自动 800ms 重试 1 次 |
| 单实例 QPS | 与连接池 `maxconnections=10` 匹配;超出排队 |
| 单次连接 | connect_timeout=5s,read_timeout=5s |
| 列表分页 | 打卡默认 10,最大 50;计划默认 10,最大 50 |
| 客户端压缩 | 计划图片 >1MB 自动 80→60→45 三级降质压缩 |
| 上传上限 | 单文件 10MB(计划)/ 4MB(头像) |
### 10.2 可用性
- 单会话 90 天有效,过期即失效(前端需重新登录)
- 计划历史周期只读(can_submit=false)
- 学员只能查自己所在小组
- 切换 tab / 周选择器 / 周期选择器都有 fallback(数据为空时不刷)
### 10.3 可观测性(缺)
代码中未发现业务埋点 / 性能监控 / 错误聚合;`uvicorn.log` 仅记录关键 warn/error(如 `微信登录 code 换取 openid 失败`)。
### 10.4 兼容性
- 小程序基础库 `>=3.0.0`(`wx.getAccountInfoSync`)
- Python ≥3.9(FastAPI 最低版本)
---
## 11. 错误约定
- HTTP 状态码:
- `200` 业务正常
- `400` 参数错误(含业务校验:「仅可填写当前周期」「已过打卡时间」等)
- `401` 缺 token / token 无效或过期
- `403` 无权限(学员查他组 / 删他人附件)
- `404` 资源不存在
- `500` 未捕获的运行时错误(FastAPI 默认)
- 错误体:
- FastAPI 默认:`{"detail": "..."}`
- 业务自定义:`{"error": "..."}` 或 `{"success": false, "error": "..."}` 或 `{"need_phone": true, "error": "..."}`
- 前端解析顺序(`pages/checkin/checkin.js`):`err.detail → err.error → err.message`
---
> 文档结束。如有功能/接口改动,请同步更新本文件章节 5/6 并在文末「更新日志」追加条目。