app #1 simpleasm: 从 oci 迁过来,asm.famzheng.me 已上线
- 后端 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 拍板不带历史进度)
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
//! 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user