388b505e0b
- 后端 FastAPI 重写为 axum + rusqlite (musl static, 2.8MB) - 前端原样搬运 (Vue3 + Vite + Pinia + vue-router + vite-plugin-yaml) - k8s: cube-simpleasm ns + 1Gi PVC (k3s local-path) + Recreate strategy - CI: 复刻 deploy-cube.yml,按 apps/simpleasm/** 触发 - cube 门户里 simpleasm 状态从 pending 改成 live - 数据冷启 (Fam 拍板不带历史进度)
215 lines
6.3 KiB
Rust
215 lines
6.3 KiB
Rust
//! asm.famzheng.me — 汇编教学小游戏。
|
||
//!
|
||
//! 4 个 endpoint:
|
||
//! - `GET /api/health` 前端 ping 用
|
||
//! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map
|
||
//! - `POST /api/progress` 单关卡 upsert(stars 只能增加,code 覆盖)
|
||
//! - `GET /api/leaderboard` top 50(按 total_stars desc, levels_completed desc)
|
||
//!
|
||
//! 静态前端 + SPA fallback 由 cube-core::base 处理。
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{Arc, Mutex};
|
||
|
||
use axum::{
|
||
extract::State,
|
||
http::StatusCode,
|
||
response::{IntoResponse, Json as JsonResp},
|
||
routing::{get, post},
|
||
Json, Router,
|
||
};
|
||
use rusqlite::{params, Connection, OptionalExtension};
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::json;
|
||
|
||
type Db = Arc<Mutex<Connection>>;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> std::io::Result<()> {
|
||
cube_core::init_tracing();
|
||
|
||
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
|
||
let dist = std::env::var("SIMPLEASM_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||
|
||
let conn = Connection::open(&db_path).expect("open sqlite");
|
||
conn.execute_batch(
|
||
"PRAGMA journal_mode=WAL;
|
||
CREATE TABLE IF NOT EXISTS players (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT UNIQUE NOT NULL,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
CREATE TABLE IF NOT EXISTS progress (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
player_id INTEGER NOT NULL,
|
||
level_id INTEGER NOT NULL,
|
||
stars INTEGER NOT NULL DEFAULT 0,
|
||
code TEXT,
|
||
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (player_id) REFERENCES players(id),
|
||
UNIQUE (player_id, level_id)
|
||
);",
|
||
)
|
||
.expect("init schema");
|
||
tracing::info!(%db_path, "sqlite ready");
|
||
|
||
let db: Db = Arc::new(Mutex::new(conn));
|
||
|
||
let api = Router::new()
|
||
.route("/health", get(|| async { "ok" }))
|
||
.route("/players", post(create_or_get_player))
|
||
.route("/progress", post(save_progress))
|
||
.route("/leaderboard", get(leaderboard))
|
||
.with_state(db);
|
||
|
||
let app = cube_core::base(dist).nest("/api", api);
|
||
cube_core::serve(app, 8080).await
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct PlayerCreate {
|
||
name: String,
|
||
}
|
||
|
||
#[derive(Deserialize)]
|
||
struct ProgressSave {
|
||
player_id: i64,
|
||
level_id: i64,
|
||
stars: i64,
|
||
#[serde(default)]
|
||
code: String,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct ProgressItem {
|
||
stars: i64,
|
||
code: String,
|
||
completed: bool,
|
||
}
|
||
|
||
#[derive(Serialize)]
|
||
struct LeaderboardRow {
|
||
name: String,
|
||
total_stars: i64,
|
||
levels_completed: i64,
|
||
}
|
||
|
||
/// `POST /api/players` — 按 name upsert。返回 `{id, name, progress: {level_id: {...}}}`。
|
||
async fn create_or_get_player(
|
||
State(db): State<Db>,
|
||
Json(data): Json<PlayerCreate>,
|
||
) -> Result<impl IntoResponse, AppError> {
|
||
let name = data.name.trim().to_string();
|
||
if name.is_empty() {
|
||
return Err(AppError::bad_request("Name cannot be empty"));
|
||
}
|
||
|
||
let conn = db.lock().unwrap();
|
||
let player_id: i64 = match conn
|
||
.query_row(
|
||
"SELECT id FROM players WHERE name = ?1",
|
||
params![name],
|
||
|r| r.get(0),
|
||
)
|
||
.optional()?
|
||
{
|
||
Some(id) => id,
|
||
None => {
|
||
conn.execute("INSERT INTO players (name) VALUES (?1)", params![name])?;
|
||
conn.last_insert_rowid()
|
||
}
|
||
};
|
||
|
||
let mut stmt = conn.prepare(
|
||
"SELECT level_id, stars, code FROM progress WHERE player_id = ?1",
|
||
)?;
|
||
let mut rows = stmt.query(params![player_id])?;
|
||
let mut progress: HashMap<String, ProgressItem> = HashMap::new();
|
||
while let Some(r) = rows.next()? {
|
||
let level_id: i64 = r.get(0)?;
|
||
let stars: i64 = r.get(1)?;
|
||
let code: String = r.get::<_, Option<String>>(2)?.unwrap_or_default();
|
||
progress.insert(
|
||
level_id.to_string(),
|
||
ProgressItem { stars, code, completed: true },
|
||
);
|
||
}
|
||
|
||
Ok(JsonResp(json!({
|
||
"id": player_id,
|
||
"name": name,
|
||
"progress": progress,
|
||
})))
|
||
}
|
||
|
||
/// `POST /api/progress` — 单关 upsert。stars 取 max(old, new),code 永远覆盖。
|
||
async fn save_progress(
|
||
State(db): State<Db>,
|
||
Json(data): Json<ProgressSave>,
|
||
) -> Result<impl IntoResponse, AppError> {
|
||
let conn = db.lock().unwrap();
|
||
conn.execute(
|
||
"INSERT INTO progress (player_id, level_id, stars, code)
|
||
VALUES (?1, ?2, ?3, ?4)
|
||
ON CONFLICT (player_id, level_id)
|
||
DO UPDATE SET stars = MAX(stars, excluded.stars),
|
||
code = excluded.code,
|
||
completed_at = CURRENT_TIMESTAMP",
|
||
params![data.player_id, data.level_id, data.stars, data.code],
|
||
)?;
|
||
Ok(JsonResp(json!({ "success": true })))
|
||
}
|
||
|
||
/// `GET /api/leaderboard` — top 50 by (total_stars desc, levels_completed desc)。
|
||
async fn leaderboard(State(db): State<Db>) -> Result<impl IntoResponse, AppError> {
|
||
let conn = db.lock().unwrap();
|
||
let mut stmt = conn.prepare(
|
||
"SELECT p.name, COALESCE(SUM(pr.stars), 0) AS total_stars,
|
||
COUNT(pr.id) AS levels_completed
|
||
FROM players p
|
||
LEFT JOIN progress pr ON p.id = pr.player_id
|
||
GROUP BY p.id
|
||
ORDER BY total_stars DESC, levels_completed DESC
|
||
LIMIT 50",
|
||
)?;
|
||
let rows = stmt
|
||
.query_map([], |r| {
|
||
Ok(LeaderboardRow {
|
||
name: r.get(0)?,
|
||
total_stars: r.get(1)?,
|
||
levels_completed: r.get(2)?,
|
||
})
|
||
})?
|
||
.collect::<Result<Vec<_>, _>>()?;
|
||
Ok(JsonResp(json!(rows)))
|
||
}
|
||
|
||
enum AppError {
|
||
BadRequest(String),
|
||
Db(rusqlite::Error),
|
||
}
|
||
|
||
impl AppError {
|
||
fn bad_request(msg: impl Into<String>) -> Self {
|
||
Self::BadRequest(msg.into())
|
||
}
|
||
}
|
||
|
||
impl From<rusqlite::Error> for AppError {
|
||
fn from(e: rusqlite::Error) -> Self {
|
||
Self::Db(e)
|
||
}
|
||
}
|
||
|
||
impl IntoResponse for AppError {
|
||
fn into_response(self) -> axum::response::Response {
|
||
match self {
|
||
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||
Self::Db(e) => {
|
||
tracing::error!(error = %e, "sqlite error");
|
||
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
|
||
}
|
||
}
|
||
}
|
||
}
|