This commit is contained in:
Tsuki 2025-07-28 07:26:03 +08:00
parent d88a9b920a
commit d194c0941a
45 changed files with 4847 additions and 158 deletions

4
.env Normal file
View File

@ -0,0 +1,4 @@
DATABASE_URL=postgresql://mmap:yjhcfzXWrzslzl1331@8.217.64.157:5433/mmap
JWT_SECRET="JvGpWgGWLHAhvhxN7BuOVtUWfMXm6xAqjClaTwOcAnI="
RUST_LOG=debug
PORT=3050

View File

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "code",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "used_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "is_used",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
true
]
},
"hash": "0325b8932e0a71f18e0018185b5559eb6ec748c67417efaf62520a690ca2b385"
}

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT value FROM system_config WHERE key = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "18a1c337af1a4308cac915ab8161c8e5a19b1e798d91445bdeeed8235210fcc4"
}

View File

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO system_config (key, value) \n VALUES ($1, $2)\n ON CONFLICT (key) \n DO UPDATE SET value = $2, updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "238508bcec88a825c8bd87965aed4ccc3ce313835d9ed49d111b2722e207bd62"
}

View File

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n FROM users WHERE username = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "2ab074bca985746d32cb5d932c0012019486847c80eb12d5fccda6841a22613d"
}

View File

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE code = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "code",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "used_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "is_used",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
true
]
},
"hash": "353dee803eee3a4f1e68a8e5f391879128dfc59b963bd519e8dfb2fae24c6c3b"
}

View File

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE invite_codes \n SET is_used = true, used_by = $1, used_at = NOW()\n WHERE code = $2 AND is_used = false\n RETURNING id\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "3923b5d03196cc7753417e1748eb1c85a8cd2f5f912ff98ced99506647d770a4"
}

View File

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n FROM users WHERE id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "3bdc7146e0751bcb04a3ed6831c0d094a5d4b38937542f5f1529748c8c6b3aa6"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users WHERE is_activate = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "41e05b2ce23645a612d385f0faa02dad038c2facb9cb1d566fe7572d659c0a03"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users WHERE role = 'user'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "56c6cd9cd434821ad87afdc1e0e9194d1965d2876676780275ecdb45ee9e16f5"
}

View File

@ -0,0 +1,64 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at\n FROM invite_codes \n WHERE created_by = $1\n ORDER BY created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "code",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "created_by",
"type_info": "Uuid"
},
{
"ordinal": 3,
"name": "used_by",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "is_used",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
true
]
},
"hash": "6e1bf3857d6eb4df7f14d0eca0823f39c50f1b18e61bc469d616598a20c5bb91"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE system_config \n SET value = 'true', updated_at = NOW()\n WHERE key = 'admin_initialized'\n ",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "751a432b1a28e71faa4f87c9c470d6ca5212ffcde18ddb574c9c6e705cc02b01"
}

View File

@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE users SET invite_code_id = $1 WHERE id = $2\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "84af2d99fe5036ae0f0041aa539427312e89b9faba1c8b6900164fbd0f7a703e"
}

View File

@ -0,0 +1,73 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (username, email, password_hash, role)\n VALUES ($1, $2, $3, $4)\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, is_activate, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "is_activate",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
false,
true,
true
]
},
"hash": "8a1b2c956e1ff38b2a2a6179f5f7aaddd80dfef77ed05694ba17d7dedebac9dc"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT value FROM system_config WHERE key = 'admin_initialized'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "9f0ea2fedffe65fa3211fc80bccfa7856d05ea48646879b8dd3579e45e4b6131"
}

View File

@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at FROM users LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "a27168afd0caa5f71fa1036e2508d0a7f83a74433a42d7a31c71a97c522aed79"
}

View File

@ -0,0 +1,67 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO users (username, email, password_hash, role)\n VALUES ($1, $2, $3, $4)\n RETURNING id, username, email, password_hash, role as \"role: Role\", invite_code_id, created_at, updated_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "password_hash",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "role: Role",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "invite_code_id",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "updated_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true
]
},
"hash": "c2c999a6ab3b058deced5568868eff7e70cf981804cecd228b6477885e1386ae"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users WHERE is_activate = true",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "d470eafcaff0b536c38e0d29a1a4f35867068c68ebed4b326517631a8e978da5"
}

View File

@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT create_invite_code($1, $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "create_invite_code",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Int4"
]
},
"nullable": [
null
]
},
"hash": "d4a34771ec58927f36305a1e5de9b1d6006b44eaf693f666cf63923d322c987f"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users WHERE role = 'admin'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "d718520b0f79dd1b0999cbb65bd334dca8d7209fa914355a38287af587ae8d4c"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM users",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "dc64e1d25d9ced3a49130cee99f6edc3f70a4917910cf3b76faefc24ac32159d"
}

2682
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,30 @@
[package]
name = "mapp"
version = "0.1.0"
edition = "2024"
edition = "2021"
[dependencies]
axum = "0.8.4"
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.46.1", features = ["full"] }
tower-http = { version = "0.6.0", features = ["cors"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.19"
async-graphql = { version = "7.0.17", features = ["chrono", "uuid"] }
async-graphql-axum = "7.0.17"
axum = { version = "0.8.4", features = ["ws", "macros"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls",
"postgres",
"chrono",
"uuid",
"migrate",
] }
tokio = { version = "1.0", features = ["full"] }
tower-http = { version = "0.5", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"
jsonwebtoken = "9.0"
argon2 = "0.5"
dotenvy = "0.15"
uuid = { version = "1.0", features = ["v4", "serde"] }
futures-util = "0.3"
tower = "0.4"
async-stream = "0.3"
axum-jwt-auth = "0.5.1"

27
migrations/001_init.sql Normal file
View File

@ -0,0 +1,27 @@
-- Create users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'User',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create index on username and email for faster lookups
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
-- Create function to update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Create trigger to automatically update updated_at
CREATE TRIGGER update_users_updated_at BEFORE UPDATE
ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@ -0,0 +1,59 @@
-- Create invite_codes table
CREATE TABLE invite_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) NOT NULL UNIQUE,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
used_by UUID REFERENCES users(id) ON DELETE SET NULL,
is_used BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
used_at TIMESTAMPTZ
);
-- Create indexes for better performance
CREATE INDEX idx_invite_codes_code ON invite_codes(code);
CREATE INDEX idx_invite_codes_created_by ON invite_codes(created_by);
CREATE INDEX idx_invite_codes_used_by ON invite_codes(used_by);
CREATE INDEX idx_invite_codes_is_used ON invite_codes(is_used);
CREATE INDEX idx_invite_codes_expires_at ON invite_codes(expires_at);
-- Add invite_code_id column to users table
ALTER TABLE users ADD COLUMN invite_code_id UUID REFERENCES invite_codes(id) ON DELETE SET NULL;
-- Create index on the new column
CREATE INDEX idx_users_invite_code_id ON users(invite_code_id);
-- Create function to generate random invite codes
CREATE OR REPLACE FUNCTION generate_invite_code()
RETURNS VARCHAR(20) AS $$
DECLARE
chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
result VARCHAR(20) := '';
i INTEGER;
BEGIN
FOR i IN 1..8 LOOP
result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Create function to create a new invite code
CREATE OR REPLACE FUNCTION create_invite_code(creator_id UUID, expires_in_days INTEGER DEFAULT 30)
RETURNS VARCHAR(20) AS $$
DECLARE
new_code VARCHAR(20);
code_exists BOOLEAN;
BEGIN
LOOP
new_code := generate_invite_code();
SELECT EXISTS(SELECT 1 FROM invite_codes WHERE code = new_code) INTO code_exists;
EXIT WHEN NOT code_exists;
END LOOP;
INSERT INTO invite_codes (code, created_by, expires_at)
VALUES (new_code, creator_id, NOW() + (expires_in_days || ' days')::INTERVAL);
RETURN new_code;
END;
$$ LANGUAGE plpgsql;

View File

@ -0,0 +1,20 @@
-- Create system_config table to track system-wide settings
CREATE TABLE system_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(255) NOT NULL UNIQUE,
value TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create index on key for faster lookups
CREATE INDEX idx_system_config_key ON system_config(key);
-- Create trigger to automatically update updated_at
CREATE TRIGGER update_system_config_updated_at BEFORE UPDATE
ON system_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Insert initial system configuration
INSERT INTO system_config (key, value, description)
VALUES ('admin_initialized', 'false', 'Whether the first admin user has been initialized');

View File

@ -0,0 +1,8 @@
-- Add is_activate column to users table
ALTER TABLE users ADD COLUMN is_activate BOOLEAN NOT NULL DEFAULT true;
-- Create index on is_activate for faster filtering
CREATE INDEX idx_users_is_activate ON users(is_activate);
-- Update existing users to be activated by default
UPDATE users SET is_activate = true WHERE is_activate IS NULL;

99
src/app.rs Normal file
View File

@ -0,0 +1,99 @@
use std::sync::Arc;
use async_graphql::{
http::{playground_source, GraphQLPlaygroundConfig},
Schema,
};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse, GraphQLSubscription};
use axum::{
extract::{FromRef, State},
response::{Html, IntoResponse},
routing::{get, post},
Router,
};
use jsonwebtoken::DecodingKey;
use sqlx::PgPool;
use tower_http::cors::CorsLayer;
use crate::{
auth::{AuthUser, AuthUserState, Claims as MyClaims},
config::Config,
graphql::{MutationRoot, QueryRoot, SubscriptionRoot},
services::{
invite_code_service::InviteCodeService, system_config_service::SystemConfigService,
user_service::UserService,
},
};
use axum_jwt_auth::{Claims, JwtDecoderState, LocalDecoder};
use jsonwebtoken::Validation;
pub type AppSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
#[derive(Clone, FromRef)]
pub struct AppState {
pub schema: AppSchema,
pub decoder: JwtDecoderState<MyClaims>,
}
pub fn create_router(pool: PgPool, config: Config) -> Router {
let user_service = UserService::new(pool.clone(), config.jwt_secret.clone());
let invite_code_service = InviteCodeService::new(pool.clone());
let system_config_service = SystemConfigService::new(pool.clone());
let schema = Schema::build(QueryRoot, MutationRoot, SubscriptionRoot)
.data(pool)
.data(user_service)
.data(invite_code_service)
.data(system_config_service)
.data(config.clone())
.finish();
let keys = vec![DecodingKey::from_secret(config.jwt_secret.as_bytes())];
let validation = Validation::default();
let decoder = LocalDecoder::builder()
.keys(keys)
.validation(validation)
.build()
.unwrap();
let app_state = AppState {
schema,
decoder: JwtDecoderState {
decoder: Arc::new(decoder),
},
};
Router::new()
.route("/", get(graphql_playground))
.route("/graphql", get(graphql_playground).post(graphql_handler))
// .route("/ws", get(graphql_subscription_handler))
.layer(CorsLayer::permissive())
.with_state(app_state)
}
#[axum::debug_handler]
async fn graphql_handler(
AuthUserState(user): AuthUserState,
State(state): State<AppState>,
req: GraphQLRequest,
) -> GraphQLResponse {
let mut request = req.into_inner();
if let Some(user) = user {
request = request.data(user);
}
state.schema.execute(request).await.into()
}
async fn graphql_playground() -> impl IntoResponse {
Html(playground_source(GraphQLPlaygroundConfig::new("/graphql")))
}
// async fn graphql_subscription_handler(
// State(state): State<AppState>,
// ws: axum::extract::WebSocketUpgrade,
// ) -> Response {
// ws.on_upgrade(move |socket| async move {
// GraphQLSubscription::new(socket, state.schema).serve().await
// })
// }

163
src/auth.rs Normal file
View File

@ -0,0 +1,163 @@
use std::convert::Infallible;
use async_graphql::{Context, Error, Result};
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
RequestPartsExt,
};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
models::user::{Role, User},
services::user_service::UserService,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub username: String,
pub role: Role,
pub exp: usize,
}
impl Claims {
pub fn new(user: &User, jwt_secret: &str) -> Result<String> {
let expiration = chrono::Utc::now()
.checked_add_signed(chrono::Duration::hours(24))
.unwrap()
.timestamp() as usize;
let claims = Claims {
sub: user.id.to_string(),
username: user.username.clone(),
role: user.role,
exp: expiration,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(jwt_secret.as_ref()),
)
.map_err(|e| Error::new(format!("Failed to create token: {}", e)))
}
pub fn verify(token: &str, jwt_secret: &str) -> Result<Claims> {
decode::<Claims>(
token,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&Validation::default(),
)
.map(|data| data.claims)
.map_err(|e| Error::new(format!("Invalid token: {}", e)))
}
}
#[derive(Clone)]
pub struct AuthUserState(pub Option<AuthUser>);
impl<S> FromRequestParts<S> for AuthUserState
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.and_then(|header| header.to_str().ok());
let auth_header = match auth_header {
Some(header) => header,
None => return Ok(AuthUserState(None)),
};
if !auth_header.starts_with("Bearer ") {
return Ok(AuthUserState(None));
}
let token = &auth_header[7..];
let jwt_secret = match std::env::var("JWT_SECRET") {
Ok(secret) => secret,
Err(_) => return Ok(AuthUserState(None)),
};
let claims = match Claims::verify(token, &jwt_secret) {
Ok(claims) => claims,
Err(_) => return Ok(AuthUserState(None)),
};
let id = match Uuid::parse_str(&claims.sub) {
Ok(id) => id,
Err(_) => return Ok(AuthUserState(None)),
};
Ok(AuthUserState(Some(AuthUser {
id,
username: claims.username,
role: claims.role,
})))
}
}
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: Uuid,
pub username: String,
pub role: Role,
}
pub async fn get_auth_user<'a>(ctx: &'a Context<'_>) -> Result<AuthUser> {
let claims = ctx
.data::<AuthUser>()
.map_err(|_| Error::new("Authentication required"))?;
let uid = &claims.id;
let user = ctx
.data::<UserService>()
.unwrap()
.get_user_by_id(*uid)
.await?
.map(|user| AuthUser {
id: user.id,
username: user.username,
role: user.role,
})
.ok_or(Error::new("User not found"))?;
Ok(user)
}
// #[derive(Debug, Clone)]
// pub struct AuthUser {
// pub id: Uuid,
// pub username: String,
// pub role: Role,
// }
// pub async fn get_auth_user<'a>(ctx: &'a Context<'_>) -> Result<AuthUser> {
// let claims = ctx
// .data::<Claims>()
// .map_err(|_| Error::new("Authentication required"))?;
// let uid = &claims.sub;
// let user = ctx
// .data::<UserService>()
// .unwrap()
// .get_user_by_id(Uuid::parse_str(uid).unwrap())
// .await?
// .map(|user| AuthUser {
// id: user.id,
// username: user.username,
// role: user.role,
// })
// .ok_or(Error::new("User not found"))?;
// Ok(user)
// }

23
src/config.rs Normal file
View File

@ -0,0 +1,23 @@
use std::env;
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub port: u16,
}
impl Config {
pub fn from_env() -> Result<Self, env::VarError> {
dotenvy::dotenv().ok();
Ok(Config {
database_url: env::var("DATABASE_URL")?,
jwt_secret: env::var("JWT_SECRET")?,
port: env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.unwrap_or(3000),
})
}
}

14
src/db.rs Normal file
View File

@ -0,0 +1,14 @@
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(30))
.connect(database_url)
.await
}
pub async fn run_migrations(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}

20
src/graphql/guards.rs Normal file
View File

@ -0,0 +1,20 @@
use crate::auth::get_auth_user;
use crate::models::user::Role;
use async_graphql::{Context, Error, Guard, Result};
pub struct RequireRole(pub Role);
impl Guard for RequireRole {
async fn check(&self, ctx: &Context<'_>) -> Result<()> {
let user = get_auth_user(ctx).await?;
match (self.0, user.role) {
(Role::User, Role::User) | (Role::User, Role::Admin) => Ok(()),
(Role::Admin, Role::Admin) => Ok(()),
_ => Err(Error::new(format!(
"Insufficient permissions. Required: {:?}, Current: {:?}",
self.0, user.role
))),
}
}
}

9
src/graphql/mod.rs Normal file
View File

@ -0,0 +1,9 @@
pub mod guards;
pub mod mutation;
pub mod query;
pub mod subscription;
pub mod types;
pub use mutation::MutationRoot;
pub use query::QueryRoot;
pub use subscription::SubscriptionRoot;

83
src/graphql/mutation.rs Normal file
View File

@ -0,0 +1,83 @@
use crate::auth::get_auth_user;
use crate::graphql::guards::RequireRole;
use crate::graphql::types::{
CreateInviteCodeInput, CreateUserInput, InitializeAdminInput, InitializeAdminResponse,
InviteCodeResponse, LoginInput, LoginResponse, RegisterInput,
};
use crate::models::user::Role;
use crate::models::user::User;
use crate::services::invite_code_service::InviteCodeService;
use crate::services::user_service::UserService;
use async_graphql::{Context, Object, Result};
pub struct MutationRoot;
#[Object]
impl MutationRoot {
async fn register(&self, ctx: &Context<'_>, input: RegisterInput) -> Result<User> {
let user_service = ctx.data::<UserService>()?;
user_service.register(input).await
}
#[graphql(guard = "RequireRole(Role::Admin)")]
async fn create_user(&self, ctx: &Context<'_>, input: CreateUserInput) -> Result<User> {
let user_service = ctx.data::<UserService>()?;
user_service.create_user(input).await
}
async fn login(&self, ctx: &Context<'_>, input: LoginInput) -> Result<LoginResponse> {
let user_service = ctx.data::<UserService>()?;
user_service.login(input).await
}
async fn create_invite_code(
&self,
ctx: &Context<'_>,
input: CreateInviteCodeInput,
) -> Result<InviteCodeResponse> {
let auth_user = get_auth_user(ctx).await?;
let invite_code_service = ctx.data::<InviteCodeService>()?;
let code = invite_code_service
.create_invite_code(auth_user.id, input)
.await?;
// Get the invite code details to return expires_at
let invite_codes = invite_code_service
.get_invite_codes_by_creator(auth_user.id)
.await?;
let invite_code = invite_codes
.into_iter()
.find(|ic| ic.code == code)
.ok_or_else(|| async_graphql::Error::new("Failed to retrieve created invite code"))?;
Ok(InviteCodeResponse {
code,
expires_at: invite_code.expires_at,
})
}
async fn initialize_admin(
&self,
ctx: &Context<'_>,
input: InitializeAdminInput,
) -> Result<InitializeAdminResponse> {
let user_service = ctx.data::<UserService>()?;
match user_service
.initialize_admin(input.username, input.email, input.password)
.await
{
Ok(user) => Ok(InitializeAdminResponse {
success: true,
message: "Admin user initialized successfully".to_string(),
user: Some(user),
}),
Err(e) => Ok(InitializeAdminResponse {
success: false,
message: e.message,
user: None,
}),
}
}
}

71
src/graphql/query.rs Normal file
View File

@ -0,0 +1,71 @@
use crate::auth::get_auth_user;
use crate::graphql::guards::RequireRole;
use crate::graphql::types::UserInfoRespnose;
use crate::models::invite_code::InviteCode;
use crate::models::user::{Role, User};
use crate::services::invite_code_service::InviteCodeService;
use crate::services::user_service::UserService;
use async_graphql::{Context, Object, Result};
use tracing::info;
pub struct QueryRoot;
#[Object]
impl QueryRoot {
async fn health_check(&self) -> &str {
"OK"
}
#[graphql(guard = "RequireRole(Role::User)")]
async fn current_user(&self, ctx: &Context<'_>) -> Result<User> {
let auth_user = get_auth_user(ctx).await?;
let user_service = ctx.data::<UserService>()?;
user_service
.get_user_by_id(auth_user.id)
.await?
.ok_or_else(|| async_graphql::Error::new("User not found"))
}
#[graphql(guard = "RequireRole(Role::Admin)")]
async fn secret_data(&self, _ctx: &Context<'_>) -> &str {
"This is super secret admin data!"
}
#[graphql(guard = "RequireRole(Role::Admin)")]
async fn users(
&self,
ctx: &Context<'_>,
offset: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<User>> {
let user_service = ctx.data::<UserService>()?;
info!("users im here");
let offset = offset.unwrap_or(0);
let limit = limit.unwrap_or(20);
user_service.get_all_users(offset, limit).await
}
#[graphql(guard = "RequireRole(Role::User)")]
async fn my_invite_codes(&self, ctx: &Context<'_>) -> Result<Vec<InviteCode>> {
let auth_user = get_auth_user(ctx).await?;
let invite_code_service = ctx.data::<InviteCodeService>()?;
invite_code_service
.get_invite_codes_by_creator(auth_user.id)
.await
}
#[graphql(guard = "RequireRole(Role::Admin)")]
async fn validate_invite_code(&self, ctx: &Context<'_>, code: String) -> Result<bool> {
let invite_code_service = ctx.data::<InviteCodeService>()?;
invite_code_service
.validate_invite_code(crate::models::invite_code::ValidateInviteCodeInput { code })
.await
}
#[graphql(guard = "RequireRole(Role::Admin)")]
async fn users_info(&self, ctx: &Context<'_>) -> Result<UserInfoRespnose> {
let user_service = ctx.data::<UserService>()?;
user_service.users_info().await
}
}

View File

@ -0,0 +1,22 @@
use async_graphql::{Subscription, Result};
use futures_util::{Stream, StreamExt};
use std::time::Duration;
use tokio::time::interval;
pub struct SubscriptionRoot;
#[Subscription]
impl SubscriptionRoot {
async fn ticks(&self) -> impl Stream<Item = i32> {
let mut interval = interval(Duration::from_secs(1));
let mut counter = 0;
async_stream::stream! {
loop {
interval.tick().await;
counter += 1;
yield counter;
}
}
}
}

70
src/graphql/types.rs Normal file
View File

@ -0,0 +1,70 @@
use crate::models::user::Role;
use async_graphql::{InputObject, SimpleObject};
#[derive(InputObject)]
pub struct RegisterInput {
pub username: String,
pub email: String,
pub password: String,
pub invite_code: String,
pub role: Option<Role>,
}
#[derive(InputObject)]
pub struct CreateUserInput {
pub username: String,
pub email: String,
pub password: String,
pub role: Option<Role>,
}
#[derive(InputObject)]
pub struct LoginInput {
pub username: String,
pub password: String,
}
#[derive(SimpleObject)]
pub struct LoginResponse {
pub token: String,
pub user_id: String,
}
#[derive(InputObject)]
pub struct CreateInviteCodeInput {
pub expires_in_days: Option<i32>,
}
#[derive(InputObject)]
pub struct ValidateInviteCodeInput {
pub code: String,
}
#[derive(SimpleObject)]
pub struct InviteCodeResponse {
pub code: String,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(InputObject)]
pub struct InitializeAdminInput {
pub username: String,
pub email: String,
pub password: String,
}
#[derive(SimpleObject)]
pub struct InitializeAdminResponse {
pub success: bool,
pub message: String,
pub user: Option<crate::models::user::User>,
}
#[derive(SimpleObject)]
pub struct UserInfoRespnose {
pub total_users: i64,
pub total_active_users: i64,
pub total_inactive_users: i64,
pub total_admin_users: i64,
pub total_user_users: i64,
}

View File

@ -1,146 +1,36 @@
use axum::{
Router,
extract::{Path, Query},
http::{StatusCode, header},
response::{IntoResponse, Response},
routing::get,
};
mod app;
mod auth;
mod config;
mod db;
mod graphql;
mod models;
mod services;
use std::collections::HashMap;
use tokio::fs;
use tower_http::cors::{Any, CorsLayer};
use app::create_router;
use config::Config;
use db::{create_pool, run_migrations};
#[tokio::main]
async fn main() {
// 初始化日志
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
// 配置更精确的CORS
let cors = CorsLayer::new()
.allow_origin(Any) // 允许所有来源,也可以指定特定域名
.allow_methods([axum::http::Method::GET]) // 只允许GET请求
.allow_headers([axum::http::header::CONTENT_TYPE]); // 允许的头部
let config = Config::from_env()?;
let pool = create_pool(&config.database_url).await?;
// 构建应用路由
let app = Router::new()
.route("/", get(root))
.route("/tiles/{z}/{x}/{y}", get(get_tile))
.route("/test", get(get_test))
.layer(cors); // 使用配置好的CORS
run_migrations(&pool).await?;
// 启动服务器,监听端口 3050
let listener = tokio::net::TcpListener::bind("0.0.0.0:3050").await.unwrap();
println!("瓦片服务器启动成功,监听地址: http://0.0.0.0:3050");
println!("API 示例: GET /tiles/6/42/20.png?time=202507220006");
let router = create_router(pool, config.clone());
axum::serve(listener, app).await.unwrap();
}
// 根路径处理器
async fn root() -> &'static str {
"瓦片服务器运行中!\n\n使用方式:\nGET /tiles/{z}/{x}/{y}.png?time=YYYYMMDDHHMM\n\n示例:\nGET /tiles/6/42/20.png?time=202507220006"
}
// 瓦片处理器
async fn get_tile(
Path((z, x, y)): Path<(String, String, String)>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Response, TileError> {
// 解析时间参数
let time = params.get("time").ok_or(TileError::MissingTimeParameter)?;
// 验证时间格式YYYYMMDDHHMM12位数字
if time.len() != 12 || !time.chars().all(|c| c.is_ascii_digit()) {
return Err(TileError::InvalidTimeFormat);
}
// 构建文件路径
let file_path = format!("tiles/{}/{}/{}/{}.png", time, z, x, y);
// 异步读取文件
match fs::read(&file_path).await {
Ok(file_data) => {
// 创建响应,设置正确的 Content-Type 和缓存头
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/png")
.header(header::CACHE_CONTROL, "public, max-age=3600") // 缓存1小时
.header(header::ETAG, format!("\"{}\"", generate_etag(&file_path)))
.body(file_data.into())
.map_err(|_| TileError::InternalServerError)?;
Ok(response)
}
Err(_) => {
tracing::warn!("瓦片文件不存在: {}", file_path);
Err(TileError::TileNotFound)
}
}
}
// 瓦片处理器
async fn get_test() -> Result<Response, TileError> {
// 异步读取文件
match fs::read("/Users/xiang.li1/projects/radarprocess/china.png").await {
Ok(file_data) => {
// 创建响应,设置正确的 Content-Type 和缓存头
let response = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/png")
.header(header::CACHE_CONTROL, "public, max-age=3600") // 缓存1小时
.header(
header::ETAG,
format!(
"\"{}\"",
generate_etag("/Users/xiang.li1/projects/radarprocess/china.png")
),
)
.body(file_data.into())
.map_err(|_| TileError::InternalServerError)?;
Ok(response)
}
Err(_) => Err(TileError::TileNotFound),
}
}
// 生成简单的 ETag
fn generate_etag(path: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
format!("{:x}", hasher.finish())
}
// 错误类型定义
#[derive(Debug)]
enum TileError {
MissingTimeParameter,
InvalidTimeFormat,
TileNotFound,
InternalServerError,
}
// 错误响应实现
impl IntoResponse for TileError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
TileError::MissingTimeParameter => (
StatusCode::BAD_REQUEST,
"缺少时间参数。请使用 ?time=YYYYMMDDHHMM 格式",
),
TileError::InvalidTimeFormat => (
StatusCode::BAD_REQUEST,
"时间格式无效。请使用 YYYYMMDDHHMM 格式12位数字",
),
TileError::TileNotFound => (StatusCode::NOT_FOUND, "瓦片文件不存在"),
TileError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, "服务器内部错误"),
};
tracing::error!("瓦片服务错误: {:?}", self);
(status, error_message).into_response()
}
let listener = tokio::net::TcpListener::bind(&format!("0.0.0.0:{}", config.port)).await?;
tracing::info!("GraphQL server running on http://0.0.0.0:{}", config.port);
tracing::info!(
"GraphiQL playground: http://0.0.0.0:{}/graphql",
config.port
);
tracing::info!("WebSocket subscriptions: ws://0.0.0.0:{}/ws", config.port);
axum::serve(listener, router).await?;
Ok(())
}

27
src/models/invite_code.rs Normal file
View File

@ -0,0 +1,27 @@
use async_graphql::SimpleObject;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, FromRow, SimpleObject)]
pub struct InviteCode {
pub id: Uuid,
pub code: String,
pub created_by: Uuid,
pub used_by: Option<Uuid>,
pub is_used: bool,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: Option<DateTime<Utc>>,
pub used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UseInviteCodeInput {
pub code: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ValidateInviteCodeInput {
pub code: String,
}

5
src/models/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod invite_code;
pub mod user;
pub use invite_code::*;
pub use user::*;

47
src/models/user.rs Normal file
View File

@ -0,0 +1,47 @@
use async_graphql::{Enum, SimpleObject};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Enum, Copy, PartialEq, Eq, sqlx::Type)]
#[sqlx(type_name = "VARCHAR", rename_all = "PascalCase")]
pub enum Role {
User,
Admin,
}
impl Default for Role {
fn default() -> Self {
Role::User
}
}
#[derive(Debug, Clone, FromRow, SimpleObject)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
#[graphql(skip)]
pub password_hash: String,
pub role: Role,
pub invite_code_id: Option<Uuid>,
pub is_activate: bool,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateUserInput {
pub username: String,
pub email: String,
pub password: String,
pub invite_code: String,
pub role: Option<Role>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LoginInput {
pub username: String,
pub password: String,
}

View File

@ -0,0 +1,130 @@
use async_graphql::{Error, Result};
use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
graphql::types::CreateInviteCodeInput,
models::invite_code::{InviteCode, UseInviteCodeInput, ValidateInviteCodeInput},
};
pub struct InviteCodeService {
pool: PgPool,
}
impl InviteCodeService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn create_invite_code(
&self,
creator_id: Uuid,
input: CreateInviteCodeInput,
) -> Result<String> {
let expires_in_days = input.expires_in_days.unwrap_or(30);
let code = sqlx::query_scalar!(
"SELECT create_invite_code($1, $2)",
creator_id,
expires_in_days
)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Failed to create invite code: {}", e)))?
.unwrap();
Ok(code)
}
pub async fn validate_invite_code(&self, input: ValidateInviteCodeInput) -> Result<bool> {
let invite_code = sqlx::query_as!(
InviteCode,
r#"
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
FROM invite_codes
WHERE code = $1
"#,
input.code
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?
.ok_or_else(|| Error::new("Invalid invite code"))?;
// Check if code is already used
if invite_code.is_used {
return Err(Error::new("Invite code has already been used"));
}
// Check if code has expired
if let Some(expires_at) = invite_code.expires_at {
if expires_at < Utc::now() {
return Err(Error::new("Invite code has expired"));
}
}
Ok(true)
}
pub async fn use_invite_code(&self, code: &str, user_id: Uuid) -> Result<Uuid> {
// First validate the code
self.validate_invite_code(ValidateInviteCodeInput {
code: code.to_string(),
})
.await?;
// Use the code in a transaction
let invite_code_id = sqlx::query_scalar!(
r#"
UPDATE invite_codes
SET is_used = true, used_by = $1, used_at = NOW()
WHERE code = $2 AND is_used = false
RETURNING id
"#,
user_id,
code
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?
.ok_or_else(|| Error::new("Failed to use invite code"))?;
Ok(invite_code_id)
}
pub async fn get_invite_codes_by_creator(&self, creator_id: Uuid) -> Result<Vec<InviteCode>> {
let invite_codes = sqlx::query_as!(
InviteCode,
r#"
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
FROM invite_codes
WHERE created_by = $1
ORDER BY created_at DESC
"#,
creator_id
)
.fetch_all(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
Ok(invite_codes)
}
pub async fn get_invite_code_by_id(&self, id: Uuid) -> Result<Option<InviteCode>> {
let invite_code = sqlx::query_as!(
InviteCode,
r#"
SELECT id, code, created_by, used_by, is_used, expires_at, created_at, used_at
FROM invite_codes
WHERE id = $1
"#,
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
Ok(invite_code)
}
}

3
src/services/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod invite_code_service;
pub mod system_config_service;
pub mod user_service;

View File

@ -0,0 +1,80 @@
use async_graphql::{Error, Result};
use sqlx::PgPool;
use tracing::info;
pub struct SystemConfigService {
pool: PgPool,
}
impl SystemConfigService {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
pub async fn is_admin_initialized(&self) -> Result<bool> {
let value = sqlx::query_scalar!(
r#"
SELECT value FROM system_config WHERE key = 'admin_initialized'
"#
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?
.unwrap_or_else(|| "false".to_string());
Ok(value == "true")
}
pub async fn set_admin_initialized(&self) -> Result<()> {
sqlx::query!(
r#"
UPDATE system_config
SET value = 'true', updated_at = NOW()
WHERE key = 'admin_initialized'
"#
)
.execute(&self.pool)
.await
.map_err(|e| {
Error::new(format!(
"Failed to update admin initialization status: {}",
e
))
})?;
info!("Admin initialization status set to true");
Ok(())
}
pub async fn get_config_value(&self, key: &str) -> Result<Option<String>> {
let value = sqlx::query_scalar!(
r#"
SELECT value FROM system_config WHERE key = $1
"#,
key
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
Ok(value)
}
pub async fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
sqlx::query!(
r#"
INSERT INTO system_config (key, value)
VALUES ($1, $2)
ON CONFLICT (key)
DO UPDATE SET value = $2, updated_at = NOW()
"#,
key,
value
)
.execute(&self.pool)
.await
.map_err(|e| Error::new(format!("Failed to set config value: {}", e)))?;
Ok(())
}
}

View File

@ -0,0 +1,338 @@
use argon2::password_hash::{rand_core::OsRng, SaltString};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use async_graphql::{Error, Result};
use sqlx::PgPool;
use tracing::info;
use uuid::Uuid;
use crate::auth::Claims;
use crate::graphql::types::{
CreateUserInput, LoginInput, LoginResponse, RegisterInput, UserInfoRespnose,
};
use crate::models::user::{Role, User};
use crate::services::invite_code_service::InviteCodeService;
use crate::services::system_config_service::SystemConfigService;
pub struct UserService {
pool: PgPool,
jwt_secret: String,
}
impl UserService {
pub fn new(pool: PgPool, jwt_secret: String) -> Self {
Self { pool, jwt_secret }
}
pub async fn register(&self, input: RegisterInput) -> Result<User> {
let password_hash = self.hash_password(&input.password)?;
let role = input.role.unwrap_or(Role::User);
// Use transaction to ensure data consistency
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Error::new(format!("Failed to start transaction: {}", e)))?;
// Validate invite code first
let invite_code_service = InviteCodeService::new(self.pool.clone());
invite_code_service
.validate_invite_code(crate::models::invite_code::ValidateInviteCodeInput {
code: input.invite_code.clone(),
})
.await?;
// Create user first
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (username, email, password_hash, role, is_activate)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
"#,
input.username,
input.email,
password_hash,
role as Role,
true
)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
Error::new("Username or email already exists")
} else {
Error::new(format!("Failed to create user: {}", e))
}
})?;
// Use the invite code
let invite_code_id = sqlx::query_scalar!(
r#"
UPDATE invite_codes
SET is_used = true, used_by = $1, used_at = NOW()
WHERE code = $2 AND is_used = false
RETURNING id
"#,
user.id,
input.invite_code
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?
.ok_or_else(|| Error::new("Failed to use invite code"))?;
// Update user with invite code id
let user = sqlx::query_as!(
User,
r#"
UPDATE users SET invite_code_id = $1 WHERE id = $2
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
"#,
invite_code_id,
user.id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::new(format!("Failed to update user with invite code: {}", e)))?;
// Commit transaction
tx.commit()
.await
.map_err(|e| Error::new(format!("Failed to commit transaction: {}", e)))?;
Ok(user)
}
pub async fn create_user(&self, input: CreateUserInput) -> Result<User> {
let password_hash = self.hash_password(&input.password)?;
let role = input.role.unwrap_or(Role::User);
// Create user first
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (username, email, password_hash, role, is_activate)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
"#,
input.username,
input.email,
password_hash,
role as Role,
true
)
.fetch_one(&self.pool)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
Error::new("Username or email already exists")
} else {
Error::new(format!("Failed to create user: {}", e))
}
})?;
Ok(user)
}
pub async fn login(&self, input: LoginInput) -> Result<LoginResponse> {
let user = sqlx::query_as!(
User,
r#"
SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
FROM users WHERE username = $1
"#,
input.username
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?
.ok_or_else(|| Error::new("Invalid credentials"))?;
if !self.verify_password(&input.password, &user.password_hash)? {
return Err(Error::new("Invalid credentials"));
}
let token = Claims::new(&user, &self.jwt_secret)?;
Ok(LoginResponse {
token,
user_id: user.id.to_string(),
})
}
pub async fn get_user_by_id(&self, id: Uuid) -> Result<Option<User>> {
let user = sqlx::query_as!(
User,
r#"
SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
FROM users WHERE id = $1
"#,
id
)
.fetch_optional(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
Ok(user)
}
pub async fn get_all_users(&self, offset: i64, limit: i64) -> Result<Vec<User>> {
let users = sqlx::query_as!(
User,
r#"SELECT id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at FROM users LIMIT $1 OFFSET $2"#,
limit,
offset
)
.fetch_all(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
info!("users: {:?}", users);
Ok(users)
}
pub async fn initialize_admin(
&self,
username: String,
email: String,
password: String,
) -> Result<User> {
// Check if admin is already initialized
let system_config_service = SystemConfigService::new(self.pool.clone());
if system_config_service.is_admin_initialized().await? {
return Err(Error::new("Admin has already been initialized"));
}
// Check if any users exist
let user_count = sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
if user_count.unwrap_or(0) > 0 {
return Err(Error::new("Users already exist in the system"));
}
let password_hash = self.hash_password(&password)?;
// Use transaction to ensure data consistency
let mut tx = self
.pool
.begin()
.await
.map_err(|e| Error::new(format!("Failed to start transaction: {}", e)))?;
// Create admin user
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (username, email, password_hash, role, is_activate)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, password_hash, role as "role: Role", invite_code_id, is_activate, created_at, updated_at
"#,
username,
email,
password_hash,
Role::Admin as Role,
true
)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
Error::new("Username or email already exists")
} else {
Error::new(format!("Failed to create admin user: {}", e))
}
})?;
// Mark admin as initialized
system_config_service.set_admin_initialized().await?;
// Commit transaction
tx.commit()
.await
.map_err(|e| Error::new(format!("Failed to commit transaction: {}", e)))?;
info!("Admin user initialized: {}", user.username);
Ok(user)
}
fn hash_password(&self, password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| Error::new(format!("Failed to hash password: {}", e)))?
.to_string()
.pipe(Ok)
}
fn verify_password(&self, password: &str, hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|e| Error::new(format!("Invalid password hash: {}", e)))?;
let argon2 = Argon2::default();
Ok(argon2
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok())
}
pub async fn users_info(&self) -> Result<UserInfoRespnose> {
let total_users = sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
let total_active_users =
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE is_activate = true"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
let total_inactive_users =
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE is_activate = false"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
let total_admin_users =
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE role = 'admin'"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
let total_user_users =
sqlx::query_scalar!(r#"SELECT COUNT(*) FROM users WHERE role = 'user'"#)
.fetch_one(&self.pool)
.await
.map_err(|e| Error::new(format!("Database error: {}", e)))?;
Ok(UserInfoRespnose {
total_users: total_users.unwrap_or(0),
total_active_users: total_active_users.unwrap_or(0),
total_inactive_users: total_inactive_users.unwrap_or(0),
total_admin_users: total_admin_users.unwrap_or(0),
total_user_users: total_user_users.unwrap_or(0),
})
}
}
trait Pipe<T> {
fn pipe<F, R>(self, f: F) -> R
where
F: FnOnce(Self) -> R,
Self: Sized;
}
impl<T> Pipe<T> for T {
fn pipe<F, R>(self, f: F) -> R
where
F: FnOnce(Self) -> R,
{
f(self)
}
}