Files
Genarrative/server-rs/crates/api-server/src/admin.rs

1225 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "后台测试");
}
}