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