1225 lines
39 KiB
Rust
1225 lines
39 KiB
Rust
use std::{
|
||
collections::BTreeSet,
|
||
net::{IpAddr, Ipv4Addr, Ipv6Addr},
|
||
};
|
||
|
||
use axum::{
|
||
Json,
|
||
extract::{Extension, Request, State},
|
||
http::{
|
||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode,
|
||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||
},
|
||
middleware::Next,
|
||
response::{Html, Response},
|
||
};
|
||
use reqwest::Client;
|
||
use serde::Deserialize;
|
||
use serde_json::Value;
|
||
use shared_contracts::admin::{
|
||
AdminDatabaseOverviewPayload, AdminDatabaseTableStatPayload, AdminDebugHeaderInput,
|
||
AdminDebugHttpRequest, AdminDebugHttpResponse, AdminLoginRequest, AdminLoginResponse,
|
||
AdminMeResponse, AdminOverviewResponse, AdminServiceOverviewPayload, AdminSessionPayload,
|
||
};
|
||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||
|
||
use crate::{
|
||
api_response::json_success_body,
|
||
http_error::AppError,
|
||
request_context::RequestContext,
|
||
state::{AdminRuntime, AppState},
|
||
};
|
||
|
||
// 首版调试台只允许有限大小的请求体,避免把后台当作通用代理大包转发器。
|
||
const MAX_DEBUG_BODY_BYTES: usize = 128 * 1024;
|
||
const BLOCKED_DEBUG_HEADERS: &[&str] = &[
|
||
"host",
|
||
"content-length",
|
||
"connection",
|
||
"transfer-encoding",
|
||
"expect",
|
||
];
|
||
// 数据库概览首版只统计受控白名单表,禁止后台页面直接输入任意 SQL。
|
||
const DATABASE_OVERVIEW_TABLES: &[&str] = &[
|
||
"runtime_setting",
|
||
"runtime_snapshot",
|
||
"user_browse_history",
|
||
"profile_dashboard_state",
|
||
"profile_wallet_ledger",
|
||
"profile_played_world",
|
||
"profile_save_archive",
|
||
"story_session",
|
||
"story_event",
|
||
"battle_state",
|
||
"inventory_slot",
|
||
"quest_record",
|
||
"quest_log",
|
||
"treasure_record",
|
||
"npc_state",
|
||
"custom_world_profile",
|
||
"custom_world_gallery_entry",
|
||
"custom_world_agent_session",
|
||
"custom_world_agent_message",
|
||
"custom_world_agent_operation",
|
||
"custom_world_draft_card",
|
||
"big_fish_creation_session",
|
||
"big_fish_agent_message",
|
||
"big_fish_asset_slot",
|
||
"puzzle_work_profile",
|
||
"puzzle_agent_session",
|
||
"puzzle_agent_message",
|
||
"puzzle_runtime_run",
|
||
"ai_task",
|
||
"ai_task_stage",
|
||
"ai_text_chunk",
|
||
"ai_result_reference",
|
||
"asset_object",
|
||
"asset_entity_binding",
|
||
];
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct AuthenticatedAdmin {
|
||
session: AdminSessionPayload,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SpacetimeDatabaseInfoResponse {
|
||
database_identity: Option<String>,
|
||
owner_identity: Option<String>,
|
||
host_type: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SpacetimeSchemaResponse {
|
||
tables: Option<Vec<SpacetimeSchemaTable>>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SpacetimeSchemaTable {
|
||
name: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SpacetimeSqlRow {
|
||
#[serde(flatten)]
|
||
columns: serde_json::Map<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct SpacetimeSqlResponse {
|
||
rows: Option<Vec<SpacetimeSqlRow>>,
|
||
}
|
||
|
||
impl AuthenticatedAdmin {
|
||
pub fn new(session: AdminSessionPayload) -> Self {
|
||
Self { session }
|
||
}
|
||
|
||
pub fn session(&self) -> &AdminSessionPayload {
|
||
&self.session
|
||
}
|
||
}
|
||
|
||
pub async fn admin_console_page() -> Html<&'static str> {
|
||
Html(ADMIN_CONSOLE_HTML)
|
||
}
|
||
|
||
pub async fn admin_login(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Json(payload): Json<AdminLoginRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||
})?;
|
||
|
||
let expected_username = runtime.username().trim();
|
||
let expected_password = runtime.password().trim();
|
||
let submitted_username = payload.username.trim();
|
||
let submitted_password = payload.password.trim();
|
||
if expected_username.is_empty() || expected_password.is_empty() {
|
||
return Err(
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||
);
|
||
}
|
||
|
||
if submitted_username != expected_username || submitted_password != expected_password {
|
||
return Err(
|
||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("管理员用户名或密码错误")
|
||
);
|
||
}
|
||
|
||
let now = OffsetDateTime::now_utc();
|
||
let claims = runtime.build_claims(now).map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||
})?;
|
||
let token = runtime.sign_token(&claims).map_err(|error| {
|
||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error)
|
||
})?;
|
||
|
||
Ok(json_success_body(
|
||
Some(&request_context),
|
||
AdminLoginResponse {
|
||
token,
|
||
admin: build_admin_session_payload(runtime.build_session(&claims)),
|
||
},
|
||
))
|
||
}
|
||
|
||
pub async fn admin_me(
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||
) -> Json<Value> {
|
||
json_success_body(
|
||
Some(&request_context),
|
||
AdminMeResponse {
|
||
admin: admin.session().clone(),
|
||
},
|
||
)
|
||
}
|
||
|
||
pub async fn admin_overview(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||
})?;
|
||
|
||
let overview = build_admin_overview(&state, runtime).await?;
|
||
Ok(json_success_body(Some(&request_context), overview))
|
||
}
|
||
|
||
pub async fn admin_debug_http(
|
||
State(state): State<AppState>,
|
||
Extension(request_context): Extension<RequestContext>,
|
||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||
Json(payload): Json<AdminDebugHttpRequest>,
|
||
) -> Result<Json<Value>, AppError> {
|
||
let response = execute_admin_debug_http(&state, payload).await?;
|
||
Ok(json_success_body(Some(&request_context), response))
|
||
}
|
||
|
||
pub async fn require_admin_auth(
|
||
State(state): State<AppState>,
|
||
mut request: Request,
|
||
next: Next,
|
||
) -> Result<Response, AppError> {
|
||
// 后台鉴权必须同时满足:令牌验签通过、主体匹配当前管理员、roles 含 admin。
|
||
let runtime = state.admin_runtime().ok_or_else(|| {
|
||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("后台管理未启用")
|
||
})?;
|
||
let bearer_token = extract_bearer_token(request.headers())?;
|
||
let claims = runtime
|
||
.verify_token(&bearer_token)
|
||
.map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?;
|
||
|
||
let admin_session = runtime
|
||
.validate_claims(&claims)
|
||
.map_err(|error| AppError::from_status(StatusCode::FORBIDDEN).with_message(error))?;
|
||
|
||
request
|
||
.extensions_mut()
|
||
.insert(AuthenticatedAdmin::new(build_admin_session_payload(
|
||
admin_session,
|
||
)));
|
||
Ok(next.run(request).await)
|
||
}
|
||
|
||
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
|
||
let authorization = headers
|
||
.get(AUTHORIZATION)
|
||
.and_then(|value| value.to_str().ok())
|
||
.map(str::trim)
|
||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
|
||
|
||
let token = authorization
|
||
.strip_prefix("Bearer ")
|
||
.or_else(|| authorization.strip_prefix("bearer "))
|
||
.map(str::trim)
|
||
.filter(|token| !token.is_empty())
|
||
.ok_or_else(|| AppError::from_status(StatusCode::UNAUTHORIZED))?;
|
||
|
||
Ok(token.to_string())
|
||
}
|
||
|
||
async fn build_admin_overview(
|
||
state: &AppState,
|
||
runtime: &AdminRuntime,
|
||
) -> Result<AdminOverviewResponse, AppError> {
|
||
let service = AdminServiceOverviewPayload {
|
||
bind_host: state.config.bind_host.clone(),
|
||
bind_port: state.config.bind_port,
|
||
jwt_issuer: state.config.jwt_issuer.clone(),
|
||
admin_enabled: runtime.is_enabled(),
|
||
spacetime_server_url: state.config.spacetime_server_url.clone(),
|
||
spacetime_database: state.config.spacetime_database.clone(),
|
||
};
|
||
let database = fetch_database_overview(state).await;
|
||
|
||
Ok(AdminOverviewResponse { service, database })
|
||
}
|
||
|
||
async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPayload {
|
||
// 概览直接读取 SpacetimeDB HTTP API,保证后台看到的是真实数据库元信息而不是本地缓存。
|
||
let client = Client::new();
|
||
let server_root = state.config.spacetime_server_url.trim_end_matches('/');
|
||
let database = state.config.spacetime_database.trim();
|
||
let token = state
|
||
.config
|
||
.spacetime_token
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty());
|
||
let mut fetch_errors = Vec::new();
|
||
|
||
let database_info = fetch_spacetime_json::<SpacetimeDatabaseInfoResponse>(
|
||
&client,
|
||
&format!("{server_root}/v1/database/{database}"),
|
||
token,
|
||
)
|
||
.await
|
||
.map_err(|error| fetch_errors.push(format!("数据库信息读取失败:{error}")))
|
||
.ok()
|
||
.flatten();
|
||
|
||
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
|
||
&client,
|
||
&format!("{server_root}/v1/database/{database}/schema"),
|
||
token,
|
||
)
|
||
.await
|
||
.map_err(|error| fetch_errors.push(format!("数据库 schema 读取失败:{error}")))
|
||
.ok()
|
||
.flatten();
|
||
|
||
let mut schema_table_names = schema
|
||
.as_ref()
|
||
.and_then(|value| value.tables.as_ref())
|
||
.map(|tables| {
|
||
tables
|
||
.iter()
|
||
.filter_map(|table| table.name.as_deref())
|
||
.map(str::trim)
|
||
.filter(|name| !name.is_empty())
|
||
.map(ToOwned::to_owned)
|
||
.collect::<BTreeSet<_>>()
|
||
.into_iter()
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
let mut table_stats = Vec::new();
|
||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||
let sql = format!("SELECT COUNT(*) AS row_count FROM {table_name}");
|
||
match fetch_spacetime_sql_count(&client, server_root, database, token, &sql).await {
|
||
Ok(row_count) => table_stats.push(AdminDatabaseTableStatPayload {
|
||
table_name: (*table_name).to_string(),
|
||
row_count: Some(row_count),
|
||
error_message: None,
|
||
}),
|
||
Err(error) => {
|
||
table_stats.push(AdminDatabaseTableStatPayload {
|
||
table_name: (*table_name).to_string(),
|
||
row_count: None,
|
||
error_message: Some(error),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
for table_name in DATABASE_OVERVIEW_TABLES {
|
||
if !schema_table_names.iter().any(|name| name == table_name) {
|
||
schema_table_names.push((*table_name).to_string());
|
||
}
|
||
}
|
||
schema_table_names.sort();
|
||
|
||
AdminDatabaseOverviewPayload {
|
||
database_identity: database_info
|
||
.as_ref()
|
||
.and_then(|value| value.database_identity.clone()),
|
||
owner_identity: database_info
|
||
.as_ref()
|
||
.and_then(|value| value.owner_identity.clone()),
|
||
host_type: database_info
|
||
.as_ref()
|
||
.and_then(|value| value.host_type.clone()),
|
||
schema_table_names,
|
||
table_stats,
|
||
fetch_errors,
|
||
}
|
||
}
|
||
|
||
async fn fetch_spacetime_json<T>(
|
||
client: &Client,
|
||
url: &str,
|
||
token: Option<&str>,
|
||
) -> Result<Option<T>, String>
|
||
where
|
||
T: for<'de> Deserialize<'de>,
|
||
{
|
||
let mut request = client.get(url);
|
||
if let Some(token) = token {
|
||
request = request.bearer_auth(token);
|
||
}
|
||
|
||
let response = request
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("请求失败:{error}"))?;
|
||
if !response.status().is_success() {
|
||
let status = response.status();
|
||
let body = response.text().await.unwrap_or_default();
|
||
return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body)));
|
||
}
|
||
|
||
response
|
||
.json::<T>()
|
||
.await
|
||
.map(Some)
|
||
.map_err(|error| format!("响应解析失败:{error}"))
|
||
}
|
||
|
||
async fn fetch_spacetime_sql_count(
|
||
client: &Client,
|
||
server_root: &str,
|
||
database: &str,
|
||
token: Option<&str>,
|
||
sql: &str,
|
||
) -> Result<u64, String> {
|
||
let mut request = client
|
||
.post(format!("{server_root}/v1/database/{database}/sql"))
|
||
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
|
||
.body(sql.to_string());
|
||
if let Some(token) = token {
|
||
request = request.bearer_auth(token);
|
||
}
|
||
|
||
let response = request
|
||
.send()
|
||
.await
|
||
.map_err(|error| format!("SQL 请求失败:{error}"))?;
|
||
if !response.status().is_success() {
|
||
let status = response.status();
|
||
let body = response.text().await.unwrap_or_default();
|
||
return Err(format!("HTTP {}:{}", status.as_u16(), trim_preview(&body)));
|
||
}
|
||
|
||
let payload = response
|
||
.json::<SpacetimeSqlResponse>()
|
||
.await
|
||
.map_err(|error| format!("SQL 响应解析失败:{error}"))?;
|
||
let row = payload
|
||
.rows
|
||
.and_then(|rows| rows.into_iter().next())
|
||
.ok_or_else(|| "SQL 结果为空".to_string())?;
|
||
extract_sql_count(row.columns)
|
||
}
|
||
|
||
fn extract_sql_count(columns: serde_json::Map<String, Value>) -> Result<u64, String> {
|
||
for key in ["row_count", "count", "COUNT(*)"] {
|
||
if let Some(value) = columns.get(key) {
|
||
return parse_count_value(value);
|
||
}
|
||
}
|
||
columns
|
||
.values()
|
||
.next()
|
||
.ok_or_else(|| "SQL 结果缺少 count 字段".to_string())
|
||
.and_then(parse_count_value)
|
||
}
|
||
|
||
fn parse_count_value(value: &Value) -> Result<u64, String> {
|
||
match value {
|
||
Value::Number(number) => number
|
||
.as_u64()
|
||
.ok_or_else(|| "count 字段不是无符号整数".to_string()),
|
||
Value::String(text) => text
|
||
.trim()
|
||
.parse::<u64>()
|
||
.map_err(|error| format!("count 字段解析失败:{error}")),
|
||
_ => Err("count 字段类型非法".to_string()),
|
||
}
|
||
}
|
||
|
||
async fn execute_admin_debug_http(
|
||
state: &AppState,
|
||
payload: AdminDebugHttpRequest,
|
||
) -> Result<AdminDebugHttpResponse, AppError> {
|
||
// 调试请求始终回打当前 api-server,同源受控,不允许作为外部代理使用。
|
||
let method = Method::from_bytes(payload.method.trim().as_bytes()).map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("HTTP 方法不合法")
|
||
})?;
|
||
let path = normalize_debug_path(&payload.path)?;
|
||
let base_url = build_debug_base_url(&state.config.bind_host, state.config.bind_port);
|
||
let target_url = format!("{base_url}{path}");
|
||
let body_text = payload.body.unwrap_or_default();
|
||
if body_text.len() > MAX_DEBUG_BODY_BYTES {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求体超过长度限制")
|
||
);
|
||
}
|
||
|
||
let client = Client::new();
|
||
let mut request = client.request(method, &target_url);
|
||
if !body_text.is_empty() {
|
||
request = request.body(body_text.clone());
|
||
}
|
||
|
||
for header in payload.headers.unwrap_or_default() {
|
||
let header_name = header.name.trim().to_ascii_lowercase();
|
||
if BLOCKED_DEBUG_HEADERS
|
||
.iter()
|
||
.any(|blocked| *blocked == header_name)
|
||
{
|
||
continue;
|
||
}
|
||
let name = HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头名称不合法")
|
||
})?;
|
||
let value = HeaderValue::from_str(header.value.trim()).map_err(|_| {
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试请求头值不合法")
|
||
})?;
|
||
request = request.header(name, value);
|
||
}
|
||
|
||
let response = request.send().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||
.with_message(format!("调试请求失败:{error}"))
|
||
})?;
|
||
let status = response.status();
|
||
let headers = response
|
||
.headers()
|
||
.iter()
|
||
.map(|(name, value)| AdminDebugHeaderInput {
|
||
name: name.to_string(),
|
||
value: value.to_str().unwrap_or_default().to_string(),
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let response_body = response.bytes().await.map_err(|error| {
|
||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||
.with_message(format!("调试响应读取失败:{error}"))
|
||
})?;
|
||
let body_preview = build_body_preview(&response_body);
|
||
let body_json = serde_json::from_slice::<Value>(&response_body).ok();
|
||
|
||
Ok(AdminDebugHttpResponse {
|
||
status: status.as_u16(),
|
||
status_text: status.canonical_reason().unwrap_or("Unknown").to_string(),
|
||
headers,
|
||
body_text: body_preview,
|
||
body_json,
|
||
})
|
||
}
|
||
|
||
fn build_debug_base_url(bind_host: &str, bind_port: u16) -> String {
|
||
let debug_host = resolve_debug_host(bind_host);
|
||
let authority_host = format_http_authority_host(&debug_host);
|
||
format!("http://{authority_host}:{bind_port}")
|
||
}
|
||
|
||
fn resolve_debug_host(bind_host: &str) -> String {
|
||
let trimmed = bind_host.trim();
|
||
if trimmed.is_empty() {
|
||
return Ipv4Addr::LOCALHOST.to_string();
|
||
}
|
||
|
||
match trimmed.parse::<IpAddr>() {
|
||
Ok(IpAddr::V4(ip)) if ip.is_unspecified() => Ipv4Addr::LOCALHOST.to_string(),
|
||
Ok(IpAddr::V6(ip)) if ip.is_unspecified() => Ipv6Addr::LOCALHOST.to_string(),
|
||
Ok(ip) => ip.to_string(),
|
||
Err(_) => trimmed.to_string(),
|
||
}
|
||
}
|
||
|
||
fn format_http_authority_host(host: &str) -> String {
|
||
if host.starts_with('[') && host.ends_with(']') {
|
||
return host.to_string();
|
||
}
|
||
if host.parse::<Ipv6Addr>().is_ok() {
|
||
return format!("[{host}]");
|
||
}
|
||
host.to_string()
|
||
}
|
||
|
||
fn normalize_debug_path(path: &str) -> Result<String, AppError> {
|
||
// 只允许 `/xxx` 形式的同源相对路径,明确拒绝绝对 URL 与后台登录接口。
|
||
let trimmed = path.trim();
|
||
if trimmed.is_empty() {
|
||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径不能为空"));
|
||
}
|
||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("只允许调试同源相对路径")
|
||
);
|
||
}
|
||
if !trimmed.starts_with('/') {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("调试路径必须以 / 开头")
|
||
);
|
||
}
|
||
if trimmed == "/admin/api/login" {
|
||
return Err(
|
||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("禁止调试后台登录接口")
|
||
);
|
||
}
|
||
Ok(trimmed.to_string())
|
||
}
|
||
|
||
fn build_body_preview(bytes: &[u8]) -> String {
|
||
if bytes.is_empty() {
|
||
return String::new();
|
||
}
|
||
let text = String::from_utf8_lossy(bytes).to_string();
|
||
trim_preview(&text)
|
||
}
|
||
|
||
fn trim_preview(text: &str) -> String {
|
||
let trimmed = text.trim();
|
||
if trimmed.chars().count() <= 4000 {
|
||
return trimmed.to_string();
|
||
}
|
||
trimmed.chars().take(4000).collect::<String>()
|
||
}
|
||
|
||
fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSessionPayload {
|
||
AdminSessionPayload {
|
||
subject: session.subject,
|
||
username: session.username,
|
||
display_name: session.display_name,
|
||
roles: session.roles,
|
||
issued_at: session
|
||
.issued_at
|
||
.format(&Rfc3339)
|
||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||
expires_at: session
|
||
.expires_at
|
||
.format(&Rfc3339)
|
||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
||
}
|
||
}
|
||
|
||
// 首版后台页面内嵌在 api-server,避免新增独立前端工程与静态资源发布链。
|
||
static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Genarrative 管理后台</title>
|
||
<style>
|
||
:root {
|
||
--bg: linear-gradient(180deg, #f4efe5 0%, #e7ddd0 100%);
|
||
--panel: rgba(255, 249, 240, 0.92);
|
||
--panel-strong: #fffaf2;
|
||
--line: rgba(90, 61, 41, 0.14);
|
||
--text: #2f241d;
|
||
--muted: #7b6657;
|
||
--accent: #b45a2f;
|
||
--accent-strong: #8f431f;
|
||
--ok: #2c7a54;
|
||
--danger: #a63f2f;
|
||
--shadow: 0 20px 50px rgba(70, 41, 19, 0.12);
|
||
--radius: 20px;
|
||
--font: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: var(--font);
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
min-height: 100vh;
|
||
}
|
||
.shell {
|
||
max-width: 1180px;
|
||
margin: 0 auto;
|
||
padding: 20px 16px 40px;
|
||
}
|
||
.hero {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.hero h1 {
|
||
margin: 0;
|
||
font-size: 28px;
|
||
line-height: 1.1;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.hero p {
|
||
margin: 10px 0 0;
|
||
color: var(--muted);
|
||
font-size: 14px;
|
||
}
|
||
.status-chip {
|
||
padding: 10px 14px;
|
||
border-radius: 999px;
|
||
background: rgba(180, 90, 47, 0.1);
|
||
color: var(--accent-strong);
|
||
font-size: 13px;
|
||
white-space: nowrap;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 340px minmax(0, 1fr);
|
||
gap: 16px;
|
||
}
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
.panel-head {
|
||
padding: 18px 18px 0;
|
||
}
|
||
.panel-head h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
}
|
||
.panel-head p {
|
||
margin: 8px 0 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
}
|
||
.panel-body {
|
||
padding: 18px;
|
||
}
|
||
.form {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
label {
|
||
display: grid;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
input, textarea, select {
|
||
width: 100%;
|
||
border: 1px solid rgba(78, 53, 37, 0.12);
|
||
border-radius: 14px;
|
||
background: var(--panel-strong);
|
||
color: var(--text);
|
||
font: inherit;
|
||
padding: 12px 14px;
|
||
outline: none;
|
||
}
|
||
textarea {
|
||
min-height: 140px;
|
||
resize: vertical;
|
||
}
|
||
.btn-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
button {
|
||
border: none;
|
||
border-radius: 14px;
|
||
padding: 11px 16px;
|
||
background: var(--accent);
|
||
color: #fff8f2;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
button.secondary {
|
||
background: rgba(180, 90, 47, 0.14);
|
||
color: var(--accent-strong);
|
||
}
|
||
button:disabled {
|
||
opacity: 0.6;
|
||
cursor: wait;
|
||
}
|
||
.stack {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
.metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.metric {
|
||
padding: 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255,255,255,0.45);
|
||
}
|
||
.metric .k {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.metric .v {
|
||
margin-top: 8px;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
.data-grid {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 160px 1fr;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
align-items: start;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid rgba(78, 53, 37, 0.08);
|
||
}
|
||
.row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.row .k {
|
||
color: var(--muted);
|
||
}
|
||
.table-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
max-height: 420px;
|
||
overflow: auto;
|
||
padding-right: 4px;
|
||
}
|
||
.table-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border-radius: 14px;
|
||
background: rgba(255,255,255,0.52);
|
||
border: 1px solid rgba(78, 53, 37, 0.08);
|
||
align-items: center;
|
||
}
|
||
.table-item small {
|
||
color: var(--muted);
|
||
display: block;
|
||
margin-top: 4px;
|
||
}
|
||
.count {
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.count.err {
|
||
color: var(--danger);
|
||
font-weight: 600;
|
||
}
|
||
.result-panel {
|
||
border-radius: 16px;
|
||
border: 1px solid var(--line);
|
||
background: rgba(255,255,255,0.55);
|
||
padding: 14px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
background: rgba(47, 36, 29, 0.06);
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
overflow: auto;
|
||
}
|
||
.hint {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
.err-text { color: var(--danger); }
|
||
.ok-text { color: var(--ok); }
|
||
@media (max-width: 900px) {
|
||
.grid { grid-template-columns: 1fr; }
|
||
.hero { flex-direction: column; }
|
||
.row { grid-template-columns: 1fr; gap: 4px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="shell">
|
||
<div class="hero">
|
||
<div>
|
||
<h1>Genarrative 管理后台</h1>
|
||
<p>查看服务状态、数据库概览,并对当前 API 做受控调试。</p>
|
||
</div>
|
||
<div id="admin-status" class="status-chip">未登录</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="stack">
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>管理员登录</h2>
|
||
<p>使用配置的后台账号进入管理域。</p>
|
||
</div>
|
||
<div class="panel-body">
|
||
<form id="login-form" class="form">
|
||
<label>用户名
|
||
<input id="login-username" name="username" autocomplete="username" />
|
||
</label>
|
||
<label>密码
|
||
<input id="login-password" name="password" type="password" autocomplete="current-password" />
|
||
</label>
|
||
<div class="btn-row">
|
||
<button id="login-submit" type="submit">登录后台</button>
|
||
<button id="login-clear" class="secondary" type="button">清空令牌</button>
|
||
</div>
|
||
<div id="login-message" class="hint"></div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>邀请码管理</h2>
|
||
<p>创建或更新管理员邀请码。</p>
|
||
</div>
|
||
<div class="panel-body">
|
||
<form id="invite-code-form" class="form">
|
||
<label>邀请码
|
||
<input id="invite-code-value" autocomplete="off" />
|
||
</label>
|
||
<label>Metadata JSON
|
||
<textarea id="invite-code-metadata">{}</textarea>
|
||
</label>
|
||
<div class="btn-row">
|
||
<button id="invite-code-submit" type="submit">保存邀请码</button>
|
||
</div>
|
||
<div id="invite-code-message" class="hint"></div>
|
||
<div id="invite-code-result" class="result-panel" style="display:none;"></div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>API 调试</h2>
|
||
<p>对当前服务做同源受控请求。</p>
|
||
</div>
|
||
<div class="panel-body">
|
||
<form id="debug-form" class="form">
|
||
<label>方法
|
||
<select id="debug-method">
|
||
<option>GET</option>
|
||
<option>POST</option>
|
||
<option>PUT</option>
|
||
<option>DELETE</option>
|
||
<option>PATCH</option>
|
||
</select>
|
||
</label>
|
||
<label>路径
|
||
<input id="debug-path" value="/healthz" />
|
||
</label>
|
||
<label>附加请求头(JSON 数组)
|
||
<textarea id="debug-headers">[]</textarea>
|
||
</label>
|
||
<label>请求体
|
||
<textarea id="debug-body"></textarea>
|
||
</label>
|
||
<div class="btn-row">
|
||
<button id="debug-submit" type="submit">发送调试请求</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="stack">
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>数据库概览</h2>
|
||
<p>读取当前服务配置和 SpacetimeDB 数据库真相。</p>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div class="btn-row" style="margin-bottom:14px;">
|
||
<button id="refresh-overview" type="button">刷新概览</button>
|
||
</div>
|
||
<div id="overview-metrics" class="metrics"></div>
|
||
<div id="overview-detail" class="data-grid" style="margin-top:14px;"></div>
|
||
<div id="overview-errors" class="hint err-text" style="margin-top:10px;"></div>
|
||
<div id="overview-tables" class="table-list" style="margin-top:14px;"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-head">
|
||
<h2>调试结果</h2>
|
||
<p>返回状态、响应头和内容预览。</p>
|
||
</div>
|
||
<div class="panel-body">
|
||
<div id="debug-result" class="result-panel">
|
||
<div class="hint">尚未执行调试请求。</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const TOKEN_KEY = 'genarrative_admin_token';
|
||
const statusEl = document.getElementById('admin-status');
|
||
const loginMessageEl = document.getElementById('login-message');
|
||
const overviewMetricsEl = document.getElementById('overview-metrics');
|
||
const overviewDetailEl = document.getElementById('overview-detail');
|
||
const overviewTablesEl = document.getElementById('overview-tables');
|
||
const overviewErrorsEl = document.getElementById('overview-errors');
|
||
const debugResultEl = document.getElementById('debug-result');
|
||
const inviteCodeMessageEl = document.getElementById('invite-code-message');
|
||
const inviteCodeResultEl = document.getElementById('invite-code-result');
|
||
|
||
function getToken() {
|
||
return window.localStorage.getItem(TOKEN_KEY) || '';
|
||
}
|
||
|
||
function setToken(token) {
|
||
if (!token) {
|
||
window.localStorage.removeItem(TOKEN_KEY);
|
||
return;
|
||
}
|
||
window.localStorage.setItem(TOKEN_KEY, token);
|
||
}
|
||
|
||
function setStatus(text, ok) {
|
||
statusEl.textContent = text;
|
||
statusEl.style.background = ok ? 'rgba(44,122,84,0.12)' : 'rgba(180,90,47,0.1)';
|
||
statusEl.style.color = ok ? '#2c7a54' : '#8f431f';
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const headers = new Headers(options.headers || {});
|
||
const token = getToken();
|
||
if (token) {
|
||
headers.set('authorization', `Bearer ${token}`);
|
||
}
|
||
if (options.json !== undefined) {
|
||
headers.set('content-type', 'application/json');
|
||
options.body = JSON.stringify(options.json);
|
||
}
|
||
const response = await fetch(path, { ...options, headers });
|
||
const text = await response.text();
|
||
let data = null;
|
||
try { data = text ? JSON.parse(text) : null; } catch (_) {}
|
||
if (!response.ok) {
|
||
const message = data?.error?.message || data?.message || text || `HTTP ${response.status}`;
|
||
throw new Error(message);
|
||
}
|
||
return data?.data ?? data;
|
||
}
|
||
|
||
function renderOverview(overview) {
|
||
const service = overview.service || {};
|
||
const database = overview.database || {};
|
||
const stats = Array.isArray(database.tableStats) ? database.tableStats : [];
|
||
overviewMetricsEl.innerHTML = `
|
||
<div class="metric"><div class="k">后台状态</div><div class="v">${service.adminEnabled ? '已启用' : '未启用'}</div></div>
|
||
<div class="metric"><div class="k">服务监听</div><div class="v">${service.bindHost || '-'}:${service.bindPort || '-'}</div></div>
|
||
<div class="metric"><div class="k">SpacetimeDB</div><div class="v">${service.spacetimeDatabase || '-'}</div></div>
|
||
<div class="metric"><div class="k">统计表数</div><div class="v">${stats.length}</div></div>
|
||
`;
|
||
overviewDetailEl.innerHTML = `
|
||
<div class="row"><div class="k">JWT Issuer</div><div>${service.jwtIssuer || '-'}</div></div>
|
||
<div class="row"><div class="k">Spacetime 服务</div><div>${service.spacetimeServerUrl || '-'}</div></div>
|
||
<div class="row"><div class="k">数据库 Identity</div><div>${database.databaseIdentity || '-'}</div></div>
|
||
<div class="row"><div class="k">Owner Identity</div><div>${database.ownerIdentity || '-'}</div></div>
|
||
<div class="row"><div class="k">Host Type</div><div>${database.hostType || '-'}</div></div>
|
||
<div class="row"><div class="k">Schema 表数量</div><div>${(database.schemaTableNames || []).length}</div></div>
|
||
`;
|
||
overviewTablesEl.innerHTML = stats.map((item) => `
|
||
<div class="table-item">
|
||
<div>
|
||
<strong>${item.tableName}</strong>
|
||
${item.errorMessage ? `<small class="err-text">${item.errorMessage}</small>` : ''}
|
||
</div>
|
||
<div class="count ${item.errorMessage ? 'err' : ''}">${item.rowCount ?? '失败'}</div>
|
||
</div>
|
||
`).join('');
|
||
overviewErrorsEl.textContent = (database.fetchErrors || []).join(' | ');
|
||
}
|
||
|
||
function renderDebugResult(result) {
|
||
const headerText = (result.headers || []).map((item) => `${item.name}: ${item.value}`).join('\n');
|
||
debugResultEl.innerHTML = `
|
||
<div><strong>状态:</strong><span class="${result.status < 400 ? 'ok-text' : 'err-text'}">${result.status} ${result.statusText}</span></div>
|
||
<div><strong>响应头</strong><pre>${headerText || '(无)'}</pre></div>
|
||
<div><strong>响应体预览</strong><pre>${result.bodyText || '(空)'}</pre></div>
|
||
<div><strong>响应 JSON</strong><pre>${result.bodyJson ? JSON.stringify(result.bodyJson, null, 2) : '(不是 JSON)'}</pre></div>
|
||
`;
|
||
}
|
||
|
||
function renderInviteCodeResult(result) {
|
||
inviteCodeResultEl.style.display = 'grid';
|
||
inviteCodeResultEl.innerHTML = `
|
||
<div><strong>User ID:</strong>${result.userId || '-'}</div>
|
||
<div><strong>邀请码:</strong>${result.inviteCode || '-'}</div>
|
||
<div><strong>更新时间:</strong>${result.updatedAt || '-'}</div>
|
||
<div><strong>Metadata</strong><pre>${JSON.stringify(result.metadata || {}, null, 2)}</pre></div>
|
||
`;
|
||
}
|
||
|
||
async function loadMe() {
|
||
const token = getToken();
|
||
if (!token) {
|
||
setStatus('未登录', false);
|
||
return;
|
||
}
|
||
try {
|
||
const result = await request('/admin/api/me');
|
||
setStatus(`管理员:${result.admin.displayName}`, true);
|
||
} catch (error) {
|
||
setToken('');
|
||
setStatus('未登录', false);
|
||
}
|
||
}
|
||
|
||
async function loadOverview() {
|
||
try {
|
||
const overview = await request('/admin/api/overview');
|
||
renderOverview(overview);
|
||
} catch (error) {
|
||
overviewMetricsEl.innerHTML = '';
|
||
overviewDetailEl.innerHTML = '';
|
||
overviewTablesEl.innerHTML = '';
|
||
overviewErrorsEl.textContent = error.message;
|
||
}
|
||
}
|
||
|
||
document.getElementById('login-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
loginMessageEl.textContent = '正在登录...';
|
||
try {
|
||
const result = await request('/admin/api/login', {
|
||
method: 'POST',
|
||
json: {
|
||
username: document.getElementById('login-username').value,
|
||
password: document.getElementById('login-password').value,
|
||
},
|
||
});
|
||
setToken(result.token);
|
||
loginMessageEl.textContent = '登录成功';
|
||
await loadMe();
|
||
await loadOverview();
|
||
} catch (error) {
|
||
loginMessageEl.textContent = error.message;
|
||
}
|
||
});
|
||
|
||
document.getElementById('login-clear').addEventListener('click', () => {
|
||
setToken('');
|
||
setStatus('未登录', false);
|
||
loginMessageEl.textContent = '已清空本地令牌';
|
||
});
|
||
|
||
document.getElementById('refresh-overview').addEventListener('click', async () => {
|
||
await loadOverview();
|
||
});
|
||
|
||
document.getElementById('debug-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
debugResultEl.innerHTML = '<div class="hint">正在请求...</div>';
|
||
try {
|
||
const headers = JSON.parse(document.getElementById('debug-headers').value || '[]');
|
||
const result = await request('/admin/api/debug/http', {
|
||
method: 'POST',
|
||
json: {
|
||
method: document.getElementById('debug-method').value,
|
||
path: document.getElementById('debug-path').value,
|
||
headers,
|
||
body: document.getElementById('debug-body').value,
|
||
},
|
||
});
|
||
renderDebugResult(result);
|
||
} catch (error) {
|
||
debugResultEl.innerHTML = `<div class="err-text">${error.message}</div>`;
|
||
}
|
||
});
|
||
|
||
document.getElementById('invite-code-form').addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
inviteCodeMessageEl.textContent = '正在保存...';
|
||
try {
|
||
const rawMetadata = document.getElementById('invite-code-metadata').value.trim() || '{}';
|
||
const metadata = JSON.parse(rawMetadata);
|
||
const result = await request('/admin/api/profile/invite-codes', {
|
||
method: 'POST',
|
||
json: {
|
||
inviteCode: document.getElementById('invite-code-value').value,
|
||
metadata,
|
||
},
|
||
});
|
||
inviteCodeMessageEl.textContent = '已保存';
|
||
renderInviteCodeResult(result);
|
||
} catch (error) {
|
||
inviteCodeMessageEl.textContent = error.message;
|
||
}
|
||
});
|
||
|
||
loadMe().then(loadOverview);
|
||
</script>
|
||
</body>
|
||
</html>"#;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview};
|
||
use axum::{http::StatusCode, response::IntoResponse};
|
||
|
||
#[test]
|
||
fn normalize_debug_path_rejects_absolute_url() {
|
||
let error =
|
||
normalize_debug_path("https://example.com/api").expect_err("absolute url should fail");
|
||
|
||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_debug_path_rejects_admin_login_route() {
|
||
let error =
|
||
normalize_debug_path("/admin/api/login").expect_err("admin login route should fail");
|
||
|
||
assert_eq!(error.into_response().status(), StatusCode::BAD_REQUEST);
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_debug_path_accepts_healthz() {
|
||
let path = normalize_debug_path("/healthz").expect("healthz path should pass validation");
|
||
|
||
assert_eq!(path, "/healthz");
|
||
}
|
||
|
||
#[test]
|
||
fn build_debug_base_url_rewrites_wildcard_ipv4_to_loopback() {
|
||
let url = build_debug_base_url("0.0.0.0", 3200);
|
||
|
||
assert_eq!(url, "http://127.0.0.1:3200");
|
||
}
|
||
|
||
#[test]
|
||
fn build_debug_base_url_wraps_ipv6_host() {
|
||
let url = build_debug_base_url("::1", 3200);
|
||
|
||
assert_eq!(url, "http://[::1]:3200");
|
||
}
|
||
|
||
#[test]
|
||
fn trim_preview_limits_length() {
|
||
let text = "a".repeat(5000);
|
||
|
||
assert_eq!(trim_preview(&text).chars().count(), 4000);
|
||
}
|
||
|
||
#[test]
|
||
fn build_body_preview_handles_utf8() {
|
||
let preview = build_body_preview("后台测试".as_bytes());
|
||
|
||
assert_eq!(preview, "后台测试");
|
||
}
|
||
}
|