From e819742aa9d8964cf176445a53d6819980a7fe70 Mon Sep 17 00:00:00 2001 From: n4mlz Date: Mon, 31 Aug 2026 17:03:44 +0900 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E7=AE=A1=E7=90=86=E8=80=85?= =?UTF-8?q?=E5=90=91=E3=81=91=E6=A5=BD=E6=9B=B2=E4=B8=80=E8=A6=A7=20API=20?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 3 + Cargo.toml | 2 + crates/domain/src/repository/music.rs | 19 ++++ crates/infrastructure/Cargo.toml | 1 + crates/infrastructure/src/music/mod.rs | 14 ++- crates/infrastructure/src/music/read.rs | 67 +++++++++++++- crates/presentation/Cargo.toml | 3 + crates/presentation/src/error/mod.rs | 5 ++ crates/presentation/src/route/admin.rs | 111 +++++++++++++++++++++++- crates/presentation/src/route/mod.rs | 5 +- crates/usecase/src/music/mod.rs | 23 ++++- docs/openapi.yaml | 53 +++++++++++ 12 files changed, 297 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 538f1549..e4918aaa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1205,6 +1205,7 @@ dependencies = [ "sea-orm", "tokio", "tracing", + "uuid", ] [[package]] @@ -1717,6 +1718,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "chrono", "domain", "dotenvy", @@ -1732,6 +1734,7 @@ dependencies = [ "tracing", "tracing-subscriber", "usecase", + "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f7a63fd1..51ca8e18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ [workspace.dependencies] anyhow = "1.0.95" axum = "0.8.4" +base64 = "0.22.1" bigdecimal = "0.4" chrono = { version = "0.4.39", features = ["serde", "clock"] } domain = { path = "crates/domain" } @@ -46,3 +47,4 @@ tower-http = { version = "0.6.6", features = ["trace", "cors"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } usecase = { path = "crates/usecase" } +uuid = "1.18.1" diff --git a/crates/domain/src/repository/music.rs b/crates/domain/src/repository/music.rs index 1dfad324..e26687a3 100644 --- a/crates/domain/src/repository/music.rs +++ b/crates/domain/src/repository/music.rs @@ -1,5 +1,6 @@ use std::future::Future; +use chrono::{DateTime, Utc}; use mockall::automock; use thiserror::Error; @@ -17,6 +18,18 @@ pub struct MusicWithSheets { pub sheets: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MusicListCursor { + pub registration_date: DateTime, + pub id: String, +} + +#[derive(Debug)] +pub struct MusicListPage { + pub items: Vec, + pub next_cursor: Option, +} + impl MusicWithSheets { pub fn new(music: Music, sheets: Vec) -> Self { Self { music, sheets } @@ -28,4 +41,10 @@ pub trait MusicRepository: Send + Sync { fn list_with_sheets( &self, ) -> impl Future, MusicRepositoryError>> + Send; + + fn list_with_sheets_page( + &self, + cursor: Option, + limit: u64, + ) -> impl Future> + Send; } diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 079a6a1c..f8fb3df5 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -10,6 +10,7 @@ chrono.workspace = true domain.workspace = true sea-orm.workspace = true tracing.workspace = true +uuid.workspace = true [dev-dependencies] domain = { workspace = true, features = ["test-support"] } diff --git a/crates/infrastructure/src/music/mod.rs b/crates/infrastructure/src/music/mod.rs index 89474b3c..c2850222 100644 --- a/crates/infrastructure/src/music/mod.rs +++ b/crates/infrastructure/src/music/mod.rs @@ -3,7 +3,9 @@ mod read; use std::sync::Arc; -use domain::repository::music::{MusicRepository, MusicRepositoryError, MusicWithSheets}; +use domain::repository::music::{ + MusicListCursor, MusicListPage, MusicRepository, MusicRepositoryError, MusicWithSheets, +}; use sea_orm::DbConn; use tracing::{debug, info, instrument}; @@ -25,4 +27,14 @@ impl MusicRepository for MusicRepositoryImpl { info!(count = musics.len(), "Music metadata loaded"); Ok(musics) } + + #[instrument(skip(self))] + async fn list_with_sheets_page( + &self, + cursor: Option, + limit: u64, + ) -> Result { + debug!(limit, "Loading a page of music metadata via SeaORM"); + read::list_with_sheets_page(self.db.as_ref(), cursor, limit).await + } } diff --git a/crates/infrastructure/src/music/read.rs b/crates/infrastructure/src/music/read.rs index 1097206e..36c8e583 100644 --- a/crates/infrastructure/src/music/read.rs +++ b/crates/infrastructure/src/music/read.rs @@ -1,6 +1,9 @@ use anyhow::Error as AnyError; -use domain::repository::music::{MusicRepositoryError, MusicWithSheets}; -use sea_orm::{DbConn, EntityTrait, QueryOrder}; +use chrono::Utc; +use domain::repository::music::{ + MusicListCursor, MusicListPage, MusicRepositoryError, MusicWithSheets, +}; +use sea_orm::{ColumnTrait, Condition, DbConn, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use tracing::{debug, error}; use super::adapter; @@ -32,3 +35,63 @@ pub async fn list_with_sheets(db: &DbConn) -> Result, Music Ok(musics) } + +/// Loads one ordered page of music and its sheets directly from the database. +pub async fn list_with_sheets_page( + db: &DbConn, + cursor: Option, + limit: u64, +) -> Result { + debug!( + limit, + has_cursor = cursor.is_some(), + "Querying a page of musics with related sheets" + ); + + let mut query = entities::musics::Entity::find(); + if let Some(cursor) = cursor { + let cursor_id = uuid::Uuid::parse_str(&cursor.id) + .map_err(|error| MusicRepositoryError::InternalError(AnyError::from(error)))?; + query = query.filter( + Condition::any() + .add(entities::musics::Column::RegistrationDate.gt(cursor.registration_date)) + .add( + Condition::all() + .add( + entities::musics::Column::RegistrationDate.eq(cursor.registration_date), + ) + .add(entities::musics::Column::Id.gt(cursor_id)), + ), + ); + } + + let mut models = query + .order_by_asc(entities::musics::Column::RegistrationDate) + .order_by_asc(entities::musics::Column::Id) + .limit(limit + 1) + .find_with_related(entities::sheets::Entity) + .all(db) + .await + .map_err(|err| { + error!(error = %err, "Failed to fetch a page of musics"); + MusicRepositoryError::InternalError(AnyError::from(err)) + })?; + + let next_cursor = if models.len() > limit as usize { + models.pop().map(|(model, _)| MusicListCursor { + registration_date: model.registration_date.with_timezone(&Utc), + id: model.id.to_string(), + }) + } else { + None + }; + + let mut items = Vec::with_capacity(models.len()); + for (music_model, sheet_models) in models { + let music = adapter::convert_music(music_model)?; + let sheets = adapter::convert_sheets(sheet_models)?; + items.push(MusicWithSheets::new(music, sheets)); + } + + Ok(MusicListPage { items, next_cursor }) +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 9666a737..d78a76fd 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -6,6 +6,8 @@ edition = "2024" [dependencies] anyhow.workspace = true axum.workspace = true +base64.workspace = true +chrono.workspace = true domain.workspace = true dotenvy.workspace = true infrastructure.workspace = true @@ -19,6 +21,7 @@ tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true usecase.workspace = true +uuid.workspace = true [dev-dependencies] chrono.workspace = true diff --git a/crates/presentation/src/error/mod.rs b/crates/presentation/src/error/mod.rs index 5a84758b..83c09ab5 100644 --- a/crates/presentation/src/error/mod.rs +++ b/crates/presentation/src/error/mod.rs @@ -1,5 +1,6 @@ pub mod convert; +#[derive(Debug)] pub struct AppError { pub status_code: axum::http::StatusCode, pub message: String, @@ -19,6 +20,10 @@ impl AppError { "Resource not found".to_owned(), ) } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(axum::http::StatusCode::BAD_REQUEST, message.into()) + } } impl axum::response::IntoResponse for AppError { diff --git a/crates/presentation/src/route/admin.rs b/crates/presentation/src/route/admin.rs index 44770223..bb128630 100644 --- a/crates/presentation/src/route/admin.rs +++ b/crates/presentation/src/route/admin.rs @@ -1,8 +1,36 @@ -use axum::{Json, extract::State}; -use serde::Serialize; +use axum::{ + Json, + extract::{Query, State}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Utc}; +use domain::repository::music::MusicListCursor; +use serde::{Deserialize, Serialize}; use tracing::info; -use crate::error::AppError; +use crate::{error::AppError, model::sync::SyncItemResponse}; + +const DEFAULT_PAGE_LIMIT: u64 = 50; +const MAX_PAGE_LIMIT: u64 = 100; + +#[derive(Deserialize)] +pub struct MusicListQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MusicListResponse { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Deserialize, Serialize)] +struct CursorPayload { + registration_date: String, + id: String, +} #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -11,6 +39,57 @@ pub struct DbSynchronizationResponse { pub updated_ratings: u64, } +pub async fn handle_list_musics( + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let limit = query.limit.unwrap_or(DEFAULT_PAGE_LIMIT); + if !(1..=MAX_PAGE_LIMIT).contains(&limit) { + return Err(AppError::bad_request(format!( + "limit must be between 1 and {MAX_PAGE_LIMIT}" + ))); + } + + let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?; + let page = state.usecases.music.list_page(cursor, limit).await?; + let next_cursor = page.next_cursor.map(encode_cursor).transpose()?; + let items = page.items.into_iter().map(SyncItemResponse::from).collect(); + + Ok(Json(MusicListResponse { items, next_cursor })) +} + +fn encode_cursor(cursor: MusicListCursor) -> Result { + let payload = CursorPayload { + registration_date: cursor.registration_date.to_rfc3339(), + id: cursor.id, + }; + let bytes = serde_json::to_vec(&payload).map_err(|error| { + AppError::new( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + error.to_string(), + ) + })?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn decode_cursor(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| AppError::bad_request("cursor is invalid"))?; + let payload: CursorPayload = + serde_json::from_slice(&bytes).map_err(|_| AppError::bad_request("cursor is invalid"))?; + let registration_date = DateTime::parse_from_rfc3339(&payload.registration_date) + .map_err(|_| AppError::bad_request("cursor is invalid"))? + .with_timezone(&Utc); + if uuid::Uuid::parse_str(&payload.id).is_err() { + return Err(AppError::bad_request("cursor is invalid")); + } + Ok(MusicListCursor { + registration_date, + id: payload.id, + }) +} + pub async fn handle_db_synchronization( State(state): State, ) -> Result, AppError> { @@ -24,3 +103,29 @@ pub async fn handle_db_synchronization( updated_ratings: result.updated_ratings, })) } + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::*; + + #[test] + fn cursor_round_trip_preserves_ordering_key() { + let cursor = MusicListCursor { + registration_date: Utc.with_ymd_and_hms(2025, 10, 1, 12, 0, 0).unwrap(), + id: "00000000-0000-0000-0000-000000000001".to_owned(), + }; + + let encoded = encode_cursor(cursor.clone()).unwrap(); + assert_eq!(decode_cursor(&encoded).unwrap(), cursor); + } + + #[test] + fn invalid_cursor_is_rejected() { + assert_eq!( + decode_cursor("not-a-cursor").unwrap_err().status_code, + axum::http::StatusCode::BAD_REQUEST + ); + } +} diff --git a/crates/presentation/src/route/mod.rs b/crates/presentation/src/route/mod.rs index 0bb738f9..7e14a003 100644 --- a/crates/presentation/src/route/mod.rs +++ b/crates/presentation/src/route/mod.rs @@ -43,8 +43,9 @@ pub fn create_app(state: State, authenticator: Option) -> Router .route("/rating", get(ranking::handle_get_rating_ranking)) .route("/xp", get(ranking::handle_get_xp_ranking)); let health = Router::new().route("/", get(|| async { "OK" })); - let admin_routes = - Router::new().route("/db/synchronize", post(admin::handle_db_synchronization)); + let admin_routes = Router::new() + .route("/musics", get(admin::handle_list_musics)) + .route("/db/synchronize", post(admin::handle_db_synchronization)); let private_routes = Router::new() .nest("/users", users) diff --git a/crates/usecase/src/music/mod.rs b/crates/usecase/src/music/mod.rs index 25ec567b..3d68e11d 100644 --- a/crates/usecase/src/music/mod.rs +++ b/crates/usecase/src/music/mod.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::repository::{ Repositories, - music::{MusicRepository, MusicRepositoryError, MusicWithSheets}, + music::{MusicListCursor, MusicRepository, MusicRepositoryError, MusicWithSheets}, }; use thiserror::Error; @@ -27,6 +27,27 @@ impl MusicUsecase { let musics = self.repositories.music().list_with_sheets().await?; Ok(musics.into_iter().map(MusicWithSheetsDto::from).collect()) } + + pub async fn list_page( + &self, + cursor: Option, + limit: u64, + ) -> Result { + let page = self + .repositories + .music() + .list_with_sheets_page(cursor, limit) + .await?; + Ok(MusicPageDto { + items: page.items.into_iter().map(Into::into).collect(), + next_cursor: page.next_cursor, + }) + } +} + +pub struct MusicPageDto { + pub items: Vec, + pub next_cursor: Option, } impl Clone for MusicUsecase { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4dac12ab..09d67dc9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -143,6 +143,46 @@ paths: description: Forbidden - The authenticated user is not an administrator "500": description: Internal server error + /admin/musics: + get: + tags: + - admin + summary: 楽曲と譜面の一覧を取得 + description: 楽曲を登録日時とIDの昇順で取得する。cursorを指定すると続きから取得する。 + security: + - userAuth: [] + parameters: + - name: cursor + in: query + description: 前ページのレスポンスに含まれる次のページ用カーソル + required: false + schema: + type: string + - name: limit + in: query + description: 取得件数 (1〜100、既定値50) + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 100 + default: 50 + responses: + "200": + description: 楽曲と譜面のページ + content: + application/json: + schema: + $ref: "#/components/schemas/adminMusicListResponse" + "400": + description: Bad request - Invalid cursor or limit + "401": + description: Unauthorized - Invalid user access token + "403": + description: Forbidden - The authenticated user is not an administrator + "500": + description: Internal server error /users/{userId}/credits/increment: post: tags: @@ -662,6 +702,19 @@ components: required: - music - sheets + adminMusicListResponse: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/musicWithSheets" + nextCursor: + type: string + nullable: true + required: + - items + - nextCursor healthCheck: type: object properties: