4951b71d71
Project CI / AI game creator shell Rust crates (push) Successful in 2m46s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m37s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m5s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m40s
Project CI / Repository checks (push) Successful in 5m37s
Project CI / Frontend tests (push) Successful in 6m21s
Project CI / Backend tests (push) Successful in 9m56s
Project CI / Native shell tests (push) Successful in 10m57s
Project CI / AI game creator shell web tests (push) Successful in 5m29s
项目快照对象键升级为 agc/project-snapshots/v2/{channel}/{user}/{project}/,渠道取部署配置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL(缺省沿用客户端下载渠道),非法渠道在写入处失败关闭
后台新增渠道列表接口,项目工程列表与下载接受 channel,游标绑定渠道并拒绝跨渠道复用
后台项目工程用户列改为素材查询口径:昵称 + 陶泥号 + 用户详情入口,由 api-server 解析作者信息
后台项目工程改为游标分页:每页 20/50/100、上一页/下一页与当前页提示,翻页失败保留当前页
部署环境示例补充快照渠道配置,并同步运维、技术方案、里程碑与决策记录文档
424 lines
16 KiB
Rust
424 lines
16 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/project-snapshots",
|
|
get(crate::admin_project_snapshots::admin_list_project_snapshots),
|
|
),
|
|
(
|
|
"/admin/api/project-snapshots/channels",
|
|
get(crate::admin_project_snapshots::admin_list_project_snapshot_channels),
|
|
),
|
|
(
|
|
"/admin/api/project-snapshots/{user_id}/{project_id}/download",
|
|
get(crate::admin_project_snapshots::admin_download_project_snapshot),
|
|
),
|
|
(
|
|
"/admin/api/agc-templates",
|
|
get(crate::admin_templates::admin_list_agc_templates),
|
|
),
|
|
(
|
|
"/admin/api/agc-templates/{id}",
|
|
axum::routing::put(crate::admin_templates::admin_update_agc_template)
|
|
.layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)),
|
|
),
|
|
(
|
|
"/admin/api/agc-templates/import",
|
|
axum::routing::post(crate::admin_templates::admin_import_agc_templates).layer(
|
|
axum::extract::DefaultBodyLimit::max(
|
|
crate::admin_templates::AGC_TEMPLATE_IMPORT_BODY_LIMIT_BYTES,
|
|
),
|
|
),
|
|
),
|
|
(
|
|
"/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/project-snapshots", &["GET"]),
|
|
("/admin/api/project-snapshots/channels", &["GET"]),
|
|
(
|
|
"/admin/api/project-snapshots/{user_id}/{project_id}/download",
|
|
&["GET"],
|
|
),
|
|
("/admin/api/agc-templates", &["GET"]),
|
|
("/admin/api/agc-templates/{id}", &["PUT"]),
|
|
("/admin/api/agc-templates/import", &["POST"]),
|
|
("/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())
|
|
);
|
|
}
|
|
}
|