chore: merge Rust backend into monorepo
git-subtree-dir: server git-subtree-mainline:fb6a84d75fgit-subtree-split:8a88706ff9
This commit is contained in:
31
server/.env.example
Normal file
31
server/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# The API starts without this variable, but data endpoints return HTTP 503.
|
||||
# DATABASE_URL=postgres://username:password@hostname:5432/teaching_feedback?sslmode=require
|
||||
|
||||
HOST=127.0.0.1
|
||||
PORT=8080
|
||||
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
|
||||
11
server/.gitignore
vendored
Normal file
11
server/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Rust build output
|
||||
/target/
|
||||
/data/
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Graphify generated knowledge graphs
|
||||
/graphify-out/
|
||||
131
server/API.md
Normal file
131
server/API.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 教学反馈助手 API
|
||||
|
||||
## 交互文档
|
||||
|
||||
服务启动后提供以下文档入口:
|
||||
|
||||
- Scalar:`GET /scalar`
|
||||
- OpenAPI JSON:`GET /openapi.json`
|
||||
- 完整调用流程:[API_GUIDE.md](./API_GUIDE.md)
|
||||
|
||||
Scalar 中的业务接口已预填开发用户 ID 和请求体示例。创建、列表、预设和健康检查可以直接发起请求;按 ID 操作时,应使用创建或列表接口返回的真实 ID。
|
||||
|
||||
## 启动和数据库策略
|
||||
|
||||
本服务不会创建或启动本地 PostgreSQL。
|
||||
|
||||
- 未设置 `DATABASE_URL` 时,`cargo run` 只启动 HTTP 服务;`GET /health` 返回成功,数据接口返回 `503`。
|
||||
- 获得远程 PostgreSQL 地址后,将其写入本地未提交的 `server/.env`,执行 `cargo run --bin migrate`,再执行 `cargo run`。
|
||||
- 迁移不会使用 PostgreSQL 扩展,UUID 由 Rust 服务生成。
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# 在 .env 中填写 DATABASE_URL
|
||||
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。例如:
|
||||
|
||||
```text
|
||||
X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
|
||||
```
|
||||
|
||||
这是微信登录接入完成前的开发身份边界,用于确保每位教师只能访问自己的记录。接入微信登录后,后端会从登录凭据解析用户身份,客户端不再直接提供此请求头。
|
||||
|
||||
## 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/health` | 服务、数据库、语音提供方及模型可用状态。 |
|
||||
| `GET` | `/api/v1/profiles` | 列出学生档案;可选 `query`、`grade`、`subject` 参数。 |
|
||||
| `POST` | `/api/v1/profiles` | 创建学生档案。 |
|
||||
| `GET` | `/api/v1/profiles/{profile_id}` | 读取单个学生档案。 |
|
||||
| `PUT` | `/api/v1/profiles/{profile_id}` | 更新学生档案。 |
|
||||
| `DELETE` | `/api/v1/profiles/{profile_id}` | 删除学生档案。 |
|
||||
| `GET` | `/api/v1/profile-defaults` | 读取学生档案预设;未配置时返回美术课默认值。 |
|
||||
| `PUT` | `/api/v1/profile-defaults` | 写入年级、学科和上课时长预设。 |
|
||||
| `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` | 汇总多节转录并生成反馈正文。 |
|
||||
|
||||
### 创建学生档案
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 保存默认预设
|
||||
|
||||
```json
|
||||
{
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"lesson_duration_minutes": 60
|
||||
}
|
||||
```
|
||||
|
||||
### 创建反馈记录
|
||||
|
||||
```json
|
||||
{
|
||||
"profile_id": "5fa5af2c-cfc9-4bb8-b0b3-8715f080b167",
|
||||
"feedback_date": "2026-07-14",
|
||||
"content": "今天完成了色彩搭配练习,构图有明显进步。",
|
||||
"feedback_session_id": null
|
||||
}
|
||||
```
|
||||
128
server/API_GUIDE.md
Normal file
128
server/API_GUIDE.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 教学反馈助手 API 使用指南
|
||||
|
||||
## 1. 启动服务
|
||||
|
||||
先在项目根目录启动中文转录服务:
|
||||
|
||||
```bash
|
||||
docker compose -f compose.asr.yml up --build -d
|
||||
```
|
||||
|
||||
在 `server/.env` 中配置 `DATABASE_URL` 后执行:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cargo run --bin migrate
|
||||
cargo run
|
||||
```
|
||||
|
||||
默认地址取决于 `.env` 中的 `HOST` 和 `PORT`。使用示例配置时,可访问:
|
||||
|
||||
- Scalar 交互文档:<http://127.0.0.1:8080/scalar>
|
||||
- OpenAPI JSON:<http://127.0.0.1:8080/openapi.json>
|
||||
- 健康检查:<http://127.0.0.1:8080/health>
|
||||
|
||||
如果未配置 `DATABASE_URL`,服务仍可启动并访问健康检查和 API 文档,但业务接口返回 `503`。
|
||||
|
||||
## 2. 在 Scalar 中直接测试
|
||||
|
||||
打开 `/scalar`,选择接口后点击 **Test Request**。业务接口已经预填以下开发用户 ID:
|
||||
|
||||
```text
|
||||
X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0
|
||||
```
|
||||
|
||||
请求体接口也已提供可发送的默认 JSON。建议按以下顺序测试:
|
||||
|
||||
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` 和 `feedback_session_id` 为 `null`,无需档案或录音即可直接创建。
|
||||
7. 如需关联档案,将第 4 步得到的 `id` 写入 `profile_id`。创建后保存反馈响应中的 `id`。
|
||||
8. 将真实反馈 `id` 填入 `{record_id}`,测试删除接口。
|
||||
|
||||
OpenAPI 中的 `11111111-...` 和 `22222222-...` 仅用于展示路径参数格式,并不是数据库预置记录。调用按 ID 查询、更新或删除的接口时,必须换成创建或列表接口返回的真实 ID。
|
||||
|
||||
## 3. 常用请求示例
|
||||
|
||||
### 健康检查
|
||||
|
||||
```bash
|
||||
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' \
|
||||
-d '{
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建不关联档案的反馈
|
||||
|
||||
```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' \
|
||||
-d '{
|
||||
"profile_id": null,
|
||||
"feedback_date": "2026-07-15",
|
||||
"content": "今天完成了色彩搭配练习,构图有明显进步。",
|
||||
"feedback_session_id": null
|
||||
}'
|
||||
```
|
||||
|
||||
### 筛选反馈记录
|
||||
|
||||
```bash
|
||||
curl 'http://127.0.0.1:8080/api/v1/feedback-records?profile_id=<真实档案ID>' \
|
||||
-H 'X-User-Id: 5a4da7f0-d70c-465f-bcab-124c504aa9f0'
|
||||
```
|
||||
|
||||
## 4. 身份和数据隔离
|
||||
|
||||
当前尚未接入微信登录,所有业务接口临时使用 `X-User-Id` 标识用户。相同 UUID 可以访问自己创建的数据,不同 UUID 之间的数据互相不可见。
|
||||
|
||||
`X-User-Id` 缺失时返回 `401`,格式不是 UUID 时返回 `400`。接入微信登录后,应由后端根据登录凭据确定用户身份,客户端不再直接提交该请求头。
|
||||
|
||||
小程序开发客户端当前使用同一个测试 UUID,并默认连接 `http://127.0.0.1:8080`。可在小程序“我的”页面修改 API 地址并执行健康检查。真机或正式环境应使用已配置为微信 request 合法域名的 HTTPS 地址。
|
||||
|
||||
## 5. 响应状态码
|
||||
|
||||
| 状态码 | 含义 |
|
||||
| --- | --- |
|
||||
| `200` | 查询或更新成功 |
|
||||
| `201` | 创建成功 |
|
||||
| `204` | 删除成功,无响应体 |
|
||||
| `400` | 参数格式或业务校验失败 |
|
||||
| `401` | 缺少 `X-User-Id` |
|
||||
| `404` | 记录不存在或不属于当前用户 |
|
||||
| `500` | 数据库查询或服务内部错误 |
|
||||
| `503` | 未配置数据库连接 |
|
||||
|
||||
错误响应统一为:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "错误说明"
|
||||
}
|
||||
```
|
||||
|
||||
录音上传接口在音频入库后立即返回 `processing`。Rust 后台工作线程会调用 FunASR,并将片段更新为 `ready` 或 `failed`;小程序会自动刷新状态,短录音转写完成后仍会直接加入反馈正文。
|
||||
|
||||
## 6. OpenAPI 维护方式
|
||||
|
||||
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和默认身份参数。
|
||||
2729
server/Cargo.lock
generated
Normal file
2729
server/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/Cargo.toml
Normal file
28
server/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "teaching-feedback-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
default-run = "teaching-feedback-api"
|
||||
|
||||
[dependencies]
|
||||
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 = ["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"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
45
server/migrations/0001_initial_schema.sql
Normal file
45
server/migrations/0001_initial_schema.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE student_profiles (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id UUID NOT NULL,
|
||||
name VARCHAR(20) NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 20),
|
||||
grade VARCHAR(40) NOT NULL CHECK (char_length(btrim(grade)) > 0),
|
||||
subject VARCHAR(40) NOT NULL CHECK (char_length(btrim(subject)) > 0),
|
||||
academic_year SMALLINT NOT NULL CHECK (academic_year BETWEEN 2000 AND 2200),
|
||||
term VARCHAR(1) NOT NULL CHECK (term IN ('春', '暑', '秋', '寒')),
|
||||
guardian_title VARCHAR(24) NOT NULL CHECK (char_length(btrim(guardian_title)) BETWEEN 1 AND 24),
|
||||
start_time CHAR(5) NOT NULL,
|
||||
end_time CHAR(5) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX student_profiles_owner_updated_idx
|
||||
ON student_profiles (owner_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX student_profiles_owner_grade_subject_idx
|
||||
ON student_profiles (owner_id, grade, subject);
|
||||
|
||||
CREATE TABLE student_profile_defaults (
|
||||
owner_id UUID PRIMARY KEY,
|
||||
grade VARCHAR(40) NOT NULL CHECK (char_length(btrim(grade)) > 0),
|
||||
subject VARCHAR(40) NOT NULL CHECK (char_length(btrim(subject)) > 0),
|
||||
lesson_duration_minutes SMALLINT NOT NULL
|
||||
CHECK (lesson_duration_minutes BETWEEN 15 AND 360 AND lesson_duration_minutes % 15 = 0),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE feedback_records (
|
||||
id UUID PRIMARY KEY,
|
||||
owner_id UUID NOT NULL,
|
||||
profile_id UUID REFERENCES student_profiles(id) ON DELETE SET NULL,
|
||||
feedback_date DATE NOT NULL,
|
||||
content TEXT NOT NULL CHECK (char_length(btrim(content)) > 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX feedback_records_owner_date_idx
|
||||
ON feedback_records (owner_id, feedback_date DESC, updated_at DESC);
|
||||
|
||||
CREATE INDEX feedback_records_owner_profile_idx
|
||||
ON feedback_records (owner_id, profile_id);
|
||||
57
server/migrations/0002_voice_feedback_sessions.sql
Normal file
57
server/migrations/0002_voice_feedback_sessions.sql
Normal 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);
|
||||
8
server/migrations/0003_async_transcription_jobs.sql
Normal file
8
server/migrations/0003_async_transcription_jobs.sql
Normal 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';
|
||||
17
server/src/bin/migrate.rs
Normal file
17
server/src/bin/migrate.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use sqlx::{migrate::Migrator, postgres::PgPoolOptions};
|
||||
|
||||
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.map_err(|_| "DATABASE_URL is required before running migrations")?;
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await?;
|
||||
MIGRATOR.run(&pool).await?;
|
||||
println!("PostgreSQL migrations completed");
|
||||
Ok(())
|
||||
}
|
||||
167
server/src/config.rs
Normal file
167
server/src/config.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
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 {
|
||||
pub host: IpAddr,
|
||||
pub port: u16,
|
||||
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 {
|
||||
pub fn from_env() -> Result<Self, String> {
|
||||
let host = env::var("HOST")
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_owned())
|
||||
.parse()
|
||||
.map_err(|_| "HOST must be a valid IP address".to_owned())?;
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| "8080".to_owned())
|
||||
.parse()
|
||||
.map_err(|_| "PORT must be a valid u16 value".to_owned())?;
|
||||
let database_max_connections = env::var("DATABASE_MAX_CONNECTIONS")
|
||||
.unwrap_or_else(|_| "5".to_owned())
|
||||
.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,
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
database_max_connections,
|
||||
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")),
|
||||
)
|
||||
}
|
||||
96
server/src/error.rs
Normal file
96
server/src/error.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use utoipa::{IntoResponses, ToSchema};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
#[error("authentication is required")]
|
||||
Unauthorized,
|
||||
#[error("resource not found")]
|
||||
NotFound,
|
||||
#[error("database has not been configured")]
|
||||
DatabaseUnavailable,
|
||||
#[error("{0}")]
|
||||
ServiceUnavailable(String),
|
||||
#[error("internal server error")]
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// API 错误响应。
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[schema(example = json!({"error": "resource not found"}))]
|
||||
pub struct ErrorBody {
|
||||
/// 面向调用方的错误信息。
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
/// 所有需要数据库和临时用户身份的业务接口都可能返回的公共错误。
|
||||
#[allow(dead_code)]
|
||||
#[derive(IntoResponses)]
|
||||
pub enum CommonApiErrors {
|
||||
/// 参数格式或业务校验失败。
|
||||
#[response(
|
||||
status = 400,
|
||||
description = "请求参数无效",
|
||||
example = json!({"error": "X-User-Id must be a UUID"})
|
||||
)]
|
||||
BadRequest(ErrorBody),
|
||||
/// 缺少 `X-User-Id` 请求头。
|
||||
#[response(
|
||||
status = 401,
|
||||
description = "缺少开发阶段身份请求头",
|
||||
example = json!({"error": "authentication is required"})
|
||||
)]
|
||||
Unauthorized(ErrorBody),
|
||||
/// 服务未配置数据库连接。
|
||||
#[response(
|
||||
status = 503,
|
||||
description = "未配置 DATABASE_URL",
|
||||
example = json!({"error": "database has not been configured"})
|
||||
)]
|
||||
DatabaseUnavailable(ErrorBody),
|
||||
/// 数据库查询或服务内部处理失败。
|
||||
#[response(
|
||||
status = 500,
|
||||
description = "服务内部错误",
|
||||
example = json!({"error": "internal server error"})
|
||||
)]
|
||||
Internal(ErrorBody),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match self {
|
||||
Self::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
|
||||
Self::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"authentication is required".to_owned(),
|
||||
),
|
||||
Self::NotFound => (StatusCode::NOT_FOUND, "resource not found".to_owned()),
|
||||
Self::DatabaseUnavailable => (
|
||||
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(),
|
||||
),
|
||||
};
|
||||
|
||||
(status, Json(ErrorBody { error: message })).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
tracing::error!(?error, "database query failed");
|
||||
Self::Internal
|
||||
}
|
||||
}
|
||||
100
server/src/main.rs
Normal file
100
server/src/main.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
mod config;
|
||||
mod error;
|
||||
mod recording_routes;
|
||||
mod routes;
|
||||
mod speech;
|
||||
mod summary;
|
||||
mod transcription_worker;
|
||||
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
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};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
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]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
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 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?;
|
||||
|
||||
tracing::info!(%address, scalar = %format!("http://{address}/scalar"), "API server is listening");
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_app(state: AppState, cors_allowed_origin: Option<&str>) -> Result<Router, String> {
|
||||
let (api_router, openapi) = routes::router();
|
||||
let openapi_json = openapi.clone();
|
||||
let mut app = api_router
|
||||
.route(
|
||||
"/openapi.json",
|
||||
get(move || {
|
||||
let openapi = openapi_json.clone();
|
||||
async move { Json(openapi) }
|
||||
}),
|
||||
)
|
||||
.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 {
|
||||
let allowed_origin = HeaderValue::from_str(origin)
|
||||
.map_err(|_| "CORS_ALLOWED_ORIGIN must be a valid HTTP header value".to_owned())?;
|
||||
app = app.layer(CorsLayer::new().allow_origin(allowed_origin));
|
||||
}
|
||||
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
async fn connect_database(config: &Config) -> Result<Option<PgPool>, sqlx::Error> {
|
||||
let Some(database_url) = &config.database_url else {
|
||||
tracing::warn!("DATABASE_URL is not set; data endpoints will return HTTP 503");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(config.database_max_connections)
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
tracing::info!("connected to PostgreSQL");
|
||||
Ok(Some(pool))
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutdown signal received");
|
||||
}
|
||||
957
server/src/recording_routes.rs
Normal file
957
server/src/recording_routes.rs
Normal 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(())
|
||||
}
|
||||
941
server/src/routes.rs
Normal file
941
server/src/routes.rs
Normal file
@@ -0,0 +1,941 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
};
|
||||
use chrono::{DateTime, NaiveDate, NaiveTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, PgPool, Postgres, QueryBuilder, postgres::PgQueryResult};
|
||||
use utoipa::{
|
||||
ToSchema,
|
||||
openapi::{Info, OpenApi, Server, Tag},
|
||||
};
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
error::{ApiError, CommonApiErrors, ErrorBody},
|
||||
recording_routes,
|
||||
};
|
||||
|
||||
type SharedState = Arc<AppState>;
|
||||
|
||||
const DEFAULT_GRADE: &str = "艺术类";
|
||||
const DEFAULT_SUBJECT: &str = "美术";
|
||||
const DEFAULT_DURATION_MINUTES: i16 = 60;
|
||||
|
||||
pub fn router() -> (Router<SharedState>, OpenApi) {
|
||||
let (router, mut openapi) = OpenApiRouter::new()
|
||||
.routes(routes!(health))
|
||||
.routes(routes!(list_profiles, create_profile))
|
||||
.routes(routes!(get_profile, update_profile, delete_profile))
|
||||
.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"));
|
||||
info.description = Some(
|
||||
"学生档案、档案预设和教学反馈记录 API。\n\n\
|
||||
业务接口使用 `X-User-Id` 作为开发阶段身份边界;Scalar 已预填测试 UUID 和请求体示例。"
|
||||
.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("语音反馈", "录制多节课堂、转录音频并生成反馈草稿。"),
|
||||
]);
|
||||
|
||||
(router, openapi)
|
||||
}
|
||||
|
||||
fn api_tag(name: &str, description: &str) -> Tag {
|
||||
let mut tag = Tag::new(name);
|
||||
tag.description = Some(description.to_owned());
|
||||
tag
|
||||
}
|
||||
|
||||
/// 健康检查响应。
|
||||
#[derive(Serialize, ToSchema)]
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// 检查服务状态。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "健康检查",
|
||||
summary = "健康检查",
|
||||
description = "不需要身份请求头。返回 HTTP 服务状态,并标记数据库连接是否已配置。",
|
||||
responses(
|
||||
(status = 200, description = "服务正在运行", body = HealthResponse)
|
||||
)
|
||||
)]
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 学生档案。
|
||||
#[derive(Debug, Serialize, FromRow, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00",
|
||||
"created_at": "2026-07-15T10:00:00Z",
|
||||
"updated_at": "2026-07-15T10:00:00Z"
|
||||
}))]
|
||||
struct StudentProfile {
|
||||
/// 档案 ID。
|
||||
id: Uuid,
|
||||
/// 学生姓名,1 到 20 个字符。
|
||||
name: String,
|
||||
/// 年级或学生类别。
|
||||
grade: String,
|
||||
/// 授课学科。
|
||||
subject: String,
|
||||
/// 学年,范围为 2000 到 2200。
|
||||
academic_year: i16,
|
||||
/// 学期,可选值为春、暑、秋、寒。
|
||||
term: String,
|
||||
/// 家长称呼,1 到 24 个字符。
|
||||
guardian_title: String,
|
||||
/// 上课开始时间,格式为 `HH:MM`。
|
||||
start_time: String,
|
||||
/// 上课结束时间,格式为 `HH:MM`。
|
||||
end_time: String,
|
||||
/// 创建时间,UTC。
|
||||
created_at: DateTime<Utc>,
|
||||
/// 最近更新时间,UTC。
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建或完整更新学生档案的请求体。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00"
|
||||
}))]
|
||||
struct ProfileInput {
|
||||
/// 学生姓名,1 到 20 个字符。
|
||||
name: String,
|
||||
/// 年级或学生类别,不可为空。
|
||||
grade: String,
|
||||
/// 授课学科,不可为空。
|
||||
subject: String,
|
||||
/// 学年,范围为 2000 到 2200。
|
||||
academic_year: i16,
|
||||
/// 学期,可选值为春、暑、秋、寒。
|
||||
term: String,
|
||||
/// 家长称呼,1 到 24 个字符。
|
||||
guardian_title: String,
|
||||
/// 上课开始时间,格式为 `HH:MM`。
|
||||
start_time: String,
|
||||
/// 上课结束时间,格式为 `HH:MM`。
|
||||
end_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileQuery {
|
||||
query: Option<String>,
|
||||
grade: Option<String>,
|
||||
subject: Option<String>,
|
||||
}
|
||||
|
||||
/// 列出当前用户的学生档案。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/profiles",
|
||||
tag = "学生档案",
|
||||
summary = "列出学生档案",
|
||||
description = "按最近更新时间倒序返回当前用户的档案。三个查询条件均可省略,也可以组合使用。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("query" = Option<String>, Query, description = "模糊匹配姓名、年级、学科或家长称呼", example = "小谢"),
|
||||
("grade" = Option<String>, Query, description = "精确匹配年级或学生类别", example = "艺术类"),
|
||||
("subject" = Option<String>, Query, description = "精确匹配学科", example = "美术")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "学生档案列表", body = [StudentProfile]),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn list_profiles(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ProfileQuery>,
|
||||
) -> Result<Json<Vec<StudentProfile>>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
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 \
|
||||
FROM student_profiles WHERE owner_id = ",
|
||||
);
|
||||
statement.push_bind(owner_id);
|
||||
|
||||
if let Some(grade) = query.grade.filter(|value| !value.trim().is_empty()) {
|
||||
statement.push(" AND grade = ");
|
||||
statement.push_bind(grade.trim().to_owned());
|
||||
}
|
||||
if let Some(subject) = query.subject.filter(|value| !value.trim().is_empty()) {
|
||||
statement.push(" AND subject = ");
|
||||
statement.push_bind(subject.trim().to_owned());
|
||||
}
|
||||
if let Some(keyword) = query.query.filter(|value| !value.trim().is_empty()) {
|
||||
let pattern = format!("%{}%", keyword.trim());
|
||||
statement.push(" AND (name ILIKE ");
|
||||
statement.push_bind(pattern.clone());
|
||||
statement.push(" OR grade ILIKE ");
|
||||
statement.push_bind(pattern.clone());
|
||||
statement.push(" OR subject ILIKE ");
|
||||
statement.push_bind(pattern.clone());
|
||||
statement.push(" OR guardian_title ILIKE ");
|
||||
statement.push_bind(pattern);
|
||||
statement.push(")");
|
||||
}
|
||||
|
||||
statement.push(" ORDER BY updated_at DESC");
|
||||
let profiles = statement
|
||||
.build_query_as::<StudentProfile>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(Json(profiles))
|
||||
}
|
||||
|
||||
/// 读取当前用户的单个学生档案。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/profiles/{profile_id}",
|
||||
tag = "学生档案",
|
||||
summary = "读取学生档案",
|
||||
description = "只允许读取属于当前 `X-User-Id` 的档案。请先从创建或列表接口复制真实的档案 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "学生档案", body = StudentProfile),
|
||||
(status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn get_profile(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(profile_id): Path<Uuid>,
|
||||
) -> Result<Json<StudentProfile>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
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",
|
||||
)
|
||||
.bind(profile_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool(&state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(profile))
|
||||
}
|
||||
|
||||
/// 创建学生档案。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/profiles",
|
||||
tag = "学生档案",
|
||||
summary = "创建学生档案",
|
||||
description = "使用请求头中的用户 ID 创建档案。Scalar 已预填完整请求体,可以直接执行测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
),
|
||||
request_body(
|
||||
content = ProfileInput,
|
||||
description = "学生档案内容",
|
||||
content_type = "application/json",
|
||||
example = json!({
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00"
|
||||
})
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "档案创建成功", body = StudentProfile),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn create_profile(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<ProfileInput>,
|
||||
) -> Result<(axum::http::StatusCode, Json<StudentProfile>), ApiError> {
|
||||
validate_profile(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
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) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
RETURNING id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(owner_id)
|
||||
.bind(input.name.trim())
|
||||
.bind(input.grade.trim())
|
||||
.bind(input.subject.trim())
|
||||
.bind(input.academic_year)
|
||||
.bind(input.term.trim())
|
||||
.bind(input.guardian_title.trim())
|
||||
.bind(input.start_time.trim())
|
||||
.bind(input.end_time.trim())
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
Ok((axum::http::StatusCode::CREATED, Json(profile)))
|
||||
}
|
||||
|
||||
/// 完整更新学生档案。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/profiles/{profile_id}",
|
||||
tag = "学生档案",
|
||||
summary = "更新学生档案",
|
||||
description = "完整替换指定档案的可编辑字段。请先从创建或列表接口复制真实的档案 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
request_body(
|
||||
content = ProfileInput,
|
||||
description = "更新后的完整档案内容",
|
||||
content_type = "application/json",
|
||||
example = json!({
|
||||
"name": "小谢",
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"academic_year": 2026,
|
||||
"term": "暑",
|
||||
"guardian_title": "小谢妈妈",
|
||||
"start_time": "18:00",
|
||||
"end_time": "19:00"
|
||||
})
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "更新后的学生档案", body = StudentProfile),
|
||||
(status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn update_profile(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(profile_id): Path<Uuid>,
|
||||
Json(input): Json<ProfileInput>,
|
||||
) -> Result<Json<StudentProfile>, ApiError> {
|
||||
validate_profile(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
let profile = sqlx::query_as::<_, StudentProfile>(
|
||||
"UPDATE student_profiles \
|
||||
SET name = $1, grade = $2, subject = $3, academic_year = $4, term = $5, guardian_title = $6, \
|
||||
start_time = $7, end_time = $8, updated_at = now() \
|
||||
WHERE id = $9 AND owner_id = $10 \
|
||||
RETURNING id, name, grade, subject, academic_year, term, guardian_title, start_time, end_time, created_at, updated_at",
|
||||
)
|
||||
.bind(input.name.trim())
|
||||
.bind(input.grade.trim())
|
||||
.bind(input.subject.trim())
|
||||
.bind(input.academic_year)
|
||||
.bind(input.term.trim())
|
||||
.bind(input.guardian_title.trim())
|
||||
.bind(input.start_time.trim())
|
||||
.bind(input.end_time.trim())
|
||||
.bind(profile_id)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool(&state)?)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(profile))
|
||||
}
|
||||
|
||||
/// 删除学生档案。
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/profiles/{profile_id}",
|
||||
tag = "学生档案",
|
||||
summary = "删除学生档案",
|
||||
description = "删除属于当前用户的档案。关联反馈记录不会被删除,其 `profile_id` 会被置空。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与档案所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("profile_id" = Uuid, Path, description = "学生档案 ID;将示例值替换为创建接口返回的 ID", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "档案已删除"),
|
||||
(status = 404, description = "档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn delete_profile(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(profile_id): Path<Uuid>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let result = sqlx::query("DELETE FROM student_profiles WHERE id = $1 AND owner_id = $2")
|
||||
.bind(profile_id)
|
||||
.bind(owner_id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
affected_or_not_found(result)?;
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// 当前用户的新建档案预设。
|
||||
#[derive(Debug, Serialize, FromRow, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"lesson_duration_minutes": 60,
|
||||
"updated_at": "2026-07-15T10:00:00Z"
|
||||
}))]
|
||||
struct ProfileDefaults {
|
||||
/// 默认年级或学生类别。
|
||||
grade: String,
|
||||
/// 默认授课学科。
|
||||
subject: String,
|
||||
/// 默认课程时长,必须为 15 到 360 之间的 15 的倍数。
|
||||
lesson_duration_minutes: i16,
|
||||
/// 最近更新时间,UTC;未保存过预设时为本次请求时间。
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 保存档案预设的请求体。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"lesson_duration_minutes": 60
|
||||
}))]
|
||||
struct ProfileDefaultsInput {
|
||||
/// 默认年级或学生类别,不可为空。
|
||||
grade: String,
|
||||
/// 默认授课学科,不可为空。
|
||||
subject: String,
|
||||
/// 默认课程时长,必须为 15 到 360 之间的 15 的倍数。
|
||||
lesson_duration_minutes: i16,
|
||||
}
|
||||
|
||||
/// 读取当前用户的新建档案预设。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/profile-defaults",
|
||||
tag = "档案预设",
|
||||
summary = "读取档案预设",
|
||||
description = "读取当前用户的预设;尚未保存时直接返回艺术类、美术、60 分钟的系统默认值。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "当前档案预设", body = ProfileDefaults),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn get_defaults(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<ProfileDefaults>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let defaults = sqlx::query_as::<_, ProfileDefaults>(
|
||||
"SELECT grade, subject, lesson_duration_minutes, updated_at \
|
||||
FROM student_profile_defaults WHERE owner_id = $1",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.fetch_optional(pool(&state)?)
|
||||
.await?;
|
||||
|
||||
Ok(Json(defaults.unwrap_or_else(default_profile_defaults)))
|
||||
}
|
||||
|
||||
/// 保存当前用户的新建档案预设。
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/v1/profile-defaults",
|
||||
tag = "档案预设",
|
||||
summary = "保存档案预设",
|
||||
description = "新增或覆盖当前用户的档案预设。Scalar 已预填完整请求体,可以直接执行测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
),
|
||||
request_body(
|
||||
content = ProfileDefaultsInput,
|
||||
description = "要保存的档案预设",
|
||||
content_type = "application/json",
|
||||
example = json!({
|
||||
"grade": "艺术类",
|
||||
"subject": "美术",
|
||||
"lesson_duration_minutes": 60
|
||||
})
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "保存后的档案预设", body = ProfileDefaults),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn update_defaults(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<ProfileDefaultsInput>,
|
||||
) -> Result<Json<ProfileDefaults>, ApiError> {
|
||||
validate_defaults(&input)?;
|
||||
let owner_id = current_user(&headers)?;
|
||||
let defaults = sqlx::query_as::<_, ProfileDefaults>(
|
||||
"INSERT INTO student_profile_defaults (owner_id, grade, subject, lesson_duration_minutes) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (owner_id) DO UPDATE SET \
|
||||
grade = EXCLUDED.grade, \
|
||||
subject = EXCLUDED.subject, \
|
||||
lesson_duration_minutes = EXCLUDED.lesson_duration_minutes, \
|
||||
updated_at = now() \
|
||||
RETURNING grade, subject, lesson_duration_minutes, updated_at",
|
||||
)
|
||||
.bind(owner_id)
|
||||
.bind(input.grade.trim())
|
||||
.bind(input.subject.trim())
|
||||
.bind(input.lesson_duration_minutes)
|
||||
.fetch_one(pool(&state)?)
|
||||
.await?;
|
||||
Ok(Json(defaults))
|
||||
}
|
||||
|
||||
/// 教学反馈记录。
|
||||
#[derive(Debug, Serialize, FromRow, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"id": "22222222-2222-4222-8222-222222222222",
|
||||
"profile_id": null,
|
||||
"feedback_date": "2026-07-15",
|
||||
"content": "今天完成了色彩搭配练习,构图有明显进步。",
|
||||
"created_at": "2026-07-15T10:00:00Z",
|
||||
"updated_at": "2026-07-15T10:00:00Z"
|
||||
}))]
|
||||
struct FeedbackRecord {
|
||||
/// 反馈记录 ID。
|
||||
id: Uuid,
|
||||
/// 关联的学生档案 ID;可以为空。
|
||||
profile_id: Option<Uuid>,
|
||||
/// 反馈对应日期,格式为 `YYYY-MM-DD`。
|
||||
feedback_date: NaiveDate,
|
||||
/// 教学反馈正文。
|
||||
content: String,
|
||||
/// 创建时间,UTC。
|
||||
created_at: DateTime<Utc>,
|
||||
/// 最近更新时间,UTC。
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建教学反馈记录的请求体。
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[schema(example = json!({
|
||||
"profile_id": null,
|
||||
"feedback_date": "2026-07-15",
|
||||
"content": "今天完成了色彩搭配练习,构图有明显进步。",
|
||||
"feedback_session_id": null
|
||||
}))]
|
||||
struct FeedbackRecordInput {
|
||||
/// 可选的学生档案 ID;如填写,必须属于当前用户。Scalar 默认留空以便直接测试。
|
||||
profile_id: Option<Uuid>,
|
||||
/// 反馈对应日期,格式为 `YYYY-MM-DD`。
|
||||
feedback_date: NaiveDate,
|
||||
/// 教学反馈正文,不可为空。
|
||||
content: String,
|
||||
/// 可选的语音反馈批次 ID;保存后对应批次会被标记为已完成。
|
||||
feedback_session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FeedbackRecordQuery {
|
||||
profile_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// 列出当前用户的教学反馈记录。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/feedback-records",
|
||||
tag = "反馈记录",
|
||||
summary = "列出反馈记录",
|
||||
description = "按反馈日期和更新时间倒序返回当前用户的记录;可按学生档案 ID 筛选。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("profile_id" = Option<Uuid>, Query, description = "只返回关联到指定档案的记录;直接测试时可留空", example = json!("11111111-1111-4111-8111-111111111111"))
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "教学反馈记录列表", body = [FeedbackRecord]),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn list_feedback_records(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<FeedbackRecordQuery>,
|
||||
) -> Result<Json<Vec<FeedbackRecord>>, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let pool = pool(&state)?;
|
||||
let mut statement = QueryBuilder::<Postgres>::new(
|
||||
"SELECT id, profile_id, feedback_date, content, created_at, updated_at \
|
||||
FROM feedback_records WHERE owner_id = ",
|
||||
);
|
||||
statement.push_bind(owner_id);
|
||||
if let Some(profile_id) = query.profile_id {
|
||||
statement.push(" AND profile_id = ");
|
||||
statement.push_bind(profile_id);
|
||||
}
|
||||
statement.push(" ORDER BY feedback_date DESC, updated_at DESC");
|
||||
let records = statement
|
||||
.build_query_as::<FeedbackRecord>()
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(Json(records))
|
||||
}
|
||||
|
||||
/// 创建教学反馈记录。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/feedback-records",
|
||||
tag = "反馈记录",
|
||||
summary = "创建反馈记录",
|
||||
description = "创建一条教学反馈。Scalar 默认将 `profile_id` 设为 null,因此无需先创建档案即可直接测试。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;Scalar 已预填测试值", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0"))
|
||||
),
|
||||
request_body(
|
||||
content = FeedbackRecordInput,
|
||||
description = "教学反馈内容",
|
||||
content_type = "application/json",
|
||||
example = json!({
|
||||
"profile_id": null,
|
||||
"feedback_date": "2026-07-15",
|
||||
"content": "今天完成了色彩搭配练习,构图有明显进步。",
|
||||
"feedback_session_id": null
|
||||
})
|
||||
),
|
||||
responses(
|
||||
(status = 201, description = "反馈记录创建成功", body = FeedbackRecord),
|
||||
(status = 404, description = "指定的学生档案不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn create_feedback_record(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<FeedbackRecordInput>,
|
||||
) -> Result<(axum::http::StatusCode, Json<FeedbackRecord>), ApiError> {
|
||||
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, 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) \
|
||||
RETURNING id, profile_id, feedback_date, content, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(owner_id)
|
||||
.bind(input.profile_id)
|
||||
.bind(input.feedback_date)
|
||||
.bind(input.content.trim())
|
||||
.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)))
|
||||
}
|
||||
|
||||
/// 删除教学反馈记录。
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/feedback-records/{record_id}",
|
||||
tag = "反馈记录",
|
||||
summary = "删除反馈记录",
|
||||
description = "删除属于当前用户的反馈记录。请先从创建或列表接口复制真实的记录 ID。",
|
||||
params(
|
||||
("X-User-Id" = Uuid, Header, description = "开发阶段用户 ID;必须与记录所属用户一致", example = json!("5a4da7f0-d70c-465f-bcab-124c504aa9f0")),
|
||||
("record_id" = Uuid, Path, description = "反馈记录 ID;将示例值替换为创建接口返回的 ID", example = json!("22222222-2222-4222-8222-222222222222"))
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "反馈记录已删除"),
|
||||
(status = 404, description = "记录不存在或不属于当前用户", body = ErrorBody, example = json!({"error": "resource not found"})),
|
||||
CommonApiErrors
|
||||
)
|
||||
)]
|
||||
async fn delete_feedback_record(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
Path(record_id): Path<Uuid>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
let owner_id = current_user(&headers)?;
|
||||
let result = sqlx::query("DELETE FROM feedback_records WHERE id = $1 AND owner_id = $2")
|
||||
.bind(record_id)
|
||||
.bind(owner_id)
|
||||
.execute(pool(&state)?)
|
||||
.await?;
|
||||
affected_or_not_found(result)?;
|
||||
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)
|
||||
}
|
||||
|
||||
fn validate_profile(input: &ProfileInput) -> Result<(), ApiError> {
|
||||
if input.name.trim().is_empty() || input.name.trim().chars().count() > 20 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"name must contain 1 to 20 characters".to_owned(),
|
||||
));
|
||||
}
|
||||
if input.grade.trim().is_empty() || input.subject.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"grade and subject are required".to_owned(),
|
||||
));
|
||||
}
|
||||
if !(2000..=2200).contains(&input.academic_year) {
|
||||
return Err(ApiError::BadRequest(
|
||||
"academic_year must be between 2000 and 2200".to_owned(),
|
||||
));
|
||||
}
|
||||
if !matches!(input.term.as_str(), "春" | "暑" | "秋" | "寒") {
|
||||
return Err(ApiError::BadRequest(
|
||||
"term must be 春, 暑, 秋, or 寒".to_owned(),
|
||||
));
|
||||
}
|
||||
if input.guardian_title.trim().is_empty() || input.guardian_title.trim().chars().count() > 24 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"guardian_title must contain 1 to 24 characters".to_owned(),
|
||||
));
|
||||
}
|
||||
validate_time(&input.start_time, "start_time")?;
|
||||
validate_time(&input.end_time, "end_time")
|
||||
}
|
||||
|
||||
fn validate_defaults(input: &ProfileDefaultsInput) -> Result<(), ApiError> {
|
||||
if input.grade.trim().is_empty() || input.subject.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"grade and subject are required".to_owned(),
|
||||
));
|
||||
}
|
||||
if !(15..=360).contains(&input.lesson_duration_minutes)
|
||||
|| input.lesson_duration_minutes % 15 != 0
|
||||
{
|
||||
return Err(ApiError::BadRequest(
|
||||
"lesson_duration_minutes must be a multiple of 15 between 15 and 360".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_time(value: &str, field: &str) -> Result<(), ApiError> {
|
||||
NaiveTime::parse_from_str(value, "%H:%M")
|
||||
.map(|_| ())
|
||||
.map_err(|_| ApiError::BadRequest(format!("{field} must use HH:MM format")))
|
||||
}
|
||||
|
||||
fn default_profile_defaults() -> ProfileDefaults {
|
||||
ProfileDefaults {
|
||||
grade: DEFAULT_GRADE.to_owned(),
|
||||
subject: DEFAULT_SUBJECT.to_owned(),
|
||||
lesson_duration_minutes: DEFAULT_DURATION_MINUTES,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn affected_or_not_found(result: PgQueryResult) -> Result<(), ApiError> {
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::Value;
|
||||
|
||||
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();
|
||||
let document = serde_json::to_value(openapi).expect("OpenAPI should serialize");
|
||||
let paths = document["paths"]
|
||||
.as_object()
|
||||
.expect("OpenAPI should contain paths");
|
||||
|
||||
let operations = paths
|
||||
.values()
|
||||
.flat_map(|path| {
|
||||
path.as_object()
|
||||
.expect("path item should be an object")
|
||||
.values()
|
||||
})
|
||||
.filter(|operation| operation.get("responses").is_some())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(operations.len(), 20);
|
||||
for operation in &operations {
|
||||
assert_non_empty(operation, "summary");
|
||||
assert_non_empty(operation, "description");
|
||||
}
|
||||
|
||||
let request_examples = operations
|
||||
.iter()
|
||||
.filter_map(|operation| {
|
||||
operation.pointer("/requestBody/content/application~1json/example")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(request_examples.len(), 4);
|
||||
assert!(request_examples.iter().all(|example| example.is_object()));
|
||||
|
||||
let business_operations = paths
|
||||
.iter()
|
||||
.filter(|(path, _)| path.starts_with("/api/v1/"))
|
||||
.flat_map(|(_, path)| {
|
||||
path.as_object()
|
||||
.expect("path item should be an object")
|
||||
.values()
|
||||
})
|
||||
.filter(|operation| operation.get("responses").is_some());
|
||||
|
||||
for operation in business_operations {
|
||||
let user_header = 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);
|
||||
}
|
||||
|
||||
assert_eq!(document["servers"][0]["url"], "/");
|
||||
assert!(document["info"].get("contact").is_none());
|
||||
assert!(document["info"].get("license").is_none());
|
||||
assert!(document["components"]["schemas"]["ProfileInput"]["example"].is_object());
|
||||
assert!(document["components"]["schemas"]["ProfileDefaultsInput"]["example"].is_object());
|
||||
assert!(
|
||||
document["components"]["schemas"]["FeedbackRecordInput"]["example"]["profile_id"]
|
||||
.is_null()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_non_empty(operation: &Value, field: &str) {
|
||||
assert!(
|
||||
operation[field]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.trim().is_empty()),
|
||||
"operation should have a non-empty {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
350
server/src/speech.rs
Normal file
350
server/src/speech.rs
Normal 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
server/src/summary.rs
Normal file
193
server/src/summary.rs
Normal 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
server/src/transcription_worker.rs
Normal file
202
server/src/transcription_worker.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user