Skip to main content

dtmrs_server/
http.rs

1//! HTTP 协议层:把 HTTP 请求翻译成 `Api` 调用,再把结果翻译成 DTM 的应答格式。
2//!
3//! ⚠ **这一层只做协议转换,不做任何业务判断** —— 判断全在 `api.rs`。
4//! gRPC 层(`grpc/server.rs`)是它的对偶,两边必须对同一个请求给出同样的受理/
5//! 拒绝结论。否则会出现「同一个请求走 HTTP 被拒、走 gRPC 却受理了」。
6//!
7//! 这个模块**刻意放在 dtmrs-server 而不是二进制 crate 里**:早先它写在
8//! `main.rs`,测试够不着,覆盖率是 0%,而 gRPC 层有 86% —— 防漂移的约束
9//! 只有一半受测试保护。搬过来之后 `router()` 可导出,两边就能用同一组
10//! 用例做等价性测试(见 tests/http.rs 的「HTTP 与 gRPC 等价」那几个)。
11
12use crate::api::{Api, ApiError, RegisterBranch, TransView};
13use axum::extract::{Query, State};
14use axum::http::StatusCode;
15use axum::routing::{get, post};
16use axum::{Json, Router};
17use dtmrs_core::SagaStep;
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21/// axum 的 `State` 要求 Clone;`Api` 内部是 Arc,克隆很便宜。
22#[derive(Clone)]
23pub struct App {
24    api: Api,
25}
26
27impl App {
28    pub fn new(api: Api) -> Self {
29        Self { api }
30    }
31}
32
33#[derive(Deserialize)]
34struct SubmitReq {
35    gid: String,
36    #[serde(default = "default_trans_type")]
37    trans_type: String,
38    /// saga 一次性给全部步骤;tcc/msg 走 prepare + submit,这里可以不带
39    #[serde(default)]
40    steps: Vec<SagaStep>,
41}
42
43/// 二阶段消息 / TCC 的第一阶段
44#[derive(Deserialize)]
45struct PrepareReq {
46    gid: String,
47    trans_type: String,
48    /// msg 用:正向分支列表(没有补偿)
49    #[serde(default)]
50    actions: Vec<String>,
51    /// msg 用:回查地址。进程在 prepare 和 submit 之间崩了,TC 靠它决断
52    #[serde(default)]
53    query_prepared: String,
54    /// msg 用:回查前的宽限秒数,默认 10
55    #[serde(default)]
56    grace_secs: Option<i64>,
57}
58
59/// 分支登记。TCC 用 confirm/cancel,XA 用 commit/rollback。
60#[derive(Deserialize)]
61struct RegisterBranchReq {
62    gid: String,
63    branch_id: String,
64    #[serde(default)]
65    confirm: String,
66    #[serde(default)]
67    cancel: String,
68    /// TCC 的 try,可选,只为可观测性存一份
69    #[serde(default)]
70    r#try: String,
71    #[serde(default)]
72    commit: String,
73    #[serde(default)]
74    rollback: String,
75}
76
77fn default_trans_type() -> String {
78    "saga".into()
79}
80
81#[derive(Serialize)]
82struct Reply {
83    dtm_result: &'static str,
84    #[serde(skip_serializing_if = "Option::is_none")]
85    message: Option<String>,
86}
87
88impl Reply {
89    fn ok() -> Json<Self> {
90        Json(Self {
91            dtm_result: "SUCCESS",
92            message: None,
93        })
94    }
95    fn err(m: impl Into<String>) -> Json<Self> {
96        Json(Self {
97            dtm_result: "FAILURE",
98            message: Some(m.into()),
99        })
100    }
101}
102
103/// [`ApiError`] → HTTP。
104///
105/// `Conflict` 返回 **200 + FAILURE 体**是刻意保留的历史行为(已终结的事务
106/// 再调 abort),换成 4xx 会打破现有客户端。
107fn http_err(e: ApiError) -> (StatusCode, Json<Reply>) {
108    let code = match &e {
109        ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
110        ApiError::NotFound(_) => StatusCode::NOT_FOUND,
111        ApiError::Conflict(_) => StatusCode::OK,
112        ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
113    };
114    (code, Reply::err(e.message().to_string()))
115}
116
117fn http_result(r: Result<(), ApiError>) -> (StatusCode, Json<Reply>) {
118    match r {
119        Ok(()) => (StatusCode::OK, Reply::ok()),
120        Err(e) => http_err(e),
121    }
122}
123
124
125/// 不带认证的 router(本地/内网用)。要保护请用 [`router_with_auth`]
126pub fn router(app: App) -> Router {
127    routes(app)
128}
129
130/// 带登录保护的 router。
131///
132/// ⚠ 中间件是**全局**的,不是只挡管理台页面 —— 真正危险的是它调的那些接口
133/// (abort 能中止在途事务、retry 能改调度、submit 能凭空造事务)。
134/// 白名单只有 `/health`(反代健康检查)和 `/login` `/logout`。
135pub fn router_with_auth(
136    app: App,
137    auth: std::sync::Arc<crate::auth::Auth>,
138    store: dtmrs_store::Store,
139) -> Router {
140    use axum::middleware;
141    // 三组路由三种状态:业务路由用 App、登录用 Arc<Auth>、令牌管理两个都要。
142    // 各自 with_state 收敛成 Router<()> 之后再 merge,最后统一挂中间件
143    let auth_routes = Router::new()
144        .route(
145            "/login",
146            get(crate::auth::login_page).post(crate::auth::login_submit),
147        )
148        .route("/logout", post(crate::auth::logout))
149        .with_state(auth.clone());
150    let token_routes = Router::new()
151        .route("/api/admin/tokens", get(crate::auth::tokens_list))
152        .route("/api/admin/tokens/create", post(crate::auth::tokens_create))
153        .route("/api/admin/tokens/revoke", post(crate::auth::tokens_revoke))
154        .route("/api/admin/tokens/reveal", post(crate::auth::tokens_reveal))
155        .with_state((auth.clone(), store));
156    routes(app)
157        .merge(auth_routes)
158        .merge(token_routes)
159        .layer(middleware::from_fn_with_state(auth, crate::auth::guard))
160}
161
162fn routes(app: App) -> Router {
163    Router::new()
164        .route("/api/dtmsvr/newGid", get(new_gid))
165        .route("/api/dtmsvr/prepare", post(prepare))
166        .route("/api/dtmsvr/registerBranch", post(register_branch))
167        .route("/api/dtmsvr/submit", post(submit))
168        .route("/api/dtmsvr/abort", post(abort))
169        .route("/api/dtmsvr/retry", post(retry))
170        .route("/api/dtmsvr/query", get(query))
171        .route("/api/dtmsvr/all", get(all))
172        .route("/health", get(|| async { "ok" }))
173        // 管理台。单文件内嵌,没有构建步骤也没有外部依赖 ——
174        // 内网和离线环境都能直接用
175        .route("/", get(console))
176        .route("/console", get(console))
177        .with_state(app)
178}
179
180async fn new_gid(State(app): State<App>) -> Json<HashMap<&'static str, String>> {
181    Json(HashMap::from([("gid", app.api.new_gid())]))
182}
183
184async fn submit(State(app): State<App>, Json(req): Json<SubmitReq>) -> (StatusCode, Json<Reply>) {
185    http_result(app.api.submit(&req.gid, &req.trans_type, &req.steps).await)
186}
187
188async fn prepare(State(app): State<App>, Json(req): Json<PrepareReq>) -> (StatusCode, Json<Reply>) {
189    http_result(
190        app.api
191            .prepare(
192                &req.gid,
193                &req.trans_type,
194                &req.actions,
195                &req.query_prepared,
196                req.grace_secs,
197            )
198            .await,
199    )
200}
201
202async fn register_branch(
203    State(app): State<App>,
204    Json(req): Json<RegisterBranchReq>,
205) -> (StatusCode, Json<Reply>) {
206    http_result(
207        app.api
208            .register_branch(&RegisterBranch {
209                gid: req.gid,
210                branch_id: req.branch_id,
211                confirm: req.confirm,
212                cancel: req.cancel,
213                r#try: req.r#try,
214                commit: req.commit,
215                rollback: req.rollback,
216            })
217            .await,
218    )
219}
220
221#[derive(Deserialize)]
222struct GidQuery {
223    gid: String,
224}
225
226async fn abort(State(app): State<App>, Json(q): Json<GidQuery>) -> (StatusCode, Json<Reply>) {
227    http_result(app.api.abort(&q.gid).await)
228}
229
230/// 立刻重试:把事务排到调度队首。管理台用,也可以直接调
231async fn retry(State(app): State<App>, Json(q): Json<GidQuery>) -> (StatusCode, Json<Reply>) {
232    http_result(app.api.retry(&q.gid).await)
233}
234
235/// 管理台页面。`include_str!` 编进二进制,部署时不用带额外文件
236async fn console() -> axum::response::Html<&'static str> {
237    axum::response::Html(include_str!("console.html"))
238}
239
240async fn query(
241    State(app): State<App>,
242    Query(q): Query<GidQuery>,
243) -> Result<Json<TransView>, (StatusCode, Json<Reply>)> {
244    app.api.query(&q.gid).await.map(Json).map_err(http_err)
245}
246
247async fn all(State(app): State<App>) -> Json<Vec<TransView>> {
248    Json(app.api.list_recent(100).await)
249}