Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions packages/api-server/src/domains/rankings/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,36 @@ pub struct CategoryRank {
pub points: i32,
}

/// 트렌딩 아티스트 쿼리
#[derive(Debug, Deserialize, ToSchema)]
pub struct TrendingArtistsQuery {
/// 기간 (weekly, monthly, all_time)
#[serde(default = "default_period")]
pub period: String,

/// 반환 수 (기본 20, 최대 50)
#[serde(default = "default_trending_limit")]
pub limit: u64,
}

fn default_trending_limit() -> u64 {
20
}

/// 트렌딩 아티스트 항목
#[derive(Debug, Serialize, ToSchema)]
pub struct TrendingArtistItem {
/// 아티스트명
pub artist_name: String,

/// 해당 기간 포스트 수
pub post_count: i64,

/// 대표 이미지 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
}

#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
Expand Down
29 changes: 29 additions & 0 deletions packages/api-server/src/domains/rankings/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use crate::{

use super::dto::{
CategoryRankingResponse, MyRankingDetailResponse, RankingListResponse, RankingPeriodQuery,
TrendingArtistItem, TrendingArtistsQuery,
};
use super::service::RankingsService;

Expand All @@ -28,6 +29,7 @@ pub fn router(app_config: AppConfig) -> Router<AppState> {
Router::new()
.route("/", get(list_rankings))
.route("/me", get(my_ranking_detail))
.route("/artists", get(trending_artists))
.route("/{category}", get(category_rankings))
.route_layer(axum::middleware::from_fn_with_state(
app_config.clone(),
Expand Down Expand Up @@ -97,6 +99,33 @@ async fn category_rankings(
}
}

/// 트렌딩 아티스트 조회 (공개)
#[utoipa::path(
get,
path = "/api/v1/rankings/artists",
params(
("period" = Option<String>, Query, description = "Period (weekly, monthly, all_time)"),
("limit" = Option<u64>, Query, description = "Number of artists to return (max 50)"),
),
responses(
(status = 200, description = "Trending artists", body = Vec<TrendingArtistItem>),
(status = 500, description = "Internal server error", body = ErrorResponse)
),
tag = "rankings"
)]
async fn trending_artists(
State(state): State<AppState>,
Query(query): Query<TrendingArtistsQuery>,
) -> Result<impl IntoResponse, StatusCode> {
let result =
RankingsService::get_trending_artists(&state.db, &query.period, query.limit).await;

match result {
Ok(response) => Ok(Json(response)),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}

/// 내 랭킹 상세 조회 (인증 필요)
#[utoipa::path(
get,
Expand Down
52 changes: 52 additions & 0 deletions packages/api-server/src/domains/rankings/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use sea_orm::{
use std::collections::HashMap;
use uuid::Uuid;

use sea_orm::{ConnectionTrait, DbBackend, Statement};

use crate::{
config::AppState,
entities,
Expand Down Expand Up @@ -512,6 +514,56 @@ impl RankingsService {
})
}

/// 트렌딩 아티스트 조회
pub async fn get_trending_artists(
db: &DatabaseConnection,
period: &str,
limit: u64,
) -> AppResult<Vec<super::dto::TrendingArtistItem>> {
let limit = limit.min(50);
let now = chrono::Utc::now();
let period_start = match period {
"monthly" => now - chrono::Duration::days(30),
"all_time" => chrono::DateTime::<chrono::Utc>::MIN_UTC,
_ => now - chrono::Duration::weeks(1), // weekly default
};

let sql = r#"
SELECT
artist_name,
COUNT(*)::BIGINT AS post_count,
(ARRAY_AGG(image_url ORDER BY view_count DESC))[1] AS top_image_url
FROM public.posts
WHERE status = 'active'
AND artist_name IS NOT NULL
AND image_url IS NOT NULL
AND created_at >= $1
GROUP BY artist_name
ORDER BY post_count DESC
LIMIT $2
"#;

let rows = db
.query_all(Statement::from_sql_and_values(
DbBackend::Postgres,
sql,
[period_start.into(), (limit as i64).into()],
))
.await
.map_err(AppError::DatabaseError)?;

let items = rows
.into_iter()
.map(|row| super::dto::TrendingArtistItem {
artist_name: row.try_get::<String>("", "artist_name").unwrap_or_default(),
post_count: row.try_get::<i64>("", "post_count").unwrap_or(0),
image_url: row.try_get::<Option<String>>("", "top_image_url").ok().flatten(),
})
.collect();

Ok(items)
}

/// 내 랭킹 상세 조회
pub async fn get_my_ranking_detail(
state: &AppState,
Expand Down
50 changes: 50 additions & 0 deletions packages/api-server/src/domains/users/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ pub struct UserResponse {
/// 관리자 여부
pub is_admin: bool,

/// 잉크 크레딧
pub ink_credits: i32,

/// 스타일 DNA
#[serde(skip_serializing_if = "Option::is_none")]
pub style_dna: Option<serde_json::Value>,

/// 팔로워 수
pub followers_count: i64,

Expand All @@ -62,6 +69,8 @@ impl From<UserModel> for UserResponse {
rank: user.rank,
total_points: user.total_points,
is_admin: user.is_admin,
ink_credits: user.ink_credits,
style_dna: user.style_dna,
followers_count: 0,
following_count: 0,
}
Expand Down Expand Up @@ -109,6 +118,47 @@ pub struct UserStatsResponse {
pub rank: String,
}

/// 소셜 계정 응답
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SocialAccountResponse {
/// 소셜 프로바이더 (google, kakao 등)
pub provider: String,

/// 프로바이더 사용자 ID
pub provider_user_id: String,

/// 마지막 동기화 시간
#[serde(skip_serializing_if = "Option::is_none")]
pub last_synced_at: Option<DateTime<Utc>>,
}

/// 유저 Spot 아이템 (프로필 목록용)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserSpotItem {
pub id: Uuid,
pub post_id: Uuid,
/// 포스트 이미지 URL
#[serde(skip_serializing_if = "Option::is_none")]
pub post_image_url: Option<String>,
pub position_left: String,
pub position_top: String,
pub status: String,
pub created_at: DateTime<Utc>,
}

/// 유저 Solution 아이템 (프로필 목록용)
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UserSolutionItem {
pub id: Uuid,
pub spot_id: Uuid,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumbnail_url: Option<String>,
pub is_adopted: bool,
pub is_verified: bool,
pub created_at: DateTime<Utc>,
}

/// VTON 히스토리 아이템
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TryItem {
Expand Down
78 changes: 76 additions & 2 deletions packages/api-server/src/domains/users/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ use crate::{

use super::{
dto::{
SavedItem, TryItem, UpdateUserDto, UserActivitiesQuery, UserActivityItem, UserActivityType,
UserResponse, UserStatsResponse,
SavedItem, SocialAccountResponse, TryItem, UpdateUserDto, UserActivitiesQuery,
UserActivityItem, UserActivityType, UserResponse, UserSolutionItem, UserSpotItem,
UserStatsResponse,
},
service,
};
Expand Down Expand Up @@ -199,6 +200,76 @@ pub async fn get_my_saved(
Ok(Json(result))
}

/// GET /api/v1/users/me/spots - 내 Spot 목록
#[utoipa::path(
get,
path = "/api/v1/users/me/spots",
tag = "Users",
summary = "GET /api/v1/users/me/spots - 내 Spot 목록",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page (max 50)"),
),
responses(
(status = 200, description = "Spot 목록", body = PaginatedResponse<UserSpotItem>),
(status = 401, description = "인증 필요"),
),
security(("bearer_auth" = []))
)]
pub async fn get_my_spots(
State(state): State<AppState>,
Extension(user): Extension<User>,
Query(pagination): Query<Pagination>,
) -> AppResult<Json<PaginatedResponse<UserSpotItem>>> {
let result = service::list_user_spots(&state.db, user.id, pagination).await?;
Ok(Json(result))
}

/// GET /api/v1/users/me/solutions - 내 Solution 목록
#[utoipa::path(
get,
path = "/api/v1/users/me/solutions",
tag = "Users",
summary = "GET /api/v1/users/me/solutions - 내 Solution 목록",
params(
("page" = Option<u64>, Query, description = "Page number"),
("per_page" = Option<u64>, Query, description = "Items per page (max 50)"),
),
responses(
(status = 200, description = "Solution 목록", body = PaginatedResponse<UserSolutionItem>),
(status = 401, description = "인증 필요"),
),
security(("bearer_auth" = []))
)]
pub async fn get_my_solutions(
State(state): State<AppState>,
Extension(user): Extension<User>,
Query(pagination): Query<Pagination>,
) -> AppResult<Json<PaginatedResponse<UserSolutionItem>>> {
let result = service::list_user_solutions(&state.db, user.id, pagination).await?;
Ok(Json(result))
}

/// GET /api/v1/users/me/social-accounts - 내 소셜 계정 목록
#[utoipa::path(
get,
path = "/api/v1/users/me/social-accounts",
tag = "Users",
summary = "GET /api/v1/users/me/social-accounts - 내 소셜 계정 목록",
responses(
(status = 200, description = "소셜 계정 목록", body = Vec<SocialAccountResponse>),
(status = 401, description = "인증 필요"),
),
security(("bearer_auth" = []))
)]
pub async fn get_my_social_accounts(
State(state): State<AppState>,
Extension(user): Extension<User>,
) -> AppResult<Json<Vec<SocialAccountResponse>>> {
let accounts = service::list_social_accounts(&state.db, user.id).await?;
Ok(Json(accounts))
}

/// Users 도메인 라우터
pub fn router(app_config: AppConfig) -> Router<AppState> {
let protected_routes = Router::new()
Expand All @@ -207,6 +278,9 @@ pub fn router(app_config: AppConfig) -> Router<AppState> {
.route("/me/stats", get(get_my_stats))
.route("/me/tries", get(get_my_tries))
.route("/me/saved", get(get_my_saved))
.route("/me/spots", get(get_my_spots))
.route("/me/solutions", get(get_my_solutions))
.route("/me/social-accounts", get(get_my_social_accounts))
.route_layer(from_fn_with_state(app_config, auth_middleware));

Router::new()
Expand Down
Loading
Loading