init
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ target/
|
|||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
idea/
|
||||||
# RustRover
|
# RustRover
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "minecraft_computer_craft_api_backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros"] }
|
||||||
|
dotenvy = "0.15"
|
||||||
@@ -1,2 +1,45 @@
|
|||||||
# Minecraft-Computer-Craft-API-Backend
|
# Minecraft-Computer-Craft-API-Backend
|
||||||
|
|
||||||
|
Ein minimales Rust-API-Backend mit `axum` und SQLite (`sqlx`).
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Rust (stable)
|
||||||
|
- Cargo
|
||||||
|
|
||||||
|
## Starten
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Standardwerte:
|
||||||
|
|
||||||
|
- `PORT=3000`
|
||||||
|
- `DATABASE_URL=sqlite://data/app.db`
|
||||||
|
|
||||||
|
Optional per Umgebungsvariablen setzen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PORT="3000"
|
||||||
|
$env:DATABASE_URL="sqlite://data/app.db"
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
- `GET /health` -> API-Status
|
||||||
|
- `GET /db-test` -> Testet DB-Verbindung (`SELECT 1`)
|
||||||
|
|
||||||
|
Beispielaufrufe:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl http://127.0.0.1:3000/health
|
||||||
|
curl http://127.0.0.1:3000/db-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
- `src/main.rs` - Serverstart, Router, App-State
|
||||||
|
- `src/db.rs` - SQLite-Pool + Basisschema
|
||||||
|
- `src/routes.rs` - API-Handler
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1,219 @@
|
|||||||
|
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool, QueryBuilder, Sqlite};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct MeSystemLive {
|
||||||
|
pub name: String,
|
||||||
|
pub amount: i64,
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct MeSystemHistory {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub amount: i64,
|
||||||
|
pub recorded_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_db(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
if let Some(parent) = database_url
|
||||||
|
.strip_prefix("sqlite://")
|
||||||
|
.and_then(|path| std::path::Path::new(path).parent())
|
||||||
|
{
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
let _ = std::fs::create_dir_all(parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Minimales Basisschema fuer Verbindungs- und Schreibtest.
|
||||||
|
sqlx::query(
|
||||||
|
"-- Aktuelle Bestände
|
||||||
|
-- Wir führen eine ID ein, um in der History Platz zu sparen.
|
||||||
|
CREATE TABLE IF NOT EXISTS me_system_live (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL, -- 'minecraft:iron_ingot'
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
display_name TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Historische Aufzeichnung
|
||||||
|
-- Hier speichern wir nur noch die item_id statt den vollen Namen.
|
||||||
|
-- recorded_at als Unix Timestamp (INTEGER)
|
||||||
|
CREATE TABLE IF NOT EXISTS me_system_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
recorded_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
FOREIGN KEY (item_id) REFERENCES me_system_live(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index für schnelle Zeitabfragen und Joins
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_item_time ON me_system_history (item_id, recorded_at);
|
||||||
|
-- Index auf Name in Live Tabelle ist durch UNIQUE constraint implizit vorhanden, aber für FK lookups gut zu wissen.
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_me_system_live(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
items: &[MeSystemLive],
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Alles auf 0 setzen (Reset)
|
||||||
|
sqlx::query("UPDATE me_system_live SET amount = 0 WHERE amount > 0")
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Neue Daten einfügen/aktualisieren (Upsert)
|
||||||
|
// Wir nutzen hier name als unique key
|
||||||
|
if !items.is_empty() {
|
||||||
|
for chunk in items.chunks(4000) {
|
||||||
|
let mut query_builder: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||||
|
"INSERT INTO me_system_live (name, amount, display_name) "
|
||||||
|
);
|
||||||
|
|
||||||
|
query_builder.push_values(chunk, |mut b, item| {
|
||||||
|
b.push_bind(&item.name)
|
||||||
|
.push_bind(item.amount)
|
||||||
|
.push_bind(&item.display_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
query_builder.push(
|
||||||
|
" ON CONFLICT(name) DO UPDATE SET
|
||||||
|
amount = excluded.amount,
|
||||||
|
display_name = excluded.display_name"
|
||||||
|
);
|
||||||
|
|
||||||
|
query_builder.build().execute(&mut *transaction).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_me_system_history(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
items: &[MeSystemLive],
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
if items.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut transaction = pool.begin().await?;
|
||||||
|
|
||||||
|
// 1. Temporäre Tabelle für den Batch-Input erstellen
|
||||||
|
sqlx::query("CREATE TEMPORARY TABLE IF NOT EXISTS temp_history_input (name TEXT, amount INTEGER)")
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Tabelle leeren
|
||||||
|
sqlx::query("DELETE FROM temp_history_input")
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Input Daten in Temp Tabelle schreiben
|
||||||
|
for chunk in items.chunks(4000) {
|
||||||
|
let mut query_builder: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||||
|
"INSERT INTO temp_history_input (name, amount) "
|
||||||
|
);
|
||||||
|
query_builder.push_values(chunk, |mut b, item| {
|
||||||
|
b.push_bind(&item.name)
|
||||||
|
.push_bind(item.amount);
|
||||||
|
});
|
||||||
|
query_builder.build().execute(&mut *transaction).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Insert in History Tabelle durch Join mit Live Tabelle, um die IDs zu bekommen
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO me_system_history (item_id, amount)
|
||||||
|
SELECT l.id, t.amount
|
||||||
|
FROM temp_history_input t
|
||||||
|
JOIN me_system_live l ON t.name = l.name"
|
||||||
|
)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Cleanup
|
||||||
|
sqlx::query("DROP TABLE temp_history_input")
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_me_system_live(pool: &SqlitePool) -> Result<Vec<MeSystemLive>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, MeSystemLive>(
|
||||||
|
r#"
|
||||||
|
SELECT name, amount, display_name
|
||||||
|
FROM me_system_live
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me_system_live(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<MeSystemLive>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, MeSystemLive>(
|
||||||
|
r#"
|
||||||
|
SELECT name, amount, display_name
|
||||||
|
FROM me_system_live
|
||||||
|
WHERE name = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me_system_history(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Vec<MeSystemHistory>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, MeSystemHistory>(
|
||||||
|
r#"
|
||||||
|
SELECT h.id, l.name, h.amount, h.recorded_at
|
||||||
|
FROM me_system_history h
|
||||||
|
JOIN me_system_live l ON h.item_id = l.id
|
||||||
|
WHERE l.name = ?
|
||||||
|
ORDER BY h.recorded_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_me_system_hourely_change(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
names: &str,
|
||||||
|
) -> Result<Vec<MeSystemHistory>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, MeSystemHistory>(
|
||||||
|
r#"
|
||||||
|
SELECT h.id, l.name, h.amount, h.recorded_at
|
||||||
|
FROM me_system_history h
|
||||||
|
JOIN me_system_live l ON h.item_id = l.id
|
||||||
|
WHERE l.name IN (??)
|
||||||
|
ORDER BY h.recorded_at DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(names)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
mod db;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
|
use axum::{routing::{get, post}, Router};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicI64;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db_pool: SqlitePool,
|
||||||
|
pub last_history_update: Arc<AtomicI64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite://data/app.db".to_string());
|
||||||
|
let port = std::env::var("PORT")
|
||||||
|
.unwrap_or_else(|_| "3000".to_string())
|
||||||
|
.parse::<u16>()?;
|
||||||
|
|
||||||
|
let db_pool = db::init_db(&database_url).await?;
|
||||||
|
let state = AppState {
|
||||||
|
db_pool,
|
||||||
|
last_history_update: Arc::new(AtomicI64::new(0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(routes::health))
|
||||||
|
.route("/db-test", get(routes::db_test))
|
||||||
|
.route("/updateMeSystem", post(routes::update_me_system))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
|
println!("Server laeuft auf http://{}", addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::db::{self, MeSystemLive};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
status: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DbTestResponse {
|
||||||
|
status: &'static str,
|
||||||
|
db_ok: bool,
|
||||||
|
result: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health() -> Json<HealthResponse> {
|
||||||
|
Json(HealthResponse { status: "ok" })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn db_test(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<DbTestResponse>, (axum::http::StatusCode, String)> {
|
||||||
|
let value: i64 = sqlx::query_scalar("SELECT 1")
|
||||||
|
.fetch_one(&state.db_pool)
|
||||||
|
.await
|
||||||
|
.map_err(internal_error)?;
|
||||||
|
|
||||||
|
Ok(Json(DbTestResponse {
|
||||||
|
status: "ok",
|
||||||
|
db_ok: value == 1,
|
||||||
|
result: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_me_system(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(payload): Json<Vec<MeSystemLive>>
|
||||||
|
) -> Json<Value> {
|
||||||
|
println!("Received update request with {} items", payload.len());
|
||||||
|
|
||||||
|
let count = payload.len();
|
||||||
|
|
||||||
|
// 1. Immer Live-Daten aktualisieren
|
||||||
|
if let Err(e) = db::insert_me_system_live(&state.db_pool, &payload).await {
|
||||||
|
eprintln!("Error updating live data: {}", e);
|
||||||
|
return Json(serde_json::json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": format!("DB Error (Live): {}", e),
|
||||||
|
"success": 0,
|
||||||
|
"errors": count
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Historie nur alle X Sekunden aktualisieren (z.B. 300 Sekunden)
|
||||||
|
let history_interval_seconds = 300;
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
let last_update = state.last_history_update.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Wenn genug Zeit vergangen ist seit dem letzten Update
|
||||||
|
if now - last_update >= history_interval_seconds {
|
||||||
|
// Versuchen, den Zeitstempel zu aktualisieren (verhindert doppelte Ausführung bei gleichzeitigen Requests)
|
||||||
|
if state.last_history_update.compare_exchange(
|
||||||
|
last_update,
|
||||||
|
now,
|
||||||
|
Ordering::Relaxed,
|
||||||
|
Ordering::Relaxed
|
||||||
|
).is_ok() {
|
||||||
|
println!("Writing history snapshot...");
|
||||||
|
// Asynchron in den Hintergrund verschieben? Nein, wir warten hier, damit wir Fehler mitbekommen.
|
||||||
|
// Performance: Das kann dauern, aber der Client wartet eh.
|
||||||
|
if let Err(e) = db::insert_me_system_history(&state.db_pool, &payload).await {
|
||||||
|
eprintln!("Error writing history: {}", e);
|
||||||
|
// Wir returnen trotzdem OK, weil Live-Daten wichtig sind.
|
||||||
|
} else {
|
||||||
|
println!("History snapshot saved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"message": format!("Processed {} items", count),
|
||||||
|
"success": count,
|
||||||
|
"errors": 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_error(err: sqlx::Error) -> (axum::http::StatusCode, String) {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("DB error: {err}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user