rapid-structure-rs 1.0.0

Rust document-structure engine for PaddleOCR-family ONNX models: text detection/recognition (PP-OCRv6), table structure (SLANet-plus) and formula recognition (PP-FormulaNet) on Windows x64 via ONNX Runtime.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
//! HTTP 层:路由、准入顺序、响应头与端点分发(§4.2、§4.4、§7、§9.5)。
//!
//! # 这是**唯一**引用 `tiny_http` 的地方
//!
//! `tiny_http` 是 optional 依赖且不进 `default`(`Cargo.toml` 的 `serve` feature),
//! 库侧依旧零 HTTP 依赖。`src/bin/serve/mod.rs` 的边界测试会扫描整个 `serve/` 子树:
//! 只有本文件可以出现 `tiny_http` 的路径引用。
//!
//! # 线程模型
//!
//! accept loop 跑在一个具名线程上(`serve-accept`),主线程在它上面 join(`run.rs`)。
//! 循环用 `recv_timeout` + 关闭标志,退出时用 `Server::unblock()` 立即唤醒;
//! 静态/校验类请求**就地**处理(廉价,无 I/O 阻塞),OCR 请求只做准入 + 有界读取 +
//! 入队,**推理绝不在本线程执行**(§8.2)。
//!
//! # 准入顺序(§4.4,逐条对应 `admit.rs` 的冻结顺序)
//!
//! 1. 路由 → 404/405(本模块的 `route_of` 给出结论);
//! 2. token → 401;3. Host → 421、Origin(仅 `POST/PUT/DELETE`)→ 403;
//! 4. 队列容量预检 → 503(**尚未读取请求体**);5. `Content-Length` 预检 → 413;
//! 6. 有界流式读取(总字节上限 + 读取超时);7. 通过后才建任务。

use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};

use serde_json::{Value, json};
use tiny_http::{Header, Request, Response, Server, StatusCode};

use rapid_structure_rs::{ProviderPreference, WordOutputMode};

use super::admit::{
    self, AdmissionError, BodySource, ChunkOutcome, HttpMethod, QueueAdmission, RequestDescriptor,
    RouteDecision,
};
use super::cli::ProviderChoice;
use super::error::ServeError;
use super::evaluate;
use super::export::ExportFormat;
use super::flowlog::RequestFlow;
use super::jobs::JobQueue;
use super::queue::QueueClass;
use super::run::ServeStartError;
use super::security::{self, SECURITY_HEADERS};
use super::server::{
    Body, OcrJobParams, READ_CHUNK_BYTES, ServeContext, ServeRuntime, ServeShared,
};

/// accept loop 的等待上限(§8.1:`recv_timeout` + 关闭标志)。
const ACCEPT_TIMEOUT: Duration = Duration::from_millis(200);
/// 请求体读取的整体上限(§4.4 第 6 步的"读取超时")。
///
/// **已知边界**:`tiny_http` 不暴露 socket 读超时,因此一次已经发起、对端却不发数据的
/// `read` 无法被中断;本实现保证的是"**每次尝试读取前**检查截止时间",
/// 以及 chunked/无长度体的总字节上限。见 M1 报告的"未覆盖风险"。
const BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);
/// `?max_side=` 的允许上界(与库内 `clamp(32.0, 8192.0)` 的量级一致,留出余量)。
const MAX_SIDE_CEILING: u32 = 32_768;

/// 导出响应的 CSP(§9.5 第 2 条,**独立**于主页面的 nonce CSP)。
///
/// 逐字来自 `docs/05` §9.5:
/// `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; sandbox`。
/// 三点含义:导出文档**不允许脚本**(`ReportMode::Static` 的正文里也一个 `<script` 都没有)、
/// 样式内联允许(报告的全部样式都是内联 `<style>`)、图片只允许 `data:`(导出的标注图就是
/// 内嵌的 data URL,因此脱离服务仍可查看)。
///
/// `style-src 'unsafe-inline'` **只**出现在这条导出响应上;主页面 CSP 仍是 nonce。
pub(super) const EXPORT_CSP: &str =
    "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; sandbox";

/// 主页面 CSP(§9.5 第 3 条;**不含** `unsafe-inline`,nonce 与页面属性逐字节相同)。
///
/// `font-src 'self'` 只为一个目的存在:KaTeX 的 woff2 字体(同源、编译期嵌入,见
/// [`super::assets`])。MathJax 走 tex-svg 输出(内联路径,不取字体),不需要它。
/// 其余仍由 `default-src 'none'` 兜底;脚本与样式只认 nonce。
pub(super) fn page_csp(nonce: &str) -> String {
    format!(
        "default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; font-src \
         'self'; img-src 'self' blob: data:; connect-src 'self'; base-uri 'none'; form-action \
         'none'; frame-ancestors 'none'"
    )
}

/// 路由表(§4.2 的全部端点;M3 起包含 `annotated.png` 与 `export`)。
#[derive(Debug, Clone, PartialEq, Eq)]
enum Route {
    Page,
    /// `GET /static/*`:编译期嵌入的公式渲染引擎(KaTeX / MathJax,见 [`super::assets`])。
    /// 不在 `/api/*` 之下,因此**不**要求令牌(§7.2);路径只参与封闭查表,不碰文件系统。
    Static(String),
    Status,
    Models,
    ModelsDownload,
    /// `POST /api/models/reverify`(A2):清缓存 → 冷验证 → 重建引擎。
    ModelsReverify,
    EngineReload,
    Ocr,
    /// `POST /api/evaluate`(M4):批量评估一份已标注清单。
    Evaluate,
    Job(String),
    JobResult(String),
    JobCancel(String),
    /// `GET /api/jobs/{id}/annotated.png`(M3)。
    JobAnnotated(String),
    /// `GET /api/jobs/{id}/export?format=…`(M3)。
    JobExport(String),
}

impl Route {
    fn method(&self) -> HttpMethod {
        match self {
            Self::Page
            | Self::Status
            | Self::Models
            | Self::Job(_)
            | Self::JobResult(_)
            | Self::JobAnnotated(_)
            | Self::JobExport(_)
            | Self::Static(_) => HttpMethod::Get,
            Self::ModelsDownload
            | Self::ModelsReverify
            | Self::EngineReload
            | Self::Ocr
            | Self::Evaluate
            | Self::JobCancel(_) => HttpMethod::Post,
        }
    }

    fn allow(&self) -> &'static str {
        match self.method() {
            HttpMethod::Get => "GET",
            _ => "POST",
        }
    }

    /// §7.2:令牌对**所有 `/api/*`** 必需,`GET /`(令牌的下发者本身)除外。
    /// `/static/*` 不是 `/api/*`,且资源不含用户数据,同样不需令牌。
    fn requires_token(&self) -> bool {
        !matches!(self, Self::Page | Self::Static(_))
    }
}

/// 已绑定的监听端口(§7.6 第 1 步的产物:绑定与断言都在这里完成)。
pub(super) struct BoundServer {
    server: Arc<Server>,
    local_addr: SocketAddr,
}

impl BoundServer {
    /// 绑定**硬编码**的 loopback 地址(`security::bind_address`:签名里没有地址参数),
    /// 并断言实际绑定地址属于 loopback(§7.1)。
    pub fn bind(port: u16) -> Result<Self, ServeStartError> {
        let requested = security::bind_address(port);
        let server = Server::http(requested).map_err(|error| ServeStartError::Bind {
            address: requested,
            detail: error.to_string(),
        })?;
        let bound = server.server_addr().to_ip().ok_or(ServeStartError::Bind {
            address: requested,
            detail: "the listener does not expose an IPv4 address".to_string(),
        })?;
        let local_addr = security::assert_loopback(bound)?;
        Ok(Self {
            server: Arc::new(server),
            local_addr,
        })
    }

    pub fn local_addr(&self) -> SocketAddr {
        self.local_addr
    }
}

/// 运行期句柄:绑定的 `Server` + 运行期 + accept 线程。
pub(super) struct ServeHandle {
    server: Arc<Server>,
    runtime: ServeRuntime,
    accept: Option<JoinHandle<()>>,
    local_addr: SocketAddr,
}

impl ServeHandle {
    /// 建运行期并启动 accept 线程(绑定必须已经完成)。
    pub fn start(bound: BoundServer, context: ServeContext) -> Result<Self, ServeStartError> {
        let BoundServer { server, local_addr } = bound;
        let runtime = ServeRuntime::new(context).map_err(ServeStartError::Runtime)?;
        let accept = {
            let server = Arc::clone(&server);
            let shared = Arc::clone(runtime.shared());
            thread::Builder::new()
                .name("serve-accept".to_string())
                .spawn(move || accept_loop(&server, &shared))
                .map_err(ServeStartError::Runtime)?
        };
        Ok(Self {
            server,
            runtime,
            accept: Some(accept),
            local_addr,
        })
    }

    pub fn local_addr(&self) -> SocketAddr {
        self.local_addr
    }

    pub fn shared(&self) -> &Arc<ServeShared> {
        self.runtime.shared()
    }

    /// 阻塞直到 accept loop 退出(进程正常运行时即整个服务生命周期)。
    pub fn accept_loop(&mut self) {
        if let Some(accept) = self.accept.take() {
            let _ = accept.join();
        }
    }
}

/// 关闭是**无条件**的:设置标志 → `Server::unblock()`(立刻唤醒 accept 线程)→
/// 等 accept 与三个 worker 退出。
///
/// 放在 `Drop` 而不是一个必须被记得调用的方法上:句柄被丢弃(正常返回、`?` 提前返回、
/// panic 展开)时都必须收敛,否则监听端口与 worker 会一直留着。
impl Drop for ServeHandle {
    fn drop(&mut self) {
        self.shared().shutdown();
        self.server.unblock();
        if let Some(accept) = self.accept.take() {
            let _ = accept.join();
        }
        self.runtime.stop();
    }
}

/// accept loop(§8.1)。
fn accept_loop(server: &Arc<Server>, shared: &Arc<ServeShared>) {
    while !shared.is_shutting_down() {
        match server.recv_timeout(ACCEPT_TIMEOUT) {
            Ok(Some(request)) => handle(shared, request),
            Ok(None) => {}
            Err(error) => {
                // tiny_http 用 `unblock()` 打破 recv 时也会走到这里;关闭中就不再报错。
                if !shared.is_shutting_down() {
                    eprintln!("serve: accept failed: {error}");
                }
            }
        }
    }
}

/// 一次请求:路由 → 准入 → 端点。
fn handle(shared: &Arc<ServeShared>, mut request: Request) {
    // `HttpMethod::parse` 是 M0c 里方法解析的唯一实现(大小写不敏感)。
    let method = HttpMethod::parse(request.method().as_str());
    // `Request::url()` 借用请求本身,而后面还要可变借用它来读 body:先拷成自有字符串。
    let url = request.url().to_string();
    let (path, query) = split_url(&url);
    let (decision, route) = route_of(method, path);
    // M5:这次请求的流动日志。在整个 `handle` 里唯一创建,并交给**真正写响应**的那条
    // 路径(本线程 / 引擎工作线程 / 评估线程),因此请求 id 与耗时都只有一份来源。
    let flow = RequestFlow::begin(shared.flow(), method.name(), path);

    // §4.4 第 1 步:路由结论先于一切(404/405 不读 body、不校验 token)。
    if decision != RouteDecision::Matched {
        let headers = match (&route, decision) {
            (Some(known), RouteDecision::MethodNotAllowed) => {
                vec![("Allow".to_string(), known.allow().to_string())]
            }
            _ => Vec::new(),
        };
        let _ = respond(
            request,
            route_error(decision, method, path),
            &headers,
            &flow,
        );
        return;
    }
    let route = route.expect("a matched route decision always carries its route");

    // 第 4 步的预检对象。`queue` 参数在这里**宽容解析**:非法取值不改变判定顺序
    // (token → Host/Origin → 队列 → 长度),它会在准入之后由 `dispatch` 以 400 回答。
    // 原值留给流动日志:`?queue=formla` 这种拼写错误必须被记成它本来的样子。
    let queue_raw = query_param(query, "queue");
    let class = match route {
        Route::Ocr => shared.routing().class_for(queue_raw).ok(),
        _ => None,
    };
    // E16.2:`?table=` 与 `?queue=` **正交**(见 `OcrRouting` 的文档)。这里与 `queue` 同样
    // 宽容解析(`.ok()`),非法取值/未启用的 `1` 由 `dispatch` 以 400 回答——判定顺序不变。
    let table_raw = query_param(query, "table");
    let table = match route {
        Route::Ocr => shared.routing().table_requested(table_raw).ok(),
        _ => None,
    };
    // 第 4 步的**原子预留**入口(评审 P2-1):`admit` 在判定容量的同一次加锁里占位,
    // 因此两个并发请求不可能都通过预检、都读入大 body。
    let slot_source = class.map(|class| QueueSlotSource { shared, class });

    // 头字段先取成自有字符串:`RequestDescriptor` 借用的是这份局部数据,而不是
    // `Request` 本身,否则下面读 body 需要的可变借用会与之冲突。
    let token_header = header(&request, "X-RapidStructure-Token").map(str::to_string);
    let host_header = header(&request, "Host").map(str::to_string);
    let origin_header = header(&request, "Origin").map(str::to_string);
    let content_type_header = header(&request, "Content-Type").map(str::to_string);
    let content_length = request.body_length().map(|length| length as u64);
    let is_chunked = header(&request, "Transfer-Encoding").is_some();

    let descriptor = RequestDescriptor {
        method,
        path,
        route: decision,
        // §7.2:`GET /` 是令牌的**下发者**,它自己不可能要求令牌;其余路由一律要求。
        has_token: !route.requires_token()
            || token_header
                .as_deref()
                .is_some_and(|candidate| shared.token().matches(candidate)),
        host: host_header.as_deref(),
        origin: origin_header.as_deref(),
        // 第 4 步:队列容量(**尚未读取 body**)。判定与预留是同一次加锁;
        // 下载 channel 的容量由 `try_send` 兜底。
        queue: match &slot_source {
            Some(source) => QueueAdmission::Reserve(source),
            None => QueueAdmission::NotQueued,
        },
        content_length,
        // tiny_http 不暴露"预读进缓冲区的字节数";上限由第 6 步的流式账本兜住。
        body_so_far: 0,
        content_type: content_type_header.as_deref(),
        is_chunked,
    };

    let admitted = match admit::admit(&descriptor, shared.local(), shared.limits().max_body_bytes) {
        Ok(admitted) => admitted,
        Err(error) => {
            // 第 1 步的路由结论由 `admit` 原样回传(M0c 不伪造 code);第 2–5 步的失败
            // 就是 §11.1 的 `ServeError`。
            match &error {
                AdmissionError::Route {
                    path: route_path,
                    method: route_method,
                    decision: route_decision,
                } => {
                    let body = route_error(*route_decision, *route_method, route_path);
                    let _ = respond(request, body, &[], &flow);
                }
                AdmissionError::Rejected(_) => {
                    let serve_error = error
                        .rejected()
                        .expect("a Rejected admission error always carries a ServeError");
                    let body = shared.error_body(serve_error);
                    // M5:`/api/ocr` 在读 body 之前被挡下(队列满 503 / token 401 /
                    // Host 421 / Origin 403 / 长度 413)也是一次准入结论,同样要留下原因。
                    if matches!(route, Route::Ocr) {
                        flow.job_rejected(
                            queue_label(class, queue_raw),
                            serve_error.status_code(),
                            serve_error.code(),
                            &serve_error.detail_text(),
                        );
                    }
                    let _ = respond(request, body, &[], &flow);
                }
            }
            return;
        }
    };

    // §4.4 第 4 步之后、第 6 步(读 body)之前:公式队列的模型**可用性**预检。
    //
    // 判定用的是与 `/api/models` **同一份**哈希状态(库的身份键控校验缓存:键 = 路径 +
    // 体积 + mtime)。首次见到某个身份时真的读盘(启动快照已经算过一次),命中只花一次
    // `stat`,因此"损坏但存在"的公式文件/检测模型在**读入请求体之前**就是 409
    // `models_corrupt`(评审 P1-2:只 `stat` 的旧预检会让它溜到 worker 里才失败)。
    // 文本队列不受影响。
    if matches!(route, Route::Ocr)
        && class == Some(QueueClass::Formula)
        && !shared.formula_models_ready()
    {
        // M5:公式队列的模型可用性预检同样是一次准入拒绝(**读 body 之前**),
        // 因此原因要落在请求 id 上,否则"上传没有任何反应"依旧无法解释。
        let body = shared.formula_blocked_body();
        let (code, detail) = shared.formula_blocked_reason();
        flow.job_rejected(queue_label(class, queue_raw), body.status, code, &detail);
        let _ = respond(request, body, &[], &flow);
        return;
    }

    // E16.2:表格阶段的模型可用性预检(同一处、同一口径、**读 body 之前**)。
    //
    // 表格只有一个文件,因此判据更简单,但结论的形状与公式完全一致:`409 models_missing`
    // / `models_corrupt`,`detail.scope = "table"`,并在流动日志里留下同一个请求 id。
    // `table == Some(true)` 才预检:`?table=0` 与不带参数都不该因为结构模型缺失而被拒绝。
    if matches!(route, Route::Ocr) && table == Some(true) && !shared.table_models_ready() {
        let body = shared.table_blocked_body();
        let (code, detail) = shared.table_blocked_reason();
        flow.job_rejected(queue_label(class, queue_raw), body.status, code, &detail);
        let _ = respond(request, body, &[], &flow);
        return;
    }

    let outcome = dispatch(
        shared,
        &mut request,
        &route,
        query,
        &descriptor,
        admitted,
        &flow,
    );
    match outcome {
        Ok(Dispatch::Respond(body, headers)) => {
            let _ = respond(request, body, &headers, &flow);
        }
        // 响应由另一个线程写(M3 的 provider 切换 / M2 的按当前文件重建):本线程立刻回到
        // accept 循环,否则 `/api/status` 与 `/api/ocr` 会在整个建会话序列期间停摆。
        //
        // 线程没接手时(已有序列在跑 / 线程起不来)调用方拿回**同一个** `RequestFlow`,
        // 因此这次请求从头到尾只有一个请求 id(绝不因为换了条分支就换一个)。
        Ok(Dispatch::EngineWork(work)) => {
            if let Err(failure) = spawn_engine_work(shared, work, request, flow) {
                let (error, request, flow) = *failure;
                let _ = respond(request, Body::error(&error), &[], &flow);
            }
        }
        // 响应由评估线程写(M4):同一个理由(批量推理期间服务必须继续可观测、可提交任务)。
        Ok(Dispatch::Evaluate(manifest)) => {
            if let Err(failure) = spawn_evaluation(shared, manifest, request, flow) {
                let (error, request, flow) = *failure;
                let _ = respond(request, Body::error(&error), &[], &flow);
            }
        }
        Err(error) => {
            let body = shared.error_body(&error);
            let _ = respond(request, body, &[], &flow);
        }
    }
}

/// 流动日志里的队列名(准入在 `class` 解析出来之前就可能被拒绝,因此允许 `None`)。
///
/// `class = None` 时**如实写出用户发的是什么**(`raw` 是 `?queue=` 的原值),而不是把它
/// 冒充成 `text`:一个 `?queue=formla` 的拼写错误如果被记成 `queue=text`,日志就把
/// "参数非法"这件事说成了"文本队列被拒绝"。
fn queue_label(class: Option<QueueClass>, raw: Option<&str>) -> &str {
    match class {
        Some(QueueClass::Text) => QueueClass::Text.name(),
        Some(QueueClass::Formula) => QueueClass::Formula.name(),
        None => raw.filter(|raw| !raw.trim().is_empty()).unwrap_or("text"),
    }
}

/// §4.4 第 4 步的 `QueueSlots` 生产实现:把 `Arc<ServeShared>` 的预留入口交给准入层。
///
/// 它只有两个字段(共享状态 + 队列类别),因此"预留"这件事在 http 层不引入任何新的状态:
/// 容量账目始终只有 `ServeShared.jobs` 一个所有者。
struct QueueSlotSource<'a> {
    shared: &'a Arc<ServeShared>,
    class: QueueClass,
}

impl admit::QueueSlots for QueueSlotSource<'_> {
    fn reserve(&self) -> Option<admit::QueueReservation> {
        self.shared.reserve_queue_slot(self.class)
    }
}

/// 端点分发的结论(见 [`dispatch`])。
enum Dispatch {
    /// 本线程写响应。
    Respond(Body, Vec<(String, String)>),
    /// 交给 [`spawn_engine_work`]:它拥有请求对象(含写响应的责任)。
    ///
    /// `EngineWork::Reload(None)` = 无 body 的 `POST /api/engine/reload`(按磁盘上的当前文件
    /// 重建会话);`EngineWork::Reload(Some(provider))` = M3 的显式 provider 设置应用;
    /// `EngineWork::ReverifyModels` = `POST /api/models/reverify`(A2)。**三者走同一条线程
    /// 路径**:ONNX Runtime 建会话绝不在 accept 线程上发生(评审 P2-2)。
    EngineWork(EngineWork),
    /// 交给 [`spawn_evaluation`](M4):评估是批量动作,同样由独立线程写响应。
    Evaluate(PathBuf),
}

/// 一次"引擎工作"的具体形态(见 [`Dispatch::EngineWork`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EngineWork {
    /// `POST /api/engine/reload`:`None` = 按当前文件重建,`Some` = 应用 provider 设置。
    Reload(Option<ProviderPreference>),
    /// `POST /api/models/reverify`:清缓存 + 冷验证 + 重建引擎(A2)。
    ReverifyModels,
}

/// 端点分发(准入已通过,这里才允许读 body)。
fn dispatch(
    shared: &Arc<ServeShared>,
    request: &mut Request,
    route: &Route,
    query: &str,
    descriptor: &RequestDescriptor<'_>,
    admitted: admit::Admit,
    flow: &RequestFlow,
) -> Result<Dispatch, ServeError> {
    match route {
        Route::Page => Ok(Dispatch::Respond(
            Body::html(shared.page().as_bytes().to_vec()),
            vec![(
                "Content-Security-Policy".to_string(),
                page_csp(shared.nonce()),
            )],
        )),
        // 封闭表查中即 200;表里没有 → 和其它未知路径同一个 404(不区分“目录不存在”
        // 与“文件不存在”,避免把表形状泄露给非同源的探测)。缓存头由 `SECURITY_HEADERS`
        // 统一带 `no-store`:资源随二进制演进,升级后不能吃浏览器缓存里的旧版本。
        Route::Static(path) => match super::assets::lookup(path) {
            Some((content_type, bytes)) => Ok(Dispatch::Respond(
                Body::typed(200, content_type, bytes.to_vec()),
                Vec::new(),
            )),
            None => Ok(Dispatch::Respond(
                route_error(RouteDecision::NotFound, HttpMethod::Get, path),
                Vec::new(),
            )),
        },
        Route::Status => Ok(Dispatch::Respond(
            json_body(200, shared.status_json())?,
            Vec::new(),
        )),
        Route::Models => Ok(Dispatch::Respond(
            json_body(200, shared.models_json())?,
            Vec::new(),
        )),
        Route::Ocr => {
            descriptor.check_content_type()?;
            let bytes = read_body(request, &admitted)?;
            let max_side = parse_max_side(query_param(query, "max_side"), shared.min_side_len())?;
            let class = shared.routing().class_for(query_param(query, "queue"))?;
            // E16.2:`?table=` 在这里**严格**解析(`?` 而不是 `.ok()`):`?table=2` 与
            // "路由没打开却给 `1`" 都是 400。第 4 步的宽容解析只为判定顺序服务,真正的
            // 取值校验必须在建任务之前完成 —— 否则任务已经入队,才发现参数是错的。
            let table = shared
                .routing()
                .table_requested(query_param(query, "table"))?;
            let words = parse_words(query_param(query, "words"))?;
            // 三个请求侧参数在入队那一刻打包成一份([OcrJobParams]):worker 之后只看得见
            // 任务,看不见这次 HTTP 请求。
            let params = OcrJobParams {
                max_side,
                table,
                words,
            };
            // 第 4 步预留的槽位随请求一路传到这里:入队成功即提交,任何提前返回都会归还容量。
            let value = shared.submit_ocr(bytes, class, params, admitted.reservation, flow)?;
            // §4.2:任务提交返回 **202**(异步任务,不存在"同步返回结果"的第二套语义)。
            Ok(Dispatch::Respond(json_body(202, value)?, Vec::new()))
        }
        Route::ModelsDownload => {
            check_json_content_type(descriptor.content_type)?;
            let bytes = read_body(request, &admitted)?;
            let set_id = parse_set_id(&bytes)?;
            let value = match shared.submit_download(&set_id, flow) {
                Ok(value) => value,
                Err(error) => {
                    // M5:下载任务的准入拒绝同样留痕(`--allow-download` 未开、集合未知、
                    // 超出 `--max-download-mb`、磁盘不足、channel 满)。下载**不经过**双队列,
                    // 因此它没有 `queued position=` 行;准入失败与终态两行是它的全部记录。
                    flow.job_rejected(
                        JobQueue::Download.name(),
                        error.status_code(),
                        error.code(),
                        &error.detail_text(),
                    );
                    return Err(error);
                }
            };
            Ok(Dispatch::Respond(json_body(202, value)?, Vec::new()))
        }
        Route::ModelsReverify => {
            // A2:一次动作完成"重新读盘"。请求体**必须为空**(这是无参数动作,任何 body
            // 都是协议级错误),并且**委派**给引擎工作线程:它要清缓存、冷验证 566 MB
            // 量级的模型并重建会话,绝不能在 accept 线程上跑(§8.2、评审 P2-2)。
            let body = read_body(request, &admitted)?;
            if !body.is_empty() {
                return Err(ServeError::BadRequest);
            }
            Ok(Dispatch::EngineWork(EngineWork::ReverifyModels))
        }
        Route::Evaluate => {
            check_json_content_type(descriptor.content_type)?;
            let bytes = read_body(request, &admitted)?;
            let manifest = evaluate::parse_manifest_body(&bytes)?;
            // **委派**:一次评估最多 `--max-eval-cases` 张图、每张一次完整推理;在 accept
            // 线程上跑会让 `/api/status`、两个 OCR 队列与下载在整个评估期间停摆。
            // 请求对象由 `handle` 交给那个线程(这里只做"要不要委派"的判定)。
            Ok(Dispatch::Evaluate(manifest))
        }
        Route::EngineReload => {
            // 显式创建/重建引擎(§4.2、§7.6):无 body = M2 的"按当前文件重建";
            // 带 `{"provider": …}` = M3 的显式 provider 设置应用(含 Rebuilding 序列)。
            let body = read_body(request, &admitted)?;
            let provider = parse_provider_body(body)?;
            // **委派(两种形态都委派)**:建会话要排空在跑的推理、再建(最多两次)会话。
            // 在 accept 线程上执行会让整个服务(包括 `/api/status` 与 `POST /api/ocr`)
            // 在它结束前停摆,而"`Loading`/`Rebuilding` 可见""新请求入队而不是被拒绝"
            // 正是要观察的行为(评审 P2-2:无 body 的那一支以前漏了这一步)。
            Ok(Dispatch::EngineWork(EngineWork::Reload(provider)))
        }
        Route::Job(id) => Ok(Dispatch::Respond(
            json_body(200, shared.job_view(id)?)?,
            Vec::new(),
        )),
        Route::JobResult(id) => Ok(Dispatch::Respond(shared.job_result(id)?, Vec::new())),
        Route::JobCancel(id) => Ok(Dispatch::Respond(
            json_body(200, shared.cancel_job(id)?)?,
            Vec::new(),
        )),
        Route::JobAnnotated(id) => Ok(Dispatch::Respond(shared.annotated_png(id)?, Vec::new())),
        Route::JobExport(id) => {
            let Some(format) = export_format(query_param(query, "format")) else {
                // 未知/缺失的 `format` 是协议级错误(§9.5 只定义了三个取值)。
                return Err(ServeError::BadRequest);
            };
            let body = shared.export(id, format)?;
            // §9.5 第 2 条:导出以**附件**形式返回,并带**独立的导出 CSP**。
            // CSP 只加在 HTML 上:另外两种格式不是可渲染的文档,给它们加 CSP 没有意义。
            let mut headers = vec![(
                "Content-Disposition".to_string(),
                content_disposition(id, format.extension()),
            )];
            if format == ExportFormat::Html {
                headers.push((
                    "Content-Security-Policy".to_string(),
                    EXPORT_CSP.to_string(),
                ));
            }
            Ok(Dispatch::Respond(body, headers))
        }
    }
}

/// 在独立线程里执行一次"引擎工作"序列,并由那个线程写响应(M2 的按当前文件重建、
/// M3 的 provider 切换、评审 P2-2 的无 body reload、A2 的 `POST /api/models/reverify`
/// 都走这一条路径)。
///
/// - 同一时刻只允许一个引擎序列([`ServeShared::begin_provider_switch`]):第二个请求
///   立刻得到 503 `busy`,不排队。**A2 的单飞就是这一条**:`/api/models/reverify` 与
///   `/api/engine/reload` 互相排斥,因此不存在"两个清缓存/两次重建"交错;
/// - 线程创建失败 / 已有序列在跑 → 把请求连同错误原样还给调用方
///   (`Err(Box::new((error, request)))`,装箱只为不让 `Result` 的 `Err` 变体过大),
///   由它写出对应的错误响应,绝不留下"没有响应的连接";
/// - **accept 线程立刻回到循环**(`/api/status` 与 `POST /api/ocr` 在整个序列期间照常可用),
///   而客户端仍然**只在序列结束(或失败)之后**才拿到响应——三种形态在这一点上完全一致。
fn spawn_engine_work(
    shared: &Arc<ServeShared>,
    work: EngineWork,
    request: Request,
    flow: RequestFlow,
) -> Result<(), Box<(ServeError, Request, RequestFlow)>> {
    let Some(guard) = shared.begin_provider_switch() else {
        return Err(Box::new((ServeError::Busy, request, flow)));
    };
    let shared = Arc::clone(shared);
    // 请求对象经一条容量 1 的 channel 交给新线程:**不**把它 move 进闭包,
    // 因此线程创建失败时它还在这里,可以由调用方写出错误响应。
    let (tx, rx) = std::sync::mpsc::sync_channel::<Request>(1);
    // 交给线程的是这一份记录的**克隆**(同一个请求 id):线程起不来时原件还在下面,
    // 由本线程用它写出错误响应,id 因此不会因为换了条分支而变化。
    let thread_flow = flow.clone();
    let spawned = thread::Builder::new()
        .name("serve-engine-work".to_string())
        .spawn(move || {
            // 资格在线程退出时释放(含 panic)。
            let _guard = guard;
            let Ok(request) = rx.recv() else {
                return;
            };
            let result = match work {
                EngineWork::Reload(provider) => shared.reload_engine(provider),
                EngineWork::ReverifyModels => shared.reverify_models(),
            };
            let body = match result {
                Ok(value) => match serde_json::to_vec(&value) {
                    Ok(bytes) => Body::json(200, bytes),
                    Err(_) => Body::error(&ServeError::Internal),
                },
                Err(error) => Body::error(&error),
            };
            // M5:请求行由**真正写响应**的线程落笔,因此耗时覆盖整段引擎序列。
            let _ = respond(request, body, &[], &thread_flow);
        });
    match spawned {
        Ok(_) => {
            // 容量 1 且接收端已经在 `recv`,因此这次发送不会阻塞。
            let _ = tx.send(request);
            Ok(())
        }
        Err(error) => {
            eprintln!("serve: cannot start the engine-work thread: {error}");
            Err(Box::new((ServeError::Internal, request, flow)))
        }
    }
}

/// 在独立线程里执行一次评估,并由那个线程写响应(M4;见 `evaluate.rs` 的模块文档)。
///
/// 与 `spawn_provider_switch` 同一形状:
///
/// - 同一时刻只允许一个评估([`ServeShared::begin_evaluation`]):第二个请求立刻得到
///   503 `busy`,**不排队**;
/// - 线程创建失败 / 已有评估在跑 → 把请求连同错误原样还给调用方,由它写出错误响应,
///   绝不留下"没有响应的连接";
/// - 断言与 `/api/models` 同源同值的错误(模型缺失 409 / 引擎 503)由那个线程产出。
fn spawn_evaluation(
    shared: &Arc<ServeShared>,
    manifest: PathBuf,
    request: Request,
    flow: RequestFlow,
) -> Result<(), Box<(ServeError, Request, RequestFlow)>> {
    let Some(guard) = shared.begin_evaluation() else {
        return Err(Box::new((ServeError::Busy, request, flow)));
    };
    let shared = Arc::clone(shared);
    let (tx, rx) = std::sync::mpsc::sync_channel::<Request>(1);
    let thread_flow = flow.clone();
    let spawned = thread::Builder::new()
        .name("serve-evaluate".to_string())
        .spawn(move || {
            // 资格在线程退出时释放(含 panic)。
            let _guard = guard;
            let Ok(request) = rx.recv() else {
                return;
            };
            let body = match evaluate::run(&shared, &manifest) {
                Ok(value) => match serde_json::to_vec(&value) {
                    Ok(bytes) => Body::json(200, bytes),
                    Err(_) => Body::error(&ServeError::Internal),
                },
                // 与 `/api/ocr` **同一份**错误映射(含 409 的模型清单):
                // `ServeShared::error_body` 是唯一实现。
                Err(error) => shared.error_body(&error),
            };
            let _ = respond(request, body, &[], &thread_flow);
        });
    match spawned {
        Ok(_) => {
            let _ = tx.send(request);
            Ok(())
        }
        Err(error) => {
            eprintln!("serve: cannot start the evaluation thread: {error}");
            Err(Box::new((ServeError::Internal, request, flow)))
        }
    }
}

/// 404 / 405 的响应体(§4.4 第 1 步)。
///
/// 这两个状态码**不是** `ServeError` 的变体(§11.1 没有为它们定义 `code`),因此由路由层
/// 用固定的 `code` 回答;`detail` 里带上路由结论,便于定位"到底哪个路径/方法没匹配"。
fn route_error(decision: RouteDecision, method: HttpMethod, path: &str) -> Body {
    let (status, code, message) = match decision {
        RouteDecision::NotFound => (404, "not_found", "no route matches this path".to_string()),
        RouteDecision::MethodNotAllowed => (
            405,
            "method_not_allowed",
            format!("{} is not allowed on this path", method.name()),
        ),
        RouteDecision::Matched => (
            500,
            "internal",
            "the router returned no route for a matched path".to_string(),
        ),
    };
    let body = json!({
        "code": code,
        "message": message,
        "detail": { "path": path, "method": method.name(), "route": decision.name() },
    });
    Body::json(
        status,
        serde_json::to_vec(&body).expect("route error serialization cannot fail"),
    )
}

/// 端点的 JSON 响应(状态码由调用方给出:提交是 202,其余是 200)。
fn json_body(status: u16, value: Value) -> Result<Body, ServeError> {
    let bytes = serde_json::to_vec(&value).map_err(|_| ServeError::Internal)?;
    Ok(Body::json(status, bytes))
}

/// 有界读取(§4.4 第 6 步):总字节上限 + 截止时间。
///
/// 借用 `Admit` 而不是取走它:第 4 步预留的队列槽位必须留到 `submit_ocr` 才提交,
/// 中途任何失败都让它在 `dispatch` 返回时被丢弃(= 归还容量)。
fn read_body(request: &mut Request, admitted: &admit::Admit) -> Result<Vec<u8>, ServeError> {
    let mut source = HttpBody {
        request,
        deadline: Instant::now() + BODY_READ_TIMEOUT,
    };
    admit::read_body(&mut source, admitted.expected_body, admitted.max_body)
}

/// `tiny_http` 的请求体 → `BodySource`(超时判定用注入的截止时间)。
struct HttpBody<'a> {
    request: &'a mut Request,
    deadline: Instant,
}

impl BodySource for HttpBody<'_> {
    fn read_timed_out(&self) -> bool {
        Instant::now() >= self.deadline
    }

    fn next_chunk(&mut self) -> ChunkOutcome {
        let mut buffer = vec![0_u8; READ_CHUNK_BYTES];
        match self.request.as_reader().read(&mut buffer) {
            Ok(0) => ChunkOutcome::End,
            Ok(read) => {
                buffer.truncate(read);
                ChunkOutcome::Data(buffer)
            }
            Err(error)
                if matches!(
                    error.kind(),
                    io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
                ) =>
            {
                ChunkOutcome::TimedOut
            }
            Err(error) => ChunkOutcome::Failed(error.to_string()),
        }
    }
}

/// `?max_side=`(§13 的参考命令):正整数、且不低于引擎的 `min_side_len`
/// (低于它会让预处理区间上下界颠倒)。
fn parse_max_side(raw: Option<&str>, min_side: u32) -> Result<Option<u32>, ServeError> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    let value: u32 = raw.trim().parse().map_err(|_| ServeError::BadRequest)?;
    if value < min_side || value > MAX_SIDE_CEILING {
        return Err(ServeError::BadRequest);
    }
    Ok(Some(value))
}

/// `?words=`:逐词/逐字框的输出档位,**只有 `off` / `words` / `chars` 三个取值**
/// (缺省 `off`,与 CLI 的 `--words` / `--chars` 共用 [`WordOutputMode`] 那一份词表)。
///
/// 未知取值是 **400**,不是静默按 `off` 处理:静默降级会让请求方(含网页 Demo)以为
/// 字符框已经在载荷里,而结果是“选区层永远空着”这类无法从状态码看出来的故障。
/// 与 `?table=` 同一个口径:真正的取值校验必须在建任务**之前**完成。
fn parse_words(raw: Option<&str>) -> Result<WordOutputMode, ServeError> {
    match raw {
        None => Ok(WordOutputMode::Off),
        // 允许首尾空白(与 `?max_side=` 一致),但档位名本身大小写敏感、无别名。
        Some(raw) => WordOutputMode::from_name(raw.trim()).ok_or(ServeError::BadRequest),
    }
}

/// `POST /api/models/download` 的请求体:**只有** `set_id`(§7.2 禁止 URL)。
///
/// 白名单式校验:`Content-Type`(若给出)必须是 `application/json`,请求体必须是**恰好**
/// 一个 `set_id` 键的 JSON 对象——任何额外键(尤其 `url`)都会被拒绝,而不是被忽略。
fn parse_set_id(bytes: &[u8]) -> Result<String, ServeError> {
    let value: Value = serde_json::from_slice(bytes).map_err(|_| ServeError::BadRequest)?;
    let object = value.as_object().ok_or(ServeError::BadRequest)?;
    if object.len() != 1 {
        return Err(ServeError::BadRequest);
    }
    let set_id = object
        .get("set_id")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .ok_or(ServeError::BadRequest)?;
    Ok(set_id.to_string())
}

/// JSON 请求体的媒体类型校验(缺省按 JSON 处理:本机工具不强制客户端声明类型)。
fn check_json_content_type(content_type: Option<&str>) -> Result<(), ServeError> {
    let Some(raw) = content_type else {
        return Ok(());
    };
    let essence = raw
        .split(';')
        .next()
        .unwrap_or_default()
        .trim()
        .to_ascii_lowercase();
    if essence == "application/json" {
        return Ok(());
    }
    Err(ServeError::BadRequest)
}

/// `POST /api/engine/reload` 的可选请求体(§4.2、§7.5)。
///
/// - **空 body**(M1/M2 的形状):`None` → "按磁盘上的当前文件重建会话";
/// - `{"provider":"cpu|directml|cuda"}`:显式设置应用。
///
/// 与 `parse_set_id` 同一种白名单式校验:对象里**恰好**一个 `provider` 键,任何额外键
/// (包括 `allow_provider_fallback`——它是 CLI 开关,不允许从 API 改)都会被拒绝,
/// 而不是被忽略。
fn parse_provider_body(bytes: Vec<u8>) -> Result<Option<ProviderPreference>, ServeError> {
    if bytes.is_empty() {
        return Ok(None);
    }
    let value: Value = serde_json::from_slice(&bytes).map_err(|_| ServeError::BadRequest)?;
    let object = value.as_object().ok_or(ServeError::BadRequest)?;
    if object.len() != 1 {
        return Err(ServeError::BadRequest);
    }
    let raw = object
        .get("provider")
        .and_then(Value::as_str)
        .map(str::trim)
        .ok_or(ServeError::BadRequest)?;
    // 与 CLI 的 `--provider` 复用**同一个**映射(含单设备场景的 `device_id = 0`),
    // 不在这里另写一套取值。
    let provider = match raw.to_ascii_lowercase().as_str() {
        "cpu" => ProviderChoice::Cpu,
        "directml" => ProviderChoice::Directml,
        "cuda" => ProviderChoice::Cuda,
        _ => return Err(ServeError::BadRequest),
    };
    Ok(Some(provider.preference()))
}

/// `?format=` 的三个取值(§9.5)。
fn export_format(raw: Option<&str>) -> Option<ExportFormat> {
    match raw? {
        "json" => Some(ExportFormat::Json),
        "md" => Some(ExportFormat::Markdown),
        "html" => Some(ExportFormat::Html),
        _ => None,
    }
}

/// 写出 `Content-Disposition: attachment; filename="…"`(§9.5)。
fn content_disposition(job_id: &str, extension: &str) -> String {
    format!("attachment; filename=\"ocr-{job_id}.{extension}\"")
}

/// 路由表:路径 → 路由;方法不匹配 → `MethodNotAllowed`。
fn route_of(method: HttpMethod, path: &str) -> (RouteDecision, Option<Route>) {
    let candidate = match path {
        "/" => Some(Route::Page),
        "/api/status" => Some(Route::Status),
        "/api/models" => Some(Route::Models),
        "/api/models/download" => Some(Route::ModelsDownload),
        "/api/models/reverify" => Some(Route::ModelsReverify),
        "/api/engine/reload" => Some(Route::EngineReload),
        "/api/ocr" => Some(Route::Ocr),
        "/api/evaluate" => Some(Route::Evaluate),
        // 前缀路由:完整路径交给 [`super::assets::lookup`] 的封闭表,这里只判定“是不是静态”。
        _ if path.starts_with("/static/") => Some(Route::Static(path.to_string())),
        _ => job_route(path),
    };
    match candidate {
        None => (RouteDecision::NotFound, None),
        Some(route) => {
            let expected = route.method();
            // HEAD 只对 GET 路由放行(tiny_http 会自动省略响应体)。
            let matched =
                method == expected || (method == HttpMethod::Head && expected == HttpMethod::Get);
            if matched {
                (RouteDecision::Matched, Some(route))
            } else {
                (RouteDecision::MethodNotAllowed, Some(route))
            }
        }
    }
}

/// `/api/jobs/{id}`、`/api/jobs/{id}/result`、`/api/jobs/{id}/cancel`、
/// `/api/jobs/{id}/annotated.png`、`/api/jobs/{id}/export`。
fn job_route(path: &str) -> Option<Route> {
    let rest = path.strip_prefix("/api/jobs/")?;
    let mut parts = rest.split('/');
    let id = parts.next()?;
    if id.is_empty() {
        return None;
    }
    match (parts.next(), parts.next()) {
        (None, None) => Some(Route::Job(id.to_string())),
        (Some("result"), None) => Some(Route::JobResult(id.to_string())),
        (Some("cancel"), None) => Some(Route::JobCancel(id.to_string())),
        (Some("annotated.png"), None) => Some(Route::JobAnnotated(id.to_string())),
        (Some("export"), None) => Some(Route::JobExport(id.to_string())),
        _ => None,
    }
}

/// `path?query` 拆分(不做百分号解码:M1 的参数都是 ASCII 的端口/整数/枚举值)。
fn split_url(url: &str) -> (&str, &str) {
    match url.split_once('?') {
        Some((path, query)) => (path, query),
        None => (url, ""),
    }
}

/// 取一个查询参数(重复出现时取第一个)。
fn query_param<'a>(query: &'a str, name: &str) -> Option<&'a str> {
    query.split('&').find_map(|pair| {
        let (key, value) = pair.split_once('=')?;
        (key == name).then_some(value)
    })
}

/// 请求头(大小写不敏感,`tiny_http` 的 `HeaderField::equiv` 即唯一实现)。
fn header<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> {
    request
        .headers()
        .iter()
        .find(|header| header.field.equiv(name))
        .map(|header| header.value.as_str())
}

/// 写响应:安全头**每一个**响应都带(§7.3),额外头按需附加。
///
/// 这里**绝不**发送 `Access-Control-Allow-Origin`(§7.2):CORS 头只可能让浏览器里的
/// 第三方页面读走本机结果,而本页是同源的,不需要它。
///
/// M5:请求行也在这一处落下。`respond` 是**所有**响应的唯一出口(accept 线程、引擎工作
/// 线程、评估线程都经过它),因此"每个 HTTP 请求一行"不可能漏掉某条分支。
fn respond(
    request: Request,
    body: Body,
    extra: &[(String, String)],
    flow: &RequestFlow,
) -> io::Result<()> {
    flow.response(body.status, body.bytes.len());
    let mut response = Response::from_data(body.bytes).with_status_code(StatusCode(body.status));
    response.add_header(header_of("Content-Type", body.content_type));
    for (name, value) in SECURITY_HEADERS {
        response.add_header(header_of(name, value));
    }
    for (name, value) in extra {
        response.add_header(header_of(name, value));
    }
    request.respond(response)
}

/// 内部响应头构造:所有取值都是本模块的常量(唯一动态项是十六进制 nonce)。
fn header_of(name: &str, value: &str) -> Header {
    Header::from_bytes(name.as_bytes(), value.as_bytes())
        .unwrap_or_else(|()| panic!("serve: header `{name}` is not ASCII"))
}

#[cfg(test)]
mod tests {
    use super::{
        EXPORT_CSP, HttpMethod, Route, RouteDecision, check_json_content_type, content_disposition,
        export_format, page_csp, parse_max_side, parse_provider_body, parse_set_id, parse_words,
        query_param, route_of, split_url,
    };
    use crate::serve::error::ServeError;
    use crate::serve::export::ExportFormat;
    use rapid_structure_rs::{ProviderPreference, WordOutputMode};

    #[test]
    fn the_router_matches_the_documented_endpoint_set_and_nothing_else() {
        let cases = [
            (HttpMethod::Get, "/", Route::Page),
            (HttpMethod::Get, "/api/status", Route::Status),
            (HttpMethod::Get, "/api/models", Route::Models),
            (
                HttpMethod::Post,
                "/api/models/download",
                Route::ModelsDownload,
            ),
            (
                HttpMethod::Post,
                "/api/models/reverify",
                Route::ModelsReverify,
            ),
            (HttpMethod::Post, "/api/engine/reload", Route::EngineReload),
            (HttpMethod::Post, "/api/ocr", Route::Ocr),
            (HttpMethod::Post, "/api/evaluate", Route::Evaluate),
            (
                HttpMethod::Get,
                "/api/jobs/job-1",
                Route::Job("job-1".into()),
            ),
            (
                HttpMethod::Get,
                "/api/jobs/job-1/result",
                Route::JobResult("job-1".into()),
            ),
            (
                HttpMethod::Post,
                "/api/jobs/job-1/cancel",
                Route::JobCancel("job-1".into()),
            ),
            // M3 的两个端点现在是真实路由(不再是 404)。
            (
                HttpMethod::Get,
                "/api/jobs/job-1/annotated.png",
                Route::JobAnnotated("job-1".into()),
            ),
            (
                HttpMethod::Get,
                "/api/jobs/job-1/export",
                Route::JobExport("job-1".into()),
            ),
            // 公式渲染引擎的前缀路由:`/static/` 下的任意具体路径都命中路由本身,
            // 资源是否存在由 `assets::lookup` 的封闭表决定(表外 → 404)。
            (
                HttpMethod::Get,
                "/static/katex/katex.min.js",
                Route::Static("/static/katex/katex.min.js".into()),
            ),
        ];
        for (method, path, expected) in cases {
            let (decision, route) = route_of(method, path);
            assert_eq!(decision, RouteDecision::Matched, "{path}");
            assert_eq!(route.as_ref(), Some(&expected), "{path}");
        }

        // 不存在的路径仍然 404(`?format=` 属于查询串,路由只看路径)。
        for path in [
            "/api/models/download/cancel",
            "/api/jobs/job-1/annotated",
            "/api/jobs/job-1/annotated.png/extra",
            "/api/jobs/bogus/export/data",
            "/favicon.ico",
            "/api",
            "/api/jobs/",
        ] {
            let (decision, route) = route_of(HttpMethod::Get, path);
            assert_eq!(decision, RouteDecision::NotFound, "{path}");
            assert!(route.is_none(), "{path}");
        }

        // 路径存在但方法不对 → 405(并给出 Allow)。
        let (decision, route) = route_of(HttpMethod::Get, "/api/ocr");
        assert_eq!(decision, RouteDecision::MethodNotAllowed);
        assert_eq!(route.expect("known path").allow(), "POST");
        let (decision, route) = route_of(HttpMethod::Get, "/api/evaluate");
        assert_eq!(decision, RouteDecision::MethodNotAllowed);
        assert_eq!(route.expect("known path").allow(), "POST");
        let (decision, route) = route_of(HttpMethod::Post, "/api/status");
        assert_eq!(decision, RouteDecision::MethodNotAllowed);
        assert_eq!(route.expect("known path").allow(), "GET");
        for path in ["/api/jobs/job-1/annotated.png", "/api/jobs/job-1/export"] {
            let (decision, route) = route_of(HttpMethod::Post, path);
            assert_eq!(decision, RouteDecision::MethodNotAllowed, "{path}");
            assert_eq!(route.expect("known path").allow(), "GET");
        }

        // HEAD 只对 GET 路由放行。
        assert_eq!(route_of(HttpMethod::Head, "/").0, RouteDecision::Matched);
        assert_eq!(
            route_of(HttpMethod::Head, "/api/ocr").0,
            RouteDecision::MethodNotAllowed
        );
    }

    /// `/static/*`:GET 路由、POST 405,且**不要求令牌**(不在 `/api/*` 边界内,§7.2)。
    /// 令牌豁免面只有 `Page` 与 `Static` 两个变体——这里钉住边界本身。
    #[test]
    fn static_routes_are_get_only_and_outside_the_token_boundary() {
        let (decision, route) = route_of(HttpMethod::Get, "/static/mathjax/tex-svg.js");
        assert_eq!(decision, RouteDecision::Matched);
        assert!(
            !route.expect("static route").requires_token(),
            "/static/* must not require the token"
        );
        let (decision, route) = route_of(HttpMethod::Post, "/static/katex/katex.min.css");
        assert_eq!(decision, RouteDecision::MethodNotAllowed);
        assert_eq!(route.expect("known path").allow(), "GET");
        assert!(!Route::Page.requires_token());
        assert!(Route::Status.requires_token());
        assert!(Route::JobExport("job-1".into()).requires_token());
    }

    /// 只有 §9.5 的三个格式被接受;缺失或未知取值都是 400(不是"猜一个默认值")。
    #[test]
    fn only_the_three_documented_export_formats_are_accepted() {
        assert_eq!(export_format(Some("json")), Some(ExportFormat::Json));
        assert_eq!(export_format(Some("md")), Some(ExportFormat::Markdown));
        assert_eq!(export_format(Some("html")), Some(ExportFormat::Html));
        for bad in [None, Some(""), Some("JSON"), Some("pdf"), Some("mdx")] {
            assert_eq!(export_format(bad), None, "{bad:?}");
        }
        assert_eq!(ExportFormat::Json.extension(), "json");
        assert_eq!(ExportFormat::Markdown.extension(), "md");
        assert_eq!(ExportFormat::Html.extension(), "html");
        assert_eq!(
            ExportFormat::Html.content_type(),
            "text/html; charset=utf-8"
        );
        assert_eq!(
            content_disposition("job-1", ExportFormat::Html.extension()),
            "attachment; filename=\"ocr-job-1.html\""
        );
    }

    /// `POST /api/engine/reload` 的可选请求体:空 body = M2 的重建,带 provider = M3 的设置应用。
    #[test]
    fn the_reload_body_carries_at_most_a_provider() {
        assert_eq!(parse_provider_body(Vec::new()).expect("no body"), None);
        assert_eq!(
            parse_provider_body(br#"{"provider":"cpu"}"#.to_vec()).expect("cpu"),
            Some(ProviderPreference::Cpu)
        );
        assert_eq!(
            parse_provider_body(br#"{"provider":" directml "}"#.to_vec()).expect("directml"),
            Some(ProviderPreference::DirectMl { device_id: 0 })
        );
        assert_eq!(
            parse_provider_body(br#"{"provider":"CUDA"}"#.to_vec()).expect("cuda"),
            Some(ProviderPreference::Cuda { device_id: 0 })
        );
        for bad in [
            &br#"{"provider":"tpu"}"#[..],
            &br#"{"provider":null}"#[..],
            &br#"{"provider":""}"#[..],
            &br#"{"provider":"cpu","allow_provider_fallback":true}"#[..],
            &br#"{"other":"cpu"}"#[..],
            &br#"{}"#[..],
            &br#"[]"#[..],
            &br#"not json"#[..],
        ] {
            assert!(
                matches!(
                    parse_provider_body(bad.to_vec()),
                    Err(ServeError::BadRequest)
                ),
                "{}",
                String::from_utf8_lossy(bad)
            );
        }
    }

    /// §9.5 第 2 条:导出 CSP 逐字冻结,且与主页面 CSP 是两条不同的策略。
    #[test]
    fn the_export_csp_is_the_documented_one_and_differs_from_the_page_csp() {
        assert_eq!(
            EXPORT_CSP,
            "default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src 'none'; \
             sandbox"
        );
        assert!(EXPORT_CSP.contains("script-src 'none'"), "{EXPORT_CSP}");
        assert!(EXPORT_CSP.contains("img-src data:"), "{EXPORT_CSP}");
        assert!(!EXPORT_CSP.contains("nonce"), "{EXPORT_CSP}");
        // 主页面仍然不允许内联样式;`unsafe-inline` 只出现在导出这一条响应上。
        let page = page_csp("abc");
        assert!(!page.contains("unsafe-inline"), "{page}");
        assert_ne!(page, EXPORT_CSP);
    }

    #[test]
    fn urls_and_query_parameters_are_split_without_decoding() {
        assert_eq!(
            split_url("/api/ocr?max_side=2000"),
            ("/api/ocr", "max_side=2000")
        );
        assert_eq!(split_url("/"), ("/", ""));
        assert_eq!(
            query_param("max_side=2000&queue=text", "max_side"),
            Some("2000")
        );
        assert_eq!(
            query_param("max_side=2000&queue=text", "queue"),
            Some("text")
        );
        assert_eq!(query_param("a=1", "b"), None);
        assert_eq!(query_param("noseparator", "noseparator"), None);
    }

    #[test]
    fn the_documented_max_side_parameter_is_validated() {
        assert_eq!(parse_max_side(None, 30).expect("ok"), None);
        assert_eq!(parse_max_side(Some("2000"), 30).expect("ok"), Some(2000));
        assert_eq!(parse_max_side(Some(" 736 "), 30).expect("ok"), Some(736));
        for bad in ["0", "29", "abc", "-1", "999999999999999999999"] {
            assert!(parse_max_side(Some(bad), 30).is_err(), "{bad}");
        }
    }

    /// `?words=` 的三档与"未知取值必须 400"(静默降级比报错难查得多:请求方会以为
    /// 字符框已经在载荷里,而看到的只是空数组)。
    #[test]
    fn the_documented_words_parameter_is_validated() {
        assert_eq!(parse_words(None).expect("ok"), WordOutputMode::Off);
        for (raw, expected) in [
            ("off", WordOutputMode::Off),
            ("words", WordOutputMode::Words),
            ("chars", WordOutputMode::Chars),
            // 首尾空白允许(与 `?max_side=` 一致)。
            (" words ", WordOutputMode::Words),
        ] {
            assert_eq!(parse_words(Some(raw)).expect("ok"), expected, "{raw}");
        }
        // 档位名本身大小写敏感、无别名、不能是布尔量。
        for bad in [
            "Off", "CHARS", "char", "word", "", "1", "0", "true", "table",
        ] {
            assert!(parse_words(Some(bad)).is_err(), "{bad}");
        }
    }

    #[test]
    fn the_download_body_carries_only_a_set_id() {
        assert_eq!(
            parse_set_id(br#"{"set_id":"PP-OCRv6-small-ch"}"#).expect("ok"),
            "PP-OCRv6-small-ch"
        );
        // §7.2:请求体**不得**携带 URL;白名单式校验拒绝任何额外键。
        for bad in [
            &br#"{"url":"https://evil.example/x.onnx"}"#[..],
            &br#"{"set_id":"a","url":"https://evil.example/x.onnx"}"#[..],
            &br#"{"set_id":""}"#[..],
            &br#"{"set_id":null}"#[..],
            &br#"not json"#[..],
            &br#"{}"#[..],
            &br#"[]"#[..],
        ] {
            assert!(
                matches!(parse_set_id(bad), Err(ServeError::BadRequest)),
                "{}",
                String::from_utf8_lossy(bad)
            );
        }

        // JSON 体只接受 application/json(或完全不声明)。
        assert!(check_json_content_type(None).is_ok());
        assert!(check_json_content_type(Some("application/json; charset=utf-8")).is_ok());
        assert!(check_json_content_type(Some("application/octet-stream")).is_err());
        assert!(check_json_content_type(Some("multipart/form-data")).is_err());
    }

    #[test]
    fn the_main_page_csp_is_nonce_based_and_never_allows_inline() {
        let csp = page_csp("abc123");
        assert!(csp.contains("script-src 'nonce-abc123'"), "{csp}");
        assert!(csp.contains("style-src 'nonce-abc123'"), "{csp}");
        assert!(!csp.contains("unsafe-inline"), "{csp}");
        assert!(csp.contains("default-src 'none'"), "{csp}");
        assert!(csp.contains("frame-ancestors 'none'"), "{csp}");
        // 字体只对同源放行(唯一用途:KaTeX 的 woff2;MathJax tex-svg 不取字体)。
        assert!(csp.contains("font-src 'self'"), "{csp}");
        assert!(!csp.contains("font-src *"), "{csp}");
        assert!(!csp.contains("font-src 'none'"), "{csp}");
    }
}