From 67d29d3ca0b45b3be066eea58ceb6ffa82b0df91 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Mon, 20 Jul 2026 14:47:16 +0800 Subject: [PATCH] feat: add WeChat session authentication --- DEBUG_GUIDE.md | 13 +- DEPLOY_5700U.md | 13 +- README.md | 2 +- WECHAT_AUTH_PLAN.md | 21 +- compose.deploy.yml | 1 + pages/profile/index.ts | 31 +- pages/profile/index.wxml | 9 +- pages/profile/index.wxss | 10 +- project.config.json | 20 + server/.env.example | 9 + server/API.md | 22 +- server/API_GUIDE.md | 25 +- server/Cargo.lock | 2 + server/Cargo.toml | 2 + server/Dockerfile | 4 +- server/migrations/0004_wechat_auth.sql | 64 ++++ server/src/auth.rs | 485 +++++++++++++++++++++++++ server/src/bin/migrate-user-owner.rs | 160 ++++++++ server/src/config.rs | 68 +++- server/src/error.rs | 11 +- server/src/main.rs | 4 + server/src/recording_routes.rs | 80 ++-- server/src/routes.rs | 126 ++++--- utils/api.ts | 98 ++++- utils/auth.ts | 137 +++++++ utils/types.ts | 2 + 26 files changed, 1289 insertions(+), 130 deletions(-) create mode 100644 server/migrations/0004_wechat_auth.sql create mode 100644 server/src/auth.rs create mode 100644 server/src/bin/migrate-user-owner.rs create mode 100644 utils/auth.ts diff --git a/DEBUG_GUIDE.md b/DEBUG_GUIDE.md index 852d822..356bd5b 100644 --- a/DEBUG_GUIDE.md +++ b/DEBUG_GUIDE.md @@ -58,6 +58,15 @@ cd /Users/zhangheng/project/ai/miniprogram/teaching-feedback-assistant test -f server/.env || cp server/.env.example server/.env ``` +小程序业务接口还需要微信登录。在 `server/.env` 中填写小程序后台提供的 AppSecret,不要提交或粘贴到日志: + +```env +WECHAT_APP_ID=wx3fe11e262a4b4885 +WECHAT_APP_SECRET=<仅保存在服务端> +AUTH_SESSION_TTL_DAYS=30 +ALLOW_DEVELOPMENT_USER_HEADER=false +``` + 本地 FunASR 默认配置如下,无需腾讯云密钥: ```env @@ -150,7 +159,9 @@ curl http://127.0.0.1:8080/health | jq . "database_configured": true, "speech_configured": true, "speech_available": true, - "speech_provider": "local-funasr" + "speech_provider": "local-funasr", + "wechat_auth_configured": true, + "development_auth_enabled": false } ``` diff --git a/DEPLOY_5700U.md b/DEPLOY_5700U.md index 688fb53..bb3807b 100644 --- a/DEPLOY_5700U.md +++ b/DEPLOY_5700U.md @@ -104,7 +104,7 @@ DATABASE_MAX_CONNECTIONS=10 6. 至少保留 20 GB 可用磁盘空间,供镜像、Rust 构建层、约 2.1 GB 模型缓存和录音使用。 7. 宿主机不需要安装 Rust、Python、PyTorch、FunASR 或 ffmpeg,这些依赖应全部位于镜像内。 -如果缺少 `DATABASE_URL`,停止并让用户在主机本地安全填写。不得要求用户在聊天中粘贴密码,也不得在日志或最终报告中输出完整连接串。 +如果缺少 `DATABASE_URL`、`WECHAT_APP_ID` 或 `WECHAT_APP_SECRET`,停止并让用户在主机本地安全填写。不得要求用户在聊天中粘贴密码或 AppSecret,也不得在日志或最终报告中输出敏感值。 ### 2. 创建生产部署文件 @@ -119,7 +119,7 @@ DATABASE_MAX_CONNECTIONS=10 Rust 镜像要求: 1. builder 阶段使用与 `server/Cargo.toml` 中 `rust-version` 兼容的 Rust 工具链。 -2. 构建 `teaching-feedback-api` 和 `migrate` 两个 release 二进制。 +2. 构建 `teaching-feedback-api`、`migrate` 和 `migrate-user-owner` 三个 release 二进制。 3. runtime 阶段只包含运行所需 CA 证书、健康检查工具和二进制,不携带编译器与源码。 4. 尽可能使用非 root 用户运行 API,并确保录音卷目录可写。 5. 不把 `server/.env` 复制进镜像。 @@ -142,6 +142,8 @@ ASR_REQUEST_TIMEOUT_SECONDS=1800 ASR_WORKER_CONCURRENCY=1 ASR_JOB_LEASE_SECONDS=3600 DATABASE_MAX_CONNECTIONS=5 +AUTH_SESSION_TTL_DAYS=30 +ALLOW_DEVELOPMENT_USER_HEADER=false ``` FunASR 初始配置: @@ -156,7 +158,7 @@ ASR_BATCH_SIZE_SECONDS=300 MODELSCOPE_CACHE=/models ``` -`DATABASE_URL` 只从未提交的 `server/.env` 或同等安全的 Compose secret/env 文件注入。设置文件权限为仅部署用户可读,并确认 `git check-ignore` 能匹配该文件。 +`DATABASE_URL` 和 `WECHAT_APP_SECRET` 只从未提交的 `server/.env` 或同等安全的 Compose secret/env 文件注入。设置文件权限为仅部署用户可读,并确认 `git check-ignore` 能匹配该文件。 ### 3. 构建和启动 @@ -205,7 +207,9 @@ Rust 健康检查应包含: "database_configured": true, "speech_configured": true, "speech_available": true, - "speech_provider": "local-funasr" + "speech_provider": "local-funasr", + "wechat_auth_configured": true, + "development_auth_enabled": false } ``` @@ -230,6 +234,7 @@ Rust 健康检查应包含: - 三个 Compose 服务的状态和镜像版本。 - 数据库迁移结果。 - Rust API 与 FunASR 健康检查结果。 +- 微信登录是否已配置,以及生产开发身份兼容是否关闭。 - 实际并发配置,以及是否完成真实录音压测。 - 模型缓存卷和录音卷名称、占用空间及备份方式。 - 查看日志、启动、停止、重启、更新代码和重新部署的准确命令。 diff --git a/README.md b/README.md index 2567518..e5a152d 100644 --- a/README.md +++ b/README.md @@ -63,4 +63,4 @@ curl http://127.0.0.1:10095/health - 反馈页只暴露一个语音入口;反馈批次、课节、录音片段和转录状态由后端自动维护。 - 开发者工具模拟器和真机默认使用 `https://feedback.shay7sev.site`。 - 微信公众平台需要将该 HTTPS 域名同时配置为 `request` 和 `uploadFile` 合法域名。 -- 微信登录尚未接入。业务请求暂时使用固定开发用户 UUID;生产发布前按 [WECHAT_AUTH_PLAN.md](./WECHAT_AUTH_PLAN.md) 改为由后端根据微信登录凭据解析用户身份。 +- 小程序通过 `wx.login` 换取后端 Bearer 会话,普通请求和录音上传使用同一身份;部署配置和旧数据归属步骤见 [WECHAT_AUTH_PLAN.md](./WECHAT_AUTH_PLAN.md)。 diff --git a/WECHAT_AUTH_PLAN.md b/WECHAT_AUTH_PLAN.md index 3d15aa3..28c418e 100644 --- a/WECHAT_AUTH_PLAN.md +++ b/WECHAT_AUTH_PLAN.md @@ -1,6 +1,16 @@ # 微信登录与教师身份实施方案 -本文用于把当前客户端可伪造的固定 `X-User-Id`,替换为“微信身份换取后端会话”的正式登录体系。完成前不应公开发布小程序。 +本文记录已经实现的“微信身份换取后端会话”体系,以及部署和旧数据迁移步骤。完成生产配置与双账号验收前不应公开发布小程序。 + +## 当前状态 + +代码已实现数据库迁移、微信换码接口、随机会话令牌、统一 Bearer 鉴权、小程序自动登录与单次重试,以及旧用户归属迁移命令。部署时仍需完成: + +1. 在 5700U 的 `server/.env` 填写 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。 +2. 重建镜像并运行数据库迁移。 +3. 使用目标微信账号首次登录,记录“我的”页面显示的新内部 UUID。 +4. 备份数据库后运行 `migrate-user-owner`,把旧开发 UUID 数据交给该账号。 +5. 使用两个微信账号验证数据隔离,确认生产 `ALLOW_DEVELOPMENT_USER_HEADER=false`。 ## 目标流程 @@ -66,7 +76,7 @@ auth_sessions 处理要求: -1. 校验 code 非空并限制长度和登录频率。 +1. 校验 code 非空并限制长度;进程内最多同时执行 8 个微信换码请求,反向代理仍应按来源限制登录频率。 2. Rust 使用 `WECHAT_APP_ID`、`WECHAT_APP_SECRET` 调用 `code2Session`。 3. 微信返回错误码、缺少 `openid` 或网络失败时返回明确的 `401`/`502`,日志不得记录 AppSecret、session_key 或完整 code。 4. 按 `(app_id, openid)` 原子地查找或创建本地用户。 @@ -131,10 +141,13 @@ ALLOW_DEVELOPMENT_USER_HEADER=false 不能把这些数据自动交给“部署后第一个登录的人”,否则存在抢占风险。采用一次性管理命令迁移: 1. 先发布微信登录功能,由目标微信账号完成一次登录并取得新的内部用户 UUID。 -2. 在服务端运行受控 CLI,例如: +2. 在服务端运行受控 CLI: ```text -migrate-user-owner --from 5a4da7f0-d70c-465f-bcab-124c504aa9f0 --to <新用户UUID> +docker compose -f compose.deploy.yml run --rm --no-deps api \ + migrate-user-owner \ + --from 5a4da7f0-d70c-465f-bcab-124c504aa9f0 \ + --to <新用户UUID> ``` 3. CLI 在单个数据库事务中更新学生档案、预设、反馈记录和反馈会话的 `owner_id`。 diff --git a/compose.deploy.yml b/compose.deploy.yml index 50407e0..9211beb 100644 --- a/compose.deploy.yml +++ b/compose.deploy.yml @@ -65,6 +65,7 @@ services: ASR_WORKER_CONCURRENCY: "1" ASR_JOB_LEASE_SECONDS: "3600" DATABASE_MAX_CONNECTIONS: "5" + ALLOW_DEVELOPMENT_USER_HEADER: "false" ports: - "39180:8080" volumes: diff --git a/pages/profile/index.ts b/pages/profile/index.ts index 5b286fa..e25b7e1 100644 --- a/pages/profile/index.ts +++ b/pages/profile/index.ts @@ -1,5 +1,5 @@ import { - DEVELOPMENT_USER_ID, + ensureAuthentication, getApiBaseUrl, getApiErrorMessage, getHealth, @@ -11,16 +11,19 @@ type InputEvent = { detail: { value: string } } Page({ data: { apiBaseUrl: getApiBaseUrl(), - developmentUserId: DEVELOPMENT_USER_ID, connectionText: '尚未检测', connectionTone: 'idle', - testing: false + testing: false, + authText: '尚未登录', + authTone: 'idle', + authUserId: '' }, onShow() { this.getTabBar()?.setData({ selected: 3 }) this.setData({ apiBaseUrl: getApiBaseUrl() }) void this.checkConnection() + void this.checkAuthentication() }, onUrlInput(event: InputEvent) { @@ -32,6 +35,7 @@ Page({ const apiBaseUrl = setApiBaseUrl(this.data.apiBaseUrl) this.setData({ apiBaseUrl }) await this.checkConnection() + await this.checkAuthentication() } catch (error) { this.setData({ connectionText: getApiErrorMessage(error), connectionTone: 'error' }) } @@ -52,6 +56,9 @@ Page({ } else if (!health.speechAvailable) { connectionText = '服务和数据库正常,本地语音模型尚未就绪' connectionTone = 'warning' + } else if (!health.wechatAuthConfigured) { + connectionText = '服务正常,微信登录尚未配置' + connectionTone = 'warning' } this.setData({ connectionText, @@ -62,5 +69,23 @@ Page({ } finally { this.setData({ testing: false }) } + }, + + async checkAuthentication() { + this.setData({ authText: '正在登录...', authTone: 'idle', authUserId: '' }) + try { + const session = await ensureAuthentication() + this.setData({ + authText: '微信身份已登录', + authTone: 'success', + authUserId: session.userId + }) + } catch (error) { + this.setData({ + authText: getApiErrorMessage(error), + authTone: 'error', + authUserId: '' + }) + } } }) diff --git a/pages/profile/index.wxml b/pages/profile/index.wxml index 2640ce7..90527c8 100644 --- a/pages/profile/index.wxml +++ b/pages/profile/index.wxml @@ -18,8 +18,11 @@ - 开发身份 - {{developmentUserId}} - 微信登录接入前,业务数据按此开发用户隔离。 + 微信身份 + + + {{authText}} + + {{authUserId}} diff --git a/pages/profile/index.wxss b/pages/profile/index.wxss index 7ba5f22..5dc6dc9 100644 --- a/pages/profile/index.wxss +++ b/pages/profile/index.wxss @@ -13,8 +13,7 @@ } .section-title, -.identity-value, -.identity-note { +.identity-value { display: block; } @@ -82,10 +81,3 @@ font-weight: 700; word-break: break-all; } - -.identity-note { - margin-top: 12rpx; - color: #718096; - font-size: 24rpx; - line-height: 1.5; -} diff --git a/project.config.json b/project.config.json index f7d0c52..904a024 100644 --- a/project.config.json +++ b/project.config.json @@ -56,6 +56,26 @@ { "value": "compose.asr.yml", "type": "file" + }, + { + "value": "compose.deploy.yml", + "type": "file" + }, + { + "value": "README.md", + "type": "file" + }, + { + "value": "DEBUG_GUIDE.md", + "type": "file" + }, + { + "value": "DEPLOY_5700U.md", + "type": "file" + }, + { + "value": "WECHAT_AUTH_PLAN.md", + "type": "file" } ], "include": [] diff --git a/server/.env.example b/server/.env.example index baeba14..526841d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -5,6 +5,15 @@ HOST=127.0.0.1 PORT=8080 DATABASE_MAX_CONNECTIONS=5 +# WeChat login uses the free wx.login/code2Session basic capability. Keep the +# AppSecret on the server and configure both values together. +# WECHAT_APP_ID=wx3fe11e262a4b4885 +# WECHAT_APP_SECRET=your-app-secret +AUTH_SESSION_TTL_DAYS=30 + +# Local compatibility only. Production must keep this false. +ALLOW_DEVELOPMENT_USER_HEADER=false + # A single development origin. Leave unset to disable CORS middleware. # CORS_ALLOWED_ORIGIN=https://servicewechat.com diff --git a/server/API.md b/server/API.md index 0289534..467f167 100644 --- a/server/API.md +++ b/server/API.md @@ -8,7 +8,7 @@ - OpenAPI JSON:`GET /openapi.json` - 完整调用流程:[API_GUIDE.md](./API_GUIDE.md) -Scalar 中的业务接口已预填开发用户 ID 和请求体示例。创建、列表、预设和健康检查可以直接发起请求;按 ID 操作时,应使用创建或列表接口返回的真实 ID。 +Scalar 中的业务接口需要填写微信登录换取的 Bearer 令牌,请求体接口提供了示例。按 ID 操作时,应使用创建或列表接口返回的真实 ID。 ## 启动和数据库策略 @@ -22,6 +22,7 @@ Scalar 中的业务接口已预填开发用户 ID 和请求体示例。创建、 cd server cp .env.example .env # 在 .env 中填写 DATABASE_URL +# 同时填写 WECHAT_APP_ID 和 WECHAT_APP_SECRET cargo run --bin migrate cargo run ``` @@ -39,7 +40,7 @@ ASR_WORKER_CONCURRENCY=1 ASR_JOB_LEASE_SECONDS=3600 ``` -在项目根目录执行 `docker compose -f compose.asr.yml up --build -d` 启动中文模型。`GET /health` 中 `speech_available=true` 表示模型已下载并可以接收任务。 +在项目根目录执行 `docker compose -f compose.asr.yml up --build -d` 启动中文模型。`GET /health` 中 `speech_available=true` 表示模型已下载并可以接收任务;`wechat_auth_configured=true` 且 `development_auth_enabled=false` 表示生产微信鉴权已就绪。 腾讯云仅作为可选提供方保留。需要切换时配置: @@ -59,21 +60,30 @@ FEEDBACK_SUMMARY_API_KEY=your-api-key FEEDBACK_SUMMARY_MODEL=your-model ``` -## 临时身份边界 +## 微信登录和会话 -目前所有业务接口都要求 `X-User-Id` 请求头,值为 UUID。例如: +小程序调用 `wx.login()`,再把一次性 code 发送到: ```text -X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0 +POST /api/v1/auth/wechat/login ``` -这是微信登录接入完成前的开发身份边界,用于确保每位教师只能访问自己的记录。接入微信登录后,后端会从登录凭据解析用户身份,客户端不再直接提供此请求头。 +Rust API 使用服务端 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET` 调用微信 `code2Session`,把 `openid` 映射为内部教师 UUID,并返回 30 天有效的随机会话令牌。业务接口统一使用: + +```text +Authorization: Bearer +``` + +数据库仅保存令牌的 SHA-256 哈希,AppSecret 和微信 session_key 不会返回客户端。本地兼容头只有设置 `ALLOW_DEVELOPMENT_USER_HEADER=true` 时才启用,生产 Compose 强制为 `false`。 ## 接口 | 方法 | 路径 | 说明 | | --- | --- | --- | | `GET` | `/health` | 服务、数据库、语音提供方及模型可用状态。 | +| `POST` | `/api/v1/auth/wechat/login` | 用 `wx.login` code 换取后端会话。 | +| `GET` | `/api/v1/auth/me` | 读取当前教师身份和会话状态。 | +| `POST` | `/api/v1/auth/logout` | 撤销当前会话。 | | `GET` | `/api/v1/profiles` | 列出学生档案;可选 `query`、`grade`、`subject` 参数。 | | `POST` | `/api/v1/profiles` | 创建学生档案。 | | `GET` | `/api/v1/profiles/{profile_id}` | 读取单个学生档案。 | diff --git a/server/API_GUIDE.md b/server/API_GUIDE.md index a0f7962..a3bc637 100644 --- a/server/API_GUIDE.md +++ b/server/API_GUIDE.md @@ -8,7 +8,7 @@ docker compose -f compose.asr.yml up --build -d ``` -在 `server/.env` 中配置 `DATABASE_URL` 后执行: +在 `server/.env` 中配置 `DATABASE_URL`、`WECHAT_APP_ID` 和 `WECHAT_APP_SECRET` 后执行: ```bash cd server @@ -26,12 +26,14 @@ cargo run ## 2. 在 Scalar 中直接测试 -打开 `/scalar`,选择接口后点击 **Test Request**。业务接口已经预填以下开发用户 ID: +打开 `/scalar`,选择接口后点击 **Test Request**。除登录和健康检查外,业务接口需要填写: ```text -X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0 +Authorization: Bearer ``` +access token 由小程序 `wx.login` code 调用 `POST /api/v1/auth/wechat/login` 获得。code 一次性且有效时间短,AppSecret 不得放入 Scalar、小程序或命令行历史。 + 请求体接口也已提供可发送的默认 JSON。建议按以下顺序测试: 1. 执行 `GET /health`,确认 `database_configured` 和 `speech_available` 均为 `true`。首次模型下载期间后者为 `false`。 @@ -58,7 +60,7 @@ curl http://127.0.0.1:8080/health ```bash curl -X POST http://127.0.0.1:8080/api/v1/profiles \ -H 'Content-Type: application/json' \ - -H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0' \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ -d '{ "name": "小谢", "grade": "艺术类", @@ -76,7 +78,7 @@ curl -X POST http://127.0.0.1:8080/api/v1/profiles \ ```bash curl -X POST http://127.0.0.1:8080/api/v1/feedback-records \ -H 'Content-Type: application/json' \ - -H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0' \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ -d '{ "profile_id": null, "feedback_date": "2026-07-15", @@ -89,16 +91,16 @@ curl -X POST http://127.0.0.1:8080/api/v1/feedback-records \ ```bash curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' \ - -H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0' + -H "Authorization: Bearer $ACCESS_TOKEN" ``` ## 4. 身份和数据隔离 -当前尚未接入微信登录,所有业务接口临时使用 `X-User-Id` 标识用户。相同 UUID 可以访问自己创建的数据,不同 UUID 之间的数据互相不可见。 +微信 `openid` 只用于后端映射内部教师 UUID,客户端不能直接提交 `openid` 或 `user_id`。随机 access token 只在登录响应中返回,数据库仅保存哈希。 -`X-User-Id` 缺失时返回 `401`,格式不是 UUID 时返回 `400`。接入微信登录后,应由后端根据登录凭据确定用户身份,客户端不再直接提交该请求头。 +Bearer 会话不存在、过期或已撤销时返回 `401`,小程序会重新调用 `wx.login` 并重试原请求一次。本地接口测试可显式设置 `ALLOW_DEVELOPMENT_USER_HEADER=true` 使用旧开发头;生产必须保持 `false`。 -小程序开发客户端当前使用同一个测试 UUID,并默认连接 `https://feedback.shay7sev.site`。可在小程序“我的”页面修改 API 地址并执行健康检查;本地 API 调试时使用 `http://127.0.0.1:8080`。微信公众平台需将生产域名同时配置为 `request` 和 `uploadFile` 合法域名。 +小程序默认连接 `https://feedback.shay7sev.site`,并在“我的”页面显示微信登录状态和内部教师 ID。本地 API 调试时使用 `http://127.0.0.1:8080`。微信公众平台需将生产域名同时配置为 `request` 和 `uploadFile` 合法域名。 ## 5. 响应状态码 @@ -108,7 +110,8 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' | `201` | 创建成功 | | `204` | 删除成功,无响应体 | | `400` | 参数格式或业务校验失败 | -| `401` | 缺少 `X-User-Id` | +| `401` | 登录 code 或 Bearer 会话无效 | +| `502` | 微信登录上游暂时不可用 | | `404` | 记录不存在或不属于当前用户 | | `500` | 数据库查询或服务内部错误 | | `503` | 未配置数据库连接 | @@ -125,4 +128,4 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' ## 6. OpenAPI 维护方式 -`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和默认身份参数。 +`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和 Bearer 身份参数。 diff --git a/server/Cargo.lock b/server/Cargo.lock index b79f207..ce2098a 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1897,10 +1897,12 @@ dependencies = [ "chrono", "dotenvy", "hmac", + "rand 0.8.7", "reqwest", "serde", "serde_json", "sha1", + "sha2", "sqlx", "thiserror", "tokio", diff --git a/server/Cargo.toml b/server/Cargo.toml index e976154..26e6525 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -11,10 +11,12 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" hmac = "0.12" +rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.10" +sha2 = "0.10" sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] } thiserror = "2" tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "net", "signal"] } diff --git a/server/Dockerfile b/server/Dockerfile index 01e4963..c336013 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -8,7 +8,8 @@ COPY src ./src RUN cargo build --locked --release \ --bin teaching-feedback-api \ - --bin migrate + --bin migrate \ + --bin migrate-user-owner FROM debian:bookworm-slim AS runtime @@ -24,6 +25,7 @@ RUN sed -i "s|http://deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/ COPY --from=builder /build/target/release/teaching-feedback-api /usr/local/bin/teaching-feedback-api COPY --from=builder /build/target/release/migrate /usr/local/bin/migrate +COPY --from=builder /build/target/release/migrate-user-owner /usr/local/bin/migrate-user-owner ENV HOST=0.0.0.0 \ PORT=8080 \ diff --git a/server/migrations/0004_wechat_auth.sql b/server/migrations/0004_wechat_auth.sql new file mode 100644 index 0000000..c34b91c --- /dev/null +++ b/server/migrations/0004_wechat_auth.sql @@ -0,0 +1,64 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY, + status VARCHAR(16) NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'disabled')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO users (id) +SELECT owner_id FROM student_profiles +UNION +SELECT owner_id FROM student_profile_defaults +UNION +SELECT owner_id FROM feedback_records +UNION +SELECT owner_id FROM feedback_sessions +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE wechat_identities ( + app_id VARCHAR(64) NOT NULL, + openid VARCHAR(128) NOT NULL, + unionid VARCHAR(128), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (app_id, openid) +); + +CREATE INDEX wechat_identities_user_idx + ON wechat_identities (user_id); + +CREATE UNIQUE INDEX wechat_identities_app_unionid_idx + ON wechat_identities (app_id, unionid) + WHERE unionid IS NOT NULL; + +CREATE TABLE auth_sessions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX auth_sessions_active_user_idx + ON auth_sessions (user_id, expires_at DESC) + WHERE revoked_at IS NULL; + +ALTER TABLE student_profiles + ADD CONSTRAINT student_profiles_owner_fk + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; + +ALTER TABLE student_profile_defaults + ADD CONSTRAINT student_profile_defaults_owner_fk + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; + +ALTER TABLE feedback_records + ADD CONSTRAINT feedback_records_owner_fk + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; + +ALTER TABLE feedback_sessions + ADD CONSTRAINT feedback_sessions_owner_fk + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE RESTRICT; diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..5898683 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,485 @@ +use std::{sync::Arc, time::Duration as StdDuration}; + +use axum::{ + Json, + extract::State, + http::{HeaderMap, StatusCode}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Duration, Utc}; +use rand::{RngCore, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{PgPool, Postgres, Transaction}; +use tokio::sync::Semaphore; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use uuid::Uuid; + +use crate::{ + AppState, + config::AuthConfig, + error::{ApiError, ErrorBody}, +}; + +type SharedState = Arc; + +const BEARER_PREFIX: &str = "Bearer "; + +#[derive(Clone)] +pub struct AuthService { + config: AuthConfig, + client: reqwest::Client, + login_slots: Arc, +} + +#[derive(Debug)] +pub struct AuthenticatedUser { + pub user_id: Uuid, + pub session_id: Option, + pub expires_at: Option>, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[schema(example = json!({"code": "wx.login 返回的一次性 code"}))] +struct WechatLoginInput { + code: String, +} + +#[derive(Debug, Serialize, ToSchema)] +struct WechatLoginResponse { + access_token: String, + expires_at: DateTime, + user_id: Uuid, +} + +#[derive(Debug, Serialize, ToSchema)] +struct CurrentUserResponse { + user_id: Uuid, + expires_at: Option>, + auth_mode: &'static str, +} + +#[derive(Debug, Deserialize)] +struct Code2SessionResponse { + openid: Option, + unionid: Option, + errcode: Option, + errmsg: Option, +} + +pub fn router() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(wechat_login)) + .routes(routes!(current_user)) + .routes(routes!(logout)) +} + +impl AuthService { + pub fn new(config: AuthConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + login_slots: Arc::new(Semaphore::new(8)), + } + } + + pub fn wechat_configured(&self) -> bool { + self.config.wechat.is_some() + } + + pub fn development_auth_enabled(&self) -> bool { + self.config.allow_development_user_header + } + + pub async fn authenticate( + &self, + pool: Option<&PgPool>, + headers: &HeaderMap, + ) -> Result { + if let Some(token) = bearer_token(headers)? { + let pool = pool.ok_or(ApiError::DatabaseUnavailable)?; + return authenticate_token(pool, token).await; + } + if self.config.allow_development_user_header { + return development_user(pool, headers).await; + } + Err(ApiError::Unauthorized) + } + + async fn exchange_code(&self, code: &str) -> Result { + let config = self.config.wechat.as_ref().ok_or_else(|| { + ApiError::ServiceUnavailable("WeChat login has not been configured".to_owned()) + })?; + let _permit = self + .login_slots + .acquire() + .await + .map_err(|_| ApiError::Internal)?; + let response = self + .client + .get(&config.code2session_url) + .query(&[ + ("appid", config.app_id.as_str()), + ("secret", config.app_secret.as_str()), + ("js_code", code), + ("grant_type", "authorization_code"), + ]) + .timeout(StdDuration::from_secs(10)) + .send() + .await + .map_err(|error| { + tracing::warn!( + is_timeout = error.is_timeout(), + is_connect = error.is_connect(), + "WeChat code2Session request failed" + ); + ApiError::BadGateway("WeChat login service is unavailable".to_owned()) + })?; + if !response.status().is_success() { + tracing::warn!(status = %response.status(), "WeChat code2Session returned HTTP error"); + return Err(ApiError::BadGateway( + "WeChat login service is unavailable".to_owned(), + )); + } + let response = response.json::().await.map_err(|_| { + tracing::warn!("WeChat code2Session returned invalid JSON"); + ApiError::BadGateway("WeChat login service returned an invalid response".to_owned()) + })?; + let Some(openid) = response.openid.filter(|value| !value.trim().is_empty()) else { + tracing::warn!( + errcode = response.errcode, + errmsg = response.errmsg.as_deref().unwrap_or("unknown"), + "WeChat login code was rejected" + ); + return Err(ApiError::Unauthorized); + }; + Ok(WechatIdentity { + app_id: config.app_id.clone(), + openid, + unionid: response.unionid.filter(|value| !value.trim().is_empty()), + }) + } +} + +#[derive(Debug)] +struct WechatIdentity { + app_id: String, + openid: String, + unionid: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/auth/wechat/login", + tag = "身份认证", + summary = "微信登录", + description = "用 wx.login 的一次性 code 换取本服务的 Bearer 会话令牌。AppSecret 只保存在服务端。", + request_body(content = WechatLoginInput, content_type = "application/json"), + responses( + (status = 200, description = "登录成功", body = WechatLoginResponse), + (status = 400, description = "code 格式无效", body = ErrorBody), + (status = 401, description = "微信拒绝了 code", body = ErrorBody), + (status = 502, description = "微信登录服务不可用", body = ErrorBody), + (status = 503, description = "数据库或微信登录未配置", body = ErrorBody) + ) +)] +async fn wechat_login( + State(state): State, + Json(input): Json, +) -> Result, ApiError> { + let code = input.code.trim(); + if code.is_empty() || code.len() > 256 { + return Err(ApiError::BadRequest( + "code must contain 1 to 256 characters".to_owned(), + )); + } + let pool = state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)?; + let identity = state.auth.exchange_code(code).await?; + let user_id = upsert_identity(pool, &identity).await?; + let user_active = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND status = 'active')", + ) + .bind(user_id) + .fetch_one(pool) + .await?; + if !user_active { + return Err(ApiError::Unauthorized); + } + let (access_token, token_hash) = create_token(); + let expires_at = Utc::now() + Duration::days(state.auth.config.session_ttl_days); + sqlx::query( + "INSERT INTO auth_sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)", + ) + .bind(Uuid::new_v4()) + .bind(user_id) + .bind(token_hash) + .bind(expires_at) + .execute(pool) + .await?; + let _ = sqlx::query( + "DELETE FROM auth_sessions WHERE expires_at < now() - interval '30 days' OR revoked_at < now() - interval '30 days'", + ) + .execute(pool) + .await; + + Ok(Json(WechatLoginResponse { + access_token, + expires_at, + user_id, + })) +} + +#[utoipa::path( + get, + path = "/api/v1/auth/me", + tag = "身份认证", + summary = "读取当前身份", + description = "校验 Bearer 会话并返回内部教师 ID。开发模式可返回开发身份。", + params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ")), + responses( + (status = 200, description = "当前身份", body = CurrentUserResponse), + (status = 401, description = "会话无效或已过期", body = ErrorBody) + ) +)] +async fn current_user( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let identity = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await?; + Ok(Json(CurrentUserResponse { + user_id: identity.user_id, + expires_at: identity.expires_at, + auth_mode: if identity.session_id.is_some() { + "wechat" + } else { + "development" + }, + })) +} + +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + tag = "身份认证", + summary = "退出登录", + description = "撤销当前 Bearer 会话。", + params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ")), + responses( + (status = 204, description = "会话已撤销"), + (status = 401, description = "会话无效或已过期", body = ErrorBody) + ) +)] +async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result { + let identity = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await?; + if let Some(session_id) = identity.session_id { + sqlx::query("UPDATE auth_sessions SET revoked_at = now() WHERE id = $1") + .bind(session_id) + .execute(state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable)?) + .await?; + } + Ok(StatusCode::NO_CONTENT) +} + +async fn authenticate_token(pool: &PgPool, token: &str) -> Result { + let token_hash = hash_token(token.as_bytes()); + let result = sqlx::query_as::<_, (Uuid, Uuid, DateTime)>( + "SELECT session.user_id, session.id, session.expires_at \ + FROM auth_sessions session JOIN users ON users.id = session.user_id \ + WHERE session.token_hash = $1 AND session.revoked_at IS NULL \ + AND session.expires_at > now() AND users.status = 'active'", + ) + .bind(token_hash) + .fetch_optional(pool) + .await?; + let Some((user_id, session_id, expires_at)) = result else { + return Err(ApiError::Unauthorized); + }; + Ok(AuthenticatedUser { + user_id, + session_id: Some(session_id), + expires_at: Some(expires_at), + }) +} + +async fn upsert_identity(pool: &PgPool, identity: &WechatIdentity) -> Result { + let mut transaction = pool.begin().await?; + let existing = sqlx::query_scalar::<_, Uuid>( + "SELECT user_id FROM wechat_identities WHERE app_id = $1 AND openid = $2 FOR UPDATE", + ) + .bind(&identity.app_id) + .bind(&identity.openid) + .fetch_optional(&mut *transaction) + .await?; + let user_id = match existing { + Some(user_id) => user_id, + None => insert_identity(&mut transaction, identity).await?, + }; + sqlx::query( + "UPDATE wechat_identities SET unionid = COALESCE($3, unionid), last_login_at = now() \ + WHERE app_id = $1 AND openid = $2", + ) + .bind(&identity.app_id) + .bind(&identity.openid) + .bind(&identity.unionid) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(user_id) +} + +async fn insert_identity( + transaction: &mut Transaction<'_, Postgres>, + identity: &WechatIdentity, +) -> Result { + let proposed_user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id) VALUES ($1)") + .bind(proposed_user_id) + .execute(&mut **transaction) + .await?; + let user_id = sqlx::query_scalar::<_, Uuid>( + "INSERT INTO wechat_identities (app_id, openid, unionid, user_id) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (app_id, openid) DO UPDATE SET last_login_at = now() \ + RETURNING user_id", + ) + .bind(&identity.app_id) + .bind(&identity.openid) + .bind(&identity.unionid) + .bind(proposed_user_id) + .fetch_one(&mut **transaction) + .await?; + if user_id != proposed_user_id { + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(proposed_user_id) + .execute(&mut **transaction) + .await?; + } + Ok(user_id) +} + +fn bearer_token(headers: &HeaderMap) -> Result, ApiError> { + let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else { + return Ok(None); + }; + let value = value.to_str().map_err(|_| ApiError::Unauthorized)?; + let token = value + .strip_prefix(BEARER_PREFIX) + .ok_or(ApiError::Unauthorized)?; + if token.is_empty() { + return Err(ApiError::Unauthorized); + } + Ok(Some(token)) +} + +async fn development_user( + pool: Option<&PgPool>, + headers: &HeaderMap, +) -> Result { + let value = headers + .get("x-user-id") + .and_then(|header| header.to_str().ok()) + .ok_or(ApiError::Unauthorized)?; + let user_id = Uuid::parse_str(value) + .map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned()))?; + if let Some(pool) = pool { + sqlx::query("INSERT INTO users (id) VALUES ($1) ON CONFLICT (id) DO NOTHING") + .bind(user_id) + .execute(pool) + .await?; + } + Ok(AuthenticatedUser { + user_id, + session_id: None, + expires_at: None, + }) +} + +fn create_token() -> (String, Vec) { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + let token = URL_SAFE_NO_PAD.encode(bytes); + let token_hash = hash_token(token.as_bytes()); + (token, token_hash) +} + +fn hash_token(token: &[u8]) -> Vec { + Sha256::digest(token).to_vec() +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum::{ + Json, Router, + extract::Query, + http::{HeaderMap, HeaderValue, header::AUTHORIZATION}, + routing::get, + }; + use serde_json::json; + + use crate::config::{AuthConfig, WechatAuthConfig}; + + use super::{AuthService, bearer_token, create_token, hash_token}; + + #[test] + fn bearer_token_requires_exact_scheme_and_value() { + let mut headers = HeaderMap::new(); + assert_eq!(bearer_token(&headers).unwrap(), None); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer test-token")); + assert_eq!(bearer_token(&headers).unwrap(), Some("test-token")); + headers.insert(AUTHORIZATION, HeaderValue::from_static("Basic test-token")); + assert!(bearer_token(&headers).is_err()); + } + + #[test] + fn generated_tokens_are_unique_and_only_hashes_are_stored() { + let (first, first_hash) = create_token(); + let (second, second_hash) = create_token(); + assert_ne!(first, second); + assert_ne!(first_hash, second_hash); + assert_eq!(first_hash.len(), 32); + assert_eq!(first_hash, hash_token(first.as_bytes())); + assert_ne!(first_hash.as_slice(), first.as_bytes()); + } + + #[tokio::test] + async fn exchange_code_uses_server_credentials_and_returns_wechat_identity() { + let app = Router::new().route( + "/jscode2session", + get(|Query(query): Query>| async move { + assert_eq!(query.get("appid").map(String::as_str), Some("test-app")); + assert_eq!(query.get("secret").map(String::as_str), Some("test-secret")); + assert_eq!(query.get("js_code").map(String::as_str), Some("test-code")); + Json(json!({"openid": "openid-1", "unionid": "unionid-1"})) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let service = AuthService::new(AuthConfig { + wechat: Some(WechatAuthConfig { + app_id: "test-app".to_owned(), + app_secret: "test-secret".to_owned(), + code2session_url: format!("http://{address}/jscode2session"), + }), + session_ttl_days: 30, + allow_development_user_header: false, + }); + + let identity = service.exchange_code("test-code").await.unwrap(); + assert_eq!(identity.app_id, "test-app"); + assert_eq!(identity.openid, "openid-1"); + assert_eq!(identity.unionid.as_deref(), Some("unionid-1")); + } +} diff --git a/server/src/bin/migrate-user-owner.rs b/server/src/bin/migrate-user-owner.rs new file mode 100644 index 0000000..ad27808 --- /dev/null +++ b/server/src/bin/migrate-user-owner.rs @@ -0,0 +1,160 @@ +use std::{env, io}; + +use sqlx::{PgPool, postgres::PgPoolOptions}; +use uuid::Uuid; + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + let (source_user_id, target_user_id) = parse_arguments()?; + if source_user_id == target_user_id { + return Err(invalid_input("source and target user IDs must differ")); + } + let database_url = env::var("DATABASE_URL") + .map_err(|_| invalid_input("DATABASE_URL is required before migrating user ownership"))?; + let pool = PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await?; + migrate_ownership(&pool, source_user_id, target_user_id).await?; + Ok(()) +} + +async fn migrate_ownership( + pool: &PgPool, + source_user_id: Uuid, + target_user_id: Uuid, +) -> Result<(), Box> { + let mut transaction = pool.begin().await?; + for (label, user_id) in [("source", source_user_id), ("target", target_user_id)] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND status = 'active')", + ) + .bind(user_id) + .fetch_one(&mut *transaction) + .await?; + if !exists { + return Err(invalid_input(format!( + "{label} user {user_id} does not exist or is disabled" + ))); + } + } + + let defaults_conflict = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM student_profile_defaults WHERE owner_id = $1) \ + AND EXISTS(SELECT 1 FROM student_profile_defaults WHERE owner_id = $2)", + ) + .bind(source_user_id) + .bind(target_user_id) + .fetch_one(&mut *transaction) + .await?; + if defaults_conflict { + return Err(invalid_input( + "both users have profile defaults; resolve the conflict before migrating", + )); + } + + let active_session_conflict = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(\ + SELECT 1 FROM feedback_sessions source \ + JOIN feedback_sessions target \ + ON source.profile_id IS NOT DISTINCT FROM target.profile_id \ + AND target.owner_id = $2 AND target.status = 'active' \ + WHERE source.owner_id = $1 AND source.status = 'active'\ + )", + ) + .bind(source_user_id) + .bind(target_user_id) + .fetch_one(&mut *transaction) + .await?; + if active_session_conflict { + return Err(invalid_input( + "both users have an active feedback session for the same profile", + )); + } + + let profiles = update_owner( + &mut transaction, + "student_profiles", + source_user_id, + target_user_id, + ) + .await?; + let defaults = update_owner( + &mut transaction, + "student_profile_defaults", + source_user_id, + target_user_id, + ) + .await?; + let records = update_owner( + &mut transaction, + "feedback_records", + source_user_id, + target_user_id, + ) + .await?; + let sessions = update_owner( + &mut transaction, + "feedback_sessions", + source_user_id, + target_user_id, + ) + .await?; + sqlx::query("UPDATE users SET updated_at = now() WHERE id = $1") + .bind(target_user_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + println!( + "User ownership migrated from {source_user_id} to {target_user_id}: \ + {profiles} profiles, {defaults} defaults, {records} feedback records, \ + {sessions} feedback sessions" + ); + Ok(()) +} + +async fn update_owner( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + table: &str, + source_user_id: Uuid, + target_user_id: Uuid, +) -> Result { + let statement = format!("UPDATE {table} SET owner_id = $2 WHERE owner_id = $1"); + Ok(sqlx::query(&statement) + .bind(source_user_id) + .bind(target_user_id) + .execute(&mut **transaction) + .await? + .rows_affected()) +} + +fn parse_arguments() -> Result<(Uuid, Uuid), Box> { + let mut source = None; + let mut target = None; + let mut arguments = env::args().skip(1); + while let Some(argument) = arguments.next() { + let value = arguments + .next() + .ok_or_else(|| invalid_input(format!("missing value after {argument}")))?; + match argument.as_str() { + "--from" => source = Some(parse_uuid(&value, "--from")?), + "--to" => target = Some(parse_uuid(&value, "--to")?), + _ => return Err(invalid_input(format!("unknown argument: {argument}"))), + } + } + let source = source + .ok_or_else(|| invalid_input("usage: migrate-user-owner --from --to "))?; + let target = target + .ok_or_else(|| invalid_input("usage: migrate-user-owner --from --to "))?; + Ok((source, target)) +} + +fn parse_uuid(value: &str, argument: &str) -> Result> { + Uuid::parse_str(value).map_err(|_| invalid_input(format!("{argument} must be a valid UUID"))) +} + +fn invalid_input(message: impl Into) -> Box { + Box::new(io::Error::new(io::ErrorKind::InvalidInput, message.into())) +} diff --git a/server/src/config.rs b/server/src/config.rs index 6cf8a67..a3c1642 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,12 +1,12 @@ use std::{env, net::IpAddr, path::PathBuf}; -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum SpeechProviderConfig { Local { api_url: String }, Tencent(TencentSpeechConfig), } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct TencentSpeechConfig { pub app_id: u64, pub secret_id: String, @@ -14,7 +14,7 @@ pub struct TencentSpeechConfig { pub engine_type: String, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SpeechConfig { pub provider: SpeechProviderConfig, pub request_timeout_seconds: u64, @@ -22,14 +22,28 @@ pub struct SpeechConfig { pub job_lease_seconds: i64, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SummaryConfig { pub api_url: String, pub api_key: Option, pub model: String, } -#[derive(Debug, Clone)] +#[derive(Clone)] +pub struct WechatAuthConfig { + pub app_id: String, + pub app_secret: String, + pub code2session_url: String, +} + +#[derive(Clone)] +pub struct AuthConfig { + pub wechat: Option, + pub session_ttl_days: i64, + pub allow_development_user_header: bool, +} + +#[derive(Clone)] pub struct Config { pub host: IpAddr, pub port: u16, @@ -39,6 +53,7 @@ pub struct Config { pub audio_storage_dir: PathBuf, pub speech: Option, pub summary: Option, + pub auth: AuthConfig, } impl Config { @@ -58,6 +73,7 @@ impl Config { let speech = speech_config()?; let summary = summary_config()?; + let auth = auth_config()?; Ok(Self { host, @@ -74,10 +90,41 @@ impl Config { .unwrap_or_else(|_| PathBuf::from("./data/audio")), speech, summary, + auth, }) } } +fn auth_config() -> Result { + let app_id = optional_env("WECHAT_APP_ID"); + let app_secret = optional_env("WECHAT_APP_SECRET"); + let wechat = match (app_id, app_secret) { + (Some(app_id), Some(app_secret)) => Some(WechatAuthConfig { + app_id, + app_secret, + code2session_url: optional_env("WECHAT_CODE2SESSION_URL") + .unwrap_or_else(|| "https://api.weixin.qq.com/sns/jscode2session".to_owned()), + }), + (None, None) => None, + _ => { + return Err( + "WECHAT_APP_ID and WECHAT_APP_SECRET must be configured together".to_owned(), + ); + } + }; + let session_ttl_days = parse_env("AUTH_SESSION_TTL_DAYS", 30_i64)?; + if !(1..=365).contains(&session_ttl_days) { + return Err("AUTH_SESSION_TTL_DAYS must be between 1 and 365".to_owned()); + } + let allow_development_user_header = parse_bool_env("ALLOW_DEVELOPMENT_USER_HEADER", false)?; + + Ok(AuthConfig { + wechat, + session_ttl_days, + allow_development_user_header, + }) +} + fn speech_config() -> Result, String> { let provider = optional_env("ASR_PROVIDER").unwrap_or_else(|| "local".to_owned()); if matches!(provider.as_str(), "disabled" | "none") { @@ -165,3 +212,14 @@ where |value| value.parse().map_err(|_| format!("{name} is invalid")), ) } + +fn parse_bool_env(name: &str, default: bool) -> Result { + let Some(value) = optional_env(name) else { + return Ok(default); + }; + match value.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + _ => Err(format!("{name} must be true or false")), + } +} diff --git a/server/src/error.rs b/server/src/error.rs index 3d821ac..f2da806 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -18,6 +18,8 @@ pub enum ApiError { DatabaseUnavailable, #[error("{0}")] ServiceUnavailable(String), + #[error("{0}")] + BadGateway(String), #[error("internal server error")] Internal, } @@ -30,7 +32,7 @@ pub struct ErrorBody { pub error: String, } -/// 所有需要数据库和临时用户身份的业务接口都可能返回的公共错误。 +/// 所有需要数据库和登录身份的业务接口都可能返回的公共错误。 #[allow(dead_code)] #[derive(IntoResponses)] pub enum CommonApiErrors { @@ -38,13 +40,13 @@ pub enum CommonApiErrors { #[response( status = 400, description = "请求参数无效", - example = json!({"error": "X-User-Id must be a UUID"}) + example = json!({"error": "request parameters are invalid"}) )] BadRequest(ErrorBody), - /// 缺少 `X-User-Id` 请求头。 + /// 缺少 Bearer 令牌,或会话无效、过期、已撤销。 #[response( status = 401, - description = "缺少开发阶段身份请求头", + description = "登录会话无效", example = json!({"error": "authentication is required"}) )] Unauthorized(ErrorBody), @@ -78,6 +80,7 @@ impl IntoResponse for ApiError { "database has not been configured".to_owned(), ), Self::ServiceUnavailable(message) => (StatusCode::SERVICE_UNAVAILABLE, message), + Self::BadGateway(message) => (StatusCode::BAD_GATEWAY, message), Self::Internal => ( StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_owned(), diff --git a/server/src/main.rs b/server/src/main.rs index e22ec39..9dc4d91 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,4 @@ +mod auth; mod config; mod error; mod recording_routes; @@ -21,6 +22,7 @@ pub struct AppState { pub audio_storage_dir: PathBuf, pub speech: speech::SpeechService, pub summary: summary::SummaryService, + pub auth: auth::AuthService, } #[tokio::main] @@ -33,11 +35,13 @@ async fn main() -> Result<(), Box> { let config = Config::from_env().map_err(std::io::Error::other)?; let pool = connect_database(&config).await?; let speech = speech::SpeechService::new(config.speech.clone()); + let auth = auth::AuthService::new(config.auth.clone()); let state = AppState { pool, audio_storage_dir: config.audio_storage_dir.clone(), speech: speech.clone(), summary: summary::SummaryService::new(config.summary.clone()), + auth, }; if let Some(pool) = state.pool.clone() { if speech.configured() { diff --git a/server/src/recording_routes.rs b/server/src/recording_routes.rs index 83fbb71..1cca5ac 100644 --- a/server/src/recording_routes.rs +++ b/server/src/recording_routes.rs @@ -183,7 +183,7 @@ struct AudioSegmentUpload { summary = "读取进行中的反馈批次", description = "读取当前用户指定学生下尚未保存的反馈批次;profile_id 留空时读取不关联学生的批次。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("profile_id" = Option, Query, description = "学生档案 ID;留空表示不关联学生") ), responses( @@ -196,7 +196,11 @@ async fn get_active_feedback_session( headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let session = sqlx::query_as::<_, FeedbackSessionRow>( "SELECT id, profile_id, feedback_date, content, status, created_at, updated_at \ FROM feedback_sessions \ @@ -222,7 +226,7 @@ async fn get_active_feedback_session( tag = "语音反馈", summary = "创建或恢复反馈批次", description = "为学生创建一个反馈批次;已有未保存批次时直接恢复,并同步最新反馈日期。", - params(("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))), + params(("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ")), request_body(content = FeedbackSessionInput, content_type = "application/json"), responses( (status = 200, description = "创建或恢复后的反馈批次", body = FeedbackSessionResponse), @@ -235,7 +239,11 @@ async fn ensure_feedback_session( headers: HeaderMap, Json(input): Json, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let pool = pool(&state)?; validate_draft_content(&input.content)?; if let Some(profile_id) = input.profile_id { @@ -284,7 +292,7 @@ async fn ensure_feedback_session( summary = "自动保存反馈草稿", description = "在用户编辑反馈正文或日期时自动保存进行中的反馈批次。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("session_id" = Uuid, Path, description = "反馈批次 ID") ), request_body(content = FeedbackSessionUpdateInput, content_type = "application/json"), @@ -300,7 +308,11 @@ async fn update_feedback_session( Path(session_id): Path, Json(input): Json, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; validate_draft_content(&input.content)?; let session = sqlx::query_as::<_, FeedbackSessionRow>( "UPDATE feedback_sessions SET feedback_date = $3, content = $4, updated_at = now() \ @@ -324,7 +336,7 @@ async fn update_feedback_session( summary = "开始一节课堂录音", description = "在反馈批次中自动创建下一节课堂录音。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("session_id" = Uuid, Path, description = "反馈批次 ID") ), responses( @@ -338,7 +350,11 @@ async fn create_lesson_session( headers: HeaderMap, Path(session_id): Path, ) -> Result<(axum::http::StatusCode, Json), ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; assert_session_owner(pool(&state)?, owner_id, session_id).await?; sqlx::query( "DELETE FROM lesson_sessions \ @@ -377,7 +393,7 @@ async fn create_lesson_session( summary = "上传并转录录音片段", description = "上传单个录音片段并加入后端转录队列。接口保存音频后立即返回,页面通过反馈批次状态无感获取转录结果。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("lesson_id" = Uuid, Path, description = "课堂录音 ID") ), request_body(content = AudioSegmentUpload, content_type = "multipart/form-data"), @@ -393,7 +409,11 @@ async fn upload_audio_segment( Path(lesson_id): Path, mut multipart: Multipart, ) -> Result<(axum::http::StatusCode, Json), ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let session_id = assert_lesson_owner(pool(&state)?, owner_id, lesson_id).await?; let mut sequence = None; let mut duration_ms = None; @@ -506,7 +526,7 @@ async fn upload_audio_segment( summary = "结束一节课堂录音", description = "汇总该节课的片段状态和转录文本;短录音会标记为可直接加入反馈。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("lesson_id" = Uuid, Path, description = "课堂录音 ID") ), responses( @@ -520,7 +540,11 @@ async fn finish_lesson_session( headers: HeaderMap, Path(lesson_id): Path, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let session_id = assert_lesson_owner(pool(&state)?, owner_id, lesson_id).await?; let segments = sqlx::query_as::<_, (String, i32, Option)>( "SELECT status, duration_ms, transcript FROM audio_segments \ @@ -571,7 +595,7 @@ async fn finish_lesson_session( summary = "标记短录音已加入反馈", description = "短录音文字直接加入反馈内容后,标记该节课无需再次汇总。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("lesson_id" = Uuid, Path, description = "课堂录音 ID") ), request_body(content = MarkLessonAppliedInput, content_type = "application/json"), @@ -587,7 +611,11 @@ async fn mark_lesson_applied( Path(lesson_id): Path, Json(input): Json, ) -> Result { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; validate_draft_content(&input.content)?; let pool = pool(&state)?; let session_id = assert_lesson_owner(pool, owner_id, lesson_id).await?; @@ -618,7 +646,7 @@ async fn mark_lesson_applied( summary = "重试失败的录音转录", description = "将后端保留的失败音频重新加入转录队列,接口立即返回。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("segment_id" = Uuid, Path, description = "录音片段 ID") ), responses( @@ -632,7 +660,11 @@ async fn retry_audio_segment( headers: HeaderMap, Path(segment_id): Path, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>( "SELECT s.sequence, s.duration_ms, s.audio_path, l.id \ FROM audio_segments s \ @@ -684,7 +716,7 @@ async fn retry_audio_segment( summary = "生成汇总反馈", description = "将尚未处理的长录音或多节课堂转录与教师已有内容合并。未配置大模型时使用本地抽取式汇总。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("session_id" = Uuid, Path, description = "反馈批次 ID") ), request_body(content = GenerateFeedbackInput, content_type = "application/json"), @@ -700,7 +732,11 @@ async fn generate_feedback_content( Path(session_id): Path, Json(input): Json, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; validate_draft_content(&input.existing_content)?; assert_session_owner(pool(&state)?, owner_id, session_id).await?; let pending_status = sqlx::query_as::<_, (bool, bool)>( @@ -867,14 +903,6 @@ async fn parse_multipart_i32( .map_err(|_| ApiError::BadRequest(format!("{name} must be an integer"))) } -fn current_user(headers: &HeaderMap) -> Result { - let value = headers - .get("x-user-id") - .and_then(|header| header.to_str().ok()) - .ok_or(ApiError::Unauthorized)?; - Uuid::parse_str(value).map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned())) -} - fn pool(state: &AppState) -> Result<&PgPool, ApiError> { state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable) } diff --git a/server/src/routes.rs b/server/src/routes.rs index 4ab7de8..3ac2299 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -16,7 +16,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use uuid::Uuid; use crate::{ - AppState, + AppState, auth, error::{ApiError, CommonApiErrors, ErrorBody}, recording_routes, }; @@ -30,6 +30,7 @@ const DEFAULT_DURATION_MINUTES: i16 = 60; pub fn router() -> (Router, OpenApi) { let (router, mut openapi) = OpenApiRouter::new() .routes(routes!(health)) + .merge(auth::router()) .routes(routes!(list_profiles, create_profile)) .routes(routes!(get_profile, update_profile, delete_profile)) .routes(routes!(get_defaults, update_defaults)) @@ -41,13 +42,14 @@ pub fn router() -> (Router, OpenApi) { let mut info = Info::new("教学反馈助手 API", env!("CARGO_PKG_VERSION")); info.description = Some( "学生档案、档案预设和教学反馈记录 API。\n\n\ - 业务接口使用 `X-User-Id` 作为开发阶段身份边界;Scalar 已预填测试 UUID 和请求体示例。" + 业务接口使用微信登录换取的 Bearer 会话令牌;只有显式开启时才兼容开发身份头。" .to_owned(), ); openapi.info = info; openapi.servers = Some(vec![Server::new("/")]); openapi.tags = Some(vec![ api_tag("健康检查", "检查 HTTP 服务状态和数据库是否已配置。"), + api_tag("身份认证", "通过微信登录建立、读取和撤销后端会话。"), api_tag("学生档案", "创建、查询、更新和删除当前用户的学生档案。"), api_tag("档案预设", "读取或保存当前用户新建档案时使用的默认值。"), api_tag("反馈记录", "创建、查询和删除当前用户的教学反馈记录。"), @@ -71,7 +73,9 @@ fn api_tag(name: &str, description: &str) -> Tag { "speech_configured": true, "speech_available": true, "speech_provider": "local-funasr", - "summary_configured": false + "summary_configured": false, + "wechat_auth_configured": true, + "development_auth_enabled": false }))] struct HealthResponse { /// HTTP 服务状态;正常时固定为 `ok`。 @@ -86,6 +90,10 @@ struct HealthResponse { speech_provider: Option<&'static str>, /// 是否已经配置生成式反馈汇总服务;未配置时使用本地抽取式汇总。 summary_configured: bool, + /// 是否已经配置微信 AppID 和 AppSecret。 + wechat_auth_configured: bool, + /// 是否允许使用仅供本地调试的 `X-User-Id`。 + development_auth_enabled: bool, } /// 检查服务状态。 @@ -107,6 +115,8 @@ async fn health(State(state): State) -> Json { speech_available: state.speech.available().await, speech_provider: state.speech.provider_name(), summary_configured: state.summary.configured(), + wechat_auth_configured: state.auth.wechat_configured(), + development_auth_enabled: state.auth.development_auth_enabled(), }) } @@ -196,7 +206,7 @@ struct ProfileQuery { summary = "列出学生档案", description = "按最近更新时间倒序返回当前用户的档案。三个查询条件均可省略,也可以组合使用。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("query" = Option, Query, description = "模糊匹配姓名、年级、学科或家长称呼", example = "小谢"), ("grade" = Option, Query, description = "精确匹配年级或学生类别", example = "艺术类"), ("subject" = Option, Query, description = "精确匹配学科", example = "美术") @@ -211,7 +221,11 @@ async fn list_profiles( headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let pool = pool(&state)?; let mut statement = QueryBuilder::::new( "SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \ @@ -254,9 +268,9 @@ async fn list_profiles( path = "/api/v1/profiles/{profile_id}", tag = "学生档案", summary = "读取学生档案", - description = "只允许读取属于当前 `X-User-Id` 的档案。请先从创建或列表接口复制真实的档案 ID。", + description = "只允许读取属于当前登录教师的档案。请先从创建或列表接口复制真实的档案 ID。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( @@ -270,7 +284,11 @@ async fn get_profile( headers: HeaderMap, Path(profile_id): Path, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let profile = sqlx::query_as::<_, StudentProfile>( "SELECT id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at \ FROM student_profiles WHERE id = $1 AND owner_id = $2", @@ -289,9 +307,9 @@ async fn get_profile( path = "/api/v1/profiles", tag = "学生档案", summary = "创建学生档案", - description = "使用请求头中的用户 ID 创建档案。Scalar 已预填完整请求体,可以直接执行测试。", + description = "为当前登录教师创建档案。Scalar 已预填完整请求体,填写 Bearer 令牌后可直接测试。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ") ), request_body( content = ProfileInput, @@ -319,7 +337,11 @@ async fn create_profile( Json(input): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { validate_profile(&input)?; - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let profile = sqlx::query_as::<_, StudentProfile>( "INSERT INTO student_profiles \ (id, owner_id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time) \ @@ -349,7 +371,7 @@ async fn create_profile( summary = "更新学生档案", description = "完整替换指定档案的可编辑字段。请先从创建或列表接口复制真实的档案 ID。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), request_body( @@ -380,7 +402,11 @@ async fn update_profile( Json(input): Json, ) -> Result, ApiError> { validate_profile(&input)?; - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let profile = sqlx::query_as::<_, StudentProfile>( "UPDATE student_profiles \ SET name = $1, grade = $2, subject = $3, academic_year = $4, term = $5, guardian_title = $6, \ @@ -412,7 +438,7 @@ async fn update_profile( summary = "删除学生档案", description = "删除属于当前用户的档案。关联反馈记录不会被删除,其 `profile_id` 会被置空。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( @@ -426,7 +452,11 @@ async fn delete_profile( headers: HeaderMap, Path(profile_id): Path, ) -> Result { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let result = sqlx::query("DELETE FROM student_profiles WHERE id = $1 AND owner_id = $2") .bind(profile_id) .bind(owner_id) @@ -479,7 +509,7 @@ struct ProfileDefaultsInput { summary = "读取档案预设", description = "读取当前用户的预设;尚未保存时直接返回艺术类、美术、60 分钟的系统默认值。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ") ), responses( (status = 200, description = "当前档案预设", body = ProfileDefaults), @@ -490,7 +520,11 @@ async fn get_defaults( State(state): State, headers: HeaderMap, ) -> Result, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let defaults = sqlx::query_as::<_, ProfileDefaults>( "SELECT grade, subject, lesson_duration_minutes, updated_at \ FROM student_profile_defaults WHERE owner_id = $1", @@ -510,7 +544,7 @@ async fn get_defaults( summary = "保存档案预设", description = "新增或覆盖当前用户的档案预设。Scalar 已预填完整请求体,可以直接执行测试。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ") ), request_body( content = ProfileDefaultsInput, @@ -533,7 +567,11 @@ async fn update_defaults( Json(input): Json, ) -> Result, ApiError> { validate_defaults(&input)?; - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let defaults = sqlx::query_as::<_, ProfileDefaults>( "INSERT INTO student_profile_defaults (owner_id, grade, subject, lesson_duration_minutes) \ VALUES ($1, $2, $3, $4) \ @@ -610,7 +648,7 @@ struct FeedbackRecordQuery { summary = "列出反馈记录", description = "按反馈日期和更新时间倒序返回当前用户的记录;可按学生档案 ID 筛选。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("profile_id" = Option, Query, description = "只返回关联到指定档案的记录;直接测试时可留空", example = json!("11111111-1111-4111-8111-111111111111")) ), responses( @@ -623,7 +661,11 @@ async fn list_feedback_records( headers: HeaderMap, Query(query): Query, ) -> Result>, ApiError> { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let pool = pool(&state)?; let mut statement = QueryBuilder::::new( "SELECT id, profile_id, feedback_date, content, created_at, updated_at \ @@ -650,7 +692,7 @@ async fn list_feedback_records( summary = "创建反馈记录", description = "创建一条教学反馈。Scalar 默认将 `profile_id` 设为 null,因此无需先创建档案即可直接测试。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")) + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer ") ), request_body( content = FeedbackRecordInput, @@ -679,7 +721,11 @@ async fn create_feedback_record( "content must contain 1 to 2000 characters".to_owned(), )); } - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let pool = pool(&state)?; if let Some(profile_id) = input.profile_id { assert_profile_owner(pool, owner_id, profile_id).await?; @@ -735,7 +781,7 @@ async fn create_feedback_record( summary = "删除反馈记录", description = "删除属于当前用户的反馈记录。请先从创建或列表接口复制真实的记录 ID。", params( - ("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与记录所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")), + ("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer "), ("record_id" = Uuid, Path, description = "反馈记录 ID;将示例值替换为创建接口返回的 ID", example = json!("22222222-2222-4222-8222-222222222222")) ), responses( @@ -749,7 +795,11 @@ async fn delete_feedback_record( headers: HeaderMap, Path(record_id): Path, ) -> Result { - let owner_id = current_user(&headers)?; + let owner_id = state + .auth + .authenticate(state.pool.as_ref(), &headers) + .await? + .user_id; let result = sqlx::query("DELETE FROM feedback_records WHERE id = $1 AND owner_id = $2") .bind(record_id) .bind(owner_id) @@ -759,14 +809,6 @@ async fn delete_feedback_record( Ok(axum::http::StatusCode::NO_CONTENT) } -fn current_user(headers: &HeaderMap) -> Result { - let value = headers - .get("x-user-id") - .and_then(|header| header.to_str().ok()) - .ok_or(ApiError::Unauthorized)?; - Uuid::parse_str(value).map_err(|_| ApiError::BadRequest("X-User-Id must be a UUID".to_owned())) -} - fn pool(state: &AppState) -> Result<&PgPool, ApiError> { state.pool.as_ref().ok_or(ApiError::DatabaseUnavailable) } @@ -864,8 +906,6 @@ mod tests { use super::router; - const TEST_USER_ID: &str = "5a4da7f0-d70c-465f-bcab-124c504aa9f0"; - #[test] fn openapi_documents_all_routes_and_default_test_data() { let (_, openapi) = router(); @@ -884,7 +924,7 @@ mod tests { .filter(|operation| operation.get("responses").is_some()) .collect::>(); - assert_eq!(operations.len(), 20); + assert_eq!(operations.len(), 23); for operation in &operations { assert_non_empty(operation, "summary"); assert_non_empty(operation, "description"); @@ -901,7 +941,9 @@ mod tests { let business_operations = paths .iter() - .filter(|(path, _)| path.starts_with("/api/v1/")) + .filter(|(path, _)| { + path.starts_with("/api/v1/") && *path != "/api/v1/auth/wechat/login" + }) .flat_map(|(_, path)| { path.as_object() .expect("path item should be an object") @@ -910,13 +952,15 @@ mod tests { .filter(|operation| operation.get("responses").is_some()); for operation in business_operations { - let user_header = operation["parameters"] + let authorization = operation["parameters"] .as_array() .expect("business operation should have parameters") .iter() - .find(|parameter| parameter["name"] == "X-User-Id" && parameter["in"] == "header") - .expect("business operation should document X-User-Id"); - assert_eq!(user_header["example"], TEST_USER_ID); + .find(|parameter| { + parameter["name"] == "Authorization" && parameter["in"] == "header" + }) + .expect("business operation should document Bearer authentication"); + assert_eq!(authorization["example"], "Bearer "); } assert_eq!(document["servers"][0]["url"], "/"); diff --git a/utils/api.ts b/utils/api.ts index 443bda1..af00596 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -14,13 +14,18 @@ import type { VoiceLessonFinished, VoiceProcessingStatus } from './types' +import { + AuthenticationError, + clearAuthSession, + ensureAuthSession, + getAccessToken +} from './auth' +import type { AuthSession } from './auth' const DEFAULT_API_BASE_URL = 'https://feedback.shay7sev.site' const LEGACY_DEFAULT_API_BASE_URL = 'http://127.0.0.1:8080' const API_BASE_URL_KEY = 'teaching-feedback-api-base-url-v1' -export const DEVELOPMENT_USER_ID = '5a4da7f0-d70c-465f-bcab-124c504aa9f0' - interface ApiErrorBody { error?: string } @@ -62,6 +67,8 @@ interface RawHealthStatus { speech_available: boolean speech_provider: string | null summary_configured: boolean + wechat_auth_configured: boolean + development_auth_enabled: boolean } interface RawVoiceAudioSegment { @@ -158,23 +165,56 @@ export function setApiBaseUrl(value: string): string { } export function getApiErrorMessage(error: unknown): string { + if (error instanceof AuthenticationError) return error.message if (!(error instanceof ApiRequestError)) return '请求失败,请稍后重试' - if (error.statusCode === 401) return '开发身份未通过后端校验' + if (error.statusCode === 401) return '登录状态已失效,请重试' if (error.statusCode === 404) return '请求的数据不存在或无权访问' if (error.statusCode === 503 && error.message.includes('database')) return '后端尚未配置数据库连接' return error.message } -function request(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise { +export function ensureAuthentication(): Promise { + return ensureAuthSession(getApiBaseUrl()) +} + +async function request( + path: string, + method: HttpMethod = 'GET', + data?: object, + timeout = 10000, + authenticated = true +): Promise { + const apiBaseUrl = getApiBaseUrl() + if (!authenticated) return requestOnce(apiBaseUrl, path, method, data, timeout) + + let accessToken = await getAccessToken(apiBaseUrl) + try { + return await requestOnce(apiBaseUrl, path, method, data, timeout, accessToken) + } catch (error) { + if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error + clearAuthSession() + accessToken = await getAccessToken(apiBaseUrl, true) + return requestOnce(apiBaseUrl, path, method, data, timeout, accessToken) + } +} + +function requestOnce( + apiBaseUrl: string, + path: string, + method: HttpMethod, + data: object | undefined, + timeout: number, + accessToken?: string +): Promise { return new Promise((resolve, reject) => { wx.request({ - url: `${getApiBaseUrl()}${path}`, + url: `${apiBaseUrl}${path}`, method, data, timeout, header: { 'Content-Type': 'application/json', - 'X-User-Id': DEVELOPMENT_USER_ID + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }, success(response) { if (response.statusCode >= 200 && response.statusCode < 300) { @@ -288,14 +328,16 @@ function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession { } export async function getHealth(): Promise { - const result = await request('/health') + const result = await request('/health', 'GET', undefined, 10000, false) return { status: result.status, databaseConfigured: result.database_configured, speechConfigured: result.speech_configured, speechAvailable: result.speech_available, speechProvider: result.speech_provider, - summaryConfigured: result.summary_configured + summaryConfigured: result.summary_configured, + wechatAuthConfigured: result.wechat_auth_configured, + developmentAuthEnabled: result.development_auth_enabled } } @@ -390,7 +432,41 @@ export async function createVoiceLesson(sessionId: string): Promise { + const apiBaseUrl = getApiBaseUrl() + let accessToken = await getAccessToken(apiBaseUrl) + try { + return await uploadVoiceSegmentOnce( + apiBaseUrl, + accessToken, + lessonId, + filePath, + sequence, + durationMs + ) + } catch (error) { + if (!(error instanceof ApiRequestError) || error.statusCode !== 401) throw error + clearAuthSession() + accessToken = await getAccessToken(apiBaseUrl, true) + return uploadVoiceSegmentOnce( + apiBaseUrl, + accessToken, + lessonId, + filePath, + sequence, + durationMs + ) + } +} + +function uploadVoiceSegmentOnce( + apiBaseUrl: string, + accessToken: string, lessonId: string, filePath: string, sequence: number, @@ -398,7 +474,7 @@ export function uploadVoiceSegment( ): Promise { return new Promise((resolve, reject) => { wx.uploadFile({ - url: `${getApiBaseUrl()}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`, + url: `${apiBaseUrl}/api/v1/feedback-lessons/${encodeURIComponent(lessonId)}/segments`, filePath, name: 'audio', formData: { @@ -407,7 +483,7 @@ export function uploadVoiceSegment( audio_format: 'mp3' }, timeout: 120000, - header: { 'X-User-Id': DEVELOPMENT_USER_ID }, + header: { Authorization: `Bearer ${accessToken}` }, success(response) { let body: RawVoiceAudioSegment | ApiErrorBody try { diff --git a/utils/auth.ts b/utils/auth.ts new file mode 100644 index 0000000..316f42e --- /dev/null +++ b/utils/auth.ts @@ -0,0 +1,137 @@ +const AUTH_SESSION_KEY = 'teaching-feedback-auth-session-v1' +const EXPIRY_SKEW_MS = 60_000 + +interface RawLoginResponse { + access_token: string + expires_at: string + user_id: string +} + +interface ErrorBody { + error?: string +} + +export interface AuthSession { + apiBaseUrl: string + accessToken: string + expiresAt: number + userId: string +} + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthenticationError' + } +} + +let pendingLogin: { apiBaseUrl: string; promise: Promise } | null = null + +export function getStoredAuthSession(apiBaseUrl: string): AuthSession | null { + const value = wx.getStorageSync(AUTH_SESSION_KEY) as Partial | string + if (!value || typeof value !== 'object') return null + if ( + value.apiBaseUrl !== apiBaseUrl || + typeof value.accessToken !== 'string' || + !value.accessToken || + typeof value.expiresAt !== 'number' || + typeof value.userId !== 'string' || + !value.userId + ) { + return null + } + return value as AuthSession +} + +export function clearAuthSession(): void { + wx.removeStorageSync(AUTH_SESSION_KEY) +} + +export async function getAccessToken(apiBaseUrl: string, forceRefresh = false): Promise { + if (!forceRefresh) { + const stored = getStoredAuthSession(apiBaseUrl) + if (stored && stored.expiresAt > Date.now() + EXPIRY_SKEW_MS) return stored.accessToken + } + return (await login(apiBaseUrl)).accessToken +} + +export async function ensureAuthSession(apiBaseUrl: string): Promise { + const accessToken = await getAccessToken(apiBaseUrl) + const session = getStoredAuthSession(apiBaseUrl) + if (!session || session.accessToken !== accessToken) { + throw new AuthenticationError('登录状态保存失败,请重试') + } + return session +} + +async function login(apiBaseUrl: string): Promise { + if (pendingLogin?.apiBaseUrl === apiBaseUrl) return pendingLogin.promise + + const promise = loginWithWechat(apiBaseUrl).finally(() => { + if (pendingLogin?.promise === promise) pendingLogin = null + }) + pendingLogin = { apiBaseUrl, promise } + return promise +} + +async function loginWithWechat(apiBaseUrl: string): Promise { + const code = await getWechatLoginCode() + const response = await exchangeCode(apiBaseUrl, code) + const expiresAt = Date.parse(response.expires_at) + if ( + !response.access_token || + !response.user_id || + Number.isNaN(expiresAt) || + expiresAt <= Date.now() + ) { + throw new AuthenticationError('登录服务返回了无效会话') + } + const session: AuthSession = { + apiBaseUrl, + accessToken: response.access_token, + expiresAt, + userId: response.user_id + } + wx.setStorageSync(AUTH_SESSION_KEY, session) + return session +} + +function getWechatLoginCode(): Promise { + return new Promise((resolve, reject) => { + wx.login({ + success(result) { + if (result.code) { + resolve(result.code) + return + } + reject(new AuthenticationError('微信未返回登录凭证')) + }, + fail(error) { + reject(new AuthenticationError(`微信登录失败:${error.errMsg}`)) + } + }) + }) +} + +function exchangeCode(apiBaseUrl: string, code: string): Promise { + return new Promise((resolve, reject) => { + wx.request({ + url: `${apiBaseUrl}/api/v1/auth/wechat/login`, + method: 'POST', + data: { code }, + timeout: 15_000, + header: { 'Content-Type': 'application/json' }, + success(response) { + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(response.data as RawLoginResponse) + return + } + const body = response.data as ErrorBody + reject(new AuthenticationError(body?.error || `登录服务返回 HTTP ${response.statusCode}`)) + }, + fail(error) { + reject(new AuthenticationError(`无法连接登录服务:${error.errMsg}`)) + } + }) + }) +} diff --git a/utils/types.ts b/utils/types.ts index e6432db..984ed05 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -54,6 +54,8 @@ export interface HealthStatus { speechAvailable: boolean speechProvider: string | null summaryConfigured: boolean + wechatAuthConfigured: boolean + developmentAuthEnabled: boolean } export type VoiceProcessingStatus = 'recording' | 'processing' | 'ready' | 'failed'