app #2 piano-sheet: 钢琴谱管理 / 阅读,piano.famzheng.me
deploy cube / build-and-deploy (push) Successful in 1m8s
deploy piano-sheet / build-and-deploy (push) Failing after 1m38s
deploy simpleasm / build-and-deploy (push) Successful in 1m22s

后端 axum + sqlite,图片直接 BLOB 存进 pages 表(单张 ≤ 10MB / 单
谱 ≤ 64 页),5 个 endpoint:multipart upload、列表、详情、单页图片
(带 immutable cache header)+ healthz。

前端 vue3 + pinia + vue-router,3 个视图:列表(卡片网格 + 首页缩
略)、上传(拖拽 + 顺序预览)、阅读(全屏,左右点按 / 键盘 / 拖
拽进度条翻页,2.5s 自动隐藏 chrome)。视图状态走 URL(reader 的
当前页是 ?page=N)。

部署:cube-piano-sheet ns + 10Gi PVC + traefik ingress + 一条
buffering middleware 把 body 上限抬到 700MB。镜像 < 20MB(scratch
+ musl 静态)。
This commit was merged in pull request #1.
This commit is contained in:
Fam Zheng
2026-05-05 09:29:02 +01:00
parent e81f44662a
commit 28713e489f
23 changed files with 2616 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
//! piano.famzheng.me — 钢琴谱管理 / 阅读。
//!
//! 5 个 endpoint
//! - `GET /api/health` 前端 ping。
//! - `POST /api/upload` multiparttitle + 多张 image 文件,BLOB 存 sqlite。
//! - `GET /api/sheets` 列表,按 created_at desc。
//! - `GET /api/sheets/:id` 详情:title + 图片 id 列表(按 page asc)。
//! - `GET /api/sheets/:id/pages/:page`
//! 单页图片 BLOB,直接 image/* 响应(用于 <img src>)。
//!
//! 图片 BLOB 直存 sqlite。单张限 10MB,单曲谱最多 64 页。
//! 静态前端 + SPA fallback 由 cube-core::base 处理。
use std::sync::{Arc, Mutex};
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Json as JsonResp, Response},
routing::{get, post},
Router,
};
use rusqlite::{params, Connection, OptionalExtension};
use serde::Serialize;
use serde_json::json;
type Db = Arc<Mutex<Connection>>;
const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
const MAX_PAGES: usize = 64;
const MAX_REQUEST_BYTES: usize = MAX_IMAGE_BYTES * MAX_PAGES;
#[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("PIANO_SHEET_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 sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL,
page INTEGER NOT NULL,
mime TEXT NOT NULL,
bytes BLOB NOT NULL,
FOREIGN KEY (sheet_id) REFERENCES sheets(id) ON DELETE CASCADE,
UNIQUE (sheet_id, page)
);",
)
.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("/upload", post(upload).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)))
.route("/sheets", get(list_sheets))
.route("/sheets/:id", get(get_sheet))
.route("/sheets/:id/pages/:page", get(get_page))
.with_state(db);
let app = cube_core::base(dist).nest("/api", api);
cube_core::serve(app, 8080).await
}
#[derive(Serialize)]
struct SheetSummary {
id: i64,
title: String,
pages: i64,
created_at: String,
}
/// `POST /api/upload` — multipart`title` 字段 + 一个或多个 `images` 文件字段。
/// 文件按到达顺序编号 page (1-based)。
async fn upload(
State(db): State<Db>,
mut form: Multipart,
) -> Result<impl IntoResponse, AppError> {
let mut title: Option<String> = None;
let mut images: Vec<(String, Vec<u8>)> = Vec::new();
while let Some(field) = form
.next_field()
.await
.map_err(|e| AppError::bad_request(format!("multipart error: {e}")))?
{
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"title" => {
let s = field
.text()
.await
.map_err(|e| AppError::bad_request(format!("title read: {e}")))?;
title = Some(s.trim().to_string());
}
"images" => {
let mime = field
.content_type()
.map(|s| s.to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !mime.starts_with("image/") {
return Err(AppError::bad_request(format!(
"field 'images' must be image/*, got {mime}"
)));
}
let bytes = field
.bytes()
.await
.map_err(|e| AppError::bad_request(format!("image read: {e}")))?;
if bytes.len() > MAX_IMAGE_BYTES {
return Err(AppError::bad_request(format!(
"image too large ({} bytes, limit {MAX_IMAGE_BYTES})",
bytes.len()
)));
}
if images.len() >= MAX_PAGES {
return Err(AppError::bad_request(format!(
"too many pages (limit {MAX_PAGES})"
)));
}
images.push((mime, bytes.to_vec()));
}
_ => {}
}
}
let title = title
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::bad_request("missing 'title'"))?;
if images.is_empty() {
return Err(AppError::bad_request("at least one image required"));
}
let mut conn = db.lock().unwrap();
let tx = conn.transaction()?;
tx.execute("INSERT INTO sheets (title) VALUES (?1)", params![title])?;
let sheet_id = tx.last_insert_rowid();
for (i, (mime, bytes)) in images.iter().enumerate() {
let page = (i as i64) + 1;
tx.execute(
"INSERT INTO pages (sheet_id, page, mime, bytes) VALUES (?1, ?2, ?3, ?4)",
params![sheet_id, page, mime, bytes],
)?;
}
tx.commit()?;
Ok(JsonResp(json!({
"id": sheet_id,
"title": title,
"pages": images.len(),
})))
}
/// `GET /api/sheets` — 列表(不返回 BLOB)。
async fn list_sheets(State(db): State<Db>) -> Result<impl IntoResponse, AppError> {
let conn = db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT s.id, s.title, COUNT(p.id) AS pages, s.created_at
FROM sheets s
LEFT JOIN pages p ON p.sheet_id = s.id
GROUP BY s.id
ORDER BY s.created_at DESC, s.id DESC",
)?;
let rows = stmt
.query_map([], |r| {
Ok(SheetSummary {
id: r.get(0)?,
title: r.get(1)?,
pages: r.get(2)?,
created_at: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!(rows)))
}
/// `GET /api/sheets/:id` — title + page 列表(不带 BLOB;前端通过 page url 拿图)。
async fn get_sheet(
State(db): State<Db>,
Path(id): Path<i64>,
) -> Result<impl IntoResponse, AppError> {
let conn = db.lock().unwrap();
let row: Option<(String, String)> = conn
.query_row(
"SELECT title, created_at FROM sheets WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (title, created_at) = row.ok_or(AppError::NotFound)?;
let mut stmt = conn.prepare("SELECT page FROM pages WHERE sheet_id = ?1 ORDER BY page ASC")?;
let pages: Vec<i64> = stmt
.query_map(params![id], |r| r.get(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(JsonResp(json!({
"id": id,
"title": title,
"created_at": created_at,
"pages": pages,
})))
}
/// `GET /api/sheets/:id/pages/:page` — 单页图片 BLOB。
async fn get_page(
State(db): State<Db>,
Path((id, page)): Path<(i64, i64)>,
) -> Result<Response, AppError> {
let conn = db.lock().unwrap();
let row: Option<(String, Vec<u8>)> = conn
.query_row(
"SELECT mime, bytes FROM pages WHERE sheet_id = ?1 AND page = ?2",
params![id, page],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()?;
let (mime, bytes) = row.ok_or(AppError::NotFound)?;
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable".into()),
],
bytes,
)
.into_response())
}
enum AppError {
BadRequest(String),
NotFound,
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) -> Response {
match self {
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
Self::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
Self::Db(e) => {
tracing::error!(error = %e, "sqlite error");
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
}
}
}
}