feat: add local voice transcription pipeline

This commit is contained in:
2026-07-20 10:49:41 +08:00
parent f1e5fd8793
commit 8a88706ff9
16 changed files with 2389 additions and 28 deletions

View File

@@ -7,3 +7,25 @@ DATABASE_MAX_CONNECTIONS=5
# A single development origin. Leave unset to disable CORS middleware.
# CORS_ALLOWED_ORIGIN=https://servicewechat.com
# Private server directory used to retain uploaded audio for transcription retry.
AUDIO_STORAGE_DIR=./data/audio
# Local FunASR is the default Chinese transcription provider.
ASR_PROVIDER=local
LOCAL_ASR_URL=http://127.0.0.1:10095
ASR_REQUEST_TIMEOUT_SECONDS=1800
ASR_WORKER_CONCURRENCY=1
ASR_JOB_LEASE_SECONDS=3600
# Optional Tencent Cloud fallback. Set ASR_PROVIDER=tencent and configure all values.
# TENCENT_CLOUD_ASR_APP_ID=1234567890
# TENCENT_CLOUD_ASR_SECRET_ID=your-secret-id
# TENCENT_CLOUD_ASR_SECRET_KEY=your-secret-key
TENCENT_CLOUD_ASR_ENGINE_TYPE=16k_zh
# Optional OpenAI-compatible chat completions endpoint for polished feedback summaries.
# Without it, the API uses a local extractive summary and remains functional.
# FEEDBACK_SUMMARY_API_URL=https://api.example.com/v1/chat/completions
# FEEDBACK_SUMMARY_API_KEY=your-api-key
# FEEDBACK_SUMMARY_MODEL=your-model

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Rust build output
/target/
/data/
# Local configuration and secrets
.env

47
API.md
View File

@@ -26,6 +26,39 @@ cargo run --bin migrate
cargo run
```
## 语音转录和反馈汇总
长短录音统一通过小程序原生录音器上传到本服务。服务先将音频保存在 `AUDIO_STORAGE_DIR` 并立即响应,再由持久化后台队列调用本地 FunASR模型暂时不可用或 API 重启都不会丢失已上传音频。
```text
AUDIO_STORAGE_DIR=./data/audio
ASR_PROVIDER=local
LOCAL_ASR_URL=http://127.0.0.1:10095
ASR_REQUEST_TIMEOUT_SECONDS=1800
ASR_WORKER_CONCURRENCY=1
ASR_JOB_LEASE_SECONDS=3600
```
在项目根目录执行 `docker compose -f compose.asr.yml up --build -d` 启动中文模型。`GET /health``speech_available=true` 表示模型已下载并可以接收任务。
腾讯云仅作为可选提供方保留。需要切换时配置:
```text
ASR_PROVIDER=tencent
TENCENT_CLOUD_ASR_APP_ID=1234567890
TENCENT_CLOUD_ASR_SECRET_ID=your-secret-id
TENCENT_CLOUD_ASR_SECRET_KEY=your-secret-key
TENCENT_CLOUD_ASR_ENGINE_TYPE=16k_zh
```
反馈汇总支持可选的 OpenAI 兼容 Chat Completions 接口。未配置时使用本地抽取式汇总,录音转录和保存流程仍可工作:
```text
FEEDBACK_SUMMARY_API_URL=https://api.example.com/v1/chat/completions
FEEDBACK_SUMMARY_API_KEY=your-api-key
FEEDBACK_SUMMARY_MODEL=your-model
```
## 临时身份边界
目前所有业务接口都要求 `X-User-Id` 请求头,值为 UUID。例如
@@ -40,7 +73,7 @@ X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/health` | 服务状态及数据库是否已配置。 |
| `GET` | `/health` | 服务、数据库、语音提供方及模型可用状态。 |
| `GET` | `/api/v1/profiles` | 列出学生档案;可选 `query``grade``subject` 参数。 |
| `POST` | `/api/v1/profiles` | 创建学生档案。 |
| `GET` | `/api/v1/profiles/{profile_id}` | 读取单个学生档案。 |
@@ -51,6 +84,15 @@ X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
| `GET` | `/api/v1/feedback-records` | 列出反馈记录;可选 `profile_id` 参数。 |
| `POST` | `/api/v1/feedback-records` | 创建反馈记录。 |
| `DELETE` | `/api/v1/feedback-records/{record_id}` | 删除反馈记录。 |
| `GET` | `/api/v1/feedback-sessions/active` | 读取指定学生进行中的语音反馈批次。 |
| `POST` | `/api/v1/feedback-sessions` | 创建或恢复语音反馈批次。 |
| `PUT` | `/api/v1/feedback-sessions/{session_id}` | 自动保存反馈正文和日期。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/lessons` | 开始下一节课堂录音。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/segments` | 上传一个自动切分的片段并加入后台转录队列。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/finish` | 结束课节并汇总片段状态。 |
| `POST` | `/api/v1/feedback-lessons/{lesson_id}/mark-applied` | 标记短录音文字已直接加入反馈。 |
| `POST` | `/api/v1/audio-segments/{segment_id}/retry` | 重试失败片段的转录。 |
| `POST` | `/api/v1/feedback-sessions/{session_id}/generate` | 汇总多节转录并生成反馈正文。 |
### 创建学生档案
@@ -83,6 +125,7 @@ X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
{
"profile_id": "5fa5af2c-cfc9-4bb8-b0b3-8715f080b167",
"feedback_date": "2026-07-14",
"content": "今天完成了色彩搭配练习,构图有明显进步。"
"content": "今天完成了色彩搭配练习,构图有明显进步。",
"feedback_session_id": null
}
```

View File

@@ -2,6 +2,12 @@
## 1. 启动服务
先在项目根目录启动中文转录服务:
```bash
docker compose -f compose.asr.yml up --build -d
```
`server/.env` 中配置 `DATABASE_URL` 后执行:
```bash
@@ -28,12 +34,12 @@ X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
请求体接口也已提供可发送的默认 JSON。建议按以下顺序测试
1. 执行 `GET /health`,确认 `database_configured` `true`
1. 执行 `GET /health`,确认 `database_configured` `speech_available` 均为 `true`。首次模型下载期间后者为 `false`
2. 执行 `GET /api/v1/profile-defaults`,即使用户未保存过预设也会返回系统默认值。
3. 执行 `PUT /api/v1/profile-defaults`,可直接发送预填的美术课预设。
4. 执行 `POST /api/v1/profiles`,可直接发送预填档案;保存响应中的 `id`
5. 将真实档案 `id` 填入 `{profile_id}`,测试单条查询、更新或删除。
6. 执行 `POST /api/v1/feedback-records`。默认 `profile_id``null`,无需档案即可直接创建。
6. 执行 `POST /api/v1/feedback-records`。默认 `profile_id``feedback_session_id``null`,无需档案或录音即可直接创建。
7. 如需关联档案,将第 4 步得到的 `id` 写入 `profile_id`。创建后保存反馈响应中的 `id`
8. 将真实反馈 `id` 填入 `{record_id}`,测试删除接口。
@@ -74,7 +80,8 @@ curl -X POST http://127.0.0.1:8080/api/v1/feedback-records \
-d '{
"profile_id": null,
"feedback_date": "2026-07-15",
"content": "今天完成了色彩搭配练习,构图有明显进步。"
"content": "今天完成了色彩搭配练习,构图有明显进步。",
"feedback_session_id": null
}'
```
@@ -114,6 +121,8 @@ curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>'
}
```
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR并将片段更新为 `ready``failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文。
## 6. OpenAPI 维护方式
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和默认身份参数。

324
Cargo.lock generated
View File

@@ -67,6 +67,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"serde_core",
@@ -175,6 +176,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"rand_core 0.10.1",
]
[[package]]
name = "chrono"
version = "0.4.45"
@@ -219,6 +237,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@@ -308,6 +335,15 @@ dependencies = [
"serde",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -466,8 +502,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -477,8 +515,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasm-bindgen",
]
[[package]]
@@ -609,6 +650,23 @@ dependencies = [
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.8",
]
[[package]]
@@ -617,13 +675,21 @@ version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
@@ -764,6 +830,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "ipnet"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -845,6 +917,12 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -882,6 +960,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.2"
@@ -893,6 +981,23 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -913,7 +1018,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.7",
"smallvec",
"zeroize",
]
@@ -1069,6 +1174,62 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.46"
@@ -1092,7 +1253,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core 0.10.1",
]
[[package]]
@@ -1102,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1114,6 +1286,21 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1149,6 +1336,46 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.8",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -1176,13 +1403,19 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustls"
version = "0.23.42"
@@ -1203,6 +1436,7 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"web-time",
"zeroize",
]
@@ -1308,7 +1542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -1319,7 +1553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"digest",
]
@@ -1355,7 +1589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@@ -1521,7 +1755,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.7",
"rsa",
"serde",
"sha1",
@@ -1561,7 +1795,7 @@ dependencies = [
"md-5",
"memchr",
"once_cell",
"rand",
"rand 0.8.7",
"serde",
"serde_json",
"sha2",
@@ -1639,6 +1873,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -1656,16 +1893,21 @@ name = "teaching-feedback-api"
version = "0.1.0"
dependencies = [
"axum",
"base64",
"chrono",
"dotenvy",
"hmac",
"reqwest",
"serde",
"serde_json",
"sha1",
"sqlx",
"thiserror",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"utoipa",
"utoipa-axum",
"utoipa-scalar",
@@ -1753,6 +1995,16 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
@@ -1788,12 +2040,15 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http",
"http-body",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
"tracing",
"url",
]
[[package]]
@@ -1870,12 +2125,24 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -2006,6 +2273,15 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -2031,6 +2307,16 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
@@ -2063,6 +2349,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"

View File

@@ -6,17 +6,22 @@ rust-version = "1.85"
default-run = "teaching-feedback-api"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
axum = { version = "0.8", features = ["macros", "multipart"] }
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
dotenvy = "0.15"
hmac = "0.12"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] }
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread", "net", "signal"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2"
utoipa = { version = "5.5", features = ["chrono", "uuid"] }
utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }

View File

@@ -0,0 +1,57 @@
CREATE TABLE feedback_sessions (
id UUID PRIMARY KEY,
owner_id UUID NOT NULL,
profile_id UUID REFERENCES student_profiles(id) ON DELETE CASCADE,
feedback_date DATE NOT NULL,
content TEXT NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'finalized')),
finalized_record_id UUID REFERENCES feedback_records(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX feedback_sessions_owner_status_idx
ON feedback_sessions (owner_id, status, updated_at DESC);
CREATE UNIQUE INDEX feedback_sessions_one_active_idx
ON feedback_sessions (owner_id, COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::UUID))
WHERE status = 'active';
CREATE TABLE lesson_sessions (
id UUID PRIMARY KEY,
feedback_session_id UUID NOT NULL REFERENCES feedback_sessions(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL CHECK (sequence > 0),
status VARCHAR(16) NOT NULL DEFAULT 'recording'
CHECK (status IN ('recording', 'processing', 'ready', 'failed')),
duration_ms INTEGER NOT NULL DEFAULT 0 CHECK (duration_ms >= 0),
applied_directly BOOLEAN NOT NULL DEFAULT FALSE,
included_in_generation BOOLEAN NOT NULL DEFAULT FALSE,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (feedback_session_id, sequence)
);
CREATE INDEX lesson_sessions_feedback_session_idx
ON lesson_sessions (feedback_session_id, sequence);
CREATE TABLE audio_segments (
id UUID PRIMARY KEY,
lesson_session_id UUID NOT NULL REFERENCES lesson_sessions(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL CHECK (sequence > 0),
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
audio_format VARCHAR(8) NOT NULL,
audio_path TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'processing'
CHECK (status IN ('processing', 'ready', 'failed')),
transcript TEXT,
error_message TEXT,
provider_request_id VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (lesson_session_id, sequence)
);
CREATE INDEX audio_segments_lesson_session_idx
ON audio_segments (lesson_session_id, sequence);

View File

@@ -0,0 +1,8 @@
ALTER TABLE audio_segments
ADD COLUMN transcription_attempts INTEGER NOT NULL DEFAULT 0
CHECK (transcription_attempts >= 0),
ADD COLUMN processing_started_at TIMESTAMPTZ;
CREATE INDEX audio_segments_processing_queue_idx
ON audio_segments (created_at, id)
WHERE status = 'processing';

View File

@@ -1,4 +1,33 @@
use std::{env, net::IpAddr};
use std::{env, net::IpAddr, path::PathBuf};
#[derive(Debug, Clone)]
pub enum SpeechProviderConfig {
Local { api_url: String },
Tencent(TencentSpeechConfig),
}
#[derive(Debug, Clone)]
pub struct TencentSpeechConfig {
pub app_id: u64,
pub secret_id: String,
pub secret_key: String,
pub engine_type: String,
}
#[derive(Debug, Clone)]
pub struct SpeechConfig {
pub provider: SpeechProviderConfig,
pub request_timeout_seconds: u64,
pub worker_concurrency: usize,
pub job_lease_seconds: i64,
}
#[derive(Debug, Clone)]
pub struct SummaryConfig {
pub api_url: String,
pub api_key: Option<String>,
pub model: String,
}
#[derive(Debug, Clone)]
pub struct Config {
@@ -7,6 +36,9 @@ pub struct Config {
pub database_url: Option<String>,
pub database_max_connections: u32,
pub cors_allowed_origin: Option<String>,
pub audio_storage_dir: PathBuf,
pub speech: Option<SpeechConfig>,
pub summary: Option<SummaryConfig>,
}
impl Config {
@@ -24,6 +56,9 @@ impl Config {
.parse()
.map_err(|_| "DATABASE_MAX_CONNECTIONS must be a valid u32 value".to_owned())?;
let speech = speech_config()?;
let summary = summary_config()?;
Ok(Self {
host,
port,
@@ -34,6 +69,99 @@ impl Config {
cors_allowed_origin: env::var("CORS_ALLOWED_ORIGIN")
.ok()
.filter(|value| !value.trim().is_empty()),
audio_storage_dir: env::var("AUDIO_STORAGE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./data/audio")),
speech,
summary,
})
}
}
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") {
return Ok(None);
}
let provider = match provider.as_str() {
"local" | "funasr" => SpeechProviderConfig::Local {
api_url: optional_env("LOCAL_ASR_URL")
.unwrap_or_else(|| "http://127.0.0.1:10095".to_owned())
.trim_end_matches('/')
.to_owned(),
},
"tencent" => {
let app_id = required_env("TENCENT_CLOUD_ASR_APP_ID")?
.parse()
.map_err(|_| "TENCENT_CLOUD_ASR_APP_ID must be an integer".to_owned())?;
SpeechProviderConfig::Tencent(TencentSpeechConfig {
app_id,
secret_id: required_env("TENCENT_CLOUD_ASR_SECRET_ID")?,
secret_key: required_env("TENCENT_CLOUD_ASR_SECRET_KEY")?,
engine_type: optional_env("TENCENT_CLOUD_ASR_ENGINE_TYPE")
.unwrap_or_else(|| "16k_zh".to_owned()),
})
}
_ => {
return Err(
"ASR_PROVIDER must be local, funasr, tencent, disabled, or none".to_owned(),
);
}
};
let request_timeout_seconds = parse_env("ASR_REQUEST_TIMEOUT_SECONDS", 1_800_u64)?;
let worker_concurrency = parse_env("ASR_WORKER_CONCURRENCY", 1_usize)?;
let job_lease_seconds = parse_env("ASR_JOB_LEASE_SECONDS", 3_600_i64)?;
if request_timeout_seconds == 0 {
return Err("ASR_REQUEST_TIMEOUT_SECONDS must be greater than 0".to_owned());
}
if worker_concurrency == 0 {
return Err("ASR_WORKER_CONCURRENCY must be greater than 0".to_owned());
}
if job_lease_seconds <= 0 {
return Err("ASR_JOB_LEASE_SECONDS must be greater than 0".to_owned());
}
Ok(Some(SpeechConfig {
provider,
request_timeout_seconds,
worker_concurrency,
job_lease_seconds,
}))
}
fn summary_config() -> Result<Option<SummaryConfig>, String> {
let Some(api_url) = optional_env("FEEDBACK_SUMMARY_API_URL") else {
return Ok(None);
};
let model = optional_env("FEEDBACK_SUMMARY_MODEL").ok_or_else(|| {
"FEEDBACK_SUMMARY_MODEL is required when summary API is configured".to_owned()
})?;
Ok(Some(SummaryConfig {
api_url: api_url.trim_end_matches('/').to_owned(),
api_key: optional_env("FEEDBACK_SUMMARY_API_KEY"),
model,
}))
}
fn optional_env(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn required_env(name: &str) -> Result<String, String> {
optional_env(name).ok_or_else(|| format!("{name} is required for the selected ASR provider"))
}
fn parse_env<T>(name: &str, default: T) -> Result<T, String>
where
T: std::str::FromStr,
{
optional_env(name).map_or_else(
|| Ok(default),
|value| value.parse().map_err(|_| format!("{name} is invalid")),
)
}

View File

@@ -16,6 +16,8 @@ pub enum ApiError {
NotFound,
#[error("database has not been configured")]
DatabaseUnavailable,
#[error("{0}")]
ServiceUnavailable(String),
#[error("internal server error")]
Internal,
}
@@ -75,6 +77,7 @@ impl IntoResponse for ApiError {
StatusCode::SERVICE_UNAVAILABLE,
"database has not been configured".to_owned(),
),
Self::ServiceUnavailable(message) => (StatusCode::SERVICE_UNAVAILABLE, message),
Self::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_owned(),

View File

@@ -1,10 +1,14 @@
mod config;
mod error;
mod recording_routes;
mod routes;
mod speech;
mod summary;
mod transcription_worker;
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use axum::{Json, Router, http::HeaderValue, routing::get};
use axum::{Json, Router, extract::DefaultBodyLimit, http::HeaderValue, routing::get};
use config::Config;
use sqlx::{PgPool, postgres::PgPoolOptions};
use tower_http::{cors::CorsLayer, trace::TraceLayer};
@@ -14,6 +18,9 @@ use utoipa_scalar::{Scalar, Servable};
#[derive(Clone)]
pub struct AppState {
pub pool: Option<PgPool>,
pub audio_storage_dir: PathBuf,
pub speech: speech::SpeechService,
pub summary: summary::SummaryService,
}
#[tokio::main]
@@ -25,7 +32,19 @@ 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 app = build_app(AppState { pool }, config.cors_allowed_origin.as_deref())?;
let speech = speech::SpeechService::new(config.speech.clone());
let state = AppState {
pool,
audio_storage_dir: config.audio_storage_dir.clone(),
speech: speech.clone(),
summary: summary::SummaryService::new(config.summary.clone()),
};
if let Some(pool) = state.pool.clone()
&& speech.configured()
{
transcription_worker::spawn(pool, speech);
}
let app = build_app(state, config.cors_allowed_origin.as_deref())?;
let address = std::net::SocketAddr::from((config.host, config.port));
let listener = tokio::net::TcpListener::bind(address).await?;
@@ -49,6 +68,7 @@ pub fn build_app(state: AppState, cors_allowed_origin: Option<&str>) -> Result<R
)
.merge(Scalar::with_url("/scalar", openapi))
.with_state(Arc::new(state))
.layer(DefaultBodyLimit::max(20 * 1024 * 1024))
.layer(TraceLayer::new_for_http());
if let Some(origin) = cors_allowed_origin {

957
src/recording_routes.rs Normal file
View File

@@ -0,0 +1,957 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
Json,
extract::{Multipart, Path, Query, State},
http::HeaderMap,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, PgPool};
use utoipa::ToSchema;
use utoipa_axum::{router::OpenApiRouter, routes};
use uuid::Uuid;
use crate::{
AppState,
error::{ApiError, CommonApiErrors, ErrorBody},
};
type SharedState = Arc<AppState>;
const MAX_AUDIO_UPLOAD_BYTES: usize = 15 * 1024 * 1024;
const MAX_SEGMENT_DURATION_MS: i32 = 600_000;
const SHORT_RECORDING_DURATION_MS: i32 = 60_000;
const SHORT_TRANSCRIPT_CHARS: usize = 600;
pub fn router() -> OpenApiRouter<SharedState> {
OpenApiRouter::new()
.routes(routes!(get_active_feedback_session))
.routes(routes!(ensure_feedback_session))
.routes(routes!(update_feedback_session))
.routes(routes!(create_lesson_session))
.routes(routes!(upload_audio_segment))
.routes(routes!(finish_lesson_session))
.routes(routes!(mark_lesson_applied))
.routes(routes!(retry_audio_segment))
.routes(routes!(generate_feedback_content))
}
#[derive(Debug, Deserialize)]
struct ActiveSessionQuery {
profile_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({
"profile_id": "11111111-1111-4111-8111-111111111111",
"feedback_date": "2026-07-16",
"content": ""
}))]
struct FeedbackSessionInput {
profile_id: Option<Uuid>,
feedback_date: NaiveDate,
#[serde(default)]
content: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct FeedbackSessionUpdateInput {
feedback_date: NaiveDate,
content: String,
}
#[derive(Debug, FromRow)]
struct FeedbackSessionRow {
id: Uuid,
profile_id: Option<Uuid>,
feedback_date: NaiveDate,
content: String,
status: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug, FromRow)]
struct LessonRow {
id: Uuid,
sequence: i32,
status: String,
duration_ms: i32,
applied_directly: bool,
included_in_generation: bool,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
}
#[derive(Debug, FromRow)]
struct SegmentRow {
id: Uuid,
lesson_session_id: Uuid,
sequence: i32,
duration_ms: i32,
status: String,
error_message: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
struct FeedbackSessionResponse {
id: Uuid,
profile_id: Option<Uuid>,
feedback_date: NaiveDate,
content: String,
status: String,
lesson_count: usize,
total_duration_ms: i64,
processing_segment_count: usize,
failed_segment_count: usize,
needs_generation: bool,
lessons: Vec<LessonSummaryResponse>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
struct LessonSummaryResponse {
id: Uuid,
sequence: i32,
status: String,
duration_ms: i32,
applied_directly: bool,
included_in_generation: bool,
segments: Vec<AudioSegmentResponse>,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, ToSchema)]
struct AudioSegmentResponse {
id: Uuid,
sequence: i32,
duration_ms: i32,
status: String,
error_message: Option<String>,
}
#[derive(Debug, Serialize, ToSchema)]
struct LessonCreatedResponse {
id: Uuid,
sequence: i32,
status: String,
started_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, ToSchema)]
struct FinishedLessonResponse {
id: Uuid,
status: String,
duration_ms: i32,
transcript: String,
can_apply_directly: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
struct GenerateFeedbackInput {
#[schema(example = "学生本周构图更加稳定。")]
existing_content: String,
}
#[derive(Debug, Deserialize, ToSchema)]
struct MarkLessonAppliedInput {
content: String,
}
#[derive(Debug, Serialize, ToSchema)]
struct GenerateFeedbackResponse {
content: String,
method: String,
}
#[derive(Debug, ToSchema)]
#[allow(dead_code)]
struct AudioSegmentUpload {
sequence: i32,
duration_ms: i32,
audio_format: String,
audio: String,
}
#[utoipa::path(
get,
path = "/api/v1/feedback-sessions/active",
tag = "语音反馈",
summary = "读取进行中的反馈批次",
description = "读取当前用户指定学生下尚未保存的反馈批次profile_id 留空时读取不关联学生的批次。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("profile_id" = Option<Uuid>, Query, description = "学生档案 ID留空表示不关联学生")
),
responses(
(status = 200, description = "进行中的反馈批次;不存在时返回 null", body = Option<FeedbackSessionResponse>),
CommonApiErrors
)
)]
async fn get_active_feedback_session(
State(state): State<SharedState>,
headers: HeaderMap,
Query(query): Query<ActiveSessionQuery>,
) -> Result<Json<Option<FeedbackSessionResponse>>, ApiError> {
let owner_id = current_user(&headers)?;
let session = sqlx::query_as::<_, FeedbackSessionRow>(
"SELECT id, profile_id, feedback_date, content, status, created_at, updated_at \
FROM feedback_sessions \
WHERE owner_id = $1 AND profile_id IS NOT DISTINCT FROM $2 AND status = 'active' \
ORDER BY updated_at DESC LIMIT 1",
)
.bind(owner_id)
.bind(query.profile_id)
.fetch_optional(pool(&state)?)
.await?;
match session {
Some(session) => Ok(Json(Some(
load_session_response(pool(&state)?, session).await?,
))),
None => Ok(Json(None)),
}
}
#[utoipa::path(
post,
path = "/api/v1/feedback-sessions",
tag = "语音反馈",
summary = "创建或恢复反馈批次",
description = "为学生创建一个反馈批次;已有未保存批次时直接恢复,并同步最新反馈日期。",
params(("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))),
request_body(content = FeedbackSessionInput, content_type = "application/json"),
responses(
(status = 200, description = "创建或恢复后的反馈批次", body = FeedbackSessionResponse),
(status = 404, description = "学生档案不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn ensure_feedback_session(
State(state): State<SharedState>,
headers: HeaderMap,
Json(input): Json<FeedbackSessionInput>,
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
let owner_id = current_user(&headers)?;
let pool = pool(&state)?;
validate_draft_content(&input.content)?;
if let Some(profile_id) = input.profile_id {
assert_profile_owner(pool, owner_id, profile_id).await?;
}
let existing = sqlx::query_as::<_, FeedbackSessionRow>(
"UPDATE feedback_sessions SET feedback_date = $3, content = $4, updated_at = now() \
WHERE id = ( \
SELECT id FROM feedback_sessions \
WHERE owner_id = $1 AND profile_id IS NOT DISTINCT FROM $2 AND status = 'active' \
ORDER BY updated_at DESC LIMIT 1 \
) \
RETURNING id, profile_id, feedback_date, content, status, created_at, updated_at",
)
.bind(owner_id)
.bind(input.profile_id)
.bind(input.feedback_date)
.bind(&input.content)
.fetch_optional(pool)
.await?;
let session =
match existing {
Some(session) => session,
None => sqlx::query_as::<_, FeedbackSessionRow>(
"INSERT INTO feedback_sessions (id, owner_id, profile_id, feedback_date, content) \
VALUES ($1, $2, $3, $4, $5) \
RETURNING id, profile_id, feedback_date, content, status, created_at, updated_at",
)
.bind(Uuid::new_v4())
.bind(owner_id)
.bind(input.profile_id)
.bind(input.feedback_date)
.bind(&input.content)
.fetch_one(pool)
.await?,
};
Ok(Json(load_session_response(pool, session).await?))
}
#[utoipa::path(
put,
path = "/api/v1/feedback-sessions/{session_id}",
tag = "语音反馈",
summary = "自动保存反馈草稿",
description = "在用户编辑反馈正文或日期时自动保存进行中的反馈批次。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("session_id" = Uuid, Path, description = "反馈批次 ID")
),
request_body(content = FeedbackSessionUpdateInput, content_type = "application/json"),
responses(
(status = 200, description = "更新后的反馈批次", body = FeedbackSessionResponse),
(status = 404, description = "反馈批次不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn update_feedback_session(
State(state): State<SharedState>,
headers: HeaderMap,
Path(session_id): Path<Uuid>,
Json(input): Json<FeedbackSessionUpdateInput>,
) -> Result<Json<FeedbackSessionResponse>, ApiError> {
let owner_id = current_user(&headers)?;
validate_draft_content(&input.content)?;
let session = sqlx::query_as::<_, FeedbackSessionRow>(
"UPDATE feedback_sessions SET feedback_date = $3, content = $4, updated_at = now() \
WHERE id = $1 AND owner_id = $2 AND status = 'active' \
RETURNING id, profile_id, feedback_date, content, status, created_at, updated_at",
)
.bind(session_id)
.bind(owner_id)
.bind(input.feedback_date)
.bind(&input.content)
.fetch_optional(pool(&state)?)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(load_session_response(pool(&state)?, session).await?))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-sessions/{session_id}/lessons",
tag = "语音反馈",
summary = "开始一节课堂录音",
description = "在反馈批次中自动创建下一节课堂录音。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("session_id" = Uuid, Path, description = "反馈批次 ID")
),
responses(
(status = 201, description = "课堂录音已创建", body = LessonCreatedResponse),
(status = 404, description = "反馈批次不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn create_lesson_session(
State(state): State<SharedState>,
headers: HeaderMap,
Path(session_id): Path<Uuid>,
) -> Result<(axum::http::StatusCode, Json<LessonCreatedResponse>), ApiError> {
let owner_id = current_user(&headers)?;
assert_session_owner(pool(&state)?, owner_id, session_id).await?;
sqlx::query(
"DELETE FROM lesson_sessions \
WHERE feedback_session_id = $1 AND status IN ('recording', 'processing')",
)
.bind(session_id)
.execute(pool(&state)?)
.await?;
let id = Uuid::new_v4();
let lesson = sqlx::query_as::<_, (Uuid, i32, String, DateTime<Utc>)>(
"INSERT INTO lesson_sessions (id, feedback_session_id, sequence) \
SELECT $1, $2, COALESCE(MAX(sequence), 0) + 1 \
FROM lesson_sessions WHERE feedback_session_id = $2 \
RETURNING id, sequence, status, started_at",
)
.bind(id)
.bind(session_id)
.fetch_one(pool(&state)?)
.await?;
touch_session(pool(&state)?, session_id).await?;
Ok((
axum::http::StatusCode::CREATED,
Json(LessonCreatedResponse {
id: lesson.0,
sequence: lesson.1,
status: lesson.2,
started_at: lesson.3,
}),
))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-lessons/{lesson_id}/segments",
tag = "语音反馈",
summary = "上传并转录录音片段",
description = "上传单个录音片段并加入后端转录队列。接口保存音频后立即返回,页面通过反馈批次状态无感获取转录结果。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
),
request_body(content = AudioSegmentUpload, content_type = "multipart/form-data"),
responses(
(status = 201, description = "片段已保存并进入转录队列", body = AudioSegmentResponse),
(status = 404, description = "课堂录音不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn upload_audio_segment(
State(state): State<SharedState>,
headers: HeaderMap,
Path(lesson_id): Path<Uuid>,
mut multipart: Multipart,
) -> Result<(axum::http::StatusCode, Json<AudioSegmentResponse>), ApiError> {
let owner_id = current_user(&headers)?;
let session_id = assert_lesson_owner(pool(&state)?, owner_id, lesson_id).await?;
let mut sequence = None;
let mut duration_ms = None;
let mut audio_format = None;
let mut audio = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|error| ApiError::BadRequest(format!("invalid multipart body: {error}")))?
{
let name = field.name().unwrap_or_default().to_owned();
match name.as_str() {
"sequence" => sequence = Some(parse_multipart_i32(field, "sequence").await?),
"duration_ms" => duration_ms = Some(parse_multipart_i32(field, "duration_ms").await?),
"audio_format" => {
audio_format = Some(
field
.text()
.await
.map_err(|_| ApiError::BadRequest("audio_format is invalid".to_owned()))?,
)
}
"audio" => {
let bytes = field
.bytes()
.await
.map_err(|_| ApiError::BadRequest("audio file is invalid".to_owned()))?;
if bytes.len() > MAX_AUDIO_UPLOAD_BYTES {
return Err(ApiError::BadRequest("audio file exceeds 15 MB".to_owned()));
}
audio = Some(bytes.to_vec());
}
_ => {}
}
}
let sequence = sequence
.filter(|value| *value > 0)
.ok_or_else(|| ApiError::BadRequest("sequence must be a positive integer".to_owned()))?;
let duration_ms = duration_ms
.filter(|value| (1..=MAX_SEGMENT_DURATION_MS).contains(value))
.ok_or_else(|| {
ApiError::BadRequest("duration_ms must be between 1 and 600000".to_owned())
})?;
let audio_format = audio_format
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| matches!(value.as_str(), "mp3" | "aac" | "wav" | "m4a" | "amr"))
.ok_or_else(|| ApiError::BadRequest("unsupported audio_format".to_owned()))?;
let audio = audio
.filter(|bytes| !bytes.is_empty())
.ok_or_else(|| ApiError::BadRequest("audio file cannot be empty".to_owned()))?;
let file_id = Uuid::new_v4();
let directory = state
.audio_storage_dir
.join(owner_id.to_string())
.join(lesson_id.to_string());
tokio::fs::create_dir_all(&directory)
.await
.map_err(internal_io_error)?;
let audio_path = directory.join(format!("{file_id}.{audio_format}"));
tokio::fs::write(&audio_path, &audio)
.await
.map_err(internal_io_error)?;
let segment_id = sqlx::query_scalar::<_, Uuid>(
"INSERT INTO audio_segments \
(id, lesson_session_id, sequence, duration_ms, audio_format, audio_path, status) \
VALUES ($1, $2, $3, $4, $5, $6, 'processing') \
ON CONFLICT (lesson_session_id, sequence) DO UPDATE SET \
duration_ms = EXCLUDED.duration_ms, audio_format = EXCLUDED.audio_format, \
audio_path = EXCLUDED.audio_path, status = 'processing', transcript = NULL, \
error_message = NULL, provider_request_id = NULL, processing_started_at = NULL, \
updated_at = now() \
RETURNING id",
)
.bind(file_id)
.bind(lesson_id)
.bind(sequence)
.bind(duration_ms)
.bind(&audio_format)
.bind(audio_path.to_string_lossy().as_ref())
.fetch_one(pool(&state)?)
.await?;
sqlx::query(
"UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1",
)
.bind(lesson_id)
.execute(pool(&state)?)
.await?;
touch_session(pool(&state)?, session_id).await?;
Ok((
axum::http::StatusCode::CREATED,
Json(AudioSegmentResponse {
id: segment_id,
sequence,
duration_ms,
status: "processing".to_owned(),
error_message: None,
}),
))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-lessons/{lesson_id}/finish",
tag = "语音反馈",
summary = "结束一节课堂录音",
description = "汇总该节课的片段状态和转录文本;短录音会标记为可直接加入反馈。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
),
responses(
(status = 200, description = "课堂录音汇总结果", body = FinishedLessonResponse),
(status = 404, description = "课堂录音不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn finish_lesson_session(
State(state): State<SharedState>,
headers: HeaderMap,
Path(lesson_id): Path<Uuid>,
) -> Result<Json<FinishedLessonResponse>, ApiError> {
let owner_id = current_user(&headers)?;
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 \
WHERE lesson_session_id = $1 ORDER BY sequence",
)
.bind(lesson_id)
.fetch_all(pool(&state)?)
.await?;
let duration_ms = segments.iter().map(|segment| segment.1).sum::<i32>();
let transcript = segments
.iter()
.filter_map(|segment| segment.2.as_deref())
.collect::<Vec<_>>()
.join("\n");
let status = if segments.is_empty() || segments.iter().any(|segment| segment.0 == "failed") {
"failed"
} else if segments.iter().any(|segment| segment.0 == "processing") {
"processing"
} else {
"ready"
};
sqlx::query(
"UPDATE lesson_sessions SET status = $2, duration_ms = $3, ended_at = now(), updated_at = now() \
WHERE id = $1",
)
.bind(lesson_id)
.bind(status)
.bind(duration_ms)
.execute(pool(&state)?)
.await?;
touch_session(pool(&state)?, session_id).await?;
Ok(Json(FinishedLessonResponse {
id: lesson_id,
status: status.to_owned(),
duration_ms,
can_apply_directly: status == "ready"
&& duration_ms <= SHORT_RECORDING_DURATION_MS
&& transcript.chars().count() <= SHORT_TRANSCRIPT_CHARS,
transcript,
}))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-lessons/{lesson_id}/mark-applied",
tag = "语音反馈",
summary = "标记短录音已加入反馈",
description = "短录音文字直接加入反馈内容后,标记该节课无需再次汇总。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("lesson_id" = Uuid, Path, description = "课堂录音 ID")
),
request_body(content = MarkLessonAppliedInput, content_type = "application/json"),
responses(
(status = 204, description = "已标记"),
(status = 404, description = "课堂录音不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn mark_lesson_applied(
State(state): State<SharedState>,
headers: HeaderMap,
Path(lesson_id): Path<Uuid>,
Json(input): Json<MarkLessonAppliedInput>,
) -> Result<axum::http::StatusCode, ApiError> {
let owner_id = current_user(&headers)?;
validate_draft_content(&input.content)?;
let pool = pool(&state)?;
let session_id = assert_lesson_owner(pool, owner_id, lesson_id).await?;
let mut transaction = pool.begin().await?;
let result = sqlx::query(
"UPDATE lesson_sessions SET applied_directly = TRUE, updated_at = now() \
WHERE id = $1 AND status = 'ready'",
)
.bind(lesson_id)
.execute(&mut *transaction)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::BadRequest("lesson is not ready".to_owned()));
}
sqlx::query("UPDATE feedback_sessions SET content = $2, updated_at = now() WHERE id = $1")
.bind(session_id)
.bind(&input.content)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/api/v1/audio-segments/{segment_id}/retry",
tag = "语音反馈",
summary = "重试失败的录音转录",
description = "将后端保留的失败音频重新加入转录队列,接口立即返回。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("segment_id" = Uuid, Path, description = "录音片段 ID")
),
responses(
(status = 200, description = "片段已重新进入转录队列", body = AudioSegmentResponse),
(status = 404, description = "录音片段不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn retry_audio_segment(
State(state): State<SharedState>,
headers: HeaderMap,
Path(segment_id): Path<Uuid>,
) -> Result<Json<AudioSegmentResponse>, ApiError> {
let owner_id = current_user(&headers)?;
let segment = sqlx::query_as::<_, (i32, i32, String, Uuid)>(
"SELECT s.sequence, s.duration_ms, s.audio_path, l.id \
FROM audio_segments s \
JOIN lesson_sessions l ON l.id = s.lesson_session_id \
JOIN feedback_sessions f ON f.id = l.feedback_session_id \
WHERE s.id = $1 AND f.owner_id = $2 AND f.status = 'active'",
)
.bind(segment_id)
.bind(owner_id)
.fetch_optional(pool(&state)?)
.await?
.ok_or(ApiError::NotFound)?;
tokio::fs::metadata(&segment.2)
.await
.map_err(internal_io_error)?;
sqlx::query(
"UPDATE audio_segments SET status = 'processing', error_message = NULL, \
processing_started_at = NULL, updated_at = now() WHERE id = $1",
)
.bind(segment_id)
.execute(pool(&state)?)
.await?;
sqlx::query(
"UPDATE lesson_sessions SET status = 'processing', updated_at = now() WHERE id = $1",
)
.bind(segment.3)
.execute(pool(&state)?)
.await?;
let session_id = sqlx::query_scalar::<_, Uuid>(
"SELECT feedback_session_id FROM lesson_sessions WHERE id = $1",
)
.bind(segment.3)
.fetch_one(pool(&state)?)
.await?;
touch_session(pool(&state)?, session_id).await?;
Ok(Json(AudioSegmentResponse {
id: segment_id,
sequence: segment.0,
duration_ms: segment.1,
status: "processing".to_owned(),
error_message: None,
}))
}
#[utoipa::path(
post,
path = "/api/v1/feedback-sessions/{session_id}/generate",
tag = "语音反馈",
summary = "生成汇总反馈",
description = "将尚未处理的长录音或多节课堂转录与教师已有内容合并。未配置大模型时使用本地抽取式汇总。",
params(
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
("session_id" = Uuid, Path, description = "反馈批次 ID")
),
request_body(content = GenerateFeedbackInput, content_type = "application/json"),
responses(
(status = 200, description = "汇总后的反馈正文", body = GenerateFeedbackResponse),
(status = 404, description = "反馈批次不存在或不属于当前用户", body = ErrorBody),
CommonApiErrors
)
)]
async fn generate_feedback_content(
State(state): State<SharedState>,
headers: HeaderMap,
Path(session_id): Path<Uuid>,
Json(input): Json<GenerateFeedbackInput>,
) -> Result<Json<GenerateFeedbackResponse>, ApiError> {
let owner_id = current_user(&headers)?;
validate_draft_content(&input.existing_content)?;
assert_session_owner(pool(&state)?, owner_id, session_id).await?;
let pending_status = sqlx::query_as::<_, (bool, bool)>(
"SELECT \
EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
WHERE l.feedback_session_id = $1 AND s.status = 'processing'), \
EXISTS(SELECT 1 FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
WHERE l.feedback_session_id = $1 AND s.status = 'failed')",
)
.bind(session_id)
.fetch_one(pool(&state)?)
.await?;
if pending_status.0 {
return Err(ApiError::BadRequest(
"录音仍在后台转录中,请稍后再生成反馈".to_owned(),
));
}
if pending_status.1 {
return Err(ApiError::BadRequest(
"部分录音转录失败,请重试后再生成反馈".to_owned(),
));
}
let lessons = sqlx::query_as::<_, (Uuid, String)>(
"SELECT l.id, string_agg(s.transcript, E'\\n' ORDER BY s.sequence) \
FROM lesson_sessions l \
JOIN audio_segments s ON s.lesson_session_id = l.id \
WHERE l.feedback_session_id = $1 AND l.status = 'ready' \
AND l.applied_directly = FALSE AND l.included_in_generation = FALSE \
AND s.status = 'ready' AND s.transcript IS NOT NULL \
GROUP BY l.id, l.sequence ORDER BY l.sequence",
)
.bind(session_id)
.fetch_all(pool(&state)?)
.await?;
if lessons.is_empty() {
return Err(ApiError::BadRequest("没有待汇总的录音内容".to_owned()));
}
let transcripts = lessons
.iter()
.map(|lesson| lesson.1.clone())
.collect::<Vec<_>>();
let summary = state
.summary
.summarize(&input.existing_content, &transcripts)
.await
.map_err(ApiError::ServiceUnavailable)?;
let lesson_ids = lessons.iter().map(|lesson| lesson.0).collect::<Vec<_>>();
let pool = pool(&state)?;
let mut transaction = pool.begin().await?;
sqlx::query(
"UPDATE lesson_sessions SET included_in_generation = TRUE, updated_at = now() \
WHERE id = ANY($1)",
)
.bind(&lesson_ids)
.execute(&mut *transaction)
.await?;
sqlx::query("UPDATE feedback_sessions SET content = $2, updated_at = now() WHERE id = $1")
.bind(session_id)
.bind(&summary.content)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(Json(GenerateFeedbackResponse {
content: summary.content,
method: summary.method.to_owned(),
}))
}
async fn load_session_response(
pool: &PgPool,
session: FeedbackSessionRow,
) -> Result<FeedbackSessionResponse, ApiError> {
let lessons = sqlx::query_as::<_, LessonRow>(
"SELECT id, sequence, status, duration_ms, applied_directly, included_in_generation, \
started_at, ended_at FROM lesson_sessions \
WHERE feedback_session_id = $1 ORDER BY sequence",
)
.bind(session.id)
.fetch_all(pool)
.await?;
let segments = sqlx::query_as::<_, SegmentRow>(
"SELECT s.id, s.lesson_session_id, s.sequence, s.duration_ms, s.status, s.error_message \
FROM audio_segments s JOIN lesson_sessions l ON l.id = s.lesson_session_id \
WHERE l.feedback_session_id = $1 ORDER BY l.sequence, s.sequence",
)
.bind(session.id)
.fetch_all(pool)
.await?;
let processing_segment_count = segments
.iter()
.filter(|segment| segment.status == "processing")
.count()
+ lessons
.iter()
.filter(|lesson| matches!(lesson.status.as_str(), "recording" | "processing"))
.count();
let failed_segment_count = segments
.iter()
.filter(|segment| segment.status == "failed")
.count();
let total_duration_ms = lessons
.iter()
.map(|lesson| i64::from(lesson.duration_ms))
.sum();
let needs_generation = lessons.iter().any(|lesson| {
lesson.status == "ready" && !lesson.applied_directly && !lesson.included_in_generation
});
let mut segments_by_lesson: HashMap<Uuid, Vec<AudioSegmentResponse>> = HashMap::new();
for segment in segments {
segments_by_lesson
.entry(segment.lesson_session_id)
.or_default()
.push(AudioSegmentResponse {
id: segment.id,
sequence: segment.sequence,
duration_ms: segment.duration_ms,
status: segment.status,
error_message: segment.error_message,
});
}
let lesson_count = lessons.len();
let lesson_responses = lessons
.into_iter()
.map(|lesson| LessonSummaryResponse {
id: lesson.id,
sequence: lesson.sequence,
status: lesson.status,
duration_ms: lesson.duration_ms,
applied_directly: lesson.applied_directly,
included_in_generation: lesson.included_in_generation,
segments: segments_by_lesson.remove(&lesson.id).unwrap_or_default(),
started_at: lesson.started_at,
ended_at: lesson.ended_at,
})
.collect();
Ok(FeedbackSessionResponse {
id: session.id,
profile_id: session.profile_id,
feedback_date: session.feedback_date,
content: session.content,
status: session.status,
lesson_count,
total_duration_ms,
processing_segment_count,
failed_segment_count,
needs_generation,
lessons: lesson_responses,
created_at: session.created_at,
updated_at: session.updated_at,
})
}
async fn parse_multipart_i32(
field: axum::extract::multipart::Field<'_>,
name: &str,
) -> Result<i32, ApiError> {
field
.text()
.await
.map_err(|_| ApiError::BadRequest(format!("{name} is invalid")))?
.parse()
.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)
}
async fn assert_profile_owner(
pool: &PgPool,
owner_id: Uuid,
profile_id: Uuid,
) -> Result<(), ApiError> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM student_profiles WHERE id = $1 AND owner_id = $2)",
)
.bind(profile_id)
.bind(owner_id)
.fetch_one(pool)
.await?;
if exists {
Ok(())
} else {
Err(ApiError::NotFound)
}
}
async fn assert_session_owner(
pool: &PgPool,
owner_id: Uuid,
session_id: Uuid,
) -> Result<(), ApiError> {
let exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM feedback_sessions WHERE id = $1 AND owner_id = $2 AND status = 'active')",
)
.bind(session_id)
.bind(owner_id)
.fetch_one(pool)
.await?;
if exists {
Ok(())
} else {
Err(ApiError::NotFound)
}
}
async fn assert_lesson_owner(
pool: &PgPool,
owner_id: Uuid,
lesson_id: Uuid,
) -> Result<Uuid, ApiError> {
sqlx::query_scalar::<_, Uuid>(
"SELECT f.id FROM lesson_sessions l \
JOIN feedback_sessions f ON f.id = l.feedback_session_id \
WHERE l.id = $1 AND f.owner_id = $2 AND f.status = 'active'",
)
.bind(lesson_id)
.bind(owner_id)
.fetch_optional(pool)
.await?
.ok_or(ApiError::NotFound)
}
async fn touch_session(pool: &PgPool, session_id: Uuid) -> Result<(), ApiError> {
sqlx::query("UPDATE feedback_sessions SET updated_at = now() WHERE id = $1")
.bind(session_id)
.execute(pool)
.await?;
Ok(())
}
fn internal_io_error(error: std::io::Error) -> ApiError {
tracing::error!(?error, "audio file operation failed");
ApiError::Internal
}
fn validate_draft_content(content: &str) -> Result<(), ApiError> {
if content.chars().count() > 2000 {
return Err(ApiError::BadRequest(
"feedback content cannot exceed 2000 characters".to_owned(),
));
}
Ok(())
}

View File

@@ -18,6 +18,7 @@ use uuid::Uuid;
use crate::{
AppState,
error::{ApiError, CommonApiErrors, ErrorBody},
recording_routes,
};
type SharedState = Arc<AppState>;
@@ -34,6 +35,7 @@ pub fn router() -> (Router<SharedState>, OpenApi) {
.routes(routes!(get_defaults, update_defaults))
.routes(routes!(list_feedback_records, create_feedback_record))
.routes(routes!(delete_feedback_record))
.merge(recording_routes::router())
.split_for_parts();
let mut info = Info::new("教学反馈助手 API", env!("CARGO_PKG_VERSION"));
@@ -49,6 +51,7 @@ pub fn router() -> (Router<SharedState>, OpenApi) {
api_tag("学生档案", "创建、查询、更新和删除当前用户的学生档案。"),
api_tag("档案预设", "读取或保存当前用户新建档案时使用的默认值。"),
api_tag("反馈记录", "创建、查询和删除当前用户的教学反馈记录。"),
api_tag("语音反馈", "录制多节课堂、转录音频并生成反馈草稿。"),
]);
(router, openapi)
@@ -62,12 +65,27 @@ fn api_tag(name: &str, description: &str) -> Tag {
/// 健康检查响应。
#[derive(Serialize, ToSchema)]
#[schema(example = json!({"status": "ok", "database_configured": true}))]
#[schema(example = json!({
"status": "ok",
"database_configured": true,
"speech_configured": true,
"speech_available": true,
"speech_provider": "local-funasr",
"summary_configured": false
}))]
struct HealthResponse {
/// HTTP 服务状态;正常时固定为 `ok`。
status: &'static str,
/// 是否已经通过 `DATABASE_URL` 配置数据库连接。
database_configured: bool,
/// 是否已经配置长音频转录服务。
speech_configured: bool,
/// 当前转录服务是否可连接;本地模型启动和下载完成后为 `true`。
speech_available: bool,
/// 当前语音提供方;未启用转录时为 `null`。
speech_provider: Option<&'static str>,
/// 是否已经配置生成式反馈汇总服务;未配置时使用本地抽取式汇总。
summary_configured: bool,
}
/// 检查服务状态。
@@ -85,6 +103,10 @@ async fn health(State(state): State<SharedState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
database_configured: state.pool.is_some(),
speech_configured: state.speech.configured(),
speech_available: state.speech.available().await,
speech_provider: state.speech.provider_name(),
summary_configured: state.summary.configured(),
})
}
@@ -561,7 +583,8 @@ struct FeedbackRecord {
#[schema(example = json!({
"profile_id": null,
"feedback_date": "2026-07-15",
"content": "今天完成了色彩搭配练习,构图有明显进步。"
"content": "今天完成了色彩搭配练习,构图有明显进步。",
"feedback_session_id": null
}))]
struct FeedbackRecordInput {
/// 可选的学生档案 ID如填写必须属于当前用户。Scalar 默认留空以便直接测试。
@@ -570,6 +593,8 @@ struct FeedbackRecordInput {
feedback_date: NaiveDate,
/// 教学反馈正文,不可为空。
content: String,
/// 可选的语音反馈批次 ID保存后对应批次会被标记为已完成。
feedback_session_id: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
@@ -634,7 +659,8 @@ async fn list_feedback_records(
example = json!({
"profile_id": null,
"feedback_date": "2026-07-15",
"content": "今天完成了色彩搭配练习,构图有明显进步。"
"content": "今天完成了色彩搭配练习,构图有明显进步。",
"feedback_session_id": null
})
),
responses(
@@ -648,13 +674,33 @@ async fn create_feedback_record(
headers: HeaderMap,
Json(input): Json<FeedbackRecordInput>,
) -> Result<(axum::http::StatusCode, Json<FeedbackRecord>), ApiError> {
if input.content.trim().is_empty() {
return Err(ApiError::BadRequest("content cannot be empty".to_owned()));
if input.content.trim().is_empty() || input.content.chars().count() > 2000 {
return Err(ApiError::BadRequest(
"content must contain 1 to 2000 characters".to_owned(),
));
}
let owner_id = current_user(&headers)?;
let pool = pool(&state)?;
if let Some(profile_id) = input.profile_id {
assert_profile_owner(pool(&state)?, owner_id, profile_id).await?;
assert_profile_owner(pool, owner_id, profile_id).await?;
}
if let Some(session_id) = input.feedback_session_id {
let session_profile_id = sqlx::query_scalar::<_, Option<Uuid>>(
"SELECT profile_id FROM feedback_sessions \
WHERE id = $1 AND owner_id = $2 AND status = 'active'",
)
.bind(session_id)
.bind(owner_id)
.fetch_optional(pool)
.await?
.ok_or(ApiError::NotFound)?;
if session_profile_id != input.profile_id {
return Err(ApiError::BadRequest(
"feedback session profile does not match feedback record".to_owned(),
));
}
}
let mut transaction = pool.begin().await?;
let record = sqlx::query_as::<_, FeedbackRecord>(
"INSERT INTO feedback_records (id, owner_id, profile_id, feedback_date, content) \
VALUES ($1, $2, $3, $4, $5) \
@@ -665,8 +711,19 @@ async fn create_feedback_record(
.bind(input.profile_id)
.bind(input.feedback_date)
.bind(input.content.trim())
.fetch_one(pool(&state)?)
.fetch_one(&mut *transaction)
.await?;
if let Some(session_id) = input.feedback_session_id {
sqlx::query(
"UPDATE feedback_sessions SET status = 'finalized', finalized_record_id = $2, updated_at = now() \
WHERE id = $1",
)
.bind(session_id)
.bind(record.id)
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
Ok((axum::http::StatusCode::CREATED, Json(record)))
}
@@ -827,7 +884,7 @@ mod tests {
.filter(|operation| operation.get("responses").is_some())
.collect::<Vec<_>>();
assert_eq!(operations.len(), 11);
assert_eq!(operations.len(), 20);
for operation in &operations {
assert_non_empty(operation, "summary");
assert_non_empty(operation, "description");

350
src/speech.rs Normal file
View File

@@ -0,0 +1,350 @@
use std::{collections::BTreeMap, time::Duration};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use hmac::{Hmac, Mac};
use reqwest::{
Client,
header::{AUTHORIZATION, CONTENT_TYPE},
multipart::{Form, Part},
};
use serde::Deserialize;
use sha1::Sha1;
use crate::config::{SpeechConfig, SpeechProviderConfig, TencentSpeechConfig};
const ASR_HOST: &str = "asr.cloud.tencent.com";
#[derive(Clone)]
pub struct SpeechService {
config: Option<SpeechConfig>,
client: Client,
}
#[derive(Debug)]
pub struct Transcription {
pub text: String,
pub duration_ms: i32,
pub request_id: String,
}
#[derive(Deserialize)]
struct LocalResponse {
text: String,
#[serde(default)]
duration_ms: i32,
#[serde(default)]
request_id: String,
}
#[derive(Deserialize)]
struct FlashResponse {
code: i32,
message: String,
request_id: Option<String>,
audio_duration: Option<i32>,
#[serde(default)]
flash_result: Vec<FlashResult>,
}
#[derive(Deserialize)]
struct FlashResult {
text: String,
}
impl SpeechService {
pub fn new(config: Option<SpeechConfig>) -> Self {
let timeout = config.as_ref().map_or(Duration::from_secs(30), |value| {
Duration::from_secs(value.request_timeout_seconds)
});
let client = Client::builder()
.timeout(timeout)
.build()
.expect("reqwest client configuration should be valid");
Self { config, client }
}
pub fn configured(&self) -> bool {
self.config.is_some()
}
pub fn provider_name(&self) -> Option<&'static str> {
self.config.as_ref().map(|config| match config.provider {
SpeechProviderConfig::Local { .. } => "local-funasr",
SpeechProviderConfig::Tencent(_) => "tencent",
})
}
pub fn worker_concurrency(&self) -> usize {
self.config
.as_ref()
.map_or(0, |config| config.worker_concurrency)
}
pub fn job_lease_seconds(&self) -> i64 {
self.config
.as_ref()
.map_or(3_600, |config| config.job_lease_seconds)
}
pub async fn available(&self) -> bool {
let Some(config) = &self.config else {
return false;
};
match &config.provider {
SpeechProviderConfig::Local { api_url } => {
let request = self.client.get(format!("{api_url}/health")).send();
matches!(
tokio::time::timeout(Duration::from_secs(2), request).await,
Ok(Ok(response)) if response.status().is_success()
)
}
SpeechProviderConfig::Tencent(_) => true,
}
}
pub async fn transcribe(
&self,
audio: Vec<u8>,
audio_format: &str,
duration_ms: i32,
) -> Result<Transcription, String> {
let config = self
.config
.as_ref()
.ok_or_else(|| "语音转录服务尚未配置".to_owned())?;
match &config.provider {
SpeechProviderConfig::Local { api_url } => {
self.transcribe_local(api_url, audio, audio_format, duration_ms)
.await
}
SpeechProviderConfig::Tencent(config) => {
self.transcribe_tencent(config, audio, audio_format).await
}
}
}
async fn transcribe_local(
&self,
api_url: &str,
audio: Vec<u8>,
audio_format: &str,
duration_ms: i32,
) -> Result<Transcription, String> {
let part = Part::bytes(audio)
.file_name(format!("segment.{audio_format}"))
.mime_str(audio_content_type(audio_format))
.map_err(|error| format!("录音格式无效:{error}"))?;
let form = Form::new()
.part("file", part)
.text("language", "zh")
.text("duration_ms", duration_ms.to_string());
let response = self
.client
.post(local_transcription_url(api_url))
.multipart(form)
.send()
.await
.map_err(|error| format!("本地语音转录服务连接失败:{error}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("读取本地语音转录响应失败:{error}"))?;
if !status.is_success() {
return Err(format!(
"本地语音转录服务返回 HTTP {status}{}",
compact_error(&body)
));
}
let result: LocalResponse = serde_json::from_str(&body)
.map_err(|error| format!("本地语音转录响应格式无效:{error}"))?;
transcription(result.text, result.duration_ms, result.request_id)
}
async fn transcribe_tencent(
&self,
config: &TencentSpeechConfig,
audio: Vec<u8>,
audio_format: &str,
) -> Result<Transcription, String> {
let timestamp = chrono::Utc::now().timestamp();
let (url, signature) = signed_request(config, audio_format, timestamp)?;
let response = self
.client
.post(url)
.header(AUTHORIZATION, signature)
.header(CONTENT_TYPE, "application/octet-stream")
.body(audio)
.send()
.await
.map_err(|error| format!("语音转录服务连接失败:{error}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("读取语音转录响应失败:{error}"))?;
if !status.is_success() {
return Err(format!("语音转录服务返回 HTTP {status}"));
}
let result: FlashResponse = serde_json::from_str(&body)
.map_err(|error| format!("语音转录响应格式无效:{error}"))?;
if result.code != 0 {
return Err(format!(
"语音转录失败({}{}",
result.code, result.message
));
}
let text = result
.flash_result
.into_iter()
.map(|channel| channel.text)
.collect::<Vec<_>>()
.join("\n");
transcription(
text,
result.audio_duration.unwrap_or_default(),
result.request_id.unwrap_or_default(),
)
}
}
fn transcription(
text: String,
duration_ms: i32,
request_id: String,
) -> Result<Transcription, String> {
let text = text.trim().to_owned();
if text.is_empty() {
return Err("未识别到清晰语音".to_owned());
}
Ok(Transcription {
text,
duration_ms,
request_id,
})
}
fn local_transcription_url(api_url: &str) -> String {
format!("{}/v1/transcriptions", api_url.trim_end_matches('/'))
}
fn audio_content_type(audio_format: &str) -> &'static str {
match audio_format {
"aac" => "audio/aac",
"wav" => "audio/wav",
"m4a" => "audio/mp4",
"amr" => "audio/amr",
_ => "audio/mpeg",
}
}
fn compact_error(body: &str) -> String {
let value = body.split_whitespace().collect::<Vec<_>>().join(" ");
value.chars().take(240).collect()
}
fn signed_request(
config: &TencentSpeechConfig,
audio_format: &str,
timestamp: i64,
) -> Result<(String, String), String> {
let mut params = BTreeMap::new();
params.insert("convert_num_mode", "1".to_owned());
params.insert("engine_type", config.engine_type.clone());
params.insert("filter_dirty", "0".to_owned());
params.insert("filter_modal", "1".to_owned());
params.insert("filter_punc", "0".to_owned());
params.insert("first_channel_only", "1".to_owned());
params.insert("secretid", config.secret_id.clone());
params.insert("speaker_diarization", "0".to_owned());
params.insert("timestamp", timestamp.to_string());
params.insert("voice_format", audio_format.to_owned());
params.insert("word_info", "0".to_owned());
let query = url::form_urlencoded::Serializer::new(String::new())
.extend_pairs(params.iter())
.finish();
let path = format!("/asr/flash/v1/{}", config.app_id);
let source = format!("POST{ASR_HOST}{path}?{query}");
let mut mac = Hmac::<Sha1>::new_from_slice(config.secret_key.as_bytes())
.map_err(|_| "语音转录密钥格式无效".to_owned())?;
mac.update(source.as_bytes());
let signature = BASE64.encode(mac.finalize().into_bytes());
Ok((format!("https://{ASR_HOST}{path}?{query}"), signature))
}
#[cfg(test)]
mod tests {
use axum::{
Json, Router,
routing::{get, post},
};
use serde_json::json;
use crate::config::{SpeechConfig, SpeechProviderConfig, TencentSpeechConfig};
use super::{SpeechService, local_transcription_url, signed_request};
#[test]
fn signed_request_sorts_parameters_and_is_stable() {
let config = TencentSpeechConfig {
app_id: 123,
secret_id: "secret-id".to_owned(),
secret_key: "secret-key".to_owned(),
engine_type: "16k_zh".to_owned(),
};
let first = signed_request(&config, "mp3", 1_700_000_000).unwrap();
let second = signed_request(&config, "mp3", 1_700_000_000).unwrap();
assert_eq!(first, second);
assert!(first.0.contains("convert_num_mode=1&engine_type=16k_zh"));
assert!(first.0.ends_with("voice_format=mp3&word_info=0"));
assert!(!first.1.is_empty());
}
#[test]
fn local_url_has_one_transcription_suffix() {
assert_eq!(
local_transcription_url("http://127.0.0.1:10095/"),
"http://127.0.0.1:10095/v1/transcriptions"
);
}
#[tokio::test]
async fn local_provider_sends_audio_and_parses_response() {
let app = Router::new()
.route("/health", get(|| async { Json(json!({"status": "ok"})) }))
.route(
"/v1/transcriptions",
post(|| async {
Json(json!({
"text": "课堂练习完成。",
"duration_ms": 12_345,
"request_id": "local-request"
}))
}),
);
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 = SpeechService::new(Some(SpeechConfig {
provider: SpeechProviderConfig::Local {
api_url: format!("http://{address}"),
},
request_timeout_seconds: 5,
worker_concurrency: 1,
job_lease_seconds: 60,
}));
assert!(service.available().await);
let result = service
.transcribe(vec![1, 2, 3], "mp3", 10_000)
.await
.unwrap();
assert_eq!(result.text, "课堂练习完成。");
assert_eq!(result.duration_ms, 12_345);
assert_eq!(result.request_id, "local-request");
}
}

193
src/summary.rs Normal file
View File

@@ -0,0 +1,193 @@
use std::collections::HashSet;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
use serde::{Deserialize, Serialize};
use crate::config::SummaryConfig;
const MAX_FEEDBACK_CHARS: usize = 2000;
#[derive(Clone)]
pub struct SummaryService {
config: Option<SummaryConfig>,
client: reqwest::Client,
}
#[derive(Debug)]
pub struct SummaryResult {
pub content: String,
pub method: &'static str,
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage<'a>>,
temperature: f32,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatChoiceMessage,
}
#[derive(Deserialize)]
struct ChatChoiceMessage {
content: String,
}
impl SummaryService {
pub fn new(config: Option<SummaryConfig>) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
pub fn configured(&self) -> bool {
self.config.is_some()
}
pub async fn summarize(
&self,
existing_content: &str,
lesson_transcripts: &[String],
) -> Result<SummaryResult, String> {
let Some(config) = &self.config else {
return Ok(SummaryResult {
content: extractive_summary(existing_content, lesson_transcripts),
method: "extractive",
});
};
let transcript_text = lesson_transcripts
.iter()
.enumerate()
.map(|(index, transcript)| format!("{}节课:\n{}", index + 1, transcript))
.collect::<Vec<_>>()
.join("\n\n");
let user_prompt = format!(
"教师已填写内容:\n{}\n\n新增课堂转录:\n{}",
existing_content.trim(),
transcript_text
);
let system_prompt = "你是教学反馈整理助手。请把教师已有内容与课堂转录合并成一份面向家长的中文反馈保留具体事实按课堂表现、进步、问题和后续建议组织。不要虚构不要提及录音或转录不超过2000个汉字只输出反馈正文。";
let request = ChatRequest {
model: &config.model,
messages: vec![
ChatMessage {
role: "system",
content: system_prompt,
},
ChatMessage {
role: "user",
content: &user_prompt,
},
],
temperature: 0.2,
};
let mut builder = self
.client
.post(&config.api_url)
.header(CONTENT_TYPE, "application/json")
.json(&request);
if let Some(api_key) = &config.api_key {
builder = builder.header(AUTHORIZATION, format!("Bearer {api_key}"));
}
let response = builder
.send()
.await
.map_err(|error| format!("反馈汇总服务连接失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("反馈汇总服务返回 HTTP {status}"));
}
let result: ChatResponse = response
.json()
.await
.map_err(|error| format!("反馈汇总响应格式无效:{error}"))?;
let content = result
.choices
.into_iter()
.next()
.map(|choice| choice.message.content.trim().to_owned())
.filter(|content| !content.is_empty())
.ok_or_else(|| "反馈汇总服务未返回内容".to_owned())?;
Ok(SummaryResult {
content: content.chars().take(MAX_FEEDBACK_CHARS).collect(),
method: "llm",
})
}
}
fn extractive_summary(existing_content: &str, lesson_transcripts: &[String]) -> String {
let existing = existing_content.trim();
let mut result = existing
.chars()
.take(MAX_FEEDBACK_CHARS)
.collect::<String>();
let mut seen = HashSet::new();
let mut selected = Vec::new();
for transcript in lesson_transcripts {
for sentence in transcript.split_inclusive(['。', '', '', '\n']) {
let sentence = sentence.trim();
if sentence.chars().count() < 4 || !seen.insert(sentence.to_owned()) {
continue;
}
selected.push(sentence.to_owned());
}
}
if !selected.is_empty() && result.chars().count() < MAX_FEEDBACK_CHARS {
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str("课堂记录汇总:\n");
for sentence in selected {
let prefix = "- ";
let remaining = MAX_FEEDBACK_CHARS.saturating_sub(result.chars().count());
if remaining <= prefix.chars().count() + 4 {
break;
}
result.push_str(prefix);
result.extend(
sentence
.chars()
.take(remaining - prefix.chars().count() - 1),
);
result.push('\n');
}
}
result.trim().to_owned()
}
#[cfg(test)]
mod tests {
use super::extractive_summary;
#[test]
fn extractive_summary_preserves_manual_content_and_deduplicates() {
let result = extractive_summary(
"手动备注。",
&["完成了构图练习。完成了构图练习。色彩更大胆。".to_owned()],
);
assert!(result.starts_with("手动备注。"));
assert_eq!(result.matches("完成了构图练习。").count(), 1);
assert!(result.contains("色彩更大胆。"));
}
}

202
src/transcription_worker.rs Normal file
View File

@@ -0,0 +1,202 @@
use std::time::Duration;
use sqlx::{FromRow, PgPool};
use uuid::Uuid;
use crate::speech::{SpeechService, Transcription};
const IDLE_POLL_INTERVAL: Duration = Duration::from_millis(750);
const UNAVAILABLE_POLL_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Debug, FromRow)]
struct TranscriptionJob {
id: Uuid,
lesson_session_id: Uuid,
duration_ms: i32,
audio_format: String,
audio_path: String,
}
pub fn spawn(pool: PgPool, speech: SpeechService) {
for worker_id in 1..=speech.worker_concurrency() {
let pool = pool.clone();
let speech = speech.clone();
tokio::spawn(async move {
run(worker_id, pool, speech).await;
});
}
}
async fn run(worker_id: usize, pool: PgPool, speech: SpeechService) {
let mut service_available = true;
loop {
match has_claimable_job(&pool, speech.job_lease_seconds()).await {
Ok(false) => {
tokio::time::sleep(IDLE_POLL_INTERVAL).await;
continue;
}
Ok(true) => {}
Err(error) => {
tracing::error!(worker_id, error = %error, "failed to inspect transcription queue");
tokio::time::sleep(UNAVAILABLE_POLL_INTERVAL).await;
continue;
}
}
let available = speech.available().await;
if !available {
if service_available {
tracing::warn!(worker_id, provider = ?speech.provider_name(), "ASR service is unavailable; queued audio will wait");
}
service_available = false;
tokio::time::sleep(UNAVAILABLE_POLL_INTERVAL).await;
continue;
}
if !service_available {
tracing::info!(worker_id, provider = ?speech.provider_name(), "ASR service is available again");
}
service_available = true;
match claim_next_job(&pool, speech.job_lease_seconds()).await {
Ok(Some(job)) => {
tracing::info!(worker_id, segment_id = %job.id, "transcribing audio segment");
if let Err(error) = process_job(&pool, &speech, &job).await {
tracing::error!(worker_id, segment_id = %job.id, error = %error, "transcription job update failed");
tokio::time::sleep(IDLE_POLL_INTERVAL).await;
}
}
Ok(None) => tokio::time::sleep(IDLE_POLL_INTERVAL).await,
Err(error) => {
tracing::error!(worker_id, error = %error, "failed to claim transcription job");
tokio::time::sleep(UNAVAILABLE_POLL_INTERVAL).await;
}
}
}
}
async fn has_claimable_job(pool: &PgPool, lease_seconds: i64) -> Result<bool, sqlx::Error> {
sqlx::query_scalar(
"SELECT EXISTS(\
SELECT 1 FROM audio_segments \
WHERE status = 'processing' \
AND (processing_started_at IS NULL \
OR processing_started_at < now() - ($1 * interval '1 second'))\
)",
)
.bind(lease_seconds)
.fetch_one(pool)
.await
}
async fn claim_next_job(
pool: &PgPool,
lease_seconds: i64,
) -> Result<Option<TranscriptionJob>, sqlx::Error> {
sqlx::query_as::<_, TranscriptionJob>(
"WITH next_job AS (\
SELECT id FROM audio_segments \
WHERE status = 'processing' \
AND (processing_started_at IS NULL \
OR processing_started_at < now() - ($1 * interval '1 second')) \
ORDER BY created_at, id \
FOR UPDATE SKIP LOCKED LIMIT 1\
) \
UPDATE audio_segments AS segment \
SET processing_started_at = now(), \
transcription_attempts = transcription_attempts + 1, \
updated_at = now() \
FROM next_job \
WHERE segment.id = next_job.id \
RETURNING segment.id, segment.lesson_session_id, segment.duration_ms, \
segment.audio_format, segment.audio_path",
)
.bind(lease_seconds)
.fetch_optional(pool)
.await
}
async fn process_job(
pool: &PgPool,
speech: &SpeechService,
job: &TranscriptionJob,
) -> Result<(), sqlx::Error> {
let result = match tokio::fs::read(&job.audio_path).await {
Ok(audio) => {
speech
.transcribe(audio, &job.audio_format, job.duration_ms)
.await
}
Err(error) => Err(format!("读取录音文件失败:{error}")),
};
match result {
Ok(transcription) => complete_job(pool, job, transcription).await?,
Err(message) => fail_job(pool, job, &message).await?,
}
refresh_lesson(pool, job.lesson_session_id).await?;
Ok(())
}
async fn complete_job(
pool: &PgPool,
job: &TranscriptionJob,
transcription: Transcription,
) -> Result<(), sqlx::Error> {
let duration_ms = if transcription.duration_ms > 0 {
transcription.duration_ms
} else {
job.duration_ms
};
sqlx::query(
"UPDATE audio_segments \
SET status = 'ready', duration_ms = $2, transcript = $3, error_message = NULL, \
provider_request_id = $4, processing_started_at = NULL, updated_at = now() \
WHERE id = $1",
)
.bind(job.id)
.bind(duration_ms)
.bind(&transcription.text)
.bind(&transcription.request_id)
.execute(pool)
.await?;
tracing::info!(segment_id = %job.id, "audio segment transcription is ready");
Ok(())
}
async fn fail_job(pool: &PgPool, job: &TranscriptionJob, message: &str) -> Result<(), sqlx::Error> {
tracing::warn!(segment_id = %job.id, error = %message, "audio transcription failed");
sqlx::query(
"UPDATE audio_segments \
SET status = 'failed', error_message = $2, processing_started_at = NULL, updated_at = now() \
WHERE id = $1",
)
.bind(job.id)
.bind(message)
.execute(pool)
.await?;
Ok(())
}
async fn refresh_lesson(pool: &PgPool, lesson_id: Uuid) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE lesson_sessions AS lesson \
SET status = CASE \
WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'failed') THEN 'failed' \
WHEN EXISTS (SELECT 1 FROM audio_segments WHERE lesson_session_id = $1 AND status = 'processing') THEN 'processing' \
ELSE 'ready' \
END, \
duration_ms = COALESCE((SELECT SUM(duration_ms) FROM audio_segments WHERE lesson_session_id = $1), 0), \
updated_at = now() \
WHERE lesson.id = $1 AND lesson.ended_at IS NOT NULL",
)
.bind(lesson_id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE feedback_sessions SET updated_at = now() \
WHERE id = (SELECT feedback_session_id FROM lesson_sessions WHERE id = $1)",
)
.bind(lesson_id)
.execute(pool)
.await?;
Ok(())
}