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()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|