app #1 simpleasm: 从 oci 迁过来,asm.famzheng.me 已上线
deploy cube / build-and-deploy (push) Successful in 1m18s
deploy simpleasm / build-and-deploy (push) Successful in 1m45s

- 后端 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:
Fam Zheng
2026-05-04 15:12:22 +01:00
parent 5b2e53c040
commit 388b505e0b
40 changed files with 3985 additions and 3 deletions
+214
View File
@@ -0,0 +1,214 @@
//! 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()
}
}
}
}