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