Files
cube/apps/simpleasm/src/main.rs
T
Fam Zheng 388b505e0b
deploy cube / build-and-deploy (push) Successful in 1m18s
deploy simpleasm / build-and-deploy (push) Successful in 1m45s
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 拍板不带历史进度)
2026-05-04 15:12:22 +01:00

215 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! asm.famzheng.me — 汇编教学小游戏。
//!
//! 4 个 endpoint
//! - `GET /api/health` 前端 ping 用
//! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map
//! - `POST /api/progress` 单关卡 upsertstars 只能增加,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()
}
}
}
}