Compare commits

...

2 Commits

Author SHA1 Message Date
67d29d3ca0 feat: add WeChat session authentication 2026-07-20 14:47:16 +08:00
2ada52f35a feat: use production API endpoint 2026-07-20 14:25:13 +08:00
26 changed files with 1468 additions and 134 deletions

View File

@@ -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
}
```
@@ -177,7 +188,7 @@ cli auto-preview \
--info-output /tmp/teaching-feedback-preview.json
```
开发者工具模拟器默认访问 `http://127.0.0.1:8080`。进入小程序“我的”页面,应看到“服务、数据库和语音转录正常”。
开发者工具模拟器默认访问生产服务 `https://feedback.shay7sev.site`。调试本节启动的本地 API 时,先在小程序“我的”页面保存 `http://127.0.0.1:8080`,应看到“服务、数据库和语音转录正常”。
## 5. 录音测试流程
@@ -253,7 +264,7 @@ lsof -nP -iTCP:8080 -sTCP:LISTEN
curl http://127.0.0.1:8080/health
```
再到小程序“我的”页面确认 API 地址为 `http://127.0.0.1:8080`
再到小程序“我的”页面 API 地址保存`http://127.0.0.1:8080`
### 真机无法访问 `127.0.0.1`

View File

@@ -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 健康检查结果。
- 微信登录是否已配置,以及生产开发身份兼容是否关闭。
- 实际并发配置,以及是否完成真实录音压测。
- 模型缓存卷和录音卷名称、占用空间及备份方式。
- 查看日志、启动、停止、重启、更新代码和重新部署的准确命令。

View File

@@ -22,7 +22,7 @@ cli open --project /Users/zhangheng/project/ai/miniprogram/teaching-feedback-ass
反馈生成页使用小程序原生录音能力。短录音会直接追加到反馈内容,长录音每 8 分钟自动切片,多节录音可以汇总为一次反馈。真机首次使用时需要允许麦克风权限;中文转录默认使用项目自带的本地 FunASR 服务,不要求腾讯云密钥。
小程序默认连接 `http://127.0.0.1:8080`。在开发者工具模拟器中,可进入“我的”页面保存其他 API 地址并检测服务状态。
小程序默认连接生产服务 `https://feedback.shay7sev.site`。旧版本缓存的默认本地地址会自动迁移;如需调试本地 API可在“我的”页面保存 `http://127.0.0.1:8080` 并检测服务状态。
## 验证
@@ -61,6 +61,6 @@ curl http://127.0.0.1:10095/health
- 学生档案、档案预设和反馈记录均通过 `utils/api.ts` 访问 Rust API。
- 反馈页只暴露一个语音入口;反馈批次、课节、录音片段和转录状态由后端自动维护。
- 开发者工具模拟器使用默认地址 `http://127.0.0.1:8080`
- 真机和正式版本不能把 `127.0.0.1` 当作电脑地址;应部署可公网访问的 HTTPS API并在微信公众平台配置 request 合法域名,然后到小程序“我的”页面修改服务地址
- 微信登录尚未接入。业务请求暂时使用固定开发用户 UUID生产发布前必须改为由后端根据微信登录凭据解析用户身份
- 开发者工具模拟器和真机默认使用 `https://feedback.shay7sev.site`
- 微信公众平台需要将该 HTTPS 域名同时配置为 `request``uploadFile` 合法域名
- 小程序通过 `wx.login` 换取后端 Bearer 会话,普通请求和录音上传使用同一身份;部署配置和旧数据归属步骤见 [WECHAT_AUTH_PLAN.md](./WECHAT_AUTH_PLAN.md)

175
WECHAT_AUTH_PLAN.md Normal file
View File

@@ -0,0 +1,175 @@
# 微信登录与教师身份实施方案
本文记录已经实现的“微信身份换取后端会话”体系,以及部署和旧数据迁移步骤。完成生产配置与双账号验收前不应公开发布小程序。
## 当前状态
代码已实现数据库迁移、微信换码接口、随机会话令牌、统一 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`
## 目标流程
```text
小程序调用 wx.login()
-> 将一次性 code 发送到 Rust POST /api/v1/auth/wechat/login
-> Rust 使用服务端 AppID、AppSecret 调用微信 code2Session
-> 微信返回 openidunionid 可选)
-> Rust 查找或创建内部教师 UUID
-> Rust 签发项目自己的随机会话令牌
-> 小程序以 Authorization: Bearer <token> 调用普通接口和上传录音
```
微信官方入口:
- [`wx.login`](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html)
- [`code2Session`](https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-login/code2Session.html)
- [小程序登录流程](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html)
`WECHAT_APP_SECRET` 只能保存在 Rust 服务环境变量中。小程序不得包含 AppSecret也不得接收或保存微信 `session_key`。当前阶段只需要 `openid` 识别当前小程序中的教师。
## 数据模型
新增三张表:
```text
users
id uuid primary key
status text not null
created_at timestamptz not null
updated_at timestamptz not null
wechat_identities
app_id text not null
openid text not null
unionid text null
user_id uuid not null references users(id)
created_at timestamptz not null
last_login_at timestamptz not null
primary key (app_id, openid)
auth_sessions
id uuid primary key
user_id uuid not null references users(id)
token_hash bytea not null unique
expires_at timestamptz not null
last_used_at timestamptz not null
revoked_at timestamptz null
created_at timestamptz not null
```
业务表继续使用现有 `owner_id` UUID不直接使用 `openid`。这样微信身份变化、未来增加手机号或管理员账号时,不需要重写业务数据。
## 后端接口
### `POST /api/v1/auth/wechat/login`
请求:
```json
{ "code": "wx.login 返回的一次性 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)` 原子地查找或创建本地用户。
5. 生成至少 32 字节的加密安全随机令牌,只把 SHA-256 哈希写入数据库。
6. 原始令牌只在本次响应中返回,建议有效期 30 天。
响应:
```json
{
"access_token": "后端随机会话令牌",
"expires_at": "2026-08-20T00:00:00Z",
"user_id": "内部教师 UUID"
}
```
同时增加:
- `GET /api/v1/auth/me`:返回当前内部用户 ID 和会话到期时间。
- `POST /api/v1/auth/logout`:撤销当前令牌。
## 业务接口鉴权
实现统一的 Axum 身份 extractor
1. 读取 `Authorization: Bearer <token>`
2. 对 token 做 SHA-256 后查询 `auth_sessions`
3. 拒绝不存在、已撤销、已过期或用户已停用的会话。
4. 将数据库中的 `user_id` 作为业务 `owner_id`,客户端不能提交或覆盖它。
普通 JSON 请求和 `wx.uploadFile` 必须使用相同的 Bearer token。不得把 `openid``user_id` 或固定 UUID 当成可信请求头。
本地开发可暂时保留 `X-User-Id`,但必须由显式环境变量控制:
```env
ALLOW_DEVELOPMENT_USER_HEADER=false
```
生产默认和 `compose.deploy.yml` 必须为 `false`。只有本地测试环境可设置为 `true`,并在日志中显示开发鉴权已开启。
## 小程序登录状态
新增独立 `utils/auth.ts`
1. 从微信 Storage 读取后端 access token 和到期时间。
2. 没有有效 token 时调用 `wx.login()`,再调用后端登录接口。
3. 使用共享的 `loginPromise` 合并并发登录,避免多个页面同时换取 code。
4. 所有 `wx.request``wx.uploadFile` 自动附加 Bearer token。
5. 收到一次 `401` 时清理本地 token、重新登录并重试原请求一次禁止无限重试。
6. 退出登录时先调用后端撤销接口,再清理 Storage。
“我的”页面移除固定开发 UUID改为显示登录状态和内部教师 ID不显示 `openid`、session_key 或完整 access token。
## 现有开发数据归属
当前数据属于固定开发用户:
```text
5a4da7f0-d70c-465f-bcab-124c504aa9f0
```
不能把这些数据自动交给“部署后第一个登录的人”,否则存在抢占风险。采用一次性管理命令迁移:
1. 先发布微信登录功能,由目标微信账号完成一次登录并取得新的内部用户 UUID。
2. 在服务端运行受控 CLI
```text
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`
4. 遇到目标账号已有同类数据或唯一约束冲突时中止并报告,不做部分迁移。
5. 迁移前备份数据库,迁移后比较每张表的记录数并进行页面验收。
CLI 不接收 `openid` 或 AppSecret它只合并两个经过后端确认的内部 UUID便于审计和回滚。
## 分阶段交付
1. 数据库迁移、微信 API 客户端、登录接口和会话存储。
2. 后端统一 Bearer extractor同时在本地保留受控的开发头兼容模式。
3. 小程序登录管理、普通请求和录音上传的自动鉴权与一次重试。
4. 自动化测试code2Session 模拟、令牌过期/撤销、跨用户隔离、上传鉴权和并发登录。
5. 部署环境变量并验证目标微信账号登录。
6. 运行一次性旧数据迁移,关闭生产 `X-User-Id`,最后再提交体验版测试。
## 发布验收
- 两个不同微信账号看到的数据互相隔离。
- 客户端伪造 `X-User-Id``openid``user_id` 无法越权。
- access token 过期或撤销后必须重新登录。
- 普通接口和录音上传均能在 token 刷新后重试一次。
- AppSecret、session_key 和令牌未出现在代码、Git、日志或错误响应中。
- 原开发用户数据仅归属指定的目标微信账号。

View File

@@ -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:

View File

@@ -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: ''
})
}
}
})

View File

@@ -18,8 +18,11 @@
</view>
<view class="identity-card surface">
<text class="section-title">开发身份</text>
<text class="identity-value">{{developmentUserId}}</text>
<text class="identity-note">微信登录接入前,业务数据按此开发用户隔离。</text>
<text class="section-title">微信身份</text>
<view class="connection-row">
<text class="connection-dot connection-{{authTone}}"></text>
<text class="connection-text">{{authText}}</text>
</view>
<text wx:if="{{authUserId}}" class="identity-value">{{authUserId}}</text>
</view>
</view>

View File

@@ -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;
}

View File

@@ -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": []

View File

@@ -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

View File

@@ -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 <access-token>
```
数据库仅保存令牌的 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}` | 读取单个学生档案。 |

View File

@@ -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>
```
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默认连接 `http://127.0.0.1:8080`。可在小程序“我的”页面修改 API 地址并执行健康检查。真机或正式环境应使用已配置为微信 request 合法域名的 HTTPS 地址
小程序默认连接 `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 身份参数。

2
server/Cargo.lock generated
View File

@@ -1897,10 +1897,12 @@ dependencies = [
"chrono",
"dotenvy",
"hmac",
"rand 0.8.7",
"reqwest",
"serde",
"serde_json",
"sha1",
"sha2",
"sqlx",
"thiserror",
"tokio",

View File

@@ -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"] }

View File

@@ -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 \

View File

@@ -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;

485
server/src/auth.rs Normal file
View File

@@ -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<AppState>;
const BEARER_PREFIX: &str = "Bearer ";
#[derive(Clone)]
pub struct AuthService {
config: AuthConfig,
client: reqwest::Client,
login_slots: Arc<Semaphore>,
}
#[derive(Debug)]
pub struct AuthenticatedUser {
pub user_id: Uuid,
pub session_id: Option<Uuid>,
pub expires_at: Option<DateTime<Utc>>,
}
#[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<Utc>,
user_id: Uuid,
}
#[derive(Debug, Serialize, ToSchema)]
struct CurrentUserResponse {
user_id: Uuid,
expires_at: Option<DateTime<Utc>>,
auth_mode: &'static str,
}
#[derive(Debug, Deserialize)]
struct Code2SessionResponse {
openid: Option<String>,
unionid: Option<String>,
errcode: Option<i64>,
errmsg: Option<String>,
}
pub fn router() -> OpenApiRouter<SharedState> {
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<AuthenticatedUser, ApiError> {
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<WechatIdentity, ApiError> {
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::<Code2SessionResponse>().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<String>,
}
#[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<SharedState>,
Json(input): Json<WechatLoginInput>,
) -> Result<Json<WechatLoginResponse>, 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 <access-token>")),
responses(
(status = 200, description = "当前身份", body = CurrentUserResponse),
(status = 401, description = "会话无效或已过期", body = ErrorBody)
)
)]
async fn current_user(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Json<CurrentUserResponse>, 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 <access-token>")),
responses(
(status = 204, description = "会话已撤销"),
(status = 401, description = "会话无效或已过期", body = ErrorBody)
)
)]
async fn logout(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
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<AuthenticatedUser, ApiError> {
let token_hash = hash_token(token.as_bytes());
let result = sqlx::query_as::<_, (Uuid, Uuid, DateTime<Utc>)>(
"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<Uuid, ApiError> {
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<Uuid, ApiError> {
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<Option<&str>, 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<AuthenticatedUser, ApiError> {
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<u8>) {
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<u8> {
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<HashMap<String, String>>| 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"));
}
}

View File

@@ -0,0 +1,160 @@
use std::{env, io};
use sqlx::{PgPool, postgres::PgPoolOptions};
use uuid::Uuid;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<u64, sqlx::Error> {
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<dyn std::error::Error>> {
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 <UUID> --to <UUID>"))?;
let target = target
.ok_or_else(|| invalid_input("usage: migrate-user-owner --from <UUID> --to <UUID>"))?;
Ok((source, target))
}
fn parse_uuid(value: &str, argument: &str) -> Result<Uuid, Box<dyn std::error::Error>> {
Uuid::parse_str(value).map_err(|_| invalid_input(format!("{argument} must be a valid UUID")))
}
fn invalid_input(message: impl Into<String>) -> Box<dyn std::error::Error> {
Box::new(io::Error::new(io::ErrorKind::InvalidInput, message.into()))
}

View File

@@ -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<String>,
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<WechatAuthConfig>,
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<SpeechConfig>,
pub summary: Option<SummaryConfig>,
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<AuthConfig, String> {
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<Option<SpeechConfig>, 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<bool, String> {
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")),
}
}

View File

@@ -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(),

View File

@@ -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<dyn std::error::Error>> {
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() {

View File

@@ -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 <access-token>"),
("profile_id" = Option<Uuid>, Query, description = "学生档案 ID留空表示不关联学生")
),
responses(
@@ -196,7 +196,11 @@ async fn get_active_feedback_session(
headers: HeaderMap,
Query(query): Query<ActiveSessionQuery>,
) -> Result<Json<Option<FeedbackSessionResponse>>, 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 <access-token>")),
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<FeedbackSessionInput>,
) -> Result<Json<FeedbackSessionResponse>, 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 <access-token>"),
("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<Uuid>,
Json(input): Json<FeedbackSessionUpdateInput>,
) -> Result<Json<FeedbackSessionResponse>, 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 <access-token>"),
("session_id" = Uuid, Path, description = "反馈批次 ID")
),
responses(
@@ -338,7 +350,11 @@ async fn create_lesson_session(
headers: HeaderMap,
Path(session_id): Path<Uuid>,
) -> Result<(axum::http::StatusCode, Json<LessonCreatedResponse>), 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 <access-token>"),
("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<Uuid>,
mut multipart: Multipart,
) -> Result<(axum::http::StatusCode, Json<AudioSegmentResponse>), 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 <access-token>"),
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
),
responses(
@@ -520,7 +540,11 @@ async fn finish_lesson_session(
headers: HeaderMap,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<FinishedLessonResponse>, 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<String>)>(
"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 <access-token>"),
("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<Uuid>,
Json(input): Json<MarkLessonAppliedInput>,
) -> Result<axum::http::StatusCode, 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 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 <access-token>"),
("segment_id" = Uuid, Path, description = "录音片段 ID")
),
responses(
@@ -632,7 +660,11 @@ async fn retry_audio_segment(
headers: HeaderMap,
Path(segment_id): Path<Uuid>,
) -> Result<Json<AudioSegmentResponse>, 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 <access-token>"),
("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<Uuid>,
Json(input): Json<GenerateFeedbackInput>,
) -> Result<Json<GenerateFeedbackResponse>, 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<Uuid, ApiError> {
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)
}

View File

@@ -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<SharedState>, 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<SharedState>, 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<SharedState>) -> Json<HealthResponse> {
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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("query" = Option<String>, Query, description = "模糊匹配姓名、年级、学科或家长称呼", example = "小谢"),
("grade" = Option<String>, Query, description = "精确匹配年级或学生类别", example = "艺术类"),
("subject" = Option<String>, Query, description = "精确匹配学科", example = "美术")
@@ -211,7 +221,11 @@ async fn list_profiles(
headers: HeaderMap,
Query(query): Query<ProfileQuery>,
) -> Result<Json<Vec<StudentProfile>>, 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::<Postgres>::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 <access-token>"),
("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<Uuid>,
) -> Result<Json<StudentProfile>, 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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
),
request_body(
content = ProfileInput,
@@ -319,7 +337,11 @@ async fn create_profile(
Json(input): Json<ProfileInput>,
) -> Result<(axum::http::StatusCode, Json<StudentProfile>), 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 <access-token>"),
("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<ProfileInput>,
) -> Result<Json<StudentProfile>, 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 <access-token>"),
("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<Uuid>,
) -> Result<axum::http::StatusCode, ApiError> {
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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
),
responses(
(status = 200, description = "当前档案预设", body = ProfileDefaults),
@@ -490,7 +520,11 @@ async fn get_defaults(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Json<ProfileDefaults>, 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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
),
request_body(
content = ProfileDefaultsInput,
@@ -533,7 +567,11 @@ async fn update_defaults(
Json(input): Json<ProfileDefaultsInput>,
) -> Result<Json<ProfileDefaults>, 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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>"),
("profile_id" = Option<Uuid>, Query, description = "只返回关联到指定档案的记录;直接测试时可留空", example = json!("11111111-1111-4111-8111-111111111111"))
),
responses(
@@ -623,7 +661,11 @@ async fn list_feedback_records(
headers: HeaderMap,
Query(query): Query<FeedbackRecordQuery>,
) -> Result<Json<Vec<FeedbackRecord>>, 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::<Postgres>::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 = "开发阶段用户 IDScalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
("Authorization" = String, Header, description = "Bearer 会话令牌", example = "Bearer <access-token>")
),
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 <access-token>"),
("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<Uuid>,
) -> Result<axum::http::StatusCode, ApiError> {
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<Uuid, ApiError> {
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::<Vec<_>>();
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 <access-token>");
}
assert_eq!(document["servers"][0]["url"], "/");

View File

@@ -14,12 +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 = 'http://127.0.0.1:8080'
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
}
@@ -61,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 {
@@ -131,12 +139,24 @@ export class ApiRequestError extends Error {
}
}
function normalizeApiBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, '')
}
export function getApiBaseUrl(): string {
return (wx.getStorageSync(API_BASE_URL_KEY) as string) || DEFAULT_API_BASE_URL
const storedValue = wx.getStorageSync(API_BASE_URL_KEY)
if (typeof storedValue !== 'string' || !storedValue.trim()) return DEFAULT_API_BASE_URL
const normalized = normalizeApiBaseUrl(storedValue)
if (normalized === LEGACY_DEFAULT_API_BASE_URL) {
wx.setStorageSync(API_BASE_URL_KEY, DEFAULT_API_BASE_URL)
return DEFAULT_API_BASE_URL
}
return normalized
}
export function setApiBaseUrl(value: string): string {
const normalized = value.trim().replace(/\/+$/, '')
const normalized = normalizeApiBaseUrl(value)
if (!/^https?:\/\/[^\s]+$/i.test(normalized)) {
throw new ApiRequestError('服务地址必须以 http:// 或 https:// 开头')
}
@@ -145,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<T>(path: string, method: HttpMethod = 'GET', data?: object, timeout = 10000): Promise<T> {
export function ensureAuthentication(): Promise<AuthSession> {
return ensureAuthSession(getApiBaseUrl())
}
async function request<T>(
path: string,
method: HttpMethod = 'GET',
data?: object,
timeout = 10000,
authenticated = true
): Promise<T> {
const apiBaseUrl = getApiBaseUrl()
if (!authenticated) return requestOnce<T>(apiBaseUrl, path, method, data, timeout)
let accessToken = await getAccessToken(apiBaseUrl)
try {
return await requestOnce<T>(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<T>(apiBaseUrl, path, method, data, timeout, accessToken)
}
}
function requestOnce<T>(
apiBaseUrl: string,
path: string,
method: HttpMethod,
data: object | undefined,
timeout: number,
accessToken?: string
): Promise<T> {
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) {
@@ -275,14 +328,16 @@ function mapFeedbackSession(session: RawFeedbackSession): FeedbackSession {
}
export async function getHealth(): Promise<HealthStatus> {
const result = await request<RawHealthStatus>('/health')
const result = await request<RawHealthStatus>('/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
}
}
@@ -377,7 +432,41 @@ export async function createVoiceLesson(sessionId: string): Promise<VoiceLessonC
}
}
export function uploadVoiceSegment(
export async function uploadVoiceSegment(
lessonId: string,
filePath: string,
sequence: number,
durationMs: number
): Promise<VoiceAudioSegment> {
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,
@@ -385,7 +474,7 @@ export function uploadVoiceSegment(
): Promise<VoiceAudioSegment> {
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: {
@@ -394,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 {

137
utils/auth.ts Normal file
View File

@@ -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<AuthSession> } | null = null
export function getStoredAuthSession(apiBaseUrl: string): AuthSession | null {
const value = wx.getStorageSync(AUTH_SESSION_KEY) as Partial<AuthSession> | 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<string> {
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<AuthSession> {
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<AuthSession> {
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<AuthSession> {
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<string> {
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<RawLoginResponse> {
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}`))
}
})
})
}

View File

@@ -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'