Compare commits
2 Commits
70e97ae8b9
...
67d29d3ca0
| Author | SHA1 | Date | |
|---|---|---|---|
| 67d29d3ca0 | |||
| 2ada52f35a |
@@ -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`
|
||||
|
||||
|
||||
@@ -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 健康检查结果。
|
||||
- 微信登录是否已配置,以及生产开发身份兼容是否关闭。
|
||||
- 实际并发配置,以及是否完成真实录音压测。
|
||||
- 模型缓存卷和录音卷名称、占用空间及备份方式。
|
||||
- 查看日志、启动、停止、重启、更新代码和重新部署的准确命令。
|
||||
|
||||
@@ -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
175
WECHAT_AUTH_PLAN.md
Normal 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
|
||||
-> 微信返回 openid(unionid 可选)
|
||||
-> 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、日志或错误响应中。
|
||||
- 原开发用户数据仅归属指定的目标微信账号。
|
||||
@@ -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:
|
||||
|
||||
@@ -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: ''
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}` | 读取单个学生档案。 |
|
||||
|
||||
@@ -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
2
server/Cargo.lock
generated
@@ -1897,10 +1897,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
"hmac",
|
||||
"rand 0.8.7",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 \
|
||||
|
||||
64
server/migrations/0004_wechat_auth.sql
Normal file
64
server/migrations/0004_wechat_auth.sql
Normal 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
485
server/src/auth.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
160
server/src/bin/migrate-user-owner.rs
Normal file
160
server/src/bin/migrate-user-owner.rs
Normal 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()))
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = "开发阶段用户 ID;Scalar 已预填测试值", 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 = "开发阶段用户 ID;Scalar 已预填测试值", 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 = "开发阶段用户 ID;Scalar 已预填测试值", 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 = "开发阶段用户 ID;Scalar 已预填测试值", 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 = "开发阶段用户 ID;Scalar 已预填测试值", 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 = "开发阶段用户 ID;Scalar 已预填测试值", 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"], "/");
|
||||
|
||||
117
utils/api.ts
117
utils/api.ts
@@ -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
137
utils/auth.ts
Normal 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}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user