Files
Genarrative/server-rs/crates/api-server/src/modules/admin.rs
T
kdletters 5bf036bb81
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
收敛后端依赖装配与鉴权并完善异步追踪 (#425)
HTTP 请求取消后,在途计数原先无法释放;项目元数据和 External API 鉴权依赖完整 AppState,相同鉴权和追踪配置也散落在多个入口。本次集中装配这些依赖和横切能力,保持现有公开 API、权限、计费、幂等和事务规则。

## 修改

- 91 个受保护路由集中应用鉴权;保留方法级 404/405/HEAD/Allow、公开入口、MCP、精选缓存头及 2/4 MiB 请求限制。
- 七个项目元数据入口改用缓存的 EditorProjectState,External/MCP 鉴权改用 ExternalApiAuthState;生产实现复用 SpacetimeClient,媒体修复维持原有上传和登记顺序。
- RAII 覆盖请求 Future 取消和 panic unwind 的计数清理;正常与降级服务复用 TraceLayer,指标采用 MatchedPath 模板及固定兜底。
- LLM 普通与流式调用增加跳过参数的异步 span,保持父上下文、流式回调、错误与重试行为;补充替代依赖测试并同步锁文件和文档。

## 验证

| 验证面 | 结果 |
| --- | --- |
| platform-llm 完整本地回归 | 161 个单元测试、3 个集成测试通过;1 个真实 Provider 用例按原配置忽略 |
| api-server 完整回归 | 执行时 1075 通过、12 失败、6 忽略;其中 1 个新增公开读取 fixture 断言已修正,14 个路由契约回归随后全部通过;剩余 11 个是下述既有 Windows 失败 |
| 窄依赖、取消与追踪 | 元数据 owner/幂等/revision、鉴权及 MCP 错误传播、取消/panic/流式响应、追踪父子关系与敏感参数省略均通过 |
| 实际本地服务 | 独立 SpacetimeDB 上 102/102 检查通过,两个动态项目 ID 的路由模板及请求 ID 日志核验 3/3 通过 |
| 编译与边界 | api-server cargo check、AGC 锁文件下 platform-llm cargo check、rustfmt、编码、文档索引、DDD 与 diff 检查通过 |
| 合入最新 master 后 | 后端源码及锁文件保持已测内容;再次通过 14 个路由契约测试、3 个 Provider 追踪测试及编码/文档/DDD/diff 检查 |

实际服务检查覆盖 health/ready、两账号登录、项目 CRUD、幂等重复、跨 owner 拒绝、revision 冲突、External/MCP 读取、Key 撤销及 404/405。使用既有 test 环境的本地 Router 拒绝 fixture,未调用真实付费 Provider;自建服务已关闭,原开发实例保留。

## 已知测试限制

API 全量测试尚未全绿:11 个 wallet_refund_outbox 用例在 Windows 的目录同步处失败。其生产文件与变更前内容一致;标准库隔离复现确认 File::open(目录) 返回 OS 5,而普通文件写入、同步及 hard_link 正常。这个已有的目录持久化问题未混入本次重构,也未通过跳过或弱化相关断言掩盖。

---------

Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/425
2026-09-19 12:29:19 +08:00

386 lines
15 KiB
Rust

use axum::{
Router, middleware,
routing::{get, post},
};
use crate::{
admin::{
admin_confirm_editor_showcase_campaign_image_upload,
admin_create_editor_showcase_campaign_image_upload_ticket, admin_dashboard,
admin_debug_http, admin_get_asset_read_url, admin_get_editor_generation_pricing,
admin_get_editor_showcase_campaign, admin_get_feature_gate_config,
admin_list_database_table_rows, admin_list_database_tables, admin_list_editor_assets,
admin_list_editor_showcase_assets, admin_list_external_api_keys,
admin_list_tracking_event_keys, admin_list_tracking_events, admin_login, admin_me,
admin_overview, admin_review_editor_showcase_asset,
admin_update_editor_showcase_asset_display, admin_upsert_editor_generation_pricing,
admin_upsert_editor_showcase_campaign, admin_upsert_feature_gate_config,
require_admin_auth,
},
admin_accounts::{admin_create_account, admin_list_accounts, admin_update_account},
admin_recharge::{
admin_execute_recharge_refund, admin_get_user_detail,
admin_initialize_user_consumption_projections, admin_list_recharge_orders,
admin_preview_recharge_refund, admin_reconcile_user_consumption,
admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review,
admin_update_wallet_restriction,
},
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_get_profile_wallet_config, admin_list_profile_invite_codes,
admin_list_profile_recharge_products, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_recharge_product, admin_upsert_profile_redeem_code,
admin_upsert_profile_task_config, admin_upsert_profile_wallet_config,
},
state::AppState,
};
pub fn router(state: AppState) -> Router<AppState> {
let auth = middleware::from_fn_with_state(state, require_admin_auth);
let protected_routes = [
(
"/admin/api/agc-models",
get(crate::agc_models::admin_get_agc_models)
.put(crate::agc_models::admin_save_agc_models),
),
(
"/admin/api/accounts",
get(admin_list_accounts).post(admin_create_account),
),
(
"/admin/api/accounts/{account_id}",
axum::routing::put(admin_update_account),
),
("/admin/api/me", get(admin_me)),
("/admin/api/overview", get(admin_overview)),
("/admin/api/dashboard", get(admin_dashboard)),
(
"/admin/api/debug/http",
axum::routing::post(admin_debug_http),
),
(
"/admin/api/tracking/events",
get(admin_list_tracking_events),
),
(
"/admin/api/tracking/event-keys",
get(admin_list_tracking_event_keys),
),
(
"/admin/api/database/tables",
get(admin_list_database_tables),
),
(
"/admin/api/database/tables/{table_name}/rows",
get(admin_list_database_table_rows),
),
(
"/admin/api/external-api-keys",
get(admin_list_external_api_keys),
),
(
"/admin/api/feature-gates",
get(admin_get_feature_gate_config).put(admin_upsert_feature_gate_config),
),
(
"/admin/api/editor-generation-pricing",
get(admin_get_editor_generation_pricing).post(admin_upsert_editor_generation_pricing),
),
("/admin/api/editor-assets", get(admin_list_editor_assets)),
("/admin/api/assets/read-url", get(admin_get_asset_read_url)),
(
"/admin/api/editor-showcase/assets",
get(admin_list_editor_showcase_assets),
),
(
"/admin/api/editor-showcase/assets/review",
post(admin_review_editor_showcase_asset),
),
(
"/admin/api/editor-showcase/assets/display",
post(admin_update_editor_showcase_asset_display),
),
(
"/admin/api/editor-showcase/campaign",
get(admin_get_editor_showcase_campaign).post(admin_upsert_editor_showcase_campaign),
),
(
"/admin/api/editor-showcase/campaign/image-upload-ticket",
post(admin_create_editor_showcase_campaign_image_upload_ticket),
),
(
"/admin/api/editor-showcase/campaign/image-upload-confirm",
post(admin_confirm_editor_showcase_campaign_image_upload),
),
(
"/admin/api/profile/redeem-codes",
get(admin_list_profile_redeem_codes).post(admin_upsert_profile_redeem_code),
),
(
"/admin/api/profile/redeem-codes/disable",
axum::routing::post(admin_disable_profile_redeem_code),
),
(
"/admin/api/profile/invite-codes",
get(admin_list_profile_invite_codes).post(admin_upsert_profile_invite_code),
),
(
"/admin/api/profile/tasks",
get(admin_list_profile_task_configs).post(admin_upsert_profile_task_config),
),
(
"/admin/api/profile/tasks/disable",
axum::routing::post(admin_disable_profile_task_config),
),
(
"/admin/api/profile/wallet-config",
get(admin_get_profile_wallet_config).post(admin_upsert_profile_wallet_config),
),
(
"/admin/api/profile/recharge-products",
get(admin_list_profile_recharge_products).post(admin_upsert_profile_recharge_product),
),
(
"/admin/api/profile/recharge-orders",
get(admin_list_recharge_orders),
),
(
"/admin/api/profile/recharge-refunds/preview",
post(admin_preview_recharge_refund),
),
(
"/admin/api/profile/recharge-refunds/execute",
post(admin_execute_recharge_refund),
),
(
"/admin/api/profile/recharge-refunds/register",
post(admin_register_recharge_refund),
),
(
"/admin/api/profile/recharge-refunds/manual-review/resolve",
post(admin_resolve_recharge_refund_manual_review),
),
(
"/admin/api/profile/users/detail",
get(admin_get_user_detail),
),
(
"/admin/api/profile/users/reconcile-consumption",
post(admin_reconcile_user_consumption),
),
(
"/admin/api/profile/users/initialize-consumption-projections",
post(admin_initialize_user_consumption_projections),
),
(
"/admin/api/profile/wallet-restriction",
post(admin_update_wallet_restriction),
),
];
// 在方法层统一鉴权,未支持的方法仍由 Axum 返回 405。
let protected_router = protected_routes
.into_iter()
.fold(Router::new(), |router, (path, methods)| {
router.route(path, methods.route_layer(auth.clone()))
});
Router::new()
.route("/admin/api/login", axum::routing::post(admin_login))
.merge(protected_router)
}
#[cfg(test)]
mod route_contract_tests {
use axum::{
body::Body,
http::{Request, StatusCode, header},
middleware,
};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::router;
use crate::{config::AppConfig, request_context::attach_request_context, state::AppState};
const PROTECTED_ROUTES: &[(&str, &[&str])] = &[
("/admin/api/agc-models", &["GET", "PUT"]),
("/admin/api/accounts", &["GET", "POST"]),
("/admin/api/accounts/{account_id}", &["PUT"]),
("/admin/api/me", &["GET"]),
("/admin/api/overview", &["GET"]),
("/admin/api/dashboard", &["GET"]),
("/admin/api/debug/http", &["POST"]),
("/admin/api/tracking/events", &["GET"]),
("/admin/api/tracking/event-keys", &["GET"]),
("/admin/api/database/tables", &["GET"]),
("/admin/api/database/tables/{table_name}/rows", &["GET"]),
("/admin/api/external-api-keys", &["GET"]),
("/admin/api/feature-gates", &["GET", "PUT"]),
("/admin/api/editor-generation-pricing", &["GET", "POST"]),
("/admin/api/editor-assets", &["GET"]),
("/admin/api/assets/read-url", &["GET"]),
("/admin/api/editor-showcase/assets", &["GET"]),
("/admin/api/editor-showcase/assets/review", &["POST"]),
("/admin/api/editor-showcase/assets/display", &["POST"]),
("/admin/api/editor-showcase/campaign", &["GET", "POST"]),
(
"/admin/api/editor-showcase/campaign/image-upload-ticket",
&["POST"],
),
(
"/admin/api/editor-showcase/campaign/image-upload-confirm",
&["POST"],
),
("/admin/api/profile/redeem-codes", &["GET", "POST"]),
("/admin/api/profile/redeem-codes/disable", &["POST"]),
("/admin/api/profile/invite-codes", &["GET", "POST"]),
("/admin/api/profile/tasks", &["GET", "POST"]),
("/admin/api/profile/tasks/disable", &["POST"]),
("/admin/api/profile/wallet-config", &["GET", "POST"]),
("/admin/api/profile/recharge-products", &["GET", "POST"]),
("/admin/api/profile/recharge-orders", &["GET"]),
("/admin/api/profile/recharge-refunds/preview", &["POST"]),
("/admin/api/profile/recharge-refunds/execute", &["POST"]),
("/admin/api/profile/recharge-refunds/register", &["POST"]),
(
"/admin/api/profile/recharge-refunds/manual-review/resolve",
&["POST"],
),
("/admin/api/profile/users/detail", &["GET"]),
("/admin/api/profile/users/reconcile-consumption", &["POST"]),
(
"/admin/api/profile/users/initialize-consumption-projections",
&["POST"],
),
("/admin/api/profile/wallet-restriction", &["POST"]),
];
fn app() -> axum::Router {
let state = AppState::new(AppConfig {
admin_username: Some("root".to_string()),
admin_password: Some("secret123".to_string()),
..AppConfig::default()
})
.expect("state should build");
router(state.clone())
.layer(middleware::from_fn(attach_request_context))
.with_state(state)
}
#[tokio::test]
async fn protected_route_matrix_keeps_auth_methods_and_head_behavior() {
let app = app();
for (template, supported) in PROTECTED_ROUTES {
let path = template
.split('/')
.map(|segment| {
if segment.starts_with('{') {
"fixture"
} else {
segment
}
})
.collect::<Vec<_>>()
.join("/");
let mut allowed = supported.to_vec();
if allowed.contains(&"GET") {
allowed.push("HEAD");
}
allowed.sort_unstable();
for credential in [None, Some("Bearer invalid-token")] {
for method in [
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE",
] {
let mut request = Request::builder().method(method).uri(&path);
if let Some(credential) = credential {
request = request.header(header::AUTHORIZATION, credential);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).expect("request should build"))
.await
.expect("request should complete");
let expected = if allowed.contains(&method) {
StatusCode::UNAUTHORIZED
} else {
StatusCode::METHOD_NOT_ALLOWED
};
assert_eq!(
response.status(),
expected,
"{method} {path}, credential={credential:?}"
);
if expected == StatusCode::METHOD_NOT_ALLOWED {
let mut actual = response.headers()[header::ALLOW]
.to_str()
.expect("Allow should be ASCII")
.split(',')
.map(str::trim)
.collect::<Vec<_>>();
actual.sort_unstable();
assert_eq!(actual, allowed, "Allow for {method} {path}");
}
if method == "HEAD" {
assert!(
response
.into_body()
.collect()
.await
.expect("HEAD body should collect")
.to_bytes()
.is_empty(),
"HEAD {path} must not return a body"
);
}
}
}
}
}
#[tokio::test]
async fn unknown_paths_remain_not_found_before_authentication() {
for credential in [None, Some("Bearer invalid-token")] {
let mut request = Request::builder().uri("/admin/api/missing/unknown/route");
if let Some(credential) = credential {
request = request.header(header::AUTHORIZATION, credential);
}
let response = app()
.oneshot(request.body(Body::empty()).expect("request should build"))
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
#[tokio::test]
async fn login_remains_public_with_invalid_authorization_header() {
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header(header::AUTHORIZATION, "Bearer invalid-token")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"username":"root","password":"secret123"}"#))
.expect("login request should build"),
)
.await
.expect("login should complete");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let payload: serde_json::Value =
serde_json::from_slice(&body).expect("login should return JSON");
assert!(
payload["token"]
.as_str()
.is_some_and(|token| !token.is_empty())
);
}
}