feat: establish Rust teaching feedback API
Add the Axum and PostgreSQL service for profiles, defaults, and feedback records, including migrations, OpenAPI documentation, and development identity handling for version 0.1.0.
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Rust build output
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Local configuration and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Graphify generated knowledge graphs
|
||||||
|
/graphify-out/
|
||||||
88
API.md
Normal file
88
API.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# 教学反馈助手 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 临时身份边界
|
||||||
|
|
||||||
|
目前所有业务接口都要求 `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}` | 删除反馈记录。 |
|
||||||
|
|
||||||
|
### 创建学生档案
|
||||||
|
|
||||||
|
```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": "今天完成了色彩搭配练习,构图有明显进步。"
|
||||||
|
}
|
||||||
|
```
|
||||||
119
API_GUIDE.md
Normal file
119
API_GUIDE.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 教学反馈助手 API 使用指南
|
||||||
|
|
||||||
|
## 1. 启动服务
|
||||||
|
|
||||||
|
在 `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` 为 `true`。
|
||||||
|
2. 执行 `GET /api/v1/profile-defaults`,即使用户未保存过预设也会返回系统默认值。
|
||||||
|
3. 执行 `PUT /api/v1/profile-defaults`,可直接发送预填的美术课预设。
|
||||||
|
4. 执行 `POST /api/v1/profiles`,可直接发送预填档案;保存响应中的 `id`。
|
||||||
|
5. 将真实档案 `id` 填入 `{profile_id}`,测试单条查询、更新或删除。
|
||||||
|
6. 执行 `POST /api/v1/feedback-records`。默认 `profile_id` 为 `null`,无需档案即可直接创建。
|
||||||
|
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": "今天完成了色彩搭配练习,构图有明显进步。"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 筛选反馈记录
|
||||||
|
|
||||||
|
```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": "错误说明"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. OpenAPI 维护方式
|
||||||
|
|
||||||
|
`/openapi.json` 在服务运行时由 `utoipa` 根据 Rust 路由注解生成,Scalar 使用同一份规范。新增或修改接口时,需要同时更新处理函数上的 `#[utoipa::path]`、请求/响应 schema 注释和示例;规范完整性测试会检查操作数量、接口说明和默认身份参数。
|
||||||
2423
Cargo.lock
generated
Normal file
2423
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[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"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] }
|
||||||
|
thiserror = "2"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
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
migrations/0001_initial_schema.sql
Normal file
45
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);
|
||||||
17
src/bin/migrate.rs
Normal file
17
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(())
|
||||||
|
}
|
||||||
39
src/config.rs
Normal file
39
src/config.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use std::{env, net::IpAddr};
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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())?;
|
||||||
|
|
||||||
|
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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/error.rs
Normal file
93
src/error.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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("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::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
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/main.rs
Normal file
80
src/main.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{Json, Router, 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 app = build_app(AppState { pool }, 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(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");
|
||||||
|
}
|
||||||
884
src/routes.rs
Normal file
884
src/routes.rs
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
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},
|
||||||
|
};
|
||||||
|
|
||||||
|
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))
|
||||||
|
.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("反馈记录", "创建、查询和删除当前用户的教学反馈记录。"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
(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}))]
|
||||||
|
struct HealthResponse {
|
||||||
|
/// HTTP 服务状态;正常时固定为 `ok`。
|
||||||
|
status: &'static str,
|
||||||
|
/// 是否已经通过 `DATABASE_URL` 配置数据库连接。
|
||||||
|
database_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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 学生档案。
|
||||||
|
#[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": "今天完成了色彩搭配练习,构图有明显进步。"
|
||||||
|
}))]
|
||||||
|
struct FeedbackRecordInput {
|
||||||
|
/// 可选的学生档案 ID;如填写,必须属于当前用户。Scalar 默认留空以便直接测试。
|
||||||
|
profile_id: Option<Uuid>,
|
||||||
|
/// 反馈对应日期,格式为 `YYYY-MM-DD`。
|
||||||
|
feedback_date: NaiveDate,
|
||||||
|
/// 教学反馈正文,不可为空。
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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": "今天完成了色彩搭配练习,构图有明显进步。"
|
||||||
|
})
|
||||||
|
),
|
||||||
|
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() {
|
||||||
|
return Err(ApiError::BadRequest("content cannot be empty".to_owned()));
|
||||||
|
}
|
||||||
|
let owner_id = current_user(&headers)?;
|
||||||
|
if let Some(profile_id) = input.profile_id {
|
||||||
|
assert_profile_owner(pool(&state)?, owner_id, profile_id).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(pool(&state)?)
|
||||||
|
.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(), 11);
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user