chore: reorganize project into monorepo

This commit is contained in:
2026-03-28 10:40:22 +08:00
parent 60367a5f51
commit 1455d93246
201 changed files with 30081 additions and 93 deletions

368
backend/src/app.rs Normal file
View File

@@ -0,0 +1,368 @@
use async_trait::async_trait;
use axum::{http::Method, Router as AxumRouter};
use loco_rs::{
app::{AppContext, Hooks, Initializer},
bgworker::{BackgroundWorker, Queue},
boot::{create_app, BootResult, StartMode},
config::Config,
controller::AppRoutes,
db::{self, truncate_table},
environment::Environment,
task::Tasks,
Result,
};
use migration::Migrator;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
};
use std::path::Path;
use tower_http::cors::{Any, CorsLayer};
#[allow(unused_imports)]
use crate::{
controllers, initializers,
models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users},
tasks,
workers::downloader::DownloadWorker,
};
pub struct App;
#[async_trait]
impl Hooks for App {
fn app_name() -> &'static str {
env!("CARGO_CRATE_NAME")
}
fn app_version() -> String {
format!(
"{} ({})",
env!("CARGO_PKG_VERSION"),
option_env!("BUILD_SHA")
.or(option_env!("GITHUB_SHA"))
.unwrap_or("dev")
)
}
async fn boot(
mode: StartMode,
environment: &Environment,
config: Config,
) -> Result<BootResult> {
create_app::<Self, Migrator>(mode, environment, config).await
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![
Box::new(initializers::content_sync::ContentSyncInitializer),
Box::new(initializers::view_engine::ViewEngineInitializer),
])
}
fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::with_default_routes() // controller routes below
.add_route(controllers::admin::routes())
.add_route(controllers::review::routes())
.add_route(controllers::category::routes())
.add_route(controllers::friend_link::routes())
.add_route(controllers::tag::routes())
.add_route(controllers::comment::routes())
.add_route(controllers::post::routes())
.add_route(controllers::search::routes())
.add_route(controllers::site_settings::routes())
.add_route(controllers::auth::routes())
}
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE])
.allow_headers(Any);
Ok(router.layer(cors))
}
async fn connect_workers(ctx: &AppContext, queue: &Queue) -> Result<()> {
queue.register(DownloadWorker::build(ctx)).await?;
Ok(())
}
#[allow(unused_variables)]
fn register_tasks(tasks: &mut Tasks) {
// tasks-inject (do not remove)
}
async fn truncate(ctx: &AppContext) -> Result<()> {
truncate_table(&ctx.db, users::Entity).await?;
Ok(())
}
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
// Seed users - use loco's default seed which handles duplicates
let users_file = base.join("users.yaml");
if users_file.exists() {
if let Err(e) =
db::seed::<users::ActiveModel>(&ctx.db, &users_file.display().to_string()).await
{
tracing::warn!("Users seed skipped or failed: {}", e);
}
}
// Seed tags first (no foreign key dependencies) - use Unchanged to ignore conflicts
if let Ok(seed_data) = std::fs::read_to_string(base.join("tags.yaml")) {
let tags_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for tag in tags_data {
let id = tag["id"].as_i64().unwrap_or(0) as i32;
let name = tag["name"].as_str().unwrap_or("").to_string();
let slug = tag["slug"].as_str().unwrap_or("").to_string();
let existing = tags::Entity::find_by_id(id).one(&ctx.db).await?;
if existing.is_none() {
let new_tag = tags::ActiveModel {
id: Set(id),
name: Set(Some(name)),
slug: Set(slug),
..Default::default()
};
let _ = new_tag.insert(&ctx.db).await;
}
}
}
// Seed posts
if let Ok(seed_data) = std::fs::read_to_string(base.join("posts.yaml")) {
let posts_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for post in posts_data {
let pid = post["pid"].as_i64().unwrap_or(0) as i32;
let title = post["title"].as_str().unwrap_or("").to_string();
let slug = post["slug"].as_str().unwrap_or("").to_string();
let content = post["content"].as_str().unwrap_or("").to_string();
let excerpt = post["excerpt"].as_str().unwrap_or("").to_string();
let category = post["category"].as_str().unwrap_or("").to_string();
let pinned = post["pinned"].as_bool().unwrap_or(false);
let post_type = post["post_type"].as_str().unwrap_or("article").to_string();
let tags_vec = post["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let tags_json = if tags_vec.is_empty() {
None
} else {
Some(serde_json::json!(tags_vec))
};
let existing = posts::Entity::find_by_id(pid).one(&ctx.db).await?;
let has_existing = existing.is_some();
let mut post_model = existing
.map(|model| model.into_active_model())
.unwrap_or_else(|| posts::ActiveModel {
id: Set(pid),
..Default::default()
});
post_model.title = Set(Some(title));
post_model.slug = Set(slug);
post_model.content = Set(Some(content));
post_model.description = Set(Some(excerpt));
post_model.category = Set(Some(category));
post_model.tags = Set(tags_json);
post_model.pinned = Set(Some(pinned));
post_model.post_type = Set(Some(post_type));
if has_existing {
let _ = post_model.update(&ctx.db).await;
} else {
let _ = post_model.insert(&ctx.db).await;
}
}
}
// Seed comments
if let Ok(seed_data) = std::fs::read_to_string(base.join("comments.yaml")) {
let comments_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for comment in comments_data {
let id = comment["id"].as_i64().unwrap_or(0) as i32;
let pid = comment["pid"].as_i64().unwrap_or(0) as i32;
let author = comment["author"].as_str().unwrap_or("").to_string();
let email = comment["email"].as_str().unwrap_or("").to_string();
let content_text = comment["content"].as_str().unwrap_or("").to_string();
let approved = comment["approved"].as_bool().unwrap_or(false);
let post_slug = posts::Entity::find_by_id(pid)
.one(&ctx.db)
.await?
.map(|post| post.slug);
let existing = comments::Entity::find_by_id(id).one(&ctx.db).await?;
let has_existing = existing.is_some();
let mut comment_model = existing
.map(|model| model.into_active_model())
.unwrap_or_else(|| comments::ActiveModel {
id: Set(id),
..Default::default()
});
comment_model.author = Set(Some(author));
comment_model.email = Set(Some(email));
comment_model.content = Set(Some(content_text));
comment_model.approved = Set(Some(approved));
comment_model.post_slug = Set(post_slug);
if has_existing {
let _ = comment_model.update(&ctx.db).await;
} else {
let _ = comment_model.insert(&ctx.db).await;
}
}
}
// Seed friend links
if let Ok(seed_data) = std::fs::read_to_string(base.join("friend_links.yaml")) {
let links_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for link in links_data {
let site_name = link["site_name"].as_str().unwrap_or("").to_string();
let site_url = link["site_url"].as_str().unwrap_or("").to_string();
let avatar_url = link["avatar_url"].as_str().map(|s: &str| s.to_string());
let description = link["description"].as_str().unwrap_or("").to_string();
let category = link["category"].as_str().unwrap_or("").to_string();
let status = link["status"].as_str().unwrap_or("pending").to_string();
let existing = friend_links::Entity::find()
.filter(friend_links::Column::SiteUrl.eq(&site_url))
.one(&ctx.db)
.await?;
if existing.is_none() {
let new_link = friend_links::ActiveModel {
site_name: Set(Some(site_name)),
site_url: Set(site_url),
avatar_url: Set(avatar_url),
description: Set(Some(description)),
category: Set(Some(category)),
status: Set(Some(status)),
..Default::default()
};
let _ = new_link.insert(&ctx.db).await;
}
}
}
// Seed site settings
if let Ok(seed_data) = std::fs::read_to_string(base.join("site_settings.yaml")) {
let settings_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
if let Some(settings) = settings_data.first() {
let existing = site_settings::Entity::find()
.order_by_asc(site_settings::Column::Id)
.one(&ctx.db)
.await?;
if existing.is_none() {
let tech_stack = settings["tech_stack"]
.as_array()
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.filter(|items| !items.is_empty())
.map(|items| serde_json::json!(items));
let item = site_settings::ActiveModel {
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
site_short_name: Set(
settings["site_short_name"].as_str().map(ToString::to_string),
),
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
site_description: Set(
settings["site_description"].as_str().map(ToString::to_string),
),
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
hero_subtitle: Set(
settings["hero_subtitle"].as_str().map(ToString::to_string),
),
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
owner_title: Set(
settings["owner_title"].as_str().map(ToString::to_string),
),
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
owner_avatar_url: Set(
settings["owner_avatar_url"].as_str().and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}),
),
social_github: Set(
settings["social_github"].as_str().map(ToString::to_string),
),
social_twitter: Set(
settings["social_twitter"].as_str().map(ToString::to_string),
),
social_email: Set(
settings["social_email"].as_str().map(ToString::to_string),
),
location: Set(settings["location"].as_str().map(ToString::to_string)),
tech_stack: Set(tech_stack),
..Default::default()
};
let _ = item.insert(&ctx.db).await;
}
}
}
// Seed reviews
if let Ok(seed_data) = std::fs::read_to_string(base.join("reviews.yaml")) {
let reviews_data: Vec<serde_json::Value> =
serde_yaml::from_str(&seed_data).unwrap_or_default();
for review in reviews_data {
let title = review["title"].as_str().unwrap_or("").to_string();
let review_type = review["review_type"].as_str().unwrap_or("").to_string();
let rating = review["rating"].as_i64().unwrap_or(0) as i32;
let review_date = review["review_date"].as_str().unwrap_or("").to_string();
let status = review["status"].as_str().unwrap_or("completed").to_string();
let description = review["description"].as_str().unwrap_or("").to_string();
let cover = review["cover"].as_str().unwrap_or("📝").to_string();
let tags_vec = review["tags"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let existing = reviews::Entity::find()
.filter(reviews::Column::Title.eq(&title))
.one(&ctx.db)
.await?;
if existing.is_none() {
let new_review = reviews::ActiveModel {
title: Set(Some(title)),
review_type: Set(Some(review_type)),
rating: Set(Some(rating)),
review_date: Set(Some(review_date)),
status: Set(Some(status)),
description: Set(Some(description)),
cover: Set(Some(cover)),
tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())),
..Default::default()
};
let _ = new_review.insert(&ctx.db).await;
}
}
}
Ok(())
}
}