app #2 piano-sheet: 钢琴谱管理 / 阅读,piano.famzheng.me
后端 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:
@@ -0,0 +1,269 @@
|
||||
//! piano.famzheng.me — 钢琴谱管理 / 阅读。
|
||||
//!
|
||||
//! 5 个 endpoint:
|
||||
//! - `GET /api/health` 前端 ping。
|
||||
//! - `POST /api/upload` multipart:title + 多张 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user