From 18f6ddd44948f3ca0474368630ea301475bf41cc Mon Sep 17 00:00:00 2001 From: AndyChen1024 Date: Fri, 11 Sep 2026 13:05:10 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=20V4.0=20=E9=9C=80?= =?UTF-8?q?=E6=B1=82=E8=A7=84=E6=A0=BC=E8=AF=B4=E6=98=8E=E4=B9=A6=EF=BC=88?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F+=E5=90=8E=E7=AB=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...规格说明书-V4.0-小程序与后端.md | 657 ++++++++++++++++++ 1 file changed, 657 insertions(+) create mode 100644 docs/需求规格说明书-V4.0-小程序与后端.md diff --git a/docs/需求规格说明书-V4.0-小程序与后端.md b/docs/需求规格说明书-V4.0-小程序与后端.md new file mode 100644 index 0000000..1ed8692 --- /dev/null +++ b/docs/需求规格说明书-V4.0-小程序与后端.md @@ -0,0 +1,657 @@ +# 明缘云道场小程序 + 后端 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/` + +> 前 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()` +- **已登录** → ``,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 并在文末「更新日志」追加条目。