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:
2026-07-15 15:36:51 +08:00
commit f1e5fd8793
12 changed files with 3830 additions and 0 deletions

17
src/bin/migrate.rs Normal file
View 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
View 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
View 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
View 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
View 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 = "开发阶段用户 IDScalar 已预填测试值", 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 = "开发阶段用户 IDScalar 已预填测试值", 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 = "开发阶段用户 IDScalar 已预填测试值", 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 = "开发阶段用户 IDScalar 已预填测试值", 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 = "开发阶段用户 IDScalar 已预填测试值", 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 = "开发阶段用户 IDScalar 已预填测试值", 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}"
);
}
}