228 Commits

Author SHA1 Message Date
lingniu
3c4bece72c feat: expand vehicle data platform capabilities 2026-07-27 16:46:15 +08:00
lingniu
e3a1f80f86 docs(ops): record latest mileage reconciliation 2026-07-20 11:29:18 +08:00
lingniu
5a3b2e188e feat(web): refine semi application shell 2026-07-20 11:14:19 +08:00
lingniu
9d9185790f fix(web): harden monitor protocol list 2026-07-20 10:54:10 +08:00
lingniu
ddbba18431 docs(ops): record latest mileage backfill 2026-07-20 10:00:09 +08:00
lingniu
492c48cf47 docs(ops): record latest mileage reconciliation 2026-07-20 09:25:02 +08:00
lingniu
70211e1d7c feat(web): condense mobile protocol health 2026-07-20 09:12:28 +08:00
lingniu
ebf682d863 fix(web): align alert queue table 2026-07-20 09:00:38 +08:00
lingniu
6574b49ff9 fix(mileage): reconcile track and daily semantics 2026-07-20 08:52:27 +08:00
lingniu
6245a9b979 feat(web): refine mileage summary hierarchy 2026-07-20 08:30:51 +08:00
lingniu
02472dc4b4 docs(ops): record incremental mileage reconciliation 2026-07-20 08:13:41 +08:00
lingniu
1a5bfba20e docs(ops): record latest mileage backfill 2026-07-20 07:58:48 +08:00
lingniu
19b187d942 feat(web): add vehicle directory views 2026-07-20 07:58:45 +08:00
lingniu
3c9eea527c feat(web): focus track empty workspace 2026-07-20 07:35:44 +08:00
lingniu
19c66dc1f6 docs(ops): record same-day mileage backfill 2026-07-20 07:30:04 +08:00
lingniu
3d4e1fc01b feat(web): unify guided workspace states 2026-07-20 07:15:07 +08:00
lingniu
1a73b8bbd4 feat(web): refine mileage workspace shell 2026-07-20 07:03:01 +08:00
lingniu
1eba0ad294 docs(ops): record mileage incident recheck 2026-07-20 06:55:18 +08:00
lingniu
34d643cc87 docs(ops): record same-day mileage recovery 2026-07-20 06:35:03 +08:00
lingniu
8e54e08dba feat(web): refine track replay controls 2026-07-20 06:19:49 +08:00
lingniu
a8bbbbc5c4 feat(web): improve vehicle detail readability 2026-07-20 06:11:33 +08:00
lingniu
4de487e057 feat(web): polish account login workspace 2026-07-20 05:57:05 +08:00
lingniu
270bb163eb fix(web): clarify cumulative mileage evidence 2026-07-20 05:41:45 +08:00
lingniu
2c09fd7cf0 feat(web): refine alert notification workspace 2026-07-20 05:41:45 +08:00
lingniu
843690cc71 feat(web): unify vehicle source navigation 2026-07-20 05:26:08 +08:00
lingniu
c150e45f11 feat(web): refine track and mileage workspaces 2026-07-20 05:12:12 +08:00
lingniu
9eb77a2d57 feat(web): unify vehicle evidence workspace 2026-07-20 04:56:27 +08:00
lingniu
952f886459 feat(web): unify async workspace states 2026-07-20 04:45:51 +08:00
lingniu
04ec00d4be feat(web): unify history and account states 2026-07-20 04:25:46 +08:00
lingniu
adf92ecd17 docs(ops): record gps mileage cleanup 2026-07-20 03:57:43 +08:00
lingniu
758da369d8 fix(stats): reconcile stale gps mileage candidates 2026-07-20 03:51:18 +08:00
lingniu
5ba636419b feat(web): unify semantic empty states 2026-07-20 03:49:28 +08:00
lingniu
df03aaac6a feat(web): unify data panel hierarchy 2026-07-20 03:22:30 +08:00
lingniu
dfa2782db6 feat(web): converge workspace identity themes 2026-07-20 02:56:13 +08:00
lingniu
0417056eeb docs(ops): record mileage reconciliation rollout 2026-07-20 02:42:20 +08:00
lingniu
2eed8d0da4 feat(web): unify admin and operations identity 2026-07-20 02:25:09 +08:00
lingniu
40fd7fe009 feat(web): unify governance workspace identity 2026-07-20 02:22:05 +08:00
lingniu
319728f976 feat(web): unify track and history identity 2026-07-20 02:15:08 +08:00
lingniu
537f8d9b45 feat(web): unify vehicle workspace identity 2026-07-20 02:02:06 +08:00
lingniu
6e9a170fbf feat(web): refine Semi UI application shell 2026-07-20 01:49:16 +08:00
lingniu
275d453954 fix(stats): reject sub-threshold gps drift 2026-07-20 01:27:16 +08:00
lingniu
2ef027c7c2 fix(stats): recover stale odometer mileage 2026-07-20 01:12:09 +08:00
lingniu
58ab1ff7bb feat(web): refine Semi UI access workspace 2026-07-20 00:12:46 +08:00
lingniu
cd13a2952d feat(web): refine Semi UI alert center 2026-07-20 00:01:47 +08:00
lingniu
e20f320f6c feat(web): refine Semi UI history workspace 2026-07-19 23:47:03 +08:00
lingniu
1c18941980 feat(web): finish Semi UI track workspace 2026-07-19 23:33:53 +08:00
lingniu
4c64c4c738 fix(mileage): recover sparse source daily distance 2026-07-19 22:57:31 +08:00
lingniu
e45daff528 fix(stats): recover jt808 mileage from safe gps tracks 2026-07-19 22:23:19 +08:00
lingniu
2f195e725a refactor(ui): unify semi mobile query workspaces 2026-07-19 21:51:03 +08:00
lingniu
f8baaf53ba refactor(ui): refine semi mobile workspace launcher 2026-07-19 21:40:31 +08:00
lingniu
d0c2bb3316 fix(mileage): fall through ineffective priority sources 2026-07-19 21:28:11 +08:00
lingniu
5539c5e0c2 fix(stats): recover durable daily mileage projections 2026-07-19 21:00:56 +08:00
lingniu
2b18a400b1 refactor(ui): converge semi mobile vehicle record 2026-07-19 20:46:54 +08:00
lingniu
f59148e260 refactor(ui): converge semi monitor list 2026-07-19 20:31:54 +08:00
lingniu
e944b797e5 refactor(ui): refine semi account governance 2026-07-19 19:57:40 +08:00
lingniu
58f2a77033 refactor(ui): refine semi access workspace 2026-07-19 19:27:43 +08:00
lingniu
2c93999417 refactor(ui): converge semi operations workspace 2026-07-19 18:56:35 +08:00
lingniu
0f51e3d375 refactor(ui): polish semi alert workspace 2026-07-19 18:21:44 +08:00
lingniu
adc01f2fa4 refactor(ui): polish semi mileage workspace 2026-07-19 17:51:36 +08:00
lingniu
53767c707c refactor(ui): converge semi track workspace 2026-07-19 17:44:21 +08:00
lingniu
263107d47a fix(stats): elect freshest forwarding source per terminal 2026-07-19 17:33:53 +08:00
lingniu
a13ee8fc30 refactor(ui): converge semi vehicle directory 2026-07-19 17:25:39 +08:00
lingniu
fa2dcf14d6 refactor(ui): converge semi history workspace 2026-07-19 17:13:55 +08:00
lingniu
a198e0fc4e refactor(ui): converge semi access workspace 2026-07-19 16:38:17 +08:00
lingniu
40c210a192 refactor(ui): polish semi mileage discovery 2026-07-19 16:25:48 +08:00
lingniu
fa60bbb2c8 fix(stats): flush throttled daily mileage projections 2026-07-19 16:00:13 +08:00
lingniu
81532d91b7 refactor: converge account and access workspaces on semi ui 2026-07-19 15:24:03 +08:00
lingniu
fb105a67c8 refactor: unify semi operational time ranges 2026-07-19 15:11:15 +08:00
lingniu
476069adc6 refactor: unify semi history date filters 2026-07-19 14:40:57 +08:00
lingniu
2d987fd185 refactor: polish semi mileage workspace 2026-07-19 14:19:43 +08:00
lingniu
4da26ad3ba fix: materialize stationary yutong daily mileage 2026-07-19 13:23:33 +08:00
lingniu
dffe559160 refactor: unify guided workspace empty states 2026-07-19 12:49:22 +08:00
lingniu
4e4f383988 fix: distinguish track mileage evidence 2026-07-19 12:32:01 +08:00
lingniu
f825c9f417 refactor: converge semi metric rail 2026-07-19 12:32:01 +08:00
lingniu
1e57fdfff1 feat: unify account directory metrics with semi ui 2026-07-19 12:10:36 +08:00
lingniu
7df015b0f0 feat: unify source diagnostics with semi ui 2026-07-19 12:07:12 +08:00
lingniu
031e22de79 feat: unify mileage metrics with semi ui 2026-07-19 12:02:10 +08:00
lingniu
2e87378c92 feat: unify history metrics with semi ui 2026-07-19 11:52:24 +08:00
lingniu
343e7c9b89 fix: hide expired session token errors on login 2026-07-19 11:27:34 +08:00
lingniu
61ca72fab2 feat: make account password login explicit 2026-07-19 11:25:57 +08:00
lingniu
a82bb28af9 feat: unify track replay summary 2026-07-19 11:18:16 +08:00
lingniu
1be76c1dfa feat: unify vehicle directory summary 2026-07-19 11:04:34 +08:00
lingniu
e02d8dba54 feat: simplify login and unify monitor summary 2026-07-19 10:48:08 +08:00
lingniu
dcc6b223d9 feat: unify monitor vehicle metric rail 2026-07-19 10:28:17 +08:00
lingniu
add47b7efc feat: unify vehicle live metric rail 2026-07-19 10:10:28 +08:00
lingniu
a6f6ce260f feat: unify operations health rail 2026-07-19 09:41:36 +08:00
lingniu
39c828ed32 feat: unify access decision rail 2026-07-19 09:25:06 +08:00
lingniu
8372ca5349 feat: unify alert operations queue 2026-07-19 09:05:32 +08:00
lingniu
26ccd2434a feat: focus operations review queue 2026-07-19 08:48:32 +08:00
lingniu
c3881bf25c feat: unify workspace dialogs 2026-07-19 08:32:48 +08:00
lingniu
49ae75937e feat: unify monitor action workbenches 2026-07-19 08:11:34 +08:00
lingniu
af71468e63 feat: unify mobile track workspace 2026-07-19 07:58:51 +08:00
lingniu
6795a8ea6d feat: unify export task workspace 2026-07-19 07:49:13 +08:00
lingniu
3e80da1b73 feat: unify filter workbenches 2026-07-19 07:36:08 +08:00
lingniu
af608b35e0 feat: unify global action workbenches 2026-07-19 07:13:09 +08:00
lingniu
3b5a66b687 feat: unify governance evidence workbenches 2026-07-19 06:51:23 +08:00
lingniu
f2fe5404d9 feat: unify track and telemetry workbenches 2026-07-19 06:29:55 +08:00
lingniu
4bd1ad8a85 feat: unify account permission workbenches 2026-07-19 06:07:26 +08:00
lingniu
860978fad8 feat: unify task and rule workbenches 2026-07-19 05:52:52 +08:00
lingniu
63b50732cb feat: unify detail side sheets 2026-07-19 05:38:17 +08:00
lingniu
7eca5ae352 feat: unify configuration side sheets 2026-07-19 05:16:55 +08:00
lingniu
2bae87f6a2 fix: keep mobile filter resets intentional 2026-07-19 04:57:31 +08:00
lingniu
9a87d14d6d feat: unify mobile filter sheets 2026-07-19 04:44:21 +08:00
lingniu
d3eb77e2c7 feat: refine global shell for mobile landscape 2026-07-19 04:29:58 +08:00
lingniu
712721bc73 feat: refine short landscape access workspace 2026-07-19 04:08:39 +08:00
lingniu
8168acc107 feat: refine short landscape operations 2026-07-19 03:47:55 +08:00
lingniu
2372d63823 feat: refine short landscape account governance 2026-07-19 03:26:48 +08:00
lingniu
bbacf3b913 feat: refine responsive vehicle record 2026-07-19 03:08:27 +08:00
lingniu
c695d6e431 feat: refine mobile vehicle directory 2026-07-19 02:37:40 +08:00
lingniu
f4f8bf9f4b feat: densify mobile monitor workspace 2026-07-19 02:15:10 +08:00
lingniu
dcfd78f73b feat: refine mobile track workspace 2026-07-19 01:50:14 +08:00
lingniu
3770afabec feat: clarify mobile mileage legend 2026-07-19 01:33:28 +08:00
lingniu
e7b49df8ed feat: guide history query start 2026-07-19 01:18:42 +08:00
lingniu
82f81eee33 feat: focus mobile alert triage 2026-07-19 01:02:39 +08:00
lingniu
862671c083 feat: refine mobile vehicle detail actions 2026-07-19 00:43:37 +08:00
lingniu
23b632a2e0 feat: unify mobile vehicle search 2026-07-19 00:17:47 +08:00
lingniu
c115b28bfe feat: unify mobile account filters 2026-07-18 23:59:11 +08:00
lingniu
045482c161 feat: unify mobile vehicle diagnostics 2026-07-18 23:41:26 +08:00
lingniu
c8814f471b feat: unify mobile history filters 2026-07-18 23:24:22 +08:00
lingniu
c358faf17d feat: optimize mobile access filters 2026-07-18 23:07:12 +08:00
lingniu
a7297abdaf refine Semi UI alert workflow 2026-07-18 22:58:07 +08:00
lingniu
2824c08eb6 refine Semi UI mileage filters 2026-07-18 22:41:46 +08:00
lingniu
b465d1ff7a refine Semi UI telemetry details 2026-07-18 22:28:00 +08:00
lingniu
76d35f54c5 refine mobile vehicle source evidence 2026-07-18 22:15:08 +08:00
lingniu
fd5e16c36c unify Semi UI track evidence 2026-07-18 22:05:21 +08:00
lingniu
e40eabac92 refine Semi UI monitor inspector 2026-07-18 21:53:35 +08:00
lingniu
911addd891 refine Semi UI vehicle flow 2026-07-18 21:33:32 +08:00
lingniu
22f1548f8a unify Semi UI protocol evidence 2026-07-18 21:10:45 +08:00
lingniu
14e87f143d refine Semi UI history evidence workspace 2026-07-18 20:51:50 +08:00
lingniu
9101d26005 refine Semi UI track replay workspace 2026-07-18 20:29:32 +08:00
lingniu
b5b235e394 refine Semi UI vehicle workspace 2026-07-18 20:04:46 +08:00
lingniu
e95fbe5c19 refine Semi UI account access readiness 2026-07-18 19:41:01 +08:00
lingniu
532524b992 refine Semi UI operations health hierarchy 2026-07-18 19:25:40 +08:00
lingniu
5d4aa92cc1 refine Semi UI access governance 2026-07-18 19:12:07 +08:00
lingniu
84684f2dc1 polish Semi UI alert subworkspaces 2026-07-18 18:56:01 +08:00
lingniu
db1079ae27 refine Semi UI alert disposition flow 2026-07-18 18:38:04 +08:00
lingniu
23669d8190 refine Semi UI track replay summary 2026-07-18 18:21:25 +08:00
lingniu
5bfdc7f637 refine Semi UI vehicle evidence 2026-07-18 18:07:15 +08:00
lingniu
801a9851ea refine Semi UI access evidence 2026-07-18 17:37:29 +08:00
lingniu
82d78926c6 refine Semi UI alert disposition 2026-07-18 17:27:08 +08:00
lingniu
29b9ce9594 refine Semi UI identity permissions 2026-07-18 17:20:25 +08:00
lingniu
dbd1d9d6ca refine Semi UI mobile vehicle grants 2026-07-18 17:09:58 +08:00
lingniu
f7b523453c refine Semi UI access evidence inspector 2026-07-18 16:50:20 +08:00
lingniu
8827a05e7e refine Semi UI operations diagnosis cockpit 2026-07-18 16:33:21 +08:00
lingniu
cedb4f66b4 refine Semi UI mobile alert rule editing 2026-07-18 16:19:40 +08:00
lingniu
f28dfebaae refine Semi UI alert mobile sheets 2026-07-18 16:00:18 +08:00
lingniu
f0c17eac4d refine shared Semi UI command tokens 2026-07-18 15:44:17 +08:00
lingniu
8a2edc8bc9 refine Semi UI access workspace 2026-07-18 15:27:47 +08:00
lingniu
b764ba1be8 refine Semi UI mobile history tools 2026-07-18 15:15:37 +08:00
lingniu
50b5107bc2 refine Semi UI mobile monitor 2026-07-18 14:59:55 +08:00
lingniu
fe056019eb refine Semi UI mileage workspace 2026-07-18 14:44:57 +08:00
lingniu
c57caabaa0 refine Semi UI track and vehicle workspaces 2026-07-18 14:34:01 +08:00
lingniu
01d165d771 refine Semi UI alert workspace 2026-07-18 14:12:44 +08:00
lingniu
32c8939eb5 refine Semi UI identity workflow 2026-07-18 13:59:33 +08:00
lingniu
10a072280b refine Semi UI operations health 2026-07-18 13:38:25 +08:00
lingniu
3b06dd86a8 refine Semi UI vehicle directory 2026-07-18 13:29:46 +08:00
lingniu
c7123f6fbd refine Semi UI access difference workspace 2026-07-18 13:14:39 +08:00
lingniu
ad81d108a7 refine Semi UI history evidence workspace 2026-07-18 13:00:33 +08:00
lingniu
8aa273e579 refine Semi UI mileage matrix workspace 2026-07-18 12:41:41 +08:00
lingniu
e5bf76ea15 refine Semi UI track replay workspace 2026-07-18 12:21:42 +08:00
lingniu
a00276816b refine Semi UI account governance 2026-07-18 12:07:56 +08:00
lingniu
f423240e6f polish operations quality workspace 2026-07-18 11:53:19 +08:00
lingniu
09e16e8d26 polish access difference workspace 2026-07-18 11:40:35 +08:00
lingniu
0802b1989a polish alert operations workspace 2026-07-18 11:24:24 +08:00
lingniu
622c4398ec polish single vehicle entry workspace 2026-07-18 11:09:27 +08:00
lingniu
5805635ccf unify Shanghai time across Semi UI workspaces 2026-07-18 10:51:55 +08:00
lingniu
bba6a621aa refine Semi UI history workspace 2026-07-18 10:39:00 +08:00
lingniu
3cd19b276d refine Semi UI account workspace 2026-07-18 10:30:28 +08:00
lingniu
41b5c41998 refine Semi UI access workspace 2026-07-18 10:17:05 +08:00
lingniu
c1c35f8962 refine Semi UI vehicle workspace 2026-07-18 10:00:15 +08:00
lingniu
04d5992252 refine Semi UI operations review flow 2026-07-18 09:35:16 +08:00
lingniu
71f334d259 refine Semi UI alert workspace 2026-07-18 09:19:23 +08:00
lingniu
1688ba1b50 unify Semi UI query metric rails 2026-07-18 09:02:54 +08:00
lingniu
fb7cb4c6d6 refine Semi UI track replay details 2026-07-18 08:49:29 +08:00
lingniu
31dd36ee70 refine Semi UI vehicle discovery on mobile 2026-07-18 08:41:39 +08:00
lingniu
9a49d015cf refine Semi UI account permission workspace 2026-07-18 08:28:23 +08:00
lingniu
aca0edf130 refine Semi UI vehicle detail workspace 2026-07-18 08:19:43 +08:00
lingniu
1adcff5ecb refine Semi UI mobile history workspace 2026-07-18 08:06:08 +08:00
lingniu
fb171fb0de fix mobile track workspace layout 2026-07-18 07:57:53 +08:00
lingniu
40fcb6fc7b refine Semi UI mileage workspace 2026-07-18 07:50:25 +08:00
lingniu
ffb1283807 refine Semi UI operations workspace 2026-07-18 07:32:32 +08:00
lingniu
0c6a3e4ef1 refine Semi UI alert workspace 2026-07-18 07:19:19 +08:00
lingniu
45c0d4d9a3 refine Semi UI map controls 2026-07-18 07:09:05 +08:00
lingniu
b54d5ba077 refine Semi UI telemetry evidence 2026-07-18 07:00:07 +08:00
lingniu
4b3bb3b3dd refine Semi UI vehicle directory 2026-07-18 06:47:39 +08:00
lingniu
4bca0482c1 refine Semi UI track playback dock 2026-07-18 06:29:46 +08:00
lingniu
f7cd3020f7 fix Semi UI account editor viewport 2026-07-18 06:19:32 +08:00
lingniu
24d3c1dcac refine Semi UI access filter workflow 2026-07-18 06:13:18 +08:00
lingniu
6bb2107f5e refine Semi UI history data workflow 2026-07-18 06:05:58 +08:00
lingniu
018ec20256 refine Semi UI responsive application shell 2026-07-18 05:50:04 +08:00
lingniu
32d97a0ddc refine Semi UI monitor vehicle inspector 2026-07-18 05:40:47 +08:00
lingniu
45820daa46 refine Semi UI monitor workspace 2026-07-18 05:31:24 +08:00
lingniu
9fa68e4ed7 refine Semi UI authentication workspace 2026-07-18 05:15:57 +08:00
lingniu
80f6880488 refine Semi UI account workspace 2026-07-18 05:02:31 +08:00
lingniu
800d956159 refine Semi UI operations workspace 2026-07-18 04:43:06 +08:00
lingniu
866dfdaf5f refine Semi UI alert and access workspaces 2026-07-18 04:26:37 +08:00
lingniu
cff2e07a57 refine Semi UI track and history workspaces 2026-07-18 04:02:49 +08:00
lingniu
8291f5ea69 refine Semi UI vehicle evidence hierarchy 2026-07-18 03:52:05 +08:00
lingniu
3cebfe1982 unify Semi UI workspace command hierarchy 2026-07-18 03:36:18 +08:00
lingniu
e39140c408 refine Semi UI account workspace 2026-07-18 03:22:35 +08:00
lingniu
91db15ebde refine Semi UI alert workspace 2026-07-18 03:13:34 +08:00
lingniu
bb3820bfaa refine Semi UI history workspace 2026-07-18 02:57:45 +08:00
lingniu
1a3a7d49ec refine Semi UI track replay workspace 2026-07-18 02:41:25 +08:00
lingniu
c29d41fd86 refine Semi UI vehicle detail workspace 2026-07-18 02:24:21 +08:00
lingniu
5a8eec16f3 refine Semi UI vehicle discovery workspace 2026-07-18 02:10:11 +08:00
lingniu
da9619617d refine Semi UI operations health workspace 2026-07-18 02:02:40 +08:00
lingniu
f3c1391322 polish Semi UI admin and operations workspaces 2026-07-18 01:57:06 +08:00
lingniu
1affd30366 polish Semi UI query workspaces 2026-07-18 01:43:37 +08:00
lingniu
6d63783630 unify Semi UI filter workspaces and pagination 2026-07-18 01:29:30 +08:00
lingniu
5c0a61f7bd refine Semi UI platform shell and states 2026-07-18 01:12:48 +08:00
lingniu
1cd92715a5 feat(web): unify Semi UI workspace states 2026-07-18 00:59:11 +08:00
lingniu
159c80b0ae feat(platform): harden telemetry pipeline and unify Semi UI workspaces 2026-07-18 00:26:36 +08:00
lingniu
65b4e4f055 feat(operations): support canonical source providers 2026-07-16 22:50:55 +08:00
lingniu
9a5e6e0c4f feat(operations): add auditable source provider maintenance 2026-07-16 20:01:08 +08:00
lingniu
c270140006 fix(master-data): enforce authoritative vehicle totals 2026-07-16 19:35:05 +08:00
lingniu
d67f42b4f4 feat(operations): add automated reconciliation center 2026-07-16 19:14:53 +08:00
lingniu
bbab018d55 docs(oneos): publish API integration runbook 2026-07-16 18:49:04 +08:00
lingniu
46f2026c03 feat(oneos): add signed scope API client 2026-07-16 18:49:01 +08:00
lingniu
1243efc7dd docs(monitor): record province aggregation release 2026-07-16 18:36:35 +08:00
lingniu
21d1baada5 feat(monitor): aggregate nationwide fleet by province 2026-07-16 18:36:31 +08:00
lingniu
ea4d576793 docs(access): record real source calibration 2026-07-16 18:10:48 +08:00
lingniu
d682b7ffe1 fix(access): expose master data maintenance queue 2026-07-16 18:10:00 +08:00
lingniu
ff6df370cb fix(access): report only real vehicle sources 2026-07-16 18:07:45 +08:00
lingniu
85de053962 docs(auth): record vehicle grant interval release 2026-07-16 18:03:22 +08:00
lingniu
ddc1bb2d96 feat(auth): manage vehicle grant intervals 2026-07-16 18:01:23 +08:00
lingniu
d6b857240c docs: record source diagnosis release 2026-07-16 17:39:07 +08:00
lingniu
55f8fbe113 fix(operations): stabilize empty policy audit 2026-07-16 17:37:29 +08:00
lingniu
96cad7eef7 feat(operations): add source diagnosis workspace 2026-07-16 17:35:10 +08:00
783 changed files with 127467 additions and 3671 deletions

View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
RUN pip install --no-cache-dir \
--index-url https://mirrors.aliyun.com/pypi/simple \
ddddocr==1.6.1
WORKDIR /app
COPY ocr.py /app/ocr.py
USER nobody
ENTRYPOINT ["python", "/app/ocr.py"]

22
deploy/feichi-ocr/ocr.py Normal file
View File

@@ -0,0 +1,22 @@
import re
import sys
import ddddocr
def main() -> int:
image = sys.stdin.buffer.read()
if not image:
print("captcha image is empty", file=sys.stderr)
return 2
result = ddddocr.DdddOcr(show_ad=False).classification(image)
code = re.sub(r"[^0-9A-Za-z]", "", result).upper()
if not re.fullmatch(r"[0-9A-Z]{4}", code):
print(f"invalid OCR result: {code!r}", file=sys.stderr)
return 3
print(code)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,15 @@
FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000
FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json
FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json
FEICHI_TARGET_ADDR=127.0.0.1:32960
FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json
FEICHI_OCR_IMAGE=lingniu/feichi-captcha-ocr:1.0.0
FEICHI_LOGIN_MAX_ATTEMPTS=20
FEICHI_LOGIN_RETRY_SECONDS=1
FEICHI_POLL_INTERVAL_SECONDS=10
FEICHI_DISCOVERY_INTERVAL_SECONDS=300
FEICHI_FETCH_CONCURRENCY=4
FEICHI_SOURCE_STALE_SECONDS=120
FEICHI_STALE_REISSUE_ENABLED=true
FEICHI_BACKFILL_ENABLED=false
HEALTH_ADDR=127.0.0.1:20219

View File

@@ -0,0 +1,28 @@
[Unit]
Description=Lingniu Feichi HTTP to GB/T 32960 Bridge
Documentation=file:/opt/lingniu-go-native/current/docs/ops/feichi-bridge.md
After=network-online.target docker.service lingniu-go-gateway.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
WorkingDirectory=/opt/lingniu-go-native/current
EnvironmentFile=/opt/lingniu-go-native/env/base.env
EnvironmentFile=/opt/lingniu-go-native/env/feichi-bridge.env
Environment=GOMEMLIMIT=256MiB
ExecStart=/opt/lingniu-go-native/current/feichi-bridge
Restart=always
RestartSec=5
LimitNOFILE=65536
MemoryLimit=384M
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWriteDirectories=/var/lib/lingniu-feichi-bridge
[Install]
WantedBy=multi-user.target

552
design-qa.md Normal file
View File

@@ -0,0 +1,552 @@
# Semi UI shell vNext — Design QA
- Source visual truth: `/Users/lingniu/.codex/attachments/e33960ff-ed87-40f8-9da6-8e6615a653c5/image-1.png`
- Desktop implementation: `/tmp/lingniu-ui-audit-20260720/14-after-desktop-pass2.png`
- Mobile implementation: `/tmp/lingniu-ui-audit-20260720/05-after-mobile-pass2.png`
- Mobile secondary navigation: `/tmp/lingniu-ui-audit-20260720/06-mobile-more.png`
- Mobile cross-page state: `/tmp/lingniu-ui-audit-20260720/07-mobile-statistics.png`
- 2K implementation: `/tmp/lingniu-ui-audit-20260720/15-after-2k.png`
- Full-view comparison: `/tmp/lingniu-ui-audit-20260720/16-reference-comparison-final.png`
- Focused navigation comparison: `/tmp/lingniu-ui-audit-20260720/17-sidebar-comparison.png`
- Viewports: 1440 × 900, 2560 × 1440, 390 × 844
- State: authenticated mock administrator; Monitor list mode with four vehicles
## Comparison scope
The supplied Semi image is a different product and data state, so it is not a
pixel-for-pixel content specification. The comparison treats its application
shell, navigation rhythm, whitespace, typography, surface treatment and
interaction hierarchy as the visual truth. The vehicle table keeps the width
required by the operational task instead of copying the reference's team rail.
## Findings and iteration history
### Pass 1
- **[P1] Layered navigation chrome made the shell feel heavy.**
- Evidence: the baseline used a soft square behind every icon, gradient
selection fills and several shadow layers; the reference uses quiet icons,
one selected row and neutral white space.
- Fix: removed icon plates and gradients, simplified hover/selected states,
aligned group labels and introduced one restrained active indicator.
- Post-fix evidence: focused navigation comparison.
- **[P1] Mobile hierarchy was too compressed.**
- Evidence: `/tmp/lingniu-ui-audit-20260720/02-before-mobile.png` used an
unbranded title bar, 9px navigation labels and truncated vehicle facts.
- Fix: added the supplied brand mark, rebuilt the bottom navigation rhythm,
increased the essential text scale and redistributed the four vehicle
metrics evenly without reducing the visible vehicle count.
- Post-fix evidence: mobile implementation and mobile cross-page state.
- **[P2] Page transitions rebuilt the content container.**
- Evidence: the shell keyed the entire content container by pathname.
- Fix: kept the content container stable and animated only the newly mounted
route surface; reduced-motion users receive no transition.
- Post-fix evidence: Monitor → Mileage route change retained the shell and
updated the title, active navigation and page content.
### Pass 2
- **[P2] Collapsed sidebar hid its reopen affordance.**
- Evidence: `/tmp/lingniu-ui-audit-20260720/08-desktop-collapsed.png` showed
an empty collapse footer because a legacy selector hid every nested span,
including Semi's icon wrapper.
- Fix: restored only the icon wrapper, rotated the chevron and preserved the
text-hidden compact state.
- Post-fix evidence:
`/tmp/lingniu-ui-audit-20260720/11-desktop-collapsed-final.png`; browser
state changed from `收起侧栏` to `展开侧栏` and back.
- **[P2] Desktop data type remained optically smaller than the reference.**
- Evidence: first full-view comparison showed the header, filter controls,
table headings and primary row values losing hierarchy at 1440px.
- Fix: increased the desktop shell header to 76px, raised navigation and
table type sizes, and increased control and row heights while retaining
the operational density.
- Post-fix evidence: final full-view comparison and 2K implementation.
## Required fidelity surfaces
- **Fonts and typography:** system/PingFang stack is appropriate for Chinese;
page titles, navigation, controls and primary values now form a clear scale.
Tabular figures are enabled for telemetry. No actionable P0P2 issue remains.
- **Spacing and layout rhythm:** the shell uses a 220px desktop rail, 76px
desktop header, 56px mobile header and 64px safe-area-aware mobile
navigation. Cards use quieter 910px radii and one-pixel boundaries.
- **Colors and tokens:** white surfaces, `#f5f6f7` canvas, `#e6e8eb` borders
and Semi blue `#0064fa` now match the reference's foreground/background
balance. Operational green/red tones remain semantic.
- **Image quality and assets:** supplied brand SVG assets are used directly.
No placeholder illustration, custom SVG or CSS-drawn brand asset was added.
- **Icons:** all shell actions use the existing Semi icon family. Selected,
hover, pressed and collapsed states remain optically aligned.
- **Copy and content:** each page title now has a short task description on
desktop. Mobile keeps only the title to protect first-screen task density.
- **Responsiveness:** 390px mobile, 1440px desktop and 2560px 2K states were
rendered without overlap, hidden persistent controls or shell overflow.
- **Accessibility:** semantic navigation names and page headings remain intact;
mobile targets are at least 42px in the shell, focus styles are preserved,
and `prefers-reduced-motion` disables the new transitions. Screenshot review
cannot prove full assistive-technology compatibility.
## Interaction and console checks
- Mobile More opens a Semi SideSheet with grouped analysis and governance
destinations, then closes without changing page state.
- Mobile Mileage navigation changes URL, heading and active tab and renders the
fixed-column mileage matrix.
- Desktop sidebar collapses and expands with a visible affordance in both
states.
- No framework error overlay or application runtime error was observed.
Semi UI's known React `findDOMNode` development deprecation warning remains.
## Follow-up polish
- P3: continue raising secondary explanatory text on legacy page-specific
surfaces as each page enters the Semi migration.
- P3: consolidate older workspace CSS overrides after the visual migration is
complete; removing them now would expand regression scope without changing
this shell's rendered result.
final result: passed
---
# Access portrait evidence viewport — Design QA
- Source visual truth:
`/tmp/access-mobile-density-20260720/01-production-320-before.png`
- Local implementation:
`/tmp/access-mobile-density-20260720/02-local-320-after.png`
- Local detail interaction:
`/tmp/access-mobile-density-20260720/03-local-320-detail.png`
- Local 390px regression:
`/tmp/access-mobile-density-20260720/04-local-390-after.png`
- Deployed 320px evidence:
`/tmp/access-mobile-density-20260720/05-production-320-after.png`
- Deployed detail interaction:
`/tmp/access-mobile-density-20260720/06-production-320-detail.png`
- Deployed 390px evidence:
`/tmp/access-mobile-density-20260720/07-production-390-after.png`
- Same-viewport production comparison:
`/tmp/access-mobile-density-20260720/08-production-320-comparison.png`
- Viewports: 320 × 568 and 390 × 844
- State: authenticated administrator; Access directory with all vehicles
selected and live protocol evidence
## Findings and comparison history
### Baseline
- **[P1] The portrait mobile evidence list was almost entirely clipped.**
- Evidence: at 320 × 568 the page reserved 72px of redundant bottom padding,
a 72px filter trigger, a 58px repeated panel heading and a 92px metric
rail. Only 19px remained for the vehicle scroller, so the first 155px
vehicle card could not be read or selected reliably.
- Impact: the primary Access task—identifying a vehicle and opening its
protocol evidence—was effectively blocked on narrow mobile devices.
- Fix: removed the redundant portrait-only page reserve, reduced the filter
trigger to 56px, reduced the metric rail to 60px minimum height and hid the
repeated mobile panel heading. The established command bar, filters,
metrics, pagination and bottom navigation remain present.
### Final pass
- No actionable P0, P1 or P2 issue remains in this mobile Access flow.
- At 320 × 568 the evidence scroller increased from 19px to 195px, a 176px
gain and more than tenfold improvement. One complete vehicle card and the
next-card affordance are visible.
- At 390 × 844 the scroller is 471px tall and three evidence cards are visible
before pagination.
- Both viewports report document width equal to viewport width. The bottom
navigation, pagination and detail sheet do not overlap or introduce
horizontal scrolling.
## Required fidelity surfaces
- **Fonts and typography:** existing Semi UI typography, plate hierarchy and
protocol metadata remain unchanged and readable.
- **Spacing and layout rhythm:** only portrait Access density changed. Existing
short-landscape rules and Web layout are untouched.
- **Colors and visual tokens:** the existing neutral, blue, green and amber
semantic states are preserved, including partial-source warning evidence.
- **Image quality and assets:** no new imagery, icon approximation or
replacement asset was introduced.
- **Copy and content:** command, filter, vehicle, source and evidence labels are
unchanged; only the duplicate `接入差异清单` mobile heading is visually
removed.
- **Icons:** existing Semi icons and controls retain their current sizing and
active states.
- **Responsiveness:** 320px and 390px portrait layouts have no horizontal
overflow. The portrait-only media query does not alter desktop or landscape
composition.
- **Accessibility:** every vehicle remains a semantic button and the selected
vehicle opens a labeled `车辆接入详情` dialog with a labeled close action.
This visual and DOM pass does not replace a full assistive-technology audit.
## Interaction and console checks
- Production Access → first vehicle card → vehicle detail → close was exercised
successfully at 320px.
- The detail sheet measured 320px wide and 477px tall, retained its internal
scroller, and exposed `接入健康概览` plus `协议证据`.
- Automated gate: 73 test files and 383 tests passed. The targeted Access and
production-entry gate passed 14 tests.
- TypeScript, Vite, distribution preparation and the release build verifier
completed successfully with 74 current assets.
- Release `access-mobile-list-20260720182508` is active. `/access` returned
HTTP 200, the service is active, and the deployed
`app-DC_UXKeU.css` SHA-256 matches the local production asset:
`8e723387c096e156fb70e2a234ade464c0d1faca000c2d54ef8a65e402638c78`.
- No application runtime failure or visible error state was observed during
local or deployed interaction testing. Existing development-only Semi UI
`findDOMNode` warnings remain unchanged.
## Follow-up polish
- P3: revisit Access card vertical density only if future protocol evidence
adds a fourth simultaneous source; the current three-source layout is
readable at 320px.
final result: passed
---
# Account governance clarity pass — 2026-07-20
## Scope
- Route: `/users`
- Surfaces: desktop directory, mobile directory, desktop configuration
SideSheet, mobile configuration SideSheet.
- Production release: `users-clarity-20260720181338`.
## Findings and fixes
- **P1 — Mobile directory hierarchy consumed too much of the first viewport.**
- Before: at 320 × 568 the directory began at y=291, so only about three
complete customer rows were visible.
- Fix: removed the repeated mobile directory heading, reduced the metric
rail to 56px and kept the list itself as a single-row directory.
- After: the directory begins at y=227. Four complete rows plus the next-row
affordance are visible at 320 × 568; all five current accounts fit at
390 × 844.
- **P1 — Mobile configuration repeated identity information before the first
editable field.**
- Before: header, three-column summary, numbered tabs and identity overview
pushed the first editable field to roughly y=425.
- Fix: kept only the decision-critical permission scope and account-status
summary on mobile, removed duplicate step circles and hid the repeated
identity overview.
- After: the first identity field begins at y=253 in production. Permission
scope, account enable/disable state, section tabs and persistent save state
remain visible.
- **P2 — Narrow directory scrolling reduced usable row width.**
- Fix: hid the decorative inner scrollbar on mobile while preserving the
keyboard-focusable scroll region and native touch scrolling.
## Fidelity and interaction checks
- Desktop remains a true one-row directory with dedicated columns for menu
permissions, vehicle permissions, recent login and access state.
- Desktop configuration retains the complete three-part summary and detailed
identity overview; mobile removes only duplicated information.
- The mobile vehicle-permission tab was exercised in production: assigned
plates remain primary, VIN secondary, and effective periods, adjust/remove
actions, pagination and history remain reachable.
- No horizontal document overflow occurred at 390px or 1440px.
- Production console: no warnings or errors for the deployed route.
- Automated gate: 73 test files and 382 tests passed. Production build gate
passed with 74 release assets.
- Local and ECS `UsersPage-CNmDqRKT.css` SHA-256 matched:
`6d5e0f80489967fc5f4eac7c30236091969e66730e68031713740c3f8c603ba3`.
## Evidence
- `/tmp/responsive-audit-20260720/users-list-mobile-production.png`
- `/tmp/responsive-audit-20260720/users-config-mobile-production.png`
- `/tmp/responsive-audit-20260720/users-config-vehicles-mobile-after.png`
- `/tmp/responsive-audit-20260720/users-list-desktop-production.png`
final result: passed
# 2026-07-20 Operations mobile clarity audit
## Audit scope
- Surface: authenticated `/operations` quality workspace.
- User goal: scan the active reconciliation queue, understand the two urgent
counts, and move between reconciliation and diagnosis without leaving the
viewport or losing mobile navigation.
- Production baseline:
`/tmp/cross-route-audit-20260720/mobile-operations-stable.png`.
- Local implementation:
`/tmp/cross-route-audit-20260720/mobile-operations-local-after-stable.png`.
- Production verification:
`/tmp/cross-route-audit-20260720/mobile-operations-production-final.png`.
## Findings and resolution
1. **P1 — the operations command surfaces exceeded the phone viewport.**
- Baseline measurement: the 390px-wide command bar started at x=8 and
ended at x=398 because a desktop `width: 100%` combined with 8px mobile
margins. The adjacent workspace navigation inherited the same geometry.
- Impact: the global refresh action and right edge of the segmented
workspace control were visibly clipped.
- Fix: the mobile operations command bar and workspace navigation now use
their available flex width (`width: auto`) and remain inset from x=8 to
x=382.
2. **P1 — half of the reconciliation metric rail was reserved for hidden
desktop context.**
- Baseline measurement: the rail body retained `199px 180px` columns at
390px even though the context column was hidden. The two visible metrics
were compressed to 99.5px each, truncating both supporting notes.
- Impact: users could see the large numbers but not understand whether
`1,327` represented active items or what caused `1,189` SLA breaches.
- Fix: the hidden context column now collapses on phones. The visible list
occupies all 379px with two equal 189.5px columns, and each metric uses a
clear label/value/note stack.
## Final pass
- 390 × 844 production document width equals viewport width.
- Command bar and workspace navigation are both fully inside the viewport.
- `待复核 1,327 / 活跃 1,327 项` and
`SLA 超时 1,189 / 超过 24 小时` are fully legible.
- The `差异处置 → 单车诊断 → 差异处置` interaction was exercised against
ECS production. Both panels rendered correctly and document width remained
390px.
- 1440 × 900 retained the existing five-column reconciliation table, summary
context, side navigation and pagination without reflow.
- No production console warning or error was emitted during the smoke test.
The known Semi UI `findDOMNode` message remains development-only.
- Automated gate: 73 test files and 381 tests passed.
- Production build gate: 74 release assets passed.
- Release `ops-mobile-clarity-20260720175459` is active. The served
`app-BUsvckVz.css` SHA-256 matches the local build:
`feb4f945ff7dc586a31d1c65f985048b7ef6399574911db182767fc813c28121`.
## Evidence limits
- Screenshot and DOM evidence verifies responsive reflow, visible hierarchy,
interaction state and console health. It does not by itself prove full WCAG
conformance or screen-reader announcements.
- The diagnosis vehicle picker was not used to mutate production state; its
empty state and workspace transition were verified read-only.
final result: passed
---
# Account management guided onboarding — Design QA
- Source visual truth:
`/tmp/account-management-audit-20260720/07-users-mobile-create-production-baseline.png`
- Rendered implementation:
`/tmp/account-management-audit-20260720/08-users-mobile-create-local-after.png`
- Deployed production implementation:
`/tmp/account-management-audit-20260720/14-users-mobile-create-production-after.png`
- Full-view comparison:
`/tmp/account-management-audit-20260720/12-users-mobile-create-full-comparison.png`
- Focused summary/footer comparison:
`/tmp/account-management-audit-20260720/13-users-mobile-create-focus-comparison.png`
- Web implementation:
`/tmp/account-management-audit-20260720/11-users-desktop-create-local-after.png`
- Follow-on menu step:
`/tmp/account-management-audit-20260720/09-users-mobile-create-menu-local-after.png`
- Follow-on vehicle step:
`/tmp/account-management-audit-20260720/10-users-mobile-create-vehicles-local-after.png`
- Viewports: 390 × 844 and 1440 × 900
- State: authenticated administrator; customer directory, existing customer
editor and new-customer draft
## Findings and comparison history
### Baseline
- **[P1] The new-account default contradicted the page's least-privilege
promise.**
- Evidence: the production source capture opened a new customer with all
four customer menus already granted, while the header and supporting copy
said permissions were opened by minimum necessary scope.
- Impact: an administrator could create a broadly authorized account without
explicitly reviewing menu access.
- Fix: new drafts now begin with zero menu and zero vehicle permissions.
Existing accounts retain their saved menu and vehicle grants unchanged.
- Post-fix evidence: the implementation summary shows `0 菜单 · 0 辆`, the
badge reads `待开放菜单`, and every menu row begins unchecked.
- **[P2] Draft state and the primary action implied that an unsaved account was
already synchronized.**
- Evidence: before the fix, the new-account footer showed `已同步` and the
disabled action was `创建账号`, even though no server-side account existed.
- Impact: administrators could not distinguish a local draft from a saved
identity and were not prompted to review permissions in order.
- Fix: new drafts show `新建草稿`; the primary action advances through
`下一步:菜单权限`, `下一步:车辆权限`, then `创建账号`. The first step
remains disabled until username, display name and a 10128 character
password with at least three character categories are complete.
- Post-fix evidence: the focused comparison shows the corrected state badge,
zero-permission summary and step-specific action.
### Final pass
- No actionable P0, P1 or P2 issue remains in the audited account-management
flow.
- The existing Web single-line customer directory, mobile dense customer list,
identity editor, menu editor, vehicle paging and grant-window editor were
preserved.
- A production smoke used a disposable client-side draft, advanced from
identity to the zero-menu state, then discarded the draft. No customer was
created or modified.
## Required fidelity surfaces
- **Fonts and typography:** the existing PingFang/system stack, title weights,
compact metadata and tab hierarchy remain unchanged. Step labels fit at
390px without wrapping or truncating the primary action.
- **Spacing and layout rhythm:** the established full-width mobile sheet,
sticky summary, three-step tabs, scrollable form and sticky footer remain
aligned. The Web sheet retains its 820px maximum width and two-column fields.
- **Colors and visual tokens:** existing Semi blue, neutral and semantic
green/amber states are retained. Amber now consistently communicates an
incomplete permission scope; blue communicates a local new draft.
- **Image quality and assets:** no new imagery or replacement asset was
introduced. The product's supplied vector brand mark and Semi icon family
remain unchanged.
- **Copy and content:** `新建草稿`, `待开放菜单` and the step-specific actions
accurately describe server state and the administrator's next task.
- **Icons:** the existing Semi close, eye, plus and checkbox icons remain
aligned and use the same active/disabled treatments.
- **Responsiveness:** 390px and 1440px captures show no horizontal overflow,
clipped footer, hidden close action or obscured form field.
- **Accessibility:** the flow exposes a dialog, three semantic tabs, labeled
fields, checkboxes and buttons. Disabled state prevents advancing with an
incomplete or weak identity. Screenshot and DOM review do not replace a
full assistive-technology audit.
## Interaction and console checks
- Production customer directory → new account → identity draft → menu step →
discard draft was exercised without a write.
- Local identity fields were completed with a compliant preview password; the
primary action became enabled and advanced to a zero-menu state.
- Menu and vehicle tabs rendered their unchecked/empty states and the final
create action without layout shift.
- Existing-customer identity, menu and 44-vehicle states were captured on Web
and mobile before implementation.
- Local development emitted only Semi UI's known React `findDOMNode`
deprecation warning. Deployed production emitted no warning or error.
- Automated gate: 73 test files and 380 tests passed. TypeScript, Vite and the
release build verifier completed successfully.
- Production release `account-onboarding-ux-20260720173338` is active. The
immutable release gate verified 74 current and 116 compatibility assets; the
served UsersPage bundle is byte-for-byte identical to the local build.
## Follow-up polish
- P3: add a compact password-rule checklist to the administrator's initial
password field when the shared password-input pattern is next consolidated.
final result: passed
---
# Intermediate sidebar continuity — Design QA
- Source visual truth: `/tmp/sidebar-continuity-20260720/02-monitor-901-production.png`
- Rendered implementation: `/tmp/sidebar-continuity-20260720/11-monitor-901-local-settled.png`
- Full-view comparison: `/tmp/sidebar-continuity-20260720/12-monitor-901-full-comparison.png`
- Focused navigation comparison: `/tmp/sidebar-continuity-20260720/13-monitor-901-focus-comparison.png`
- Cross-route implementation evidence: `/tmp/sidebar-continuity-20260720/09-routes-1024-local-montage.png`
- Mobile regression evidence: `/tmp/sidebar-continuity-20260720/10-statistics-390-mobile-regression.png`
- Viewports: 680 × 700, 681 × 700, 900 × 700, 901 × 700,
1024 × 700, 1100 × 700, 1101 × 700, 1280 × 700 and 390 × 844
- State: authenticated customer; Monitor map mode, plus Vehicles, Tracks and
Statistics default workspaces
## Findings and comparison history
### Baseline
- **[P2] A one-pixel viewport change caused a disproportionate workspace
reflow.**
- Evidence: production at 900px used a 72px compact rail and an 828px main
region, while 901px immediately restored a 228px rail and reduced the main
region to 673px. The full-view source capture shows the map losing about
one third of its width even though the device width increased.
- Impact: tablet and browser-zoom users could cross the breakpoint and see
filters, telemetry and the map recompose abruptly.
- Fix: aligned the React shell state and shared CSS breakpoint so the 72px
Semi icon rail remains active from 681px through 1100px. Full navigation
returns at 1101px, where the main region still has 873px.
- Post-fix evidence: the 901px implementation has a 72px rail, 829px main
region and complete 284px account action region. The focused comparison
shows the recovered workspace width without changing the page hierarchy.
### Final pass
- No actionable P0, P1 or P2 mismatch remains.
- Boundary measurements:
- 680px: mobile shell, 680px main region.
- 681px: 72px rail, 609px main region.
- 900px: 72px rail, 828px main region.
- 901px: 72px rail, 829px main region.
- 1024px: 72px rail, 952px main region.
- 1100px: 72px rail, 1028px main region.
- 1101px: 228px rail, 873px main region.
- 1280px: 228px rail, 1052px main region.
- Vehicles, Tracks and Statistics all retained the same shell geometry at
1024px and 1101px with document width equal to viewport width.
## Required fidelity surfaces
- **Fonts and typography:** the existing PingFang/system stack, weights and
hierarchy remain unchanged. Labels stay readable in the compact rail and
the 901px topbar has no wrapping or clipping.
- **Spacing and layout rhythm:** the change preserves the established 72px and
228px shell tokens and removes the unstable intermediate reflow. Page
margins, card spacing and content density remain consistent across routes.
- **Colors and visual tokens:** no palette or semantic state color changed.
Existing white surfaces, neutral borders and Semi blue selection states
remain faithful to the current production design.
- **Image quality and assets:** the supplied vector brand mark remains the
single shell asset. No raster substitute, custom SVG or CSS-drawn asset was
introduced.
- **Copy and content:** no product copy changed. Compact navigation uses the
existing accessible menu labels while hiding only visible text.
- **Icons:** the existing Semi icon set remains optically centered in the 72px
rail; selected and inactive states were checked at 901px and 1024px.
- **Responsiveness:** no horizontal document overflow occurred at any tested
boundary. The 390px mileage matrix retained fixed vehicle and total columns,
horizontal date navigation and bottom navigation.
- **Accessibility:** complementary navigation remains present at 6811100px;
mobile navigation remains available below 681px; the account menu remains
fully inside the 901px viewport. This visual pass does not replace an
assistive-technology audit.
## Interaction and console checks
- Monitor, Vehicles, Tracks and Statistics were navigated in the authenticated
browser at 1024px and measured again at 1101px.
- Route changes retained the shell, active navigation and full-width work area.
- The 390px Statistics page preserved usable pagination and bottom navigation.
- No application runtime error was observed. Semi UI's known React
`findDOMNode` development StrictMode deprecation warning remains.
- Automated gate: 73 test files and 379 tests passed; production build and
release verification completed successfully.
- Deployed production evidence:
`/tmp/sidebar-continuity-20260720/14-monitor-901-production-after.png`.
The authenticated ECS page measured a 72px rail and 829px main region at
901px, with no horizontal overflow and no browser console warning or error.
- Release `sidebar-continuity-ux-20260720171458` is active. The immutable
release gate verified 74 current assets and 116 compatibility assets; the
served shell stylesheet is byte-for-byte identical to the local build.
## Follow-up polish
- P3: replace the remaining Semi UI components that trigger React
`findDOMNode` development warnings when the library exposes ref-safe
alternatives.
final result: passed

View File

@@ -106,6 +106,8 @@ Stat writer 每条有效里程样本都会更新 `vehicle_daily_mileage_source`
每日里程只采用同来源累计里程边界差值:`当日最新累计总里程 - 最近历史日最后累计总里程`。优先取前一自然日;前一日没有数据时继续向更早日期查找,历史完全为空才以当天第一条为基线。不同协议、平台和终端来源分别计算候选,再由来源质量和优先级选举最终结果,不混用两个来源的累计总里程。 每日里程只采用同来源累计里程边界差值:`当日最新累计总里程 - 最近历史日最后累计总里程`。优先取前一自然日;前一日没有数据时继续向更早日期查找,历史完全为空才以当天第一条为基线。不同协议、平台和终端来源分别计算候选,再由来源质量和优先级选举最终结果,不混用两个来源的累计总里程。
车辆数据中台按用户启用的协议顺序查询“单车、单日”结果,但只在有有效增量的来源之间应用优先级:首选协议为 `0 km`、其他已启用协议存在正向里程时,查询层降级到有增量的最高优先级来源;全部来源均为 `0 km` 时仍严格按原优先级返回 `0`。该规则只选择一个完整协议事实,不拼接不同协议的首末累计里程。
`vehicle_data_source``(protocol, source_ip)` 唯一识别来源。直连终端可能产生很多来源 IP这些记录可以保留但默认不要求平台名转发平台来源通过 `vehicle_identifier.source_code``jt808_registration.source_endpoint` 推断后补充 `source_code/platform_name`,后续统计优先使用 `source_id/trust_priority/enabled` 做可信源选择。 `vehicle_data_source``(protocol, source_ip)` 唯一识别来源。直连终端可能产生很多来源 IP这些记录可以保留但默认不要求平台名转发平台来源通过 `vehicle_identifier.source_code``jt808_registration.source_endpoint` 推断后补充 `source_code/platform_name`,后续统计优先使用 `source_id/trust_priority/enabled` 做可信源选择。
统计分日优先使用协议事件时间;如果事件时间明显晚于接收时间超过 10 分钟则认为设备时间异常使用接收时间归属统计日。RAW 历史仍保留原始事件时间,便于追溯。 统计分日优先使用协议事件时间;如果事件时间明显晚于接收时间超过 10 分钟则认为设备时间异常使用接收时间归属统计日。RAW 历史仍保留原始事件时间,便于追溯。

View File

@@ -24,6 +24,7 @@
| MySQL | `vehicle_realtime_snapshot` | 每个协议+VIN 的最新事件态、合并扁平字段,以及独立的首次/前次/最新接收时间、连续间隔、样本数和证据来源 | phone、device、原始嵌套解析树、raw 报文、无限接收历史 | | MySQL | `vehicle_realtime_snapshot` | 每个协议+VIN 的最新事件态、合并扁平字段,以及独立的首次/前次/最新接收时间、连续间隔、样本数和证据来源 | phone、device、原始嵌套解析树、raw 报文、无限接收历史 |
| MySQL | `vehicle_realtime_location` | 每个协议+VIN 的最新位置核心字段 | raw、parsed JSON、消息头内部字段 | | MySQL | `vehicle_realtime_location` | 每个协议+VIN 的最新位置核心字段 | raw、parsed JSON、消息头内部字段 |
| MySQL | `vehicle_daily_mileage` | 日期、VIN、协议、日里程、首末总里程、样本数 | 临时 vehicle key、泛化 metric key/value、自增 id、created_at、每帧细节、位置点列表 | | MySQL | `vehicle_daily_mileage` | 日期、VIN、协议、日里程、首末总里程、样本数 | 临时 vehicle key、泛化 metric key/value、自增 id、created_at、每帧细节、位置点列表 |
| MySQL | `vehicle_daily_gps_mileage_state` | 仅 JT808 缺累计里程时的单车/自然日/物理来源滚动坐标、累计距离和质量计数 | 轨迹点列表、RAW 报文、跨日状态、GB32960/宇通状态 |
| MySQL | `vehicle_identity_binding` | 人工维护或导入的 VIN、车牌、phone、oem 映射 | 注册历史、协议状态、device_id、自增 id、created_at | | MySQL | `vehicle_identity_binding` | 人工维护或导入的 VIN、车牌、phone、oem 映射 | 注册历史、协议状态、device_id、自增 id、created_at |
| MySQL | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at | | MySQL | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at |
| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 | | Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 |

View File

@@ -0,0 +1,272 @@
# 2026-07-20 日里程与轨迹里程核对
## 技术结论
沪A01559F 的 2026-07-19 日里程曾因统计恢复延迟缺失现已从持久化来源证据重建沪A62013F 的轨迹页展示了累计总里程,但 2026-07-19 没有可信移动证据0 km 是正确的自然日日里程。统计链路现已支持在仪表累计值停止刷新、但速度和坐标证明车辆实际移动时生成低优先级 GPS 估算候选,同时拒绝静止漂移、长间隔、乱序和不合理跳点。
## 事件范围
用户在跨自然日后反馈沪A01559F、沪A62013F“今日里程未计算但轨迹回放有里程”。核对按 `Asia/Shanghai` 自然日执行;由于反馈发生在 2026-07-20 00:00 之后,而车辆最后活动发生在前一日,本次事实窗口固定为 2026-07-19 00:00:00 至 2026-07-20 00:00:00。
“轨迹里程”是轨迹查询窗口内协议总里程差,协议总里程不可用时才使用经过质量过滤的坐标累计;“当日日里程”是同一车辆、协议、来源在上海时区自然日边界上的累计里程差。轨迹卡片中的“累计里程”只是某个点位携带的仪表总值,不等于当日日里程。
## 生产事实
| 车牌 | VIN | 轨迹事实 | 日里程结果 | 结论 |
| --- | --- | --- | --- | --- |
| 沪A01559F | `LMRKH9AC7R1004120` | 主来源 JT8082,793 个有效点;协议总里程差 769.1 km | 宇通 809 km、JT808 769.1 km宇通按来源优先级进入业务统计 | 当日曾存在统计恢复延迟,结果已恢复 |
| 沪A62013F | `LMRKH9AC9R1004099` | 宇通 520 个源点、519 个有效点;最高速度 0轨迹汇总 0 km | 宇通 0 km累计总里程 24,245 km | 0 km 正确;轨迹点展示的是累计总里程,不得补成正向日里程 |
沪A01559F 的 2026-07-19 最终日里程在 2026-07-20 00:32 恢复。沪A62013F 的宇通总里程事件时间仍为 2026-07-18 16:482026-07-19 只有静止位置证据,因此只能保留显式 0 km。
## 根因与修复
stat-writer 已支持在累计里程缺失时,用有速度证据的相邻 GPS 坐标生成低优先级日里程候选;但累计里程字段存在且持续重复时,流程会提前进入 `skipped_same_mileage`,没有执行 GPS 累计。这会影响“车辆真实移动、仪表累计值停止刷新”的 GB32960 或宇通来源。
修复后:
- 重复累计里程仍保留为 `skipped_same_mileage` 证据;
- GB32960、宇通只有在设备速度大于 0 且坐标有效时才进入 GPS 累计;
- GPS 状态仍按 10 秒节流,并拒绝超过 10 分钟、时间倒序或隐含速度超过 220 km/h 的线段;
- 低于 100 米的累计仅保留为内部状态,不生成业务日里程候选;
- 正向仪表累计里程优先于 GPS 估算,真实仪表值恢复后会自动覆盖估算;
- 静止车辆不会因 GPS 漂移产生虚假里程。
## 生产发布与回补验证
2026-07-20 02:31 发布 `mileage-track-reconcile-20260720023109`9 个数据链路服务全部通过 `/readyz`,容量巡检最终为 `status=ok`Kafka lag、消费重试队列和待处理消息均为 0。
发布后对 2026-07-17 至 2026-07-19 的 GB32960、JT808、YUTONG_MQTT 执行正式回补:
| 验证项 | 结果 |
| --- | ---: |
| 重建并写入的来源聚合 | 3,155 |
| 新增 GB32960 GPS 安全候选 | 2 |
| 新增 JT808 GPS 安全候选 | 66 |
| 新增宇通 GPS 安全候选 | 0 |
| 归一化平台来源 | 1,376 |
回补后沪A01559F 的 2026-07-19 结果稳定为宇通 809 km、JT808 769.1 km2026-07-20 在没有新移动证据时按静止结转明确写为 0 km。沪A62013F 的 2026-07-19 仍为宇通 0 km且没有生成 GPS 估算候选,证明 100 米阈值和速度证据门槛没有把约 12 米的静止定位偏差放大为业务里程。
同日全量诊断没有统计管道故障;另有 2 条与本事件无关的源数据缺口,分别为一台赛格 JT808 车辆和一台宇通车辆未上报可用总里程字段,已由诊断接口持续暴露,不能用猜测值补录。
## 同类数据审计与补录边界
生产 `stats-backfill` 对 2026-07-19 全部 19 台有宇通日活动的车辆执行只读扫描,未发现除现有正向仪表里程之外、可由有效移动线段恢复的 GPS 候选。沪A01559F 的结果已经恢复无需重复改写沪A62013F 缺少移动证据,禁止补录正数。
后续历史补录继续使用同一条安全规则:只有存在可用移动线段时才生成 `gps_coordinate_accumulation` 候选,不按累计总里程、位置点数量或页面展示值推断日里程。
2026-07-20 03:44 对当天三协议再次执行全量 dry-run共重建 448 个来源聚合,识别 5 个可由 JT808 有效移动轨迹恢复的候选03:48 正式回补写入 449 个来源聚合并归一化 272 个 JT808 平台来源。复核同时发现阈值规则上线前遗留的沪A56988F `0.004 km` GPS 候选,因此补充了“低于 100 米遗留候选清理 + 同事务重新投影”能力;后续回补会自动修复同类旧数据,而不是只阻止新漂移值写入。
03:56 新版本 `mileage-gps-candidate-reconcile-202607200350` 完成清理型正式回补:写入 452 个来源聚合,`subthresholdGPSCandidatesFound=1``subthresholdGPSCandidatesDeleted=1`。沪A56988F 当天阈值下候选及错误最终投影均已删除;剩余被选中的 GPS 候选最小值为 `0.131 km`,低于 `0.1 km` 的选中候选为 0。清理后该车显式暴露为“JT808 当日缺少新鲜总里程字段”的源数据缺口,统计管道缺口仍为 0系统保留该缺口不用 4 米静止漂移构造业务里程。
## 7 月 20 日 05:40 再次复核
针对“轨迹回放有里程、今日里程没有计算”的再次反馈,按 `2026-07-20 00:00:00``05:40:00` 重新核对生产轨迹、实时总里程证据、来源候选和最终日里程:
| 车牌 | 今日轨迹证据 | 今日统计 | 处理结论 |
| --- | --- | --- | --- |
| 沪A01559F | 宇通 260 个源点、259 个有效点;最高/平均速度均为 0坐标区间累计 0 km | 宇通 `stationary_carry_forward` 0 km仪表累计总里程 14,097 km 的最新证据仍是 7 月 19 日 21:23:59 | 已有显式 0 km 统计,无可信正向里程可补 |
| 沪A62013F | 今日 0 个源点7 月 19 日 520 个源点最高速度仍为 0区间里程 0 km | 7 月 19 日宇通 0 km累计总里程 24,245 km | 今日没有上报,昨日 0 km 正确,禁止把累计值补成日里程 |
同一时点全量诊断覆盖 480 个活跃“协议 × 车辆”项,`pipeline_issue_total=0`;仅有 1 个赛格 JT808 项缺少新鲜总里程,属于上游源数据问题,与这两辆车无关。轨迹页点位卡片同步将“累计里程”明确为“累计总里程”,并说明它不是所选时间窗里程或今日里程,避免把点位累计值误读为区间结果。
## 7 月 20 日 06:34 当天全量回补
再次收到相同车牌反馈后,按当天实时数据重新核对:
- 沪A01559F 今日 260 个源点、259 个有效点,轨迹区间里程、平均速度和最高速度均为 0宇通日里程已有 `stationary_carry_forward` 0 km。
- 沪A62013F 今日 271 个源点、259 个有效点,轨迹区间里程、平均速度和最高速度均为 0宇通日里程已有 `stationary_carry_forward` 0 km。
- 沪A01559F 的正向行驶发生在 7 月 19 日,宇通 809 km、JT808 769.1 km 均已存在沪A62013F 页面上的 24,245 km 是累计总里程,不是当日里程。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 执行全量 dry-run 和正式回补。正式运行重建并写入 642 个来源聚合,归一化 319 个 JT808 平台来源,没有宇通或 GB32960 GPS 降级候选,也没有低于 100 米的遗留漂移候选。4 个具备有效移动轨迹、但缺少正向设备累计里程的 JT808 来源按既有安全规则补录:
| 车牌 | VIN | 补录日里程 | 质量原因 |
| --- | --- | ---: | --- |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km | `gps_coordinate_accumulation` |
| 沪A60339F | `LKLG7C4E8NA774750` | 29.199 km | `gps_coordinate_accumulation` |
| 沪A69311F | `LKLG7C4E8NA774781` | 21.253 km | `gps_coordinate_accumulation` |
| 沪A66218F | `LKLG7C4E9NA774773` | 0.545 km | `gps_coordinate_accumulation` |
回补后生产诊断覆盖 569 个活跃“协议 × 车辆”项GB32960 198/198 正常、JT808 324/325 正常、宇通 46/46 正常。唯一剩余项为沪A56988F 的赛格 JT808 当日没有新鲜总里程且缺少可用移动线段属于明确上游源数据缺口未构造里程。stat-writer `/readyz`、容量巡检均成功,三协议 Kafka lag 和重试队列均为 0。
## 7 月 20 日 06:53 插入事项复核
用户再次以沪A01559F、沪A62013F 为例要求排查“轨迹回放有里程、今日里程未计算”的同类问题。按相同的当天时间窗重新读取生产轨迹 API、最终日里程、来源候选和统计诊断
- 沪A01559F 的 260 个源点中有 259 个有效点,区间里程、平均速度和最高速度仍均为 0最终日里程为宇通 `stationary_carry_forward` 0 km累计总里程为 14,097 km。
- 沪A62013F 的 271 个源点中有 259 个有效点,区间里程、平均速度和最高速度仍均为 0最终日里程为宇通 `stationary_carry_forward` 0 km累计总里程为 24,245 km。
- 沪A01559F 的 769.1 km 来自 7 月 19 日 JT808 轨迹窗口沪A62013F 的 24,245 km 是轨迹点携带的累计总里程。两者都不能作为 7 月 20 日正向日里程补录。
随后再次对 2026-07-20 的三协议执行全量 dry-run 和正式幂等回补。dry-run 重建 667 个来源聚合,正式运行写入 671 个来源聚合、归一化 327 个 JT808 平台来源;正式运行识别 3 个可用的 JT808 GPS 降级聚合,没有低于 100 米的遗留候选。回补后已选中的 JT808 GPS 安全候选包括浙F02268F 0.119 km、浙F09860F 0.303 km、沪A60339F 30.624 km、沪A69311F 21.253 km粤A05579F 的设备累计里程随后恢复并以 1.2 km 正向仪表差值覆盖 GPS 估算。
全量诊断覆盖 580 个活跃“协议 × 车辆”项,`pipeline_issue_total=0`GB32960 204/204 正常、JT808 329/330 正常、宇通 46/46 正常。唯一剩余项仍是沪A56988F 的赛格 JT808 源数据缺口;容量巡检退出码为 0stat-writer、realtime-api、history-writer 及其 `/readyz` 均正常。
## 7 月 20 日 07:29 全量安全补录
再次以沪A01559F、沪A62013F 为样本核对当天轨迹与日里程:
- 沪A01559F 的轨迹接口完整读取 260 个源点、保留 259 个有效点,最高/平均速度和区间里程均为 0最终日里程保持宇通 `stationary_carry_forward` 0 km。
- 沪A62013F 的轨迹接口完整读取 271 个源点、保留 259 个有效点,最高/平均速度和区间里程均为 0最终日里程保持宇通 `stationary_carry_forward` 0 km。
- 页面可见的 14,097 km、24,245 km 分别是两车设备累计总里程,且最新累计里程证据分别停留在 7 月 19 日 21:23:59、7 月 18 日 16:48:10不能作为 7 月 20 日正向日里程补录。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 执行全量 dry-run 和正式幂等回补。正式运行重建并写入 702 个来源聚合,补齐 13 个宇通实时累计里程静止结转,生成 7 个被最终统计选中的 JT808 GPS 安全候选,归一化 335 个 JT808 平台来源;没有发现低于 100 米的遗留漂移候选:
| 车牌 | VIN | 最终日里程 | 质量原因 |
| --- | --- | ---: | --- |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km | `gps_coordinate_accumulation` |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.461 km | `gps_coordinate_accumulation` |
| 沪A69826F | `LKLG7C4E2NA774775` | 2.274 km | `gps_coordinate_accumulation` |
| 沪A01855F | `LKLG7C4E4SA827343` | 0.104 km | `gps_coordinate_accumulation` |
| 沪A35879F | `LKLG7C4E8NA774747` | 0.365 km | `gps_coordinate_accumulation` |
| 沪A60339F | `LKLG7C4E8NA774750` | 33.212 km | `gps_coordinate_accumulation` |
| 沪A69311F | `LKLG7C4E8NA774781` | 21.253 km | `gps_coordinate_accumulation` |
回补后全量诊断覆盖 608 个活跃“协议 × 车辆”项,`pipeline_issue_total=0`GB32960 217/217 正常、JT808 343/344 正常、宇通 47/47 正常。唯一剩余项为沪A56988F 的赛格 JT808 源数据缺口实时合并快照虽保留协议字段名但没有新鲜总里程事件时间也没有满足质量门槛的移动线段因此继续保留缺口不构造数值。history-writer、stat-writer、realtime-api 的 `/readyz` 均正常,容量巡检 `status=ok`,三协议全部 Kafka 分区 lag、stat 重试队列均为 0。
## 7 月 20 日 07:56 再次全量补录
本次插入事项再次以沪A01559F、沪A62013F 为样本核对当天轨迹、累计总里程证据、来源候选与最终日里程:
- 沪A01559F 当天保留 259 个有效宇通轨迹点,区间里程、最高速度和平均速度均为 0最终日里程为宇通 `stationary_carry_forward` 0 km。设备累计总里程 14,097 km 的最新字段证据仍停留在 7 月 19 日 21:23:59。
- 沪A62013F 当天保留 259 个有效宇通轨迹点,区间里程、最高速度和平均速度均为 0最终日里程为宇通 `stationary_carry_forward` 0 km。设备累计总里程 24,245 km 的最新字段证据仍停留在 7 月 18 日 16:48:10。
- 两车都不存在当天可信正向移动证据;页面上的累计总里程不能换算成当天里程,禁止补录正数。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 执行全量 dry-run 和正式幂等回补。dry-run 重建 720 个来源聚合并识别 6 个安全 JT808 GPS 降级聚合;正式运行写入 722 个来源聚合、归一化 355 个 JT808 平台来源,仍识别 6 个安全 JT808 GPS 降级聚合。GB32960 和宇通没有可补的 GPS 正向聚合,低于 100 米的遗留漂移候选为 0。
正式回补后有 8 个 JT808 GPS 安全候选被最终统计选中:
| 车牌 | VIN | 最终日里程 |
| --- | --- | ---: |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km |
| 粤AGP3690 | `LB9A32A20R0LS1584` | 0.112 km |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.655 km |
| 沪A69826F | `LKLG7C4E2NA774775` | 5.962 km |
| 沪A05697F | `LKLG7C4E6NA774746` | 0.119 km |
| 沪A61559F | `LKLG7C4E6NA774794` | 0.166 km |
| 沪A60339F | `LKLG7C4E8NA774750` | 34.325 km |
| 沪A03397F | `LKLG7C4EXNA774748` | 1.264 km |
来源候选与最终投影再次对账,`positive_gps_but_nonpositive_final` 为空。全量诊断覆盖 630 个活跃“协议 × 车辆”项并全部正常GB32960 222/222、JT808 361/361、宇通 47/47`actionable_issue_total=0``pipeline_issue_total=0``source_data_issue_total=0`。Gateway、history-writer、stat-writer、bridge、fast-writer、realtime-api、identity-writer、fields-projector、Feichi bridge 的 `/readyz` 全部成功,容量巡检退出码为 0次日 01:30 的三协议自动回补定时器保持启用。
## 7 月 20 日 08:11 当天增量复核与补录
用户再次以沪A01559F、沪A62013F 为样本插入“轨迹回放有里程、今日里程未计算”的排查事项。按当前生产时间重新核对轨迹点、来源候选、最终投影和统计诊断:
- 沪A01559F 当天有 260 个宇通位置点,最终日里程为 `stationary_carry_forward` 0 km累计总里程为 14,097 km。
- 沪A62013F 当天有 271 个宇通位置点,最终日里程为 `stationary_carry_forward` 0 km累计总里程为 24,245 km。
- 两车当天轨迹仍没有可信正向移动线段;轨迹点携带的累计总里程不是所选自然日区间里程,禁止据此补录正数。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 先执行全量 dry-run再执行正式幂等回补。dry-run 重建 735 个来源聚合,正式运行写入 740 个来源聚合并归一化 365 个 JT808 平台来源GB32960 和宇通没有 GPS 降级聚合,未发现低于 100 米的遗留漂移候选。正式回补后有 7 个 JT808 GPS 安全候选被最终统计选中:
| 车牌 | VIN | 最终日里程 |
| --- | --- | ---: |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km |
| 粤AGP3690 | `LB9A32A20R0LS1584` | 0.112 km |
| 粤AGF0692 | `LB9A32A23R0LS1045` | 0.773 km |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.656 km |
| 沪A69826F | `LKLG7C4E2NA774775` | 5.962 km |
| 沪A05697F | `LKLG7C4E6NA774746` | 0.119 km |
| 沪A60339F | `LKLG7C4E8NA774750` | 34.874 km |
回补后的生产容量巡检为 `status=ok`Kafka lag 和各消费重试队列均为 0统计管道缺口为 0。全量诊断覆盖 645 个活跃“协议 × 车辆”项,其中 GB32960 226/226、JT808 371/371、宇通 47/48 已生成日里程;剩余宇通 VIN `LMRKH9AC9R1004121` 当天只有实时位置帧,源头没有总里程字段和可补的可信移动证据,明确归类为 `source_data` 缺口,未伪造里程。
## 7 月 20 日 08:46 增量复核与全量补录
用户再次以沪A01559F、沪A62013F 为样本反馈“轨迹回放有里程、今日里程未计算”。本次通过生产轨迹 API、最终日里程、来源候选与实时字段事件时间重新对账
- 沪A01559F 当天轨迹完整读取 260 个源点、保留 259 个有效点,区间里程、平均速度、最高速度均为 0最终日里程为宇通 `stationary_carry_forward` 0 km。页面中的 14,097 km 是设备累计总里程。
- 沪A62013F 当天轨迹完整读取 271 个源点、保留 259 个有效点,区间里程、平均速度、最高速度均为 0最终日里程为宇通 `stationary_carry_forward` 0 km。页面中的 24,245 km 是设备累计总里程。
- 两车均已生成当日日里程,不存在候选层到最终投影层的缺口;没有可信正向移动证据,禁止补录正数。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 先执行全量 dry-run再执行正式幂等回补。正式运行重建并写入 796 个来源聚合、归一化 375 个 JT808 平台来源,识别 5 个本轮可恢复的 JT808 GPS 轨迹聚合;回补后共有 7 个 JT808 GPS 安全候选被最终统计选中,最小值 0.119 km全部满足 100 米业务阈值:
| 车牌 | VIN | 最终日里程 |
| --- | --- | ---: |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km |
| 粤AGF8492 | `LB9A32A28R0LS1056` | 0.563 km |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.657 km |
| 沪A69826F | `LKLG7C4E2NA774775` | 7.596 km |
| 沪A63305F | `LKLG7C4E3NA774798` | 0.202 km |
| 沪A05697F | `LKLG7C4E6NA774746` | 0.138 km |
| 粤A05776F | `LNXNEGRR6SR319481` | 0.130 km |
回补后诊断覆盖 685 个活跃“协议 × 车辆”项,`pipeline_issue_total=0`GB32960 252/252、JT808 385/385、宇通 47/48 已生成日里程。唯一剩余源数据缺口是宇通 VIN `LMRKH9AC9R1004121`:当天快照有位置、速度等 27 个字段,但没有任何总里程字段,也没有满足质量门槛的移动里程证据,因此保留 `NO_TOTAL_MILEAGE`,不伪造数值。
容量巡检返回 `status=ok`Kafka lag、统计重试队列和待处理批次均为 09 个数据链路 `/readyz` 全部成功,自动回补 timer 继续启用。轨迹点位卡片将设备累计值改为可见的“非区间”标记,避免只能依赖悬停提示而在移动端把累计总里程误读为今日或查询区间里程。
## 7 月 20 日 09:23 全量复核与补录
再次收到沪A01559F、沪A62013F“轨迹回放有里程、今日里程未计算”的反馈后按当前生产时刻同时读取轨迹汇总、来源候选、最终投影和诊断结果
- 沪A01559F 当天 260 个宇通源点中保留 259 个有效点,只有一个有效坐标,最高/平均速度和区间里程均为 0最终日里程已经是宇通 `stationary_carry_forward` 0 km。页面中的 14,097 km 是设备累计总里程。
- 沪A62013F 当天 271 个宇通源点中保留 259 个有效点,只有一个有效坐标,最高/平均速度和区间里程均为 0最终日里程已经是宇通 `stationary_carry_forward` 0 km。页面中的 24,245 km 是设备累计总里程。
- 两车诊断均为 `OK / daily_metric_exists`,不存在候选到最终投影的漏算,也没有可信正向里程可补。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 先执行全量 dry-run再执行正式幂等回补。dry-run 重建 885 个来源聚合并识别 4 个 JT808 GPS 安全候选;正式运行随实时数据增长写入 907 个来源聚合、归一化 415 个 JT808 平台来源,并识别 5 个 JT808 GPS 安全候选。GB32960、宇通没有 GPS 降级候选,也没有低于 100 米的遗留漂移候选。正式回补后被最终统计选中的 GPS 候选为:
| 车牌 | VIN | 最终日里程 |
| --- | --- | ---: |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.119 km |
| 粤AGG3413 | `LB9A32A24R0LS1040` | 0.261 km |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.657 km |
| 沪A69826F | `LKLG7C4E2NA774775` | 10.694 km |
| 沪A63305F | `LKLG7C4E3NA774798` | 0.208 km |
回补后容量巡检为 `status=ok`Kafka lag、stat/history/realtime 重试队列均为 0`pipeline_issue_total=0`。全量仅剩两个源数据缺口:
- 沪A00113F 的 JT808 当天 59 个有效点全部为 0 km/h坐标累计只有约 8.9 米,低于 100 米业务门槛,禁止把静止漂移补成里程。
- 宇通 VIN `LMRKH9AC9R1004121` 当天 249 个有效点最高速度和区间里程均为 0且源头没有总里程字段保留 `NO_TOTAL_MILEAGE`
本轮不需要重新发布服务生产已运行包含累计里程停滞恢复、100 米漂移阈值、来源重选举和轨迹累计值“非区间”标识的修复版本;此次完成的是当前时刻全量事实复核和增量安全补录。
## 7 月 20 日 09:56 插入事项全量回补
用户再次以沪A01559F、沪A62013F 为例反馈“轨迹回放有里程、今日里程未计算”。本次不再把页面点位数值当作统计证据而是同时读取生产轨迹接口、TDengine 完整位置点、来源候选、最终投影和诊断接口:
- 沪A01559F 当天完整读取 260 个宇通源点,质量过滤后 259 点、2 个坐标值,最高和平均速度均为 0平台轨迹摘要 `distanceKm=0`;最终日里程为宇通 `stationary_carry_forward` 0 km。
- 沪A62013F 当天完整读取 271 个宇通源点,质量过滤后 259 点、2 个坐标值,最高和平均速度均为 0平台轨迹摘要 `distanceKm=0`;最终日里程为宇通 `stationary_carry_forward` 0 km。
- 两车轨迹位置点均没有可用总里程字段;实时快照中的 14,097 km、24,245 km 分别是设备累计总里程,最新累计里程事件仍停留在 7 月 19 日和 7 月 18 日。两车不是漏写,也没有可补的正向里程。
随后按 2026-07-20 上海自然日对 GB32960、JT808、YUTONG_MQTT 执行全量 dry-run 和正式幂等回补。dry-run 重建 948 个来源聚合、补齐 10 个宇通静止结转候选、计算 6 个 JT808 GPS 安全聚合;正式运行随实时数据增长写入 954 个来源聚合并规范化 426 个 JT808 平台来源。回补没有重置统计表,没有发现低于 100 米的遗留漂移候选,也没有 GB32960 或宇通 GPS 正向降级候选。
回补后共有 9 个 JT808 GPS 安全候选被最终统计选中,其中 4 辆是相对 09:23 复核新增的正向结果:
| 车牌 | VIN | 最终日里程 | 处理结果 |
| --- | --- | ---: | --- |
| 粤AGP5770 | `LB9A32A22R0LS1666` | 0.117 km | 新增补录 |
| 粤AGP5636 | `LB9A32A23R0LS1479` | 0.148 km | 新增补录 |
| 粤AGP5615 | `LB9A32A23R0LS1594` | 0.176 km | 新增补录 |
| 粤AGP5359 | `LB9A32A24R0LS1524` | 0.143 km | 新增补录 |
| 浙F02268F | `LA9HE60A6PBAF4022` | 0.241 km | 既有候选增量更新 |
| 粤AGG3413 | `LB9A32A24R0LS1040` | 0.252 km | 既有候选增量更新 |
| 沪A66216F | `LKLG7C4E1NA774783` | 1.918 km | 既有候选增量更新 |
| 沪A69826F | `LKLG7C4E2NA774775` | 14.517 km | 既有候选增量更新 |
| 沪A63305F | `LKLG7C4E3NA774798` | 0.209 km | 既有候选增量更新 |
回补后诊断覆盖 817 个活跃“协议 × 车辆”项GB32960 328 个、JT808 440 个、宇通 47 个均已有最终日里程,`pipeline_issue_total=0`。三协议 Kafka 全部分区 lag 为 0stat-writer 三个消费线程的重试队列均为 0容量巡检退出码为 0。剩余两个源数据缺口仍为沪A00113F 的 JT808 和宇通 VIN `LMRKH9AC9R1004121`,二者源头没有总里程字段且没有满足业务门槛的可信移动线段,继续保留缺口而不构造数值。
本次无需再发布 Go 服务:生产 `mileage-gps-candidate-reconcile-202607200350` 已包含实时 GPS 兜底、阈值下漂移拒绝、来源重选举和幂等回补能力。Web 轨迹点位已明确展示“设备累计值 · 非区间”,平台轨迹摘要与日里程统计因此可以按同一业务语义核验。
## 7 月 20 日 11:27 行驶增量复核与补录
用户再次以沪A01559F、沪A62013F 为例反馈“轨迹回放有里程、今日里程未计算”。本次复核捕获到沪A62013F 在 10:56 后开始行驶的实时增量:
- 沪A01559F 当天轨迹摘要仍为 0 km最高速度 0宇通最终日里程为 0 km累计总里程 14,097 km诊断为 `OK / daily_metric_exists`。该车没有可信正向移动里程,不存在可补数据。
- 沪A62013F 的宇通累计总里程从 24,245 km 增至 24,254 km最终日里程 9.0 kmJT808 累计总里程从 21,945.7 km 增至 21,955.2 km最终日里程 9.5 km。两路来源均为 `OK / daily_metric_exists`,说明实时统计已经跟随车辆行驶持续更新。
- 轨迹页展示的设备累计总里程不是自然日里程;只有轨迹摘要的区间距离、来源累计边界差和 `vehicle_daily_mileage` 可以用于判断是否漏算。
随后对 2026-07-20 的 GB32960、JT808、YUTONG_MQTT 先执行全量 dry-run再执行正式幂等回补。dry-run 可重建 1,034 个来源聚合;正式运行随实时数据增长重建并写入 1,035 个来源聚合、规范化 451 个 JT808 平台来源,补齐 11 个宇通静止结转候选并计算 9 个满足质量门槛的 JT808 GPS 兜底候选。没有发现低于 100 米的遗留漂移候选。
回补后诊断覆盖 871 个活跃“协议 × 车辆”项GB32960 355/355、JT808 465/466、宇通 49/50 已生成日里程,`missing_daily_count=0``no_source_sample_count=0`,统计管道缺口为 0。剩余两个 `NO_TOTAL_MILEAGE` 均是源数据缺口:
- 沪A00333F 的赛格 JT808 当天报文不含可用总里程字段。
- 宇通 VIN `LMRKH9AC9R1004121` 当天报文不含可用总里程字段。
两者均没有可用于正向补录的可信移动线段因此继续暴露缺口而不构造数值。9 个数据链路服务 `/readyz` 全部成功Kafka lag 和各消费重试队列均为 0容量巡检 `status=ok`。生产已运行既有完整修复版本,本次不需要重新发布二进制。
## 限制与后续监控
- GPS 估算只在仪表里程缺失或停滞时兜底,不替代 GB32960、宇通仪表里程或 JT808 设备里程。
- 超过 10 分钟的相邻点、时间倒序、隐含速度超过 220 km/h、非正速度的 GB32960/宇通点,以及不足 100 米的累计都不会形成业务候选。
- 每日巡检继续检查 `actionable_issue_total`、来源候选与最终投影差异、Kafka lag、重试队列和回补任务状态源头完全不提供里程且没有可信移动线段时系统必须保留缺口而不是构造数值。

160
docs/ops/feichi-bridge.md Normal file
View File

@@ -0,0 +1,160 @@
# 飞驰 HTTP → GB/T 32960 桥接服务
## 目标
将飞驰车辆数据平台中的 20 辆 `GB_T32960` 车辆转换为 GB/T 32960.3-2016 标准 TCP 报文,并发送到岭牛内网车辆网关的 32960 端口。
服务必须满足:
- 每 10 秒发现实时增量,不重复转发平台持续返回的最后一帧;
- 使用 `0x05` 完成平台登录,实时数据使用 `0x02`,历史补传使用 `0x03`
- 每帧收到内网网关成功 ACK 后才推进持久化游标;
- 自动完成 CSRF 初始化、验证码识别、RSA 密码登录和 `R_SESS` 会话维护;
- 源会话返回 401/403 时自动重新登录,并重放一次原 API 请求;
- TCP 断线后重新登录,并用相同原始帧重试一次;
- 重启后从本地游标继续,默认补齐最近 1 小时、每次查询 20 分钟;
- 保留源接口调用、目标 ACK、错误和活跃车辆等 Prometheus 指标;
- 不转发离线车辆反复返回的旧快照。
- 离线车辆最后快照按源时间仅补发一次 `0x03`,不伪装成实时 `0x02`
## 数据路径
```text
飞驰 HTTP API
├─ 车辆发现 vehicleRealStatuss
├─ 实时明细 vehicleRealStatussByVId
└─ 历史明细 hisdataQuerys
feichi-bridge
字段映射 → 去重 → 32960 编码 → ACK 后提交游标
内网 vehicle-gateway :32960
```
桥接服务当前编码 V2016 `##` 报文。源平台确认的主要字段如下:
| 32960 数据 | 飞驰字段 |
|---|---|
| 数据时间 | `2000`(兼容 `9999` |
| 车速、里程、挡位 | `2201``2202``2203` |
| 加速/制动踏板 | `2208``2209` |
| 运行模式、DCDC | `2213``2214` |
| 充电状态、车辆状态 | `2301``3201` |
| 总电压、总电流、绝缘电阻 | `2613``2614``2617` |
| SOC | `7615` |
| 原始经纬度 | `2502``2503` |
| 单体电压、温度 | `2003``2103` |
| 极值 | `2601``2612` |
| 燃料电池主要数据 | `2110``2121` |
| 驱动电机复合数据 | `2307``2308` |
定位必须使用 `2502/2503` 原始坐标;`.gd` 字段是页面地图转换坐标,不能写进国标数据。
## 自动登录与认证文件
服务按照网页端协议执行 `first-login → randCode → login`:读取 `x-api-csrf`/`CSRF`,验证码图片交给 ECS 本机、禁网运行的专用 OCR 容器,密码使用网页端同一 RSA 公钥做 PKCS#1 v1.5 加密,登录成功后保存响应中的 `R_SESS`。OCR 结果不会被直接信任登录接口是最终校验错误时会换一张验证码重试。密码、Cookie、验证码图片和响应正文均不写日志。
`/opt/lingniu-go-native/secrets/feichi-auth.json`
```json
{
"username": "replace-me",
"password": "replace-me"
}
```
文件权限必须为 `0600`。飞驰若提供机器账号、长期 Token 或正式接口鉴权,仍应优先切换到供应商承诺的方式。
`/opt/lingniu-go-native/secrets/feichi-target.json`
```json
{
"platformId": "FEICHIBRIDGE00001",
"username": "bridge",
"password": "replace-me"
}
```
`platformId` 必须正好 17 字节。目标网关启用 `GB32960_AUTH_MODE=enforce` 时,需在网关凭据文件中登记同一平台账号和密码。
## 环境配置
`/opt/lingniu-go-native/env/feichi-bridge.env` 最小配置:
```dotenv
FEICHI_BASE_URL=http://mob.fsfeichi.com.cn:8000
FEICHI_AUTH_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-auth.json
FEICHI_TARGET_SECRET_FILE=/opt/lingniu-go-native/secrets/feichi-target.json
FEICHI_TARGET_ADDR=127.0.0.1:32960
FEICHI_STATE_FILE=/var/lib/lingniu-feichi-bridge/state.json
HEALTH_ADDR=127.0.0.1:20219
```
可调参数及默认值:
| 参数 | 默认值 | 说明 |
|---|---:|---|
| `FEICHI_POLL_INTERVAL_SECONDS` | 10 | 实时轮询间隔 |
| `FEICHI_DISCOVERY_INTERVAL_SECONDS` | 300 | 车辆清单刷新间隔 |
| `FEICHI_FETCH_CONCURRENCY` | 4 | 明细接口并发数 |
| `FEICHI_SOURCE_STALE_SECONDS` | 120 | 超过该时间不作为实时帧转发 |
| `FEICHI_STALE_REISSUE_ENABLED` | true | 将离线旧快照去重后仅补发一次为 `0x03` |
| `FEICHI_HTTP_TIMEOUT_SECONDS` | 15 | HTTP 超时 |
| `FEICHI_OCR_IMAGE` | `lingniu/feichi-captcha-ocr:1.0.0` | 本机验证码 OCR 镜像 |
| `FEICHI_OCR_TIMEOUT_SECONDS` | 20 | 单次验证码识别超时 |
| `FEICHI_LOGIN_MAX_ATTEMPTS` | 20 | 单轮自动登录最多验证码次数 |
| `FEICHI_LOGIN_RETRY_SECONDS` | 1 | 验证码/登录失败重试间隔 |
| `FEICHI_TARGET_TIMEOUT_SECONDS` | 10 | TCP 写入和 ACK 超时 |
| `FEICHI_BACKFILL_ENABLED` | true | 是否启用历史补传 |
| `FEICHI_BACKFILL_LOOKBACK_SECONDS` | 3600 | 无游标时回看范围 |
| `FEICHI_BACKFILL_WINDOW_SECONDS` | 1200 | 单次历史查询窗口 |
| `FEICHI_BACKFILL_SAFETY_SECONDS` | 30 | 历史与实时之间的安全延迟 |
| `FEICHI_BACKFILL_INTERVAL_SECONDS` | 3600 | 补传巡检周期 |
飞驰地址当前是明文 HTTP。部署时应限制服务的出口目的地址并避免认证文件、Cookie、响应原文进入代理访问日志。
## 构建与启动
```bash
cd go/vehicle-gateway
go test ./internal/feichibridge ./cmd/feichi-bridge
CGO_ENABLED=0 go build -trimpath -o feichi-bridge ./cmd/feichi-bridge
docker build -t lingniu/feichi-captcha-ocr:1.0.0 ../../deploy/feichi-ocr
install -d -m 0750 /var/lib/lingniu-feichi-bridge
systemctl daemon-reload
systemctl enable --now lingniu-go-feichi-bridge
```
检查:
```bash
curl -fsS http://127.0.0.1:20219/healthz
curl -fsS http://127.0.0.1:20219/readyz
curl -fsS http://127.0.0.1:20219/metrics | grep vehicle_feichi_bridge
journalctl -u lingniu-go-feichi-bridge -f
jq '.vehicles | to_entries[] | select(.value.last_realtime_ack_at or .value.last_backfill_ack_at) |
{vin: .key, realtime_ack: .value.last_realtime_ack_at, backfill_ack: .value.last_backfill_ack_at}' \
/var/lib/lingniu-feichi-bridge/state.json
```
## 上线验收
先选 1 辆在线车灰度 30 分钟,再开放全部 20 辆。验收需要同时满足:
1. 网关收到一次 `0x05` 登录,后续实时帧为 `0x02`
2. 同一源时间和同一内容只落一条,重启后无持续重复;
3. 断开目标 TCP 后能重连、重新登录并继续收到 ACK
4. 使源会话过期后能自动重登;连续登录失败时 `/readyz` 变为 503游标不前移
5. 两辆离线车不会把历史最后一帧当实时数据周期发送;
6. 原始经纬度、总压、总流、SOC、单体电压和温度与平台页面抽样一致
7. 20 辆车连续运行 24 小时后,源/目标计数和平台记录数差异可解释。
## 已知边界
- 页面接口不是飞驰承诺的稳定开放 API接口结构升级可能要求同步调整客户端
- 验证码识别依赖本机 OCR 镜像和飞驰当前验证码样式;样式变化会触发登录失败告警,不会导致未 ACK 数据推进游标;
- 源接口只提供离散快照,无法恢复平台未保存或查询窗口之外的原始上行帧;
- 映射结果是标准 32960 语义报文,不是车辆原始二进制报文的逐字节复制。

View File

@@ -150,6 +150,9 @@ systemctl start lingniu-go-capacity-check.service
| `vehicle_stat_samples_total` | Mileage samples extracted and written by stat writer. Use this with `vehicle_stat_writes_total`: write `ok` only means the append call succeeded, while `status="written"` proves a sample reached `vehicle_daily_mileage_source`. | | `vehicle_stat_samples_total` | Mileage samples extracted and written by stat writer. Use this with `vehicle_stat_writes_total`: write `ok` only means the append call succeeded, while `status="written"` proves a sample reached `vehicle_daily_mileage_source`. |
| `vehicle_stat_sources_total` | Stat writer source tracking results for `vehicle_data_source`. Labels: `topic`, `protocol`, `status`; status includes `attempted`, `written`, `skipped_throttled`, `skipped_missing_endpoint`. This intentionally avoids source IP labels to keep metrics low-cardinality. | | `vehicle_stat_sources_total` | Stat writer source tracking results for `vehicle_data_source`. Labels: `topic`, `protocol`, `status`; status includes `attempted`, `written`, `skipped_throttled`, `skipped_missing_endpoint`. This intentionally avoids source IP labels to keep metrics low-cardinality. |
| `vehicle_stat_projections_total` | Final daily mileage projection results from `vehicle_daily_mileage_source` to `vehicle_daily_mileage`. Labels: `topic`, `protocol`, `status`; status includes `attempted`, `written`, `skipped_throttled`. | | `vehicle_stat_projections_total` | Final daily mileage projection results from `vehicle_daily_mileage_source` to `vehicle_daily_mileage`. Labels: `topic`, `protocol`, `status`; status includes `attempted`, `written`, `skipped_throttled`. |
| `vehicle_stat_pending_projections_total` / `vehicle_stat_pending_projection_entries` | Eventual projection tail flush. `attempted` and `written` should grow together; `error` indicates a pending final projection remains queued for retry. The entries gauge should stay bounded by active vehicle/protocol/day keys and drain after the projection interval. |
| `vehicle_stat_startup_projection_reconcile_targets` / `vehicle_stat_startup_projection_reconcile_written` / `vehicle_stat_startup_projection_reconcile_failed` | Startup recovery for durable daily-mileage candidates that are newer than, or missing from, the final projection. Healthy startup has `written = targets` and `failed = 0`. |
| `vehicle_stat_last_startup_projection_reconcile_unix_seconds` | Last startup recovery result, labeled `status="ok"` or `status="error"`. An error preserves the gap as observable evidence and must be followed by a targeted backfill or another successful restart reconciliation. |
| `vehicle_realtime_updates_total` | Redis/MySQL realtime projector updates. Labels: `topic`, `status`. | | `vehicle_realtime_updates_total` | Redis/MySQL realtime projector updates. Labels: `topic`, `status`. |
| `vehicle_realtime_kafka_messages_total` | Realtime projector Kafka message results by raw topic. Labels: `topic`, `status`; `invalid_json` messages are committed and isolated before Redis/MySQL projection. | | `vehicle_realtime_kafka_messages_total` | Realtime projector Kafka message results by raw topic. Labels: `topic`, `status`; `invalid_json` messages are committed and isolated before Redis/MySQL projection. |
| `vehicle_realtime_last_message_unix_seconds` | Last realtime Kafka message by topic/status. Labels: `topic`, `status`. | | `vehicle_realtime_last_message_unix_seconds` | Last realtime Kafka message by topic/status. Labels: `topic`, `status`. |

View File

@@ -47,8 +47,12 @@ Gateway 在 NATS 模式下设置 `FIELDS_DERIVE_FROM_RAW_ENABLED=true`,每帧
如果 stat-writer 少消费某个 fields topic对应协议的每日里程不会进入 `vehicle_daily_mileage`。stat-writer 启动时会拒绝 `vehicle.raw.*` 这类 RAW topic 配置,避免统计链路重新从原始报文解析并破坏 RAW/fields 解耦。修正后可能出现短时间 Kafka lag这是在追补历史 backlog`vehicle_stat_writes_total{status="ok"}` 只表示 append 调用成功,真正证明里程样本入库的是 `vehicle_stat_samples_total{status="written"}` 持续增长且 lag 下降。 如果 stat-writer 少消费某个 fields topic对应协议的每日里程不会进入 `vehicle_daily_mileage`。stat-writer 启动时会拒绝 `vehicle.raw.*` 这类 RAW topic 配置,避免统计链路重新从原始报文解析并破坏 RAW/fields 解耦。修正后可能出现短时间 Kafka lag这是在追补历史 backlog`vehicle_stat_writes_total{status="ok"}` 只表示 append 调用成功,真正证明里程样本入库的是 `vehicle_stat_samples_total{status="written"}` 持续增长且 lag 下降。
stat-writer 的节流尾部投影队列保存在内存中,但来源候选事实已经落在 MySQL。服务启动时默认执行 `STATS_RECONCILE_PROJECTIONS_ON_START=true` 的当天持久化候选扫描,把“候选比最终日表新”或“最终日表缺失”的车辆/协议重新投影;这用于关闭候选提交后进程重启造成的漏算窗口,尤其避免静止车辆因没有后续里程变化而一直漏到次日回补。默认超时由 `STATS_RECONCILE_PROJECTIONS_TIMEOUT_SECONDS=30` 控制。核验 `vehicle_stat_startup_projection_reconcile_targets``vehicle_stat_startup_projection_reconcile_written``vehicle_stat_startup_projection_reconcile_failed``vehicle_stat_last_startup_projection_reconcile_unix_seconds{status}`;失败不会伪装成功,仍由日志、指标和次日 backfill 继续暴露。
stat-writer 默认 `STATS_WORKERS=3`,每个 worker 是同一 consumer group 的独立 Kafka reader。Kafka 按车辆 key 固定分区,因此单车累计里程仍按分区顺序执行,不同分区并行。调整 worker 后同时核对 `vehicle_stat_config{setting="workers"}`、每个 `vehicle_stat_worker_active{worker}`、MySQL 连接数、write p99 和 stat lagworker 不应超过 fields topic 的有效分区并行度。 stat-writer 默认 `STATS_WORKERS=3`,每个 worker 是同一 consumer group 的独立 Kafka reader。Kafka 按车辆 key 固定分区,因此单车累计里程仍按分区顺序执行,不同分区并行。调整 worker 后同时核对 `vehicle_stat_config{setting="workers"}`、每个 `vehicle_stat_worker_active{worker}`、MySQL 连接数、write p99 和 stat lagworker 不应超过 fields topic 的有效分区并行度。
stat-writer 对死锁、锁等待和网络中断等瞬时 MySQL 故障按 `STATS_RETRY_ATTEMPTS` 有限重试,重试耗尽后保持失败关闭并等待存储恢复。只有 `NULL`、数值越界、类型转换、字段过长和 CHECK 约束这类明确的单消息数据错误,才会在同样的有限重试耗尽后写入 `STATS_QUARANTINE_DIR` 并提交该 Kafka 偏移量,避免一条坏消息阻塞整个分区;表结构、权限和未知程序错误不会被隔离。隔离文件包含原 topic、partition、offset、key、value 和错误,默认权限为 `0600`,处理前不得删除。
history-writer 和 realtime-writer 同样默认分别使用 `HISTORY_WORKERS=3``REALTIME_WORKERS=3`。每个 worker 只顺序处理 Kafka 分配给自己的分区TDengine 表缓存、MySQL 实时节流缓存和车牌缓存均支持并发访问。调整后必须同时核对对应的 `*_config{setting="workers"}``*_worker_active`、pending/retry、后端连接数和 Kafka lag。 history-writer 和 realtime-writer 同样默认分别使用 `HISTORY_WORKERS=3``REALTIME_WORKERS=3`。每个 worker 只顺序处理 Kafka 分配给自己的分区TDengine 表缓存、MySQL 实时节流缓存和车牌缓存均支持并发访问。调整后必须同时核对对应的 `*_config{setting="workers"}``*_worker_active`、pending/retry、后端连接数和 Kafka lag。
history-writer 启动会对既有 `vehicle_locations` stable 幂等补齐 `soc_percent DOUBLE`。发布后除 `/readyz` 外,必须抽查一台支持 SOC 的车辆在 TDengine 历史位置中 `soc_percent IS NOT NULL`;如果迁移报错且不是明确的重复列错误,服务应保持未就绪,先处理 TDengine DDL/权限,不能跳过迁移后强启。 history-writer 启动会对既有 `vehicle_locations` stable 幂等补齐 `soc_percent DOUBLE`。发布后除 `/readyz` 外,必须抽查一台支持 SOC 的车辆在 TDengine 历史位置中 `soc_percent IS NOT NULL`;如果迁移报错且不是明确的重复列错误,服务应保持未就绪,先处理 TDengine DDL/权限,不能跳过迁移后强启。
@@ -174,6 +178,8 @@ curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_kafka_lag
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_samples_total curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_samples_total
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_sources_total curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_sources_total
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_last_ curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_last_
curl -fsS http://127.0.0.1:20213/metrics | grep vehicle_stat_quarantine_total
find /var/lib/lingniu-go-native/stat-writer-quarantine -maxdepth 1 -type f -printf '%f %s bytes\n'
curl -fsS http://127.0.0.1:20216/metrics | grep vehicle_realtime_kafka_lag curl -fsS http://127.0.0.1:20216/metrics | grep vehicle_realtime_kafka_lag
curl -fsS http://127.0.0.1:20217/metrics | grep vehicle_identity_writer_kafka_lag curl -fsS http://127.0.0.1:20217/metrics | grep vehicle_identity_writer_kafka_lag
for port in 20212 20213 20216 20217 20200; do for port in 20212 20213 20216 20217 20200; do
@@ -335,10 +341,11 @@ export MYSQL_DSN="$(sed -n 's/^MYSQL_DSN=//p' /opt/lingniu-go-native/env/base.en
| `vehicle_stat_samples_total{status="skipped_missing_fields"}` | 任意持续增长,或单 topic 可行动跳过数 / `vehicle_stat_writes_total{status="ok"}` 超过 `capacity-check -stat-sample-max-actionable-skip-ratio` | fields topic 收到了 envelope 但没有扁平化 `fields`。优先查 gateway `BuildFieldsEnvelope`、NATS/Kafka bridge topic 映射,以及是否误把 RAW envelope 写进 fields topic。 | | `vehicle_stat_samples_total{status="skipped_missing_fields"}` | 任意持续增长,或单 topic 可行动跳过数 / `vehicle_stat_writes_total{status="ok"}` 超过 `capacity-check -stat-sample-max-actionable-skip-ratio` | fields topic 收到了 envelope 但没有扁平化 `fields`。优先查 gateway `BuildFieldsEnvelope`、NATS/Kafka bridge topic 映射,以及是否误把 RAW envelope 写进 fields topic。 |
| `vehicle_stat_samples_total{status="skipped_missing_vin"}` / `skipped_missing_mileage` / `skipped_non_positive_mileage` / `skipped_missing_source` | 单 topic 可行动跳过数 / `vehicle_stat_writes_total{status="ok"}` 超过 `capacity-check -stat-sample-max-actionable-skip-ratio` | fields topic 有消费但大量样本无法进入每日里程。按协议和跳过原因分别检查 VIN 映射、总里程字段映射、里程单位/异常值、`vehicle_data_source` 关联。 | | `vehicle_stat_samples_total{status="skipped_missing_vin"}` / `skipped_missing_mileage` / `skipped_non_positive_mileage` / `skipped_missing_source` | 单 topic 可行动跳过数 / `vehicle_stat_writes_total{status="ok"}` 超过 `capacity-check -stat-sample-max-actionable-skip-ratio` | fields topic 有消费但大量样本无法进入每日里程。按协议和跳过原因分别检查 VIN 映射、总里程字段映射、里程单位/异常值、`vehicle_data_source` 关联。 |
| `vehicle_stat_samples_total{status="event_time_future_adjusted"}` | 连续增长或集中在某协议/source | 设备事件时间比接收时间未来超过 10 分钟统计已回退到接收时间。优先检查上游平台或终端时钟RAW 帧仍保留设备原始时间,业务 location、Redis 当前态和每日里程使用归一后的时间。 | | `vehicle_stat_samples_total{status="event_time_future_adjusted"}` | 连续增长或集中在某协议/source | 设备事件时间比接收时间未来超过 10 分钟统计已回退到接收时间。优先检查上游平台或终端时钟RAW 帧仍保留设备原始时间,业务 location、Redis 当前态和每日里程使用归一后的时间。 |
| `vehicle_stat_samples_total{status="written"}` | stat-writer `ok` 增长但 `written` 不增长 | fields topic 有消费但没有有效里程样本入库,按 `skipped_missing_vin``skipped_missing_mileage``skipped_non_positive_mileage``skipped_missing_source``skipped_same_mileage` 定位原因;如果主要是 `skipped_same_mileage`,通常表示上游总里程未变化,不等同故障。 | | `vehicle_stat_samples_total{status="written"}` / `recovered_stationary` | stat-writer `ok` 增长但 `written` 不增长 | fields topic 有消费但没有有效里程样本入库,按 `skipped_missing_vin``skipped_missing_mileage``skipped_non_positive_mileage``skipped_missing_source``skipped_same_mileage` 定位原因;如果主要是 `skipped_same_mileage`,通常表示上游总里程未变化,不等同故障。`recovered_stationary` 表示宇通明确静止帧已用同来源历史仪表值生成 `0 km`,应与 `skipped_missing_mileage` 联合观察。 |
| `vehicle_stat_sources_total{status="written"}` / `skipped_throttled` | `vehicle_stat_samples_total{status="found"}` 增长但 source tracking 无增长 | 来源管理链路没有维护 `vehicle_data_source`。优先查 source endpoint 是否为空、`vehicle_data_source` 表结构、MySQL 写入错误和 `STATS_SOURCE_TOUCH_INTERVAL_SECONDS`。 | | `vehicle_stat_sources_total{status="written"}` / `skipped_throttled` | `vehicle_stat_samples_total{status="found"}` 增长但 source tracking 无增长 | 来源管理链路没有维护 `vehicle_data_source`。优先查 source endpoint 是否为空、`vehicle_data_source` 表结构、MySQL 写入错误和 `STATS_SOURCE_TOUCH_INTERVAL_SECONDS`。 |
| `vehicle_stat_sources_total{status="skipped_missing_endpoint"}` | 单 topic 缺 endpoint / found 超过 `capacity-check -stat-source-max-missing-ratio` | fields envelope 缺 `source_endpoint`,无法区分平台来源和维护 source。优先查 Gateway 是否填充 TCP remote/MQTT endpoint以及 NATS/Kafka bridge 是否保留 envelope 字段。 | | `vehicle_stat_sources_total{status="skipped_missing_endpoint"}` | 单 topic 缺 endpoint / found 超过 `capacity-check -stat-source-max-missing-ratio` | fields envelope 缺 `source_endpoint`,无法区分平台来源和维护 source。优先查 Gateway 是否填充 TCP remote/MQTT endpoint以及 NATS/Kafka bridge 是否保留 envelope 字段。 |
| `vehicle_stat_projections_total{status="written"}` / `skipped_throttled` | source 候选持续写入但最终日里程更新频率低 | `vehicle_daily_mileage_source` 是每样本候选事实层,`vehicle_daily_mileage` 是最终投影层。`skipped_throttled` 增长通常表示受 `STATS_PROJECT_INTERVAL_SECONDS` 节流,不代表样本丢失;如需强一致核验可临时设投影间隔为 `0`。 | | `vehicle_stat_projections_total{status="written"}` / `skipped_throttled` | source 候选持续写入但最终日里程更新频率低 | `vehicle_daily_mileage_source` 是每样本候选事实层,`vehicle_daily_mileage` 是最终投影层。`skipped_throttled` 增长通常表示受 `STATS_PROJECT_INTERVAL_SECONDS` 节流,不代表样本丢失;被节流的末条样本会进入待投影队列,即使车辆停止上报也会在后续周期完成尾刷。如需强一致核验可临时设投影间隔为 `0`。 |
| `vehicle_stat_pending_projections_total{status="error"}` / `vehicle_stat_pending_projection_entries` | 尾刷失败或待投影队列持续增长 | 先检查 MySQL 可写性、死锁和连接超时。失败项不会丢弃,会保留到下一个周期重试;健康状态下 `attempted``written` 应同步增长entries 应保持在当日活跃车辆/协议键数量以内并周期性回落。 |
| `vehicle_history_last_*_unix_seconds` / `vehicle_stat_last_*_unix_seconds` / `vehicle_realtime_last_*_unix_seconds` | 已出现过的成功 activity 超过 `capacity-check -last-activity-stale-seconds` 未刷新 | 下游消费、写入或 commit 某 topic 停止活动;结合 Kafka lag 和对应存储健康判断。 | | `vehicle_history_last_*_unix_seconds` / `vehicle_stat_last_*_unix_seconds` / `vehicle_realtime_last_*_unix_seconds` | 已出现过的成功 activity 超过 `capacity-check -last-activity-stale-seconds` 未刷新 | 下游消费、写入或 commit 某 topic 停止活动;结合 Kafka lag 和对应存储健康判断。 |
| `vehicle_stat_project_interval_seconds` | 生产异常为 `0` 或被调得过小 | `vehicle_daily_mileage_source` 仍每样本更新,但 `vehicle_daily_mileage` 选举投影会被节流;过小会放大 MySQL 压力。 | | `vehicle_stat_project_interval_seconds` | 生产异常为 `0` 或被调得过小 | `vehicle_daily_mileage_source` 仍每样本更新,但 `vehicle_daily_mileage` 选举投影会被节流;过小会放大 MySQL 压力。 |
| Kafka lag | 连续 5 分钟增长或 `> 10000` | 下游 consumer 或存储存在瓶颈。 | | Kafka lag | 连续 5 分钟增长或 `> 10000` | 下游 consumer 或存储存在瓶颈。 |
@@ -502,6 +509,16 @@ BACKFILL_DATE_TO=2026-07-12 \
/opt/lingniu-go-native/current/stats-backfill /opt/lingniu-go-native/current/stats-backfill
``` ```
宇通协议会把定位、车况和仪表里程拆成不同的稀疏帧。当天只有明确 `meter_speed=0` 的定位帧、但没有新的仪表总里程帧时stat-writer 会用同一来源最近的历史总里程生成一条显式 `0 km` 记录,`quality_reason=stationary_carry_forward`;后续收到真实总里程后仍会正常覆盖增长。`vehicle_stat_samples_total{status="recovered_stationary"}` 用于观察该恢复路径。历史补录只能在轨迹核验确认为 `0 km` 后,临时为单次 `stats-backfill` 增加 `BACKFILL_STATIONARY_CARRY_FORWARD=true`;该开关默认关闭,禁止写入 timer避免仅凭最新静止状态推断整日未行驶。
三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时stat-writer 会按同一 VIN、自然日、协议和物理来源累计相邻坐标距离状态持久化在 `vehicle_daily_gps_mileage_state`,并以 `quality_reason=gps_coordinate_accumulation` 写入低优先级候选。该路径既覆盖总里程字段缺失,也覆盖字段存在但数值与上一帧相同;重复总里程仍计入 `skipped_same_mileage` 证据,只有同时具备有效移动点时才增加 GPS 候选。JT808 兼容没有速度字段的存量终端GB32960、宇通 MQTT 必须同时满足设备速度大于 0且实时状态写入按 10 秒节流,避免静止 GPS 漂移和 1 秒级宇通上报放大 MySQL 压力。相邻点超过 10 分钟、时间倒序或隐含速度超过 220 km/h 的线段不累计;低于 100 米的自然日累计只保留在状态表中,不投影为业务候选,避免瞬时车速噪声和微小定位漂移生成虚假日里程。`stats-backfill` 每次扫描还会查找窗口内阈值规则上线前遗留的低于 100 米 GPS 候选dry-run 只报告数量,正式回补在单车/日期/协议事务中删除候选并重新执行来源选举,确保最终表不会继续保留旧漂移值。选举顺序为“正向累计里程 > 正向 GPS 轨迹估算 > 累计里程零值占位”,因此稀疏累计里程造成的错误 0 km 会被已有移动轨迹纠正,真实正向累计里程恢复后仍会自动覆盖估算值;禁止把估算值混入平台累计里程归并。`vehicle_stat_samples_total{status="recovered_gps_coordinate"}``vehicle_stat_cache_entries{cache="gps_accumulation"}` 用于观察实时恢复量及节流缓存。`stats-backfill` 默认用 TDengine `vehicle_locations` 为缺少正向累计里程但存在可用移动线段的车辆补建同口径候选;可临时设置 `BACKFILL_GPS_FALLBACK=false` 禁用全部协议的降级路径,旧环境变量 `BACKFILL_JT808_GPS_FALLBACK` 继续兼容。
GB32960 与宇通 MQTT 的每日里程同时产出 `pure_hydrogen_mileage_km`。GB32960 采用燃料电池扩展中的 `engine_work_state=2`,宇通采用 `TRIANGLE_STATE IN (4,11)` 判定燃料电池处于纯氢工作模式;只有相邻两个有效累计里程点均为已识别的纯氢模式时才累计该段距离,模式切换、未知值、倒序和异常跳变均不计入纯氢里程。候选来源证据保存在 `vehicle_daily_mileage_source`,最终值随每日里程来源选举投影到 `vehicle_daily_mileage`,并始终限制为不大于对应日总里程。
内部里程统计会关联 `vehicle_open_daily_energy``quality_status=OK` 的日用氢量,展示 `耗氢 kg`,并用同一车辆日的 `耗氢量 ÷ 纯氢里程 × 100` 计算百公里纯氢耗。区间百公里纯氢耗只汇总同时具备有效氢耗和纯氢里程的车辆日;`NO_DATA``SUSPECT` 或没有纯氢里程的数据展示为 `—`,禁止当作 `0` 或混入分母。
存量日期需要用原始报文扫描方式回算纯氢里程,因为默认 `last_diff` 只读取每日首尾累计里程,无法还原日内工作模式序列。先保持 `BACKFILL_DRY_RUN=true` 核对扫描量,再以 `BACKFILL_METHOD=scan BACKFILL_DRY_RUN=false BACKFILL_PROTOCOLS=GB32960,YUTONG_MQTT` 对指定日期窗口执行正式回算;扫描按事件时间顺序读取原始报文,并使用与实时统计完全相同的模式判定和相邻里程规则。
生产定时补算使用 systemd timer默认每天 01:30 补算昨天往前 3 天,覆盖上游晚到或断传后恢复的总里程字段: 生产定时补算使用 systemd timer默认每天 01:30 补算昨天往前 3 天,覆盖上游晚到或断传后恢复的总里程字段:
```bash ```bash

View File

@@ -6,6 +6,7 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway \ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/gateway ./cmd/gateway \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/feichi-bridge ./cmd/feichi-bridge \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/history-writer ./cmd/history-writer \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/history-writer ./cmd/history-writer \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/stat-writer ./cmd/stat-writer \ && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/stat-writer ./cmd/stat-writer \
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/realtime-api ./cmd/realtime-api && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/realtime-api ./cmd/realtime-api
@@ -18,6 +19,7 @@ RUN apt-get update \
WORKDIR /app WORKDIR /app
COPY --from=build /out/gateway /app/gateway COPY --from=build /out/gateway /app/gateway
COPY --from=build /out/feichi-bridge /app/feichi-bridge
COPY --from=build /out/history-writer /app/history-writer COPY --from=build /out/history-writer /app/history-writer
COPY --from=build /out/stat-writer /app/stat-writer COPY --from=build /out/stat-writer /app/stat-writer
COPY --from=build /out/realtime-api /app/realtime-api COPY --from=build /out/realtime-api /app/realtime-api

View File

@@ -0,0 +1,211 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/feichibridge"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/health"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability"
)
type authSecret struct {
Username string `json:"username"`
Password string `json:"password"`
}
type targetSecret struct {
PlatformID string `json:"platformId"`
Username string `json:"username"`
Password string `json:"password"`
}
type config struct {
BaseURL string
AuthSecretFile string
TargetSecret string
TargetAddress string
StateFile string
HealthAddress string
HTTPTimeout time.Duration
TargetTimeout time.Duration
OCRImage string
OCRTimeout time.Duration
LoginAttempts int
LoginRetryDelay time.Duration
Service feichibridge.ServiceConfig
}
func main() {
logger := observability.NewLogger("feichi-bridge")
cfg, err := loadConfig()
if err != nil {
logger.Error("load configuration failed", "error", err)
os.Exit(1)
}
auth, err := readJSONSecret[authSecret](cfg.AuthSecretFile)
if err != nil {
logger.Error("read Feichi API secret failed", "error", err)
os.Exit(1)
}
credentials, err := readJSONSecret[targetSecret](cfg.TargetSecret)
if err != nil {
logger.Error("read GB/T 32960 target secret failed", "error", err)
os.Exit(1)
}
source, err := feichibridge.NewAuthenticatedAPIClient(
cfg.BaseURL,
feichibridge.LoginCredentials{
Username: auth.Username, Password: auth.Password,
MaxAttempts: cfg.LoginAttempts, RetryDelay: cfg.LoginRetryDelay,
},
feichibridge.DockerCaptchaSolver{Image: cfg.OCRImage, Timeout: cfg.OCRTimeout},
cfg.HTTPTimeout,
)
if err != nil {
logger.Error("build Feichi API client failed", "error", err)
os.Exit(1)
}
state, err := feichibridge.OpenStateStore(cfg.StateFile)
if err != nil {
logger.Error("open bridge state failed", "error", err)
os.Exit(1)
}
target, err := feichibridge.NewTarget(feichibridge.TargetConfig{
Address: cfg.TargetAddress, PlatformID: credentials.PlatformID,
Username: credentials.Username, Password: credentials.Password,
Timeout: cfg.TargetTimeout,
}, state)
if err != nil {
logger.Error("build GB/T 32960 target failed", "error", err)
os.Exit(1)
}
registry := metrics.NewRegistry()
service, err := feichibridge.NewService(cfg.Service, source, target, state, logger, registry)
if err != nil {
logger.Error("build bridge service failed", "error", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
health.Start(ctx, logger, health.NewServer(cfg.HealthAddress, "feichi-bridge", []health.Check{
{Name: "bridge", Check: service.Ready},
}, registry))
logger.Info("Feichi bridge starting",
"base_url", cfg.BaseURL,
"target_address", cfg.TargetAddress,
"poll_interval", cfg.Service.PollInterval,
"backfill_enabled", cfg.Service.BackfillEnabled,
)
if strings.HasPrefix(strings.ToLower(cfg.BaseURL), "http://") {
logger.Warn("Feichi source uses clear-text HTTP; deploy only through a controlled egress path")
}
if err := service.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
logger.Error("Feichi bridge stopped unexpectedly", "error", err)
os.Exit(1)
}
logger.Info("Feichi bridge stopped")
}
func loadConfig() (config, error) {
cfg := config{
BaseURL: strings.TrimSpace(os.Getenv("FEICHI_BASE_URL")),
AuthSecretFile: env("FEICHI_AUTH_SECRET_FILE", strings.TrimSpace(os.Getenv("FEICHI_AUTH_HEADERS_FILE"))),
TargetSecret: strings.TrimSpace(os.Getenv("FEICHI_TARGET_SECRET_FILE")),
TargetAddress: env("FEICHI_TARGET_ADDR", "127.0.0.1:32960"),
StateFile: env("FEICHI_STATE_FILE", "/var/lib/lingniu-feichi-bridge/state.json"),
HealthAddress: env("HEALTH_ADDR", "127.0.0.1:20219"),
HTTPTimeout: seconds("FEICHI_HTTP_TIMEOUT_SECONDS", 15),
TargetTimeout: seconds("FEICHI_TARGET_TIMEOUT_SECONDS", 10),
OCRImage: env("FEICHI_OCR_IMAGE", "lingniu/feichi-captcha-ocr:1.0.0"),
OCRTimeout: seconds("FEICHI_OCR_TIMEOUT_SECONDS", 20),
LoginAttempts: envInt("FEICHI_LOGIN_MAX_ATTEMPTS", 20),
LoginRetryDelay: seconds("FEICHI_LOGIN_RETRY_SECONDS", 1),
Service: feichibridge.ServiceConfig{
PollInterval: seconds("FEICHI_POLL_INTERVAL_SECONDS", 10),
DiscoveryInterval: seconds("FEICHI_DISCOVERY_INTERVAL_SECONDS", 300),
BackfillInterval: seconds("FEICHI_BACKFILL_INTERVAL_SECONDS", 3600),
BackfillLookback: seconds("FEICHI_BACKFILL_LOOKBACK_SECONDS", 3600),
BackfillWindow: seconds("FEICHI_BACKFILL_WINDOW_SECONDS", 1200),
BackfillSafetyLag: seconds("FEICHI_BACKFILL_SAFETY_SECONDS", 30),
SourceStaleAfter: seconds("FEICHI_SOURCE_STALE_SECONDS", 120),
FetchConcurrency: envInt("FEICHI_FETCH_CONCURRENCY", 4),
BackfillEnabled: envBool("FEICHI_BACKFILL_ENABLED", true),
StaleReissueEnabled: envBool("FEICHI_STALE_REISSUE_ENABLED", true),
},
}
var missing []string
if cfg.BaseURL == "" {
missing = append(missing, "FEICHI_BASE_URL")
}
if cfg.AuthSecretFile == "" {
missing = append(missing, "FEICHI_AUTH_SECRET_FILE")
}
if cfg.TargetSecret == "" {
missing = append(missing, "FEICHI_TARGET_SECRET_FILE")
}
if len(missing) > 0 {
return config{}, fmt.Errorf("required configuration missing: %s", strings.Join(missing, ", "))
}
if cfg.Service.BackfillWindow > 24*time.Hour {
return config{}, errors.New("FEICHI_BACKFILL_WINDOW_SECONDS must not exceed 86400")
}
return cfg, nil
}
func readJSONSecret[T any](path string) (T, error) {
var value T
encoded, err := os.ReadFile(path)
if err != nil {
return value, err
}
if err := json.Unmarshal(encoded, &value); err != nil {
return value, fmt.Errorf("decode %s: %w", path, err)
}
return value, nil
}
func env(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) int {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return fallback
}
return parsed
}
func envBool(name string, fallback bool) bool {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func seconds(name string, fallback int) time.Duration {
return time.Duration(envInt(name, fallback)) * time.Second
}

View File

@@ -0,0 +1,743 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"math"
"os"
"regexp"
"sort"
"strings"
"time"
mysql "github.com/go-sql-driver/mysql"
)
const (
protocol = "GB32960"
legacySourceIP = "legacy-mysql.lingniu-prod"
legacySourceKey = "GB32960:LEGACY_MILEAGE@legacy-mysql.lingniu-prod"
legacySourceCode = "legacy_lingniu_prod"
legacyPlatformName = "历史生产库里程"
legacyQuality = "legacy_mysql_backfill_before_2026-07-21"
maxDailyMileageKM = 2500.0
)
var vinPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
type sourceRecord struct {
ID int64
Identifier string
StatDate time.Time
DailyKM sql.NullFloat64
TotalKM sql.NullFloat64
Now sql.NullTime
MappingOrigin string
VIN string
}
type mappingIndex struct {
Unique map[string]string
Ambiguous map[string]struct{}
}
type scanReport struct {
SourceRows int `json:"source_rows"`
SourceDistinctIdentifiers int `json:"source_distinct_identifiers"`
DedupedSourceRows int `json:"deduped_source_rows"`
DuplicateRows int `json:"duplicate_rows"`
ConflictingDuplicateGroups int `json:"conflicting_duplicate_groups"`
MappedByCurrentVehicle int `json:"mapped_by_current_vehicle"`
MappedByLegacyVehicle int `json:"mapped_by_legacy_vehicle"`
CurrentLegacyVINConflicts int `json:"current_legacy_vin_conflicts"`
UnresolvedRows int `json:"unresolved_rows"`
UnresolvedIdentifiers int `json:"unresolved_identifiers"`
UnresolvedEligibleRows int `json:"unresolved_eligible_rows"`
UnresolvedIdentifierValues []string `json:"unresolved_identifier_values,omitempty"`
NullMileageRows int `json:"null_mileage_rows"`
ZeroMileageRows int `json:"zero_mileage_rows"`
NegativeMileageRows int `json:"negative_mileage_rows"`
OverLimitMileageRows int `json:"over_limit_mileage_rows"`
EligibleRows int `json:"eligible_rows"`
VINDateCollisions int `json:"vin_date_collisions"`
ConflictingVINDateCollisions int `json:"conflicting_vin_date_collisions"`
}
type targetReport struct {
StageRows int64 `json:"stage_rows"`
MissingRows int64 `json:"missing_rows"`
ZeroRows int64 `json:"zero_rows"`
NonzeroProtectedRows int64 `json:"nonzero_protected_rows"`
ExistingPositiveCandidateRows int64 `json:"existing_positive_candidate_rows"`
ProtectedSameMileageRows int64 `json:"protected_same_mileage_rows"`
ProtectedMileageSumKM float64 `json:"protected_mileage_sum_km"`
ProtectedMileageFingerprint uint64 `json:"protected_mileage_fingerprint"`
LegacyCandidateRows int64 `json:"legacy_candidate_rows"`
LegacyCandidateTotalMatchedRows int64 `json:"legacy_candidate_total_matched_rows"`
LegacyProjectedRows int64 `json:"legacy_projected_rows"`
LegacyProjectedTotalMatchedRows int64 `json:"legacy_projected_total_matched_rows"`
LegacySelectedCandidateRows int64 `json:"legacy_selected_candidate_rows"`
PreexistingNonLegacyRows int64 `json:"preexisting_non_legacy_rows"`
PreexistingNonLegacyMileageSumKM float64 `json:"preexisting_non_legacy_mileage_sum_km"`
PreexistingNonLegacyFingerprint uint64 `json:"preexisting_non_legacy_fingerprint"`
RemainingMissingOrZeroRows int64 `json:"remaining_missing_or_zero_rows"`
PostProtectedRows int64 `json:"post_protected_rows"`
PostProtectedMileageSumKM float64 `json:"post_protected_mileage_sum_km"`
PostProtectedMileageFingerprint uint64 `json:"post_protected_mileage_fingerprint"`
ProtectedRowsUnchanged int64 `json:"protected_rows_unchanged"`
ProtectedMileageFingerprintStable bool `json:"protected_mileage_fingerprint_stable"`
}
type output struct {
Mode string `json:"mode"`
CutoffExclusive string `json:"cutoff_exclusive"`
Protocol string `json:"protocol"`
SourceTable string `json:"source_table"`
TotalMileageDivisor float64 `json:"total_mileage_divisor_m_to_km"`
Scan scanReport `json:"scan"`
Target targetReport `json:"target"`
Duration string `json:"duration"`
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
var cutoffRaw, dsn string
var apply bool
var timeout time.Duration
flag.StringVar(&cutoffRaw, "before", "2026-07-21", "exclusive upper date boundary (YYYY-MM-DD)")
flag.StringVar(&dsn, "mysql-dsn", strings.TrimSpace(os.Getenv("MYSQL_DSN")), "target MySQL DSN")
flag.BoolVar(&apply, "apply", false, "apply the migration; default is dry-run")
flag.DurationVar(&timeout, "timeout", 10*time.Minute, "overall timeout")
flag.Parse()
started := time.Now()
cutoff, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(cutoffRaw), time.FixedZone("Asia/Shanghai", 8*3600))
if err != nil {
return fmt.Errorf("invalid -before date: %w", err)
}
if strings.TrimSpace(dsn) == "" {
return errors.New("MYSQL_DSN or -mysql-dsn is required")
}
dsn, err = normalizedDSN(dsn)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := sql.Open("mysql", dsn)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(2)
if err := db.PingContext(ctx); err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
currentMappings, err := loadMappingIndex(ctx, conn, `
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM vehicle
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM vehicle_identifier
WHERE enabled = 1 AND plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''`)
if err != nil {
return fmt.Errorf("load current vehicle mappings: %w", err)
}
legacyMappings, err := loadMappingIndex(ctx, conn, `
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_truck
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.truck_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.v_vehicle_daily_stats
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_truck_remote_sync_realtime_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.view_truck_comprehensive_info
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin_code USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.tab_hecri_hydrogen_order
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin_code IS NOT NULL AND TRIM(vin_code) <> ''
UNION ALL
SELECT CONVERT(plate USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.bi_ele_charge_record
WHERE plate IS NOT NULL AND TRIM(plate) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.v_truck_task_basic
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''
UNION ALL
SELECT CONVERT(plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci AS plate,
CONVERT(vin USING utf8mb4) COLLATE utf8mb4_unicode_ci AS vin FROM lingniu_prod.view_vehicle_annual_inspection
WHERE plate_number IS NOT NULL AND TRIM(plate_number) <> '' AND vin IS NOT NULL AND TRIM(vin) <> ''`)
if err != nil {
return fmt.Errorf("load legacy vehicle mappings: %w", err)
}
records, report, err := loadAndResolveSource(ctx, conn, cutoff, currentMappings, legacyMappings)
if err != nil {
return err
}
if err := createAndLoadStage(ctx, conn, records); err != nil {
return err
}
target, err := inspectTarget(ctx, conn)
if err != nil {
return err
}
mode := "dry_run"
if apply {
mode = "apply"
if err := createProtectedSnapshot(ctx, conn); err != nil {
return err
}
if err := applyMigration(ctx, conn); err != nil {
return err
}
post, err := inspectTarget(ctx, conn)
if err != nil {
return err
}
post.PostProtectedRows, post.PostProtectedMileageSumKM, post.PostProtectedMileageFingerprint, post.ProtectedRowsUnchanged, err = inspectProtectedSnapshot(ctx, conn)
if err != nil {
return err
}
post.ProtectedMileageFingerprintStable =
target.PreexistingNonLegacyRows == post.PostProtectedRows &&
math.Abs(target.PreexistingNonLegacyMileageSumKM-post.PostProtectedMileageSumKM) < 0.0005 &&
target.PreexistingNonLegacyFingerprint == post.PostProtectedMileageFingerprint &&
post.ProtectedRowsUnchanged == target.PreexistingNonLegacyRows
post.NonzeroProtectedRows = target.NonzeroProtectedRows
post.ProtectedSameMileageRows = target.ProtectedSameMileageRows
post.ProtectedMileageSumKM = target.ProtectedMileageSumKM
post.ProtectedMileageFingerprint = target.ProtectedMileageFingerprint
target = post
}
result := output{
Mode: mode, CutoffExclusive: cutoff.Format("2006-01-02"), Protocol: protocol,
SourceTable: "lingniu_prod.ln_vehicle_day_mileage", TotalMileageDivisor: 1000, Scan: report, Target: target,
Duration: time.Since(started).Round(time.Millisecond).String(),
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func createProtectedSnapshot(ctx context.Context, conn *sql.Conn) error {
_, err := conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_legacy_mileage_protected AS
SELECT m.vin, m.stat_date, m.protocol, m.source_id, m.daily_mileage_km,
m.latest_total_mileage_km, m.updated_at
FROM tmp_legacy_mileage_stage st
JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.daily_mileage_km > 0 AND COALESCE(ds.source_ip, '') <> ?`, legacySourceIP)
if err != nil {
return fmt.Errorf("snapshot protected mileage rows: %w", err)
}
return nil
}
func inspectProtectedSnapshot(ctx context.Context, queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) (count int64, sum float64, fingerprint uint64, unchanged int64, err error) {
var nullableSum sql.NullFloat64
err = queryer.QueryRowContext(ctx, `
SELECT
COUNT(m.vin),
SUM(CASE WHEN m.vin IS NOT NULL THEN m.daily_mileage_km ELSE 0 END),
COALESCE(BIT_XOR(CASE WHEN m.vin IS NOT NULL THEN CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL'))) ELSE 0 END), 0),
COALESCE(SUM(
m.vin IS NOT NULL
AND (m.source_id <=> p.source_id)
AND (m.daily_mileage_km <=> p.daily_mileage_km)
AND (m.latest_total_mileage_km <=> p.latest_total_mileage_km)
AND (m.updated_at <=> p.updated_at)
), 0)
FROM tmp_legacy_mileage_protected p
LEFT JOIN vehicle_daily_mileage m
ON m.vin = p.vin AND m.stat_date = p.stat_date AND m.protocol = p.protocol`).Scan(
&count, &nullableSum, &fingerprint, &unchanged,
)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("verify protected mileage snapshot: %w", err)
}
if nullableSum.Valid {
sum = nullableSum.Float64
}
return count, sum, fingerprint, unchanged, nil
}
func normalizedDSN(raw string) (string, error) {
cfg, err := mysql.ParseDSN(strings.TrimSpace(raw))
if err != nil {
return "", fmt.Errorf("parse mysql dsn: %w", err)
}
cfg.ParseTime = true
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
cfg.Params = cloneMap(cfg.Params)
cfg.Params["charset"] = "utf8mb4"
return cfg.FormatDSN(), nil
}
func cloneMap(source map[string]string) map[string]string {
result := make(map[string]string, len(source)+1)
for key, value := range source {
result[key] = value
}
return result
}
func loadMappingIndex(ctx context.Context, queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, query string) (mappingIndex, error) {
rows, err := queryer.QueryContext(ctx, query)
if err != nil {
return mappingIndex{}, err
}
defer rows.Close()
values := map[string]map[string]struct{}{}
for rows.Next() {
var plate, vin sql.NullString
if err := rows.Scan(&plate, &vin); err != nil {
return mappingIndex{}, err
}
plateValue := normalizeIdentifier(plate.String)
vinValue := normalizeVIN(vin.String)
if plateValue == "" || !vinPattern.MatchString(vinValue) {
continue
}
if values[plateValue] == nil {
values[plateValue] = map[string]struct{}{}
}
values[plateValue][vinValue] = struct{}{}
}
if err := rows.Err(); err != nil {
return mappingIndex{}, err
}
result := mappingIndex{Unique: map[string]string{}, Ambiguous: map[string]struct{}{}}
for plate, vins := range values {
if len(vins) != 1 {
result.Ambiguous[plate] = struct{}{}
continue
}
for vin := range vins {
result.Unique[plate] = vin
}
}
return result, nil
}
func loadAndResolveSource(ctx context.Context, queryer interface {
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}, cutoff time.Time, current, legacy mappingIndex) ([]sourceRecord, scanReport, error) {
rows, err := queryer.QueryContext(ctx, `
SELECT id, plateNumber, dates, dayMileage, totalMileage, nowDatetime
FROM lingniu_prod.ln_vehicle_day_mileage
WHERE dates < ?
ORDER BY plateNumber, dates, (nowDatetime IS NULL), nowDatetime DESC, id DESC`, cutoff)
if err != nil {
return nil, scanReport{}, fmt.Errorf("query source mileage: %w", err)
}
defer rows.Close()
var report scanReport
distinctIdentifiers := map[string]struct{}{}
unresolvedIdentifiers := map[string]struct{}{}
byIdentifierDate := map[string]sourceRecord{}
duplicateValues := map[string]float64{}
conflictingDuplicateKeys := map[string]struct{}{}
for rows.Next() {
var item sourceRecord
var rawIdentifier sql.NullString
if err := rows.Scan(&item.ID, &rawIdentifier, &item.StatDate, &item.DailyKM, &item.TotalKM, &item.Now); err != nil {
return nil, report, err
}
report.SourceRows++
item.Identifier = normalizeIdentifier(rawIdentifier.String)
distinctIdentifiers[item.Identifier] = struct{}{}
key := item.Identifier + "\x00" + item.StatDate.Format("2006-01-02")
if existing, ok := byIdentifierDate[key]; ok {
report.DuplicateRows++
if item.DailyKM.Valid && existing.DailyKM.Valid && math.Abs(item.DailyKM.Float64-existing.DailyKM.Float64) > 0.0005 {
conflictingDuplicateKeys[key] = struct{}{}
}
continue
}
byIdentifierDate[key] = item
if item.DailyKM.Valid {
duplicateValues[key] = item.DailyKM.Float64
}
}
if err := rows.Err(); err != nil {
return nil, report, err
}
_ = duplicateValues
report.SourceDistinctIdentifiers = len(distinctIdentifiers)
report.DedupedSourceRows = len(byIdentifierDate)
report.ConflictingDuplicateGroups = len(conflictingDuplicateKeys)
keys := make([]string, 0, len(byIdentifierDate))
for key := range byIdentifierDate {
keys = append(keys, key)
}
sort.Strings(keys)
stageByVINDate := map[string]sourceRecord{}
var stageOrder []string
for _, key := range keys {
item := byIdentifierDate[key]
vin, origin, conflict := resolveVIN(item.Identifier, current, legacy)
if conflict {
report.CurrentLegacyVINConflicts++
}
if vin == "" {
report.UnresolvedRows++
unresolvedIdentifiers[item.Identifier] = struct{}{}
if item.DailyKM.Valid && item.DailyKM.Float64 > 0 && item.DailyKM.Float64 <= maxDailyMileageKM {
report.UnresolvedEligibleRows++
}
continue
}
item.VIN = vin
item.MappingOrigin = origin
if origin == "current_vehicle" {
report.MappedByCurrentVehicle++
} else {
report.MappedByLegacyVehicle++
}
if !item.DailyKM.Valid {
report.NullMileageRows++
continue
}
switch {
case item.DailyKM.Float64 == 0:
report.ZeroMileageRows++
continue
case item.DailyKM.Float64 < 0:
report.NegativeMileageRows++
continue
case item.DailyKM.Float64 > maxDailyMileageKM:
report.OverLimitMileageRows++
continue
}
report.EligibleRows++
stageKey := item.VIN + "\x00" + item.StatDate.Format("2006-01-02")
if existing, ok := stageByVINDate[stageKey]; ok {
report.VINDateCollisions++
if math.Abs(existing.DailyKM.Float64-item.DailyKM.Float64) > 0.0005 {
report.ConflictingVINDateCollisions++
}
if item.ID <= existing.ID {
continue
}
} else {
stageOrder = append(stageOrder, stageKey)
}
stageByVINDate[stageKey] = item
}
report.UnresolvedIdentifiers = len(unresolvedIdentifiers)
for identifier := range unresolvedIdentifiers {
report.UnresolvedIdentifierValues = append(report.UnresolvedIdentifierValues, identifier)
}
sort.Strings(report.UnresolvedIdentifierValues)
sort.Strings(stageOrder)
stage := make([]sourceRecord, 0, len(stageOrder))
for _, key := range stageOrder {
stage = append(stage, stageByVINDate[key])
}
return stage, report, nil
}
func resolveVIN(identifier string, current, legacy mappingIndex) (vin, origin string, conflict bool) {
if vinPattern.MatchString(identifier) {
return identifier, "source_vin", false
}
currentVIN, currentOK := current.Unique[identifier]
legacyVIN, legacyOK := legacy.Unique[identifier]
if currentOK && legacyOK && currentVIN != legacyVIN {
return "", "", true
}
if currentOK {
return currentVIN, "current_vehicle", false
}
if legacyOK {
return legacyVIN, "legacy_vehicle", false
}
return "", "", false
}
func normalizeIdentifier(value string) string {
return strings.ToUpper(strings.Join(strings.Fields(strings.TrimSpace(value)), ""))
}
func normalizeVIN(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func createAndLoadStage(ctx context.Context, conn *sql.Conn, records []sourceRecord) error {
if _, err := conn.ExecContext(ctx, `
CREATE TEMPORARY TABLE tmp_legacy_mileage_stage (
vin VARCHAR(32) NOT NULL,
stat_date DATE NOT NULL,
daily_mileage_km DECIMAL(18,3) NOT NULL,
total_mileage_km DECIMAL(18,3) NULL,
source_record_id BIGINT NOT NULL,
mapping_origin VARCHAR(32) NOT NULL,
PRIMARY KEY (vin, stat_date)
) ENGINE=InnoDB`); err != nil {
return fmt.Errorf("create temporary stage: %w", err)
}
const batchSize = 500
for start := 0; start < len(records); start += batchSize {
end := min(start+batchSize, len(records))
var query strings.Builder
query.WriteString("INSERT INTO tmp_legacy_mileage_stage (vin, stat_date, daily_mileage_km, total_mileage_km, source_record_id, mapping_origin) VALUES ")
args := make([]any, 0, (end-start)*6)
for index, item := range records[start:end] {
if index > 0 {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?,?,?)")
args = append(args, item.VIN, item.StatDate, item.DailyKM.Float64, nullableTotalMileageKM(item.TotalKM), item.ID, item.MappingOrigin)
}
if _, err := conn.ExecContext(ctx, query.String(), args...); err != nil {
return fmt.Errorf("load temporary stage batch %d: %w", start/batchSize+1, err)
}
}
return nil
}
func nullableTotalMileageKM(value sql.NullFloat64) any {
if !value.Valid {
return nil
}
return value.Float64 / 1000
}
func inspectTarget(ctx context.Context, queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}) (targetReport, error) {
var report targetReport
var protectedSum sql.NullFloat64
err := queryer.QueryRowContext(ctx, `
SELECT
COUNT(*),
COALESCE(SUM(m.vin IS NULL), 0),
COALESCE(SUM(m.vin IS NOT NULL AND m.daily_mileage_km = 0), 0),
COALESCE(SUM(m.daily_mileage_km > 0), 0),
COALESCE(SUM(m.daily_mileage_km > 0 AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.011), 0),
SUM(CASE WHEN m.daily_mileage_km > 0 THEN m.daily_mileage_km ELSE 0 END),
COALESCE(BIT_XOR(CASE WHEN m.daily_mileage_km > 0 THEN CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL'))) ELSE 0 END), 0),
COALESCE(SUM(candidate.vin IS NOT NULL AND (m.vin IS NULL OR m.daily_mileage_km = 0)), 0)
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
LEFT JOIN (
SELECT vin, stat_date
FROM vehicle_daily_mileage_source
WHERE protocol = 'GB32960' AND quality_status = 'OK' AND daily_mileage_km > 0
GROUP BY vin, stat_date
) candidate ON candidate.vin = st.vin AND candidate.stat_date = st.stat_date`).Scan(
&report.StageRows, &report.MissingRows, &report.ZeroRows, &report.NonzeroProtectedRows,
&report.ProtectedSameMileageRows, &protectedSum, &report.ProtectedMileageFingerprint,
&report.ExistingPositiveCandidateRows,
)
if err != nil {
return report, fmt.Errorf("inspect migration target: %w", err)
}
if protectedSum.Valid {
report.ProtectedMileageSumKM = protectedSum.Float64
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(s.latest_total_mileage_km <=> st.total_mileage_km), 0)
FROM vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st ON st.vin = s.vin AND st.stat_date = s.stat_date
WHERE s.protocol = 'GB32960' AND s.source_key = ?`, legacySourceKey).Scan(
&report.LegacyCandidateRows, &report.LegacyCandidateTotalMatchedRows,
); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st ON st.vin = s.vin AND st.stat_date = s.stat_date
WHERE s.protocol = 'GB32960' AND s.source_key = ? AND s.is_selected = 1`, legacySourceKey).Scan(&report.LegacySelectedCandidateRows); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*), COALESCE(SUM(m.latest_total_mileage_km <=> st.total_mileage_km), 0)
FROM vehicle_daily_mileage m
JOIN tmp_legacy_mileage_stage st ON st.vin = m.vin AND st.stat_date = m.stat_date
JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.protocol = 'GB32960' AND ds.protocol = 'GB32960' AND ds.source_ip = ?
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceIP).Scan(
&report.LegacyProjectedRows, &report.LegacyProjectedTotalMatchedRows,
); err != nil {
return report, err
}
if err := queryer.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0`).Scan(&report.RemainingMissingOrZeroRows); err != nil {
return report, err
}
var nonLegacySum sql.NullFloat64
if err := queryer.QueryRowContext(ctx, `
SELECT
COUNT(*),
SUM(m.daily_mileage_km),
COALESCE(BIT_XOR(CRC32(CONCAT_WS('|', m.vin, m.stat_date, m.protocol, m.daily_mileage_km, COALESCE(m.latest_total_mileage_km, 'NULL')))), 0)
FROM vehicle_daily_mileage m
JOIN tmp_legacy_mileage_stage st ON st.vin = m.vin AND st.stat_date = m.stat_date
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id
WHERE m.protocol = 'GB32960' AND m.daily_mileage_km > 0
AND COALESCE(ds.source_ip, '') <> ?`, legacySourceIP).Scan(
&report.PreexistingNonLegacyRows, &nonLegacySum, &report.PreexistingNonLegacyFingerprint,
); err != nil {
return report, err
}
if nonLegacySum.Valid {
report.PreexistingNonLegacyMileageSumKM = nonLegacySum.Float64
}
return report, nil
}
func applyMigration(ctx context.Context, conn *sql.Conn) error {
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_data_source
(protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind,
trust_priority, enabled, first_seen_at, latest_seen_at, remark)
VALUES ('GB32960', ?, 'lingniu_prod.ln_vehicle_day_mileage', ?, ?, 'UNKNOWN', 1000, 1,
'2024-10-17 00:00:00', '2026-07-20 23:59:59',
'仅用于迁移 2026-07-21 之前缺失或为 0 的 GB32960 日里程')
ON DUPLICATE KEY UPDATE
latest_source_endpoint = VALUES(latest_source_endpoint),
platform_name = VALUES(platform_name),
source_code = VALUES(source_code),
source_kind = VALUES(source_kind),
trust_priority = VALUES(trust_priority),
enabled = 1,
remark = VALUES(remark)`, legacySourceIP, legacyPlatformName, legacySourceCode); err != nil {
return fmt.Errorf("upsert legacy data source: %w", err)
}
var sourceID int64
if err := tx.QueryRowContext(ctx, `SELECT id FROM vehicle_data_source WHERE protocol = 'GB32960' AND source_ip = ?`, legacySourceIP).Scan(&sourceID); err != nil {
return fmt.Errorf("lookup legacy data source: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason, is_selected)
SELECT
st.vin, st.stat_date, 'GB32960', ?, ?, 'lingniu_prod.ln_vehicle_day_mileage', ?,
CASE WHEN st.total_mileage_km IS NOT NULL AND st.total_mileage_km >= st.daily_mileage_km
THEN st.total_mileage_km - st.daily_mileage_km ELSE NULL END,
st.total_mileage_km, st.daily_mileage_km, 1,
TIMESTAMP(st.stat_date, '00:00:00'), TIMESTAMP(st.stat_date, '23:59:59'),
'OK', ?, 0
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
NOT (vehicle_daily_mileage_source.first_total_mileage_km <=> VALUES(first_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
OR NOT (vehicle_daily_mileage_source.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage_source.quality_status <=> VALUES(quality_status))
OR NOT (vehicle_daily_mileage_source.quality_reason <=> VALUES(quality_reason)),
CURRENT_TIMESTAMP,
vehicle_daily_mileage_source.updated_at
),
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
platform_name = VALUES(platform_name),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason)`, legacySourceKey, legacySourceIP, legacyPlatformName, legacyQuality, sourceID); err != nil {
return fmt.Errorf("upsert legacy mileage candidates: %w", err)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km)
SELECT st.vin, st.stat_date, 'GB32960', ?, st.daily_mileage_km, st.total_mileage_km
FROM tmp_legacy_mileage_stage st
LEFT JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
WHERE m.vin IS NULL OR m.daily_mileage_km = 0 OR m.source_id = ?
ON DUPLICATE KEY UPDATE
updated_at = IF(
vehicle_daily_mileage.daily_mileage_km = 0
OR (
(vehicle_daily_mileage.source_id <=> VALUES(source_id))
AND (
NOT (vehicle_daily_mileage.daily_mileage_km <=> VALUES(daily_mileage_km))
OR NOT (vehicle_daily_mileage.latest_total_mileage_km <=> VALUES(latest_total_mileage_km))
)
),
CURRENT_TIMESTAMP,
vehicle_daily_mileage.updated_at
),
source_id = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(source_id), vehicle_daily_mileage.source_id),
latest_total_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(latest_total_mileage_km), vehicle_daily_mileage.latest_total_mileage_km),
daily_mileage_km = IF(vehicle_daily_mileage.daily_mileage_km = 0 OR (vehicle_daily_mileage.source_id <=> VALUES(source_id)), VALUES(daily_mileage_km), vehicle_daily_mileage.daily_mileage_km)`, sourceID, sourceID); err != nil {
return fmt.Errorf("upsert final daily mileage: %w", err)
}
if _, err := tx.ExecContext(ctx, `
UPDATE vehicle_daily_mileage_source s
JOIN tmp_legacy_mileage_stage st
ON st.vin = s.vin AND st.stat_date = s.stat_date
JOIN vehicle_daily_mileage m
ON m.vin = st.vin AND m.stat_date = st.stat_date AND m.protocol = 'GB32960'
SET s.is_selected = CASE WHEN s.source_key = ? THEN 1 ELSE 0 END
WHERE s.protocol = 'GB32960'
AND m.source_id = ?
AND ABS(m.daily_mileage_km - st.daily_mileage_km) <= 0.0005`, legacySourceKey, sourceID); err != nil {
return fmt.Errorf("mark legacy mileage selections: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration: %w", err)
}
return nil
}

View File

@@ -0,0 +1,56 @@
package main
import (
"database/sql"
"testing"
)
func TestResolveVINPrefersConsistentCurrentMapping(t *testing.T) {
current := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
vin, origin, conflict := resolveVIN("沪A12345", current, legacy)
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "current_vehicle" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestResolveVINRejectsCurrentLegacyConflict(t *testing.T) {
current := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4176"}}
vin, _, conflict := resolveVIN("沪A12345", current, legacy)
if !conflict || vin != "" {
t.Fatalf("expected conflict rejection, got vin=%q conflict=%v", vin, conflict)
}
}
func TestResolveVINUsesLegacyFallback(t *testing.T) {
current := mappingIndex{Unique: map[string]string{}}
legacy := mappingIndex{Unique: map[string]string{"沪A12345": "LA9GG64L0NBAF4175"}}
vin, origin, conflict := resolveVIN("沪A12345", current, legacy)
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "legacy_vehicle" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestResolveVINAcceptsSourceVIN(t *testing.T) {
vin, origin, conflict := resolveVIN("LA9GG64L0NBAF4175", mappingIndex{}, mappingIndex{})
if conflict || vin != "LA9GG64L0NBAF4175" || origin != "source_vin" {
t.Fatalf("unexpected resolution: vin=%q origin=%q conflict=%v", vin, origin, conflict)
}
}
func TestNormalizeIdentifierRemovesWhitespace(t *testing.T) {
if got := normalizeIdentifier(" 沪 a 12345 \n"); got != "沪A12345" {
t.Fatalf("normalizeIdentifier() = %q", got)
}
}
func TestNullableTotalMileageConvertsMetersToKM(t *testing.T) {
got := nullableTotalMileageKM(sql.NullFloat64{Float64: 59198000, Valid: true})
if got != 59198.0 {
t.Fatalf("nullableTotalMileageKM() = %#v", got)
}
if got := nullableTotalMileageKM(sql.NullFloat64{}); got != nil {
t.Fatalf("invalid mileage should remain nil, got %#v", got)
}
}

View File

@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -54,6 +55,7 @@ func main() {
registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds()) registry.SetGauge("vehicle_stat_cache_retention_seconds", nil, cfg.CacheRetention.Seconds())
registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds()) registry.SetGauge("vehicle_stat_cache_cleanup_interval_seconds", nil, cfg.CacheCleanupInterval.Seconds())
registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds()) registry.SetGauge("vehicle_stat_baseline_miss_ttl_seconds", nil, cfg.BaselineMissTTL.Seconds())
registry.SetGauge("vehicle_stat_baseline_hit_ttl_seconds", nil, cfg.BaselineHitTTL.Seconds())
registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers)) registry.SetGauge("vehicle_stat_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers))
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{ health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
{Name: "mysql", Check: db.PingContext}, {Name: "mysql", Check: db.PingContext},
@@ -65,6 +67,7 @@ func main() {
writer.SetCacheRetention(cfg.CacheRetention) writer.SetCacheRetention(cfg.CacheRetention)
writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval) writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
writer.SetBaselineMissTTL(cfg.BaselineMissTTL) writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
writer.SetBaselineHitTTL(cfg.BaselineHitTTL)
writer.SetMaxCacheEntries(cfg.CacheMaxEntries) writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
if cfg.EnsureSchema { if cfg.EnsureSchema {
if err := writer.EnsureSchema(ctx); err != nil { if err := writer.EnsureSchema(ctx); err != nil {
@@ -72,6 +75,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
} }
if cfg.HydrogenCapacitySyncEnabled {
syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg)
} else if loaded, err := writer.ReloadHydrogenTankCapacities(ctx); err != nil {
logger.Warn("hydrogen tank capacity cache load failed", "error", err)
} else {
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
}
if cfg.NormalizePlatformSourcesOnStart { if cfg.NormalizePlatformSourcesOnStart {
statDate := time.Now().In(cfg.Location).Format("2006-01-02") statDate := time.Now().In(cfg.Location).Format("2006-01-02")
normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout) normalizeCtx, cancel := context.WithTimeout(ctx, cfg.NormalizePlatformSourcesTimeout)
@@ -83,29 +93,172 @@ func main() {
logger.Info("platform source startup normalization finished", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized) logger.Info("platform source startup normalization finished", "stat_date", statDate, "protocol", envelope.ProtocolJT808, "normalized", normalized)
} }
} }
if cfg.ReconcileProjectionsOnStart {
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
reconcileCtx, cancel := context.WithTimeout(ctx, cfg.ReconcileProjectionsTimeout)
result, err := writer.ReconcileDateProjections(reconcileCtx, statDate)
cancel()
recordStartupProjectionReconcile(registry, result, err)
if err != nil {
logger.Warn("daily mileage startup projection reconciliation incomplete", "stat_date", statDate, "targets", result.Targets, "attempted", result.Attempted, "written", result.Written, "failed", result.Failed, "error", err)
} else {
logger.Info("daily mileage startup projection reconciliation finished", "stat_date", statDate, "targets", result.Targets, "written", result.Written)
}
}
var appender statAppender = retryStatAppender{ var appender statAppender = retryStatAppender{
delegate: writer, delegate: writer,
attempts: cfg.RetryAttempts, attempts: cfg.RetryAttempts,
delay: cfg.RetryDelay, delay: cfg.RetryDelay,
registry: registry, registry: registry,
} }
quarantiner := fileStatMessageQuarantiner{dir: cfg.QuarantineDir}
logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds()) logger.Info("stat writer started", "group", cfg.KafkaGroup, "topics", strings.Join(cfg.KafkaTopics, ","), "workers", cfg.Workers, "project_interval_seconds", cfg.ProjectInterval.Seconds(), "source_touch_interval_seconds", cfg.SourceTouchInterval.Seconds(), "cache_retention_seconds", cfg.CacheRetention.Seconds(), "cache_cleanup_interval_seconds", cfg.CacheCleanupInterval.Seconds(), "baseline_miss_ttl_seconds", cfg.BaselineMissTTL.Seconds(), "baseline_hit_ttl_seconds", cfg.BaselineHitTTL.Seconds(), "cache_max_entries", cfg.CacheMaxEntries, "batch_size", cfg.BatchSize, "batch_wait_ms", cfg.BatchWait, "retry_attempts", cfg.RetryAttempts, "retry_delay_ms", cfg.RetryDelay.Milliseconds(), "quarantine_dir", cfg.QuarantineDir)
var background sync.WaitGroup
if cfg.HydrogenCapacitySyncEnabled && cfg.HydrogenCapacitySyncInterval > 0 {
background.Add(1)
go func() {
defer background.Done()
runHydrogenCapacitySync(ctx, logger, registry, db, writer, cfg)
}()
}
if cfg.ProjectInterval > 0 {
background.Add(1)
go func() {
defer background.Done()
runPendingProjectionFlusher(ctx, logger, registry, writer, cfg.ProjectInterval)
}()
}
var workers sync.WaitGroup var workers sync.WaitGroup
for workerID := 1; workerID <= cfg.Workers; workerID++ { for workerID := 1; workerID <= cfg.Workers; workerID++ {
workers.Add(1) workers.Add(1)
go func(id int) { go func(id int) {
defer workers.Done() defer workers.Done()
runStatConsumer(ctx, logger, registry, appender, cfg, id) runStatConsumer(ctx, logger, registry, appender, quarantiner, cfg, id)
}(workerID) }(workerID)
} }
workers.Wait() workers.Wait()
background.Wait()
if cfg.ProjectInterval > 0 {
flushCtx, cancel := context.WithTimeout(context.Background(), kafkaMessageOperationTimeout)
flushPendingProjections(flushCtx, logger, registry, writer, time.Time{})
cancel()
}
}
func runHydrogenCapacitySync(ctx context.Context, logger interface {
Info(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, db *sql.DB, writer *stats.Writer, cfg config) {
ticker := time.NewTicker(cfg.HydrogenCapacitySyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg)
}
}
}
func syncHydrogenCapacities(ctx context.Context, logger interface {
Info(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, db *sql.DB, writer *stats.Writer, cfg config) {
syncCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.HydrogenCapacitySyncTimeout)
defer cancel()
result, err := stats.SyncHydrogenTankCapacities(syncCtx, db, cfg.HydrogenCapacitySourceSchema)
if err != nil {
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "error"})
logger.Warn("hydrogen tank capacity sync failed; retaining current cache", "source_schema", cfg.HydrogenCapacitySourceSchema, "error", err)
return
}
loaded, err := writer.ReloadHydrogenTankCapacities(syncCtx)
if err != nil {
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "cache_error"})
logger.Warn("hydrogen tank capacity cache reload failed", "error", err)
return
}
registry.IncCounter("vehicle_stat_hydrogen_capacity_sync_total", metrics.Labels{"status": "ok"})
registry.SetGauge("vehicle_stat_hydrogen_capacity_cache_entries", nil, float64(loaded))
metrics.RecordLastActivity(registry, "vehicle_stat_hydrogen_capacity_last_sync_unix_seconds", nil)
logger.Info("hydrogen tank capacities synchronized", "source_schema", cfg.HydrogenCapacitySourceSchema, "read", result.Read, "written", result.Written, "deactivated", result.Deactivated, "cache_entries", loaded)
}
type pendingProjectionFlusher interface {
FlushPendingProjections(context.Context, time.Time) (stats.ProjectionFlushResult, error)
}
func recordStartupProjectionReconcile(registry *metrics.Registry, result stats.ProjectionReconcileResult, err error) {
if registry == nil {
return
}
registry.SetGauge("vehicle_stat_startup_projection_reconcile_targets", nil, float64(result.Targets))
registry.SetGauge("vehicle_stat_startup_projection_reconcile_written", nil, float64(result.Written))
registry.SetGauge("vehicle_stat_startup_projection_reconcile_failed", nil, float64(result.Failed))
status := "ok"
if err != nil || result.Failed > 0 {
status = "error"
}
metrics.RecordLastActivity(registry, "vehicle_stat_last_startup_projection_reconcile_unix_seconds", metrics.Labels{"status": status})
}
func runPendingProjectionFlusher(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, flusher pendingProjectionFlusher, interval time.Duration) {
if flusher == nil || interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), kafkaMessageOperationTimeout)
flushPendingProjections(operationCtx, logger, registry, flusher, now.Add(-interval))
cancel()
}
}
}
func flushPendingProjections(ctx context.Context, logger interface {
Error(string, ...any)
Warn(string, ...any)
}, registry *metrics.Registry, flusher pendingProjectionFlusher, readyBefore time.Time) {
if flusher == nil {
return
}
result, err := flusher.FlushPendingProjections(ctx, readyBefore)
if registry != nil {
if result.Attempted > 0 {
registry.AddCounter("vehicle_stat_pending_projections_total", metrics.Labels{"status": "attempted"}, float64(result.Attempted))
}
if result.Written > 0 {
registry.AddCounter("vehicle_stat_pending_projections_total", metrics.Labels{"status": "written"}, float64(result.Written))
metrics.RecordLastActivity(registry, "vehicle_stat_last_pending_projection_unix_seconds", metrics.Labels{"status": "written"})
}
if result.Failed > 0 {
registry.AddCounter("vehicle_stat_pending_projections_total", metrics.Labels{"status": "error"}, float64(result.Failed))
} else if err != nil {
registry.IncCounter("vehicle_stat_pending_projections_total", metrics.Labels{"status": "error"})
}
if err != nil {
metrics.RecordLastActivity(registry, "vehicle_stat_last_pending_projection_unix_seconds", metrics.Labels{"status": "error"})
}
}
if err != nil && logger != nil {
logger.Warn("pending daily mileage projection flush failed", "attempted", result.Attempted, "written", result.Written, "failed", result.Failed, "error", err)
}
} }
func runStatConsumer(ctx context.Context, logger interface { func runStatConsumer(ctx context.Context, logger interface {
Error(string, ...any) Error(string, ...any)
Warn(string, ...any) Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, cfg config, workerID int) { }, registry *metrics.Registry, appender statAppender, quarantiner statMessageQuarantiner, cfg config, workerID int) {
reader := kafka.NewReader(kafka.ReaderConfig{ reader := kafka.NewReader(kafka.ReaderConfig{
Brokers: cfg.KafkaBrokers, Brokers: cfg.KafkaBrokers,
GroupID: cfg.KafkaGroup, GroupID: cfg.KafkaGroup,
@@ -129,7 +282,7 @@ func runStatConsumer(ctx context.Context, logger interface {
continue continue
} }
batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond) batch := collectStatBatch(ctx, reader, message, cfg.BatchSize, time.Duration(cfg.BatchWait)*time.Millisecond)
processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, batch, cfg.RetryDelay, workerLabels) processStatBatchReliablyForWorker(ctx, logger, registry, appender, reader, quarantiner, batch, cfg.RetryDelay, workerLabels)
} }
} }
@@ -155,6 +308,10 @@ type kafkaMessageFetcher interface {
FetchMessage(context.Context) (kafka.Message, error) FetchMessage(context.Context) (kafka.Message, error)
} }
type statMessageQuarantiner interface {
Quarantine(context.Context, kafka.Message, error) error
}
type statBatchItem struct { type statBatchItem struct {
message kafka.Message message kafka.Message
processed bool processed bool
@@ -163,6 +320,8 @@ type statBatchItem struct {
type statBatchOutcome struct { type statBatchOutcome struct {
commitMessages []kafka.Message commitMessages []kafka.Message
retryMessages []kafka.Message retryMessages []kafka.Message
failedMessage *kafka.Message
writeErr error
} }
var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} var statWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
@@ -251,7 +410,12 @@ func processStatBatchForWorker(ctx context.Context, logger interface {
addStatMetric(registry, "vehicle_stat_writes_total", message, "error") addStatMetric(registry, "vehicle_stat_writes_total", message, "error")
logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err) logger.Error("mysql append failed", "topic", message.Topic, "partition", message.Partition, "offset", message.Offset, "event_id", env.StableEventID(), "error", err)
committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items) committed, commitErr := commitStatProcessedPrefixAfterFailure(messageCtx, logger, registry, committer, items)
outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)} failedMessage := message
outcome := statBatchOutcome{
retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed),
failedMessage: &failedMessage,
writeErr: err,
}
if commitErr != nil { if commitErr != nil {
outcome.commitMessages = committed outcome.commitMessages = committed
} }
@@ -279,13 +443,13 @@ func processStatBatchReliably(ctx context.Context, logger interface {
Error(string, ...any) Error(string, ...any)
Warn(string, ...any) Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) { }, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration) {
processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, messages, retryDelay, nil) processStatBatchReliablyForWorker(ctx, logger, registry, appender, committer, nil, messages, retryDelay, nil)
} }
func processStatBatchReliablyForWorker(ctx context.Context, logger interface { func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
Error(string, ...any) Error(string, ...any)
Warn(string, ...any) Warn(string, ...any)
}, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) { }, registry *metrics.Registry, appender statAppender, committer kafkaMessageCommitter, quarantiner statMessageQuarantiner, messages []kafka.Message, retryDelay time.Duration, workerLabels metrics.Labels) {
defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0) defer registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, 0)
pending := messages pending := messages
for len(pending) > 0 { for len(pending) > 0 {
@@ -297,6 +461,19 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
} }
} }
pending = outcome.retryMessages pending = outcome.retryMessages
if outcome.writeErr != nil && outcome.failedMessage != nil && isQuarantinableStatError(outcome.writeErr) && quarantiner != nil {
failed := *outcome.failedMessage
if err := quarantiner.Quarantine(ctx, failed, outcome.writeErr); err != nil {
logger.Error("stat message quarantine failed", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", err)
registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "error", "topic": failed.Topic})
} else {
registry.IncCounter("vehicle_stat_quarantine_total", metrics.Labels{"status": "ok", "topic": failed.Topic})
logger.Warn("quarantined permanent stat write failure", "topic", failed.Topic, "partition", failed.Partition, "offset", failed.Offset, "error", outcome.writeErr)
if retryStatCommit(ctx, logger, registry, committer, []kafka.Message{failed}, retryDelay) {
pending = messagesExceptStatMessage(pending, failed)
}
}
}
registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending))) registry.SetGauge("vehicle_stat_retry_pending_messages", workerLabels, float64(len(pending)))
if len(pending) == 0 { if len(pending) == 0 {
return return
@@ -308,6 +485,105 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
} }
} }
type quarantinedStatMessage struct {
QuarantinedAt string `json:"quarantined_at"`
Topic string `json:"topic"`
Partition int `json:"partition"`
Offset int64 `json:"offset"`
Key []byte `json:"key,omitempty"`
Value []byte `json:"value"`
Error string `json:"error"`
}
type fileStatMessageQuarantiner struct {
dir string
}
func (q fileStatMessageQuarantiner) Quarantine(ctx context.Context, message kafka.Message, cause error) error {
if err := ctx.Err(); err != nil {
return err
}
dir := strings.TrimSpace(q.dir)
if dir == "" {
return errors.New("stat quarantine directory is empty")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create quarantine directory: %w", err)
}
record := quarantinedStatMessage{
QuarantinedAt: time.Now().UTC().Format(time.RFC3339Nano),
Topic: message.Topic,
Partition: message.Partition,
Offset: message.Offset,
Key: message.Key,
Value: message.Value,
Error: cause.Error(),
}
payload, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("marshal quarantine record: %w", err)
}
name := fmt.Sprintf("%s-%d-%d.json", sanitizeStatQuarantineName(message.Topic), message.Partition, message.Offset)
temporary, err := os.CreateTemp(dir, "."+name+"-*")
if err != nil {
return fmt.Errorf("create quarantine record: %w", err)
}
temporaryName := temporary.Name()
removeTemporary := true
defer func() {
_ = temporary.Close()
if removeTemporary {
_ = os.Remove(temporaryName)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return fmt.Errorf("secure quarantine record: %w", err)
}
if _, err := temporary.Write(payload); err != nil {
return fmt.Errorf("write quarantine record: %w", err)
}
if err := temporary.Sync(); err != nil {
return fmt.Errorf("sync quarantine record: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close quarantine record: %w", err)
}
if err := os.Rename(temporaryName, filepath.Join(dir, name)); err != nil {
return fmt.Errorf("publish quarantine record: %w", err)
}
removeTemporary = false
return nil
}
func sanitizeStatQuarantineName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown-topic"
}
var out strings.Builder
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
out.WriteRune(r)
} else {
out.WriteByte('_')
}
}
return out.String()
}
func messagesExceptStatMessage(messages []kafka.Message, excluded kafka.Message) []kafka.Message {
out := make([]kafka.Message, 0, len(messages))
removed := false
for _, message := range messages {
if !removed && message.Topic == excluded.Topic && message.Partition == excluded.Partition && message.Offset == excluded.Offset {
removed = true
continue
}
out = append(out, message)
}
return out
}
func retryStatCommit(ctx context.Context, logger interface { func retryStatCommit(ctx context.Context, logger interface {
Error(string, ...any) Error(string, ...any)
Warn(string, ...any) Warn(string, ...any)
@@ -447,7 +723,7 @@ func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.Fr
var err error var err error
for attempt := 1; attempt <= attempts; attempt++ { for attempt := 1; attempt <= attempts; attempt++ {
result, err = appendStatEnvelope(ctx, a.delegate, env) result, err = appendStatEnvelope(ctx, a.delegate, env)
if err == nil || !isTransientMySQLStatError(err) { if err == nil || (!isTransientMySQLStatError(err) && !isQuarantinableStatError(err)) {
return result, err return result, err
} }
if attempt == attempts { if attempt == attempts {
@@ -512,6 +788,32 @@ func isTransientMySQLStatError(err error) bool {
strings.Contains(text, "no route to host") strings.Contains(text, "no route to host")
} }
func isQuarantinableStatError(err error) bool {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
// Only deterministic row-data failures may advance past a Kafka message
// after the configured finite retries. Schema, permission and unknown
// application errors remain failure-closed so a systemic outage cannot
// silently quarantine an entire stream.
return strings.Contains(text, "error 1048") ||
strings.Contains(text, "cannot be null") ||
strings.Contains(text, "error 1264") ||
strings.Contains(text, "out of range value") ||
strings.Contains(text, "error 1265") ||
strings.Contains(text, "data truncated") ||
strings.Contains(text, "error 1292") ||
strings.Contains(text, "incorrect datetime value") ||
strings.Contains(text, "error 1366") ||
strings.Contains(text, "incorrect decimal value") ||
strings.Contains(text, "incorrect integer value") ||
strings.Contains(text, "error 1406") ||
strings.Contains(text, "data too long for column") ||
strings.Contains(text, "error 3819") ||
strings.Contains(text, "check constraint")
}
func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) { func addStatMetric(registry *metrics.Registry, name string, message kafka.Message, status string) {
if registry == nil { if registry == nil {
return return
@@ -534,12 +836,31 @@ func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message,
addStatSampleMetric(registry, message, protocol, "skipped_missing_fields", result.SamplesSkippedMissingFields) addStatSampleMetric(registry, message, protocol, "skipped_missing_fields", result.SamplesSkippedMissingFields)
addStatSampleMetric(registry, message, protocol, "skipped_missing_vin", result.SamplesSkippedMissingVIN) addStatSampleMetric(registry, message, protocol, "skipped_missing_vin", result.SamplesSkippedMissingVIN)
addStatSampleMetric(registry, message, protocol, "skipped_missing_mileage", result.SamplesSkippedMissingMileage) addStatSampleMetric(registry, message, protocol, "skipped_missing_mileage", result.SamplesSkippedMissingMileage)
addStatSampleMetric(registry, message, protocol, "recovered_stationary", result.SamplesRecoveredStationary)
addStatSampleMetric(registry, message, protocol, "recovered_gps_coordinate", result.SamplesRecoveredGPSCoordinate)
addStatSampleMetric(registry, message, protocol, "skipped_non_mileage_frame", result.SamplesSkippedNonMileageFrame) addStatSampleMetric(registry, message, protocol, "skipped_non_mileage_frame", result.SamplesSkippedNonMileageFrame)
addStatSampleMetric(registry, message, protocol, "skipped_non_positive_mileage", result.SamplesSkippedNonPositiveMileage) addStatSampleMetric(registry, message, protocol, "skipped_non_positive_mileage", result.SamplesSkippedNonPositiveMileage)
addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime) addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime)
addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage) addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource) addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime) addStatSampleMetric(registry, message, protocol, "event_time_future_adjusted", result.SamplesAdjustedFutureEventTime)
addHydrogenStreamMetric(registry, message, "found", result.HydrogenSamplesFound)
addHydrogenStreamMetric(registry, message, "written", result.HydrogenSamplesWritten)
addHydrogenStreamMetric(registry, message, "duplicate_or_late", result.HydrogenSamplesDuplicate)
addHydrogenStreamMetric(registry, message, "invalid", result.HydrogenSamplesInvalid)
addHydrogenStreamMetric(registry, message, "not_current_date", result.HydrogenSamplesNotCurrent)
}
func addHydrogenStreamMetric(registry *metrics.Registry, message kafka.Message, status string, count int) {
if registry == nil || count == 0 {
return
}
registry.AddCounter("vehicle_stat_hydrogen_stream_total", metrics.Labels{
"topic": message.Topic, "status": status,
}, float64(count))
if status == "written" {
metrics.RecordLastActivity(registry, "vehicle_stat_last_hydrogen_stream_write_unix_seconds", metrics.Labels{"status": status})
}
} }
func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) { func addStatSampleMetric(registry *metrics.Registry, message kafka.Message, protocol envelope.Protocol, status string, count int) {
@@ -609,7 +930,9 @@ func recordStatCacheMetrics(registry *metrics.Registry, appender statAppender) {
setStatCacheGauge(registry, "last_total_mileage", stats.LastTotalMileageEntries, stats.MaxEntries, stats.LastCleanupTotalMileage, stats.TotalMileageEvictions) setStatCacheGauge(registry, "last_total_mileage", stats.LastTotalMileageEntries, stats.MaxEntries, stats.LastCleanupTotalMileage, stats.TotalMileageEvictions)
setStatCacheGauge(registry, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions) setStatCacheGauge(registry, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions)
setStatCacheGauge(registry, "projection", stats.LastProjectionEntries, stats.MaxEntries, stats.LastCleanupProjection, stats.ProjectionEvictions) setStatCacheGauge(registry, "projection", stats.LastProjectionEntries, stats.MaxEntries, stats.LastCleanupProjection, stats.ProjectionEvictions)
registry.SetGauge("vehicle_stat_pending_projection_entries", nil, float64(stats.PendingProjectionEntries))
setStatCacheGauge(registry, "baseline", stats.BaselineEntries, stats.MaxEntries, stats.LastCleanupBaseline, stats.BaselineEvictions) setStatCacheGauge(registry, "baseline", stats.BaselineEntries, stats.MaxEntries, stats.LastCleanupBaseline, stats.BaselineEvictions)
setStatCacheGauge(registry, "gps_accumulation", stats.GPSAccumulationEntries, stats.MaxEntries, stats.LastCleanupGPS, stats.GPSEvictions)
if !stats.LastCleanupAt.IsZero() { if !stats.LastCleanupAt.IsZero() {
registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix())) registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix()))
} }
@@ -686,6 +1009,7 @@ type config struct {
CacheRetention time.Duration CacheRetention time.Duration
CacheCleanupInterval time.Duration CacheCleanupInterval time.Duration
BaselineMissTTL time.Duration BaselineMissTTL time.Duration
BaselineHitTTL time.Duration
CacheMaxEntries int CacheMaxEntries int
Workers int Workers int
BatchSize int BatchSize int
@@ -694,6 +1018,13 @@ type config struct {
RetryDelay time.Duration RetryDelay time.Duration
NormalizePlatformSourcesOnStart bool NormalizePlatformSourcesOnStart bool
NormalizePlatformSourcesTimeout time.Duration NormalizePlatformSourcesTimeout time.Duration
ReconcileProjectionsOnStart bool
ReconcileProjectionsTimeout time.Duration
QuarantineDir string
HydrogenCapacitySyncEnabled bool
HydrogenCapacitySourceSchema string
HydrogenCapacitySyncInterval time.Duration
HydrogenCapacitySyncTimeout time.Duration
} }
func (c config) Validate() error { func (c config) Validate() error {
@@ -728,6 +1059,7 @@ func loadConfig() config {
CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour, CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second, CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * time.Second,
BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second, BaselineMissTTL: time.Duration(envInt("STATS_BASELINE_MISS_TTL_SECONDS", 60)) * time.Second,
BaselineHitTTL: time.Duration(envInt("STATS_BASELINE_HIT_TTL_SECONDS", 300)) * time.Second,
CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000), CacheMaxEntries: envInt("STATS_CACHE_MAX_ENTRIES", 1000000),
Workers: envInt("STATS_WORKERS", 3), Workers: envInt("STATS_WORKERS", 3),
BatchSize: envInt("STATS_BATCH_SIZE", 200), BatchSize: envInt("STATS_BATCH_SIZE", 200),
@@ -736,6 +1068,13 @@ func loadConfig() config {
RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond, RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false", NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second, NormalizePlatformSourcesTimeout: time.Duration(envInt("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", 30)) * time.Second,
ReconcileProjectionsOnStart: env("STATS_RECONCILE_PROJECTIONS_ON_START", "true") != "false",
ReconcileProjectionsTimeout: time.Duration(envInt("STATS_RECONCILE_PROJECTIONS_TIMEOUT_SECONDS", 30)) * time.Second,
QuarantineDir: env("STATS_QUARANTINE_DIR", "/var/lib/lingniu-go-native/stat-writer-quarantine"),
HydrogenCapacitySyncEnabled: env("HYDROGEN_CAPACITY_SYNC_ENABLED", "true") != "false",
HydrogenCapacitySourceSchema: env("HYDROGEN_CAPACITY_SOURCE_SCHEMA", "ln_asset_management"),
HydrogenCapacitySyncInterval: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_INTERVAL_SECONDS", 21600)) * time.Second,
HydrogenCapacitySyncTimeout: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_TIMEOUT_SECONDS", 60)) * time.Second,
} }
} }

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -103,20 +105,21 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
ProjectionsWritten: 1, ProjectionsWritten: 1,
ProjectionsSkippedThrottled: 2, ProjectionsSkippedThrottled: 2,
}, cacheStats: stats.CacheStats{ }, cacheStats: stats.CacheStats{
LastTotalMileageEntries: 300, LastTotalMileageEntries: 300,
LastSourceSeenEntries: 3, LastSourceSeenEntries: 3,
LastProjectionEntries: 280, LastProjectionEntries: 280,
BaselineEntries: 295, PendingProjectionEntries: 12,
MaxEntries: 1000000, BaselineEntries: 295,
LastCleanupAt: time.Unix(1782918600, 0), MaxEntries: 1000000,
LastCleanupTotalMileage: 11, LastCleanupAt: time.Unix(1782918600, 0),
LastCleanupSourceSeen: 1, LastCleanupTotalMileage: 11,
LastCleanupProjection: 9, LastCleanupSourceSeen: 1,
LastCleanupBaseline: 10, LastCleanupProjection: 9,
TotalMileageEvictions: 4, LastCleanupBaseline: 10,
SourceSeenEvictions: 1, TotalMileageEvictions: 4,
ProjectionEvictions: 2, SourceSeenEvictions: 1,
BaselineEvictions: 3, ProjectionEvictions: 2,
BaselineEvictions: 3,
}}, }},
&contextCheckingStatCommitter{}, &contextCheckingStatCommitter{},
kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 4, Offset: 30, HighWaterMark: 41, Value: payload}, kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 4, Offset: 30, HighWaterMark: 41, Value: payload},
@@ -148,6 +151,7 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
`vehicle_stat_cache_entries{cache="last_total_mileage"} 300`, `vehicle_stat_cache_entries{cache="last_total_mileage"} 300`,
`vehicle_stat_cache_entries{cache="source_seen"} 3`, `vehicle_stat_cache_entries{cache="source_seen"} 3`,
`vehicle_stat_cache_entries{cache="projection"} 280`, `vehicle_stat_cache_entries{cache="projection"} 280`,
`vehicle_stat_pending_projection_entries 12`,
`vehicle_stat_cache_entries{cache="baseline"} 295`, `vehicle_stat_cache_entries{cache="baseline"} 295`,
`vehicle_stat_cache_max_entries{cache="last_total_mileage"} 1000000`, `vehicle_stat_cache_max_entries{cache="last_total_mileage"} 1000000`,
`vehicle_stat_cache_max_entries{cache="source_seen"} 1000000`, `vehicle_stat_cache_max_entries{cache="source_seen"} 1000000`,
@@ -175,6 +179,52 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
} }
} }
func TestFlushPendingProjectionsRecordsEventualProjectionMetrics(t *testing.T) {
registry := metrics.NewRegistry()
flusher := &recordingPendingProjectionFlusher{
result: stats.ProjectionFlushResult{Attempted: 3, Written: 3},
}
readyBefore := time.Date(2026, 7, 19, 15, 4, 45, 0, time.Local)
flushPendingProjections(context.Background(), discardStatLogger{}, registry, flusher, readyBefore)
if flusher.calls != 1 || !flusher.readyBefore.Equal(readyBefore) {
t.Fatalf("flusher calls=%d readyBefore=%s, want one call at %s", flusher.calls, flusher.readyBefore, readyBefore)
}
text := registry.Render()
for _, want := range []string{
`vehicle_stat_pending_projections_total{status="attempted"} 3`,
`vehicle_stat_pending_projections_total{status="written"} 3`,
`vehicle_stat_last_pending_projection_unix_seconds{status="written"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("pending projection metrics missing %s:\n%s", want, text)
}
}
}
func TestFlushPendingProjectionsRetainsErrorMetricForRetry(t *testing.T) {
registry := metrics.NewRegistry()
flusher := &recordingPendingProjectionFlusher{
result: stats.ProjectionFlushResult{Attempted: 3, Written: 1, Failed: 2},
err: errors.New("temporary projection failure"),
}
flushPendingProjections(context.Background(), discardStatLogger{}, registry, flusher, time.Time{})
text := registry.Render()
for _, want := range []string{
`vehicle_stat_pending_projections_total{status="attempted"} 3`,
`vehicle_stat_pending_projections_total{status="written"} 1`,
`vehicle_stat_pending_projections_total{status="error"} 2`,
`vehicle_stat_last_pending_projection_unix_seconds{status="error"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("pending projection error metrics missing %s:\n%s", want, text)
}
}
}
func TestRecordStatWriteE2EDurationSkipsMissingReceivedAt(t *testing.T) { func TestRecordStatWriteE2EDurationSkipsMissingReceivedAt(t *testing.T) {
registry := metrics.NewRegistry() registry := metrics.NewRegistry()
@@ -664,6 +714,72 @@ func TestProcessStatBatchReliablyRetriesFailedSuffix(t *testing.T) {
} }
} }
func TestProcessStatBatchReliablyQuarantinesPermanentFailureAndContinues(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
payload := marshalStatTestFieldsEnvelope(t, env)
appender := &contextCheckingStatAppender{failOnCount: 1, err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")}
committer := &contextCheckingStatCommitter{}
quarantiner := &recordingStatQuarantiner{}
registry := metrics.NewRegistry()
messages := []kafka.Message{
{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 10, Value: payload},
{Topic: "vehicle.fields.go.jt808.v1", Partition: 1, Offset: 11, Value: payload},
}
processStatBatchReliablyForWorker(
context.Background(),
discardStatLogger{},
registry,
appender,
committer,
quarantiner,
messages,
time.Nanosecond,
nil,
)
if appender.count != 2 {
t.Fatalf("append attempts = %d, want failed message once and following message once", appender.count)
}
if quarantiner.count != 1 || quarantiner.message.Offset != 10 {
t.Fatalf("quarantine = %d/%d, want one message at offset 10", quarantiner.count, quarantiner.message.Offset)
}
if got := committedOffsets(committer.messages); len(got) != 2 || got[0] != 10 || got[1] != 11 {
t.Fatalf("committed offsets = %#v, want [10 11]", got)
}
text := registry.Render()
if !strings.Contains(text, `vehicle_stat_quarantine_total{status="ok",topic="vehicle.fields.go.jt808.v1"} 1`) {
t.Fatalf("quarantine success metric missing:\n%s", text)
}
}
func TestFileStatMessageQuarantinerPersistsOriginalKafkaMessage(t *testing.T) {
dir := t.TempDir()
message := kafka.Message{
Topic: "vehicle.fields.go.jt808.v1",
Partition: 2,
Offset: 5094052,
Key: []byte("vehicle-key"),
Value: []byte(`{"event_id":"poison:fields"}`),
}
wantErr := errors.New("permanent mysql error")
if err := (fileStatMessageQuarantiner{dir: dir}).Quarantine(context.Background(), message, wantErr); err != nil {
t.Fatalf("Quarantine() error = %v", err)
}
payload, err := os.ReadFile(filepath.Join(dir, "vehicle.fields.go.jt808.v1-2-5094052.json"))
if err != nil {
t.Fatalf("read quarantine record: %v", err)
}
var record quarantinedStatMessage
if err := json.Unmarshal(payload, &record); err != nil {
t.Fatalf("unmarshal quarantine record: %v", err)
}
if record.Topic != message.Topic || record.Partition != message.Partition || record.Offset != message.Offset || string(record.Key) != string(message.Key) || string(record.Value) != string(message.Value) || record.Error != wantErr.Error() {
t.Fatalf("quarantine record = %#v, want original message and error", record)
}
}
func TestProcessStatBatchReliablyRetriesCommitWithoutReappending(t *testing.T) { func TestProcessStatBatchReliablyRetriesCommitWithoutReappending(t *testing.T) {
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"} env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
payload := marshalStatTestFieldsEnvelope(t, env) payload := marshalStatTestFieldsEnvelope(t, env)
@@ -826,6 +942,30 @@ func TestRetryStatAppenderRecordsExhaustedTransientError(t *testing.T) {
} }
} }
func TestRetryStatAppenderRetriesQuarantinableDataErrorBeforeIsolation(t *testing.T) {
registry := metrics.NewRegistry()
delegate := &contextCheckingStatAppender{err: errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null")}
appender := retryStatAppender{delegate: delegate, attempts: 3, registry: registry}
_, err := appender.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808})
if err == nil {
t.Fatal("AppendWithResult() error = nil, want exhausted data error")
}
if delegate.count != 3 {
t.Fatalf("append attempts = %d, want configured finite retries", delegate.count)
}
text := registry.Render()
for _, want := range []string{
`vehicle_stat_write_retries_total{operation="single",status="retry"} 2`,
`vehicle_stat_write_retries_total{operation="single",status="exhausted"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("retry metric missing %s:\n%s", want, text)
}
}
}
func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) { func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) {
wantErr := errors.New("validation failed") wantErr := errors.New("validation failed")
delegate := &contextCheckingStatAppender{err: wantErr} delegate := &contextCheckingStatAppender{err: wantErr}
@@ -841,6 +981,34 @@ func TestRetryStatAppenderDoesNotRetryNonTransientError(t *testing.T) {
} }
} }
func TestIsQuarantinableStatErrorOnlyAcceptsDeterministicRowDataFailures(t *testing.T) {
for _, err := range []error{
errors.New("Error 1048 (23000): Column 'daily_mileage_km' cannot be null"),
errors.New("Error 1264: Out of range value for column 'daily_mileage_km'"),
errors.New("Error 1265: Data truncated for column 'daily_mileage_km'"),
errors.New("Error 1292: Incorrect datetime value"),
errors.New("Error 1366: Incorrect decimal value"),
errors.New("Error 1406: Data too long for column"),
errors.New("Error 3819: Check constraint is violated"),
} {
if !isQuarantinableStatError(err) {
t.Fatalf("isQuarantinableStatError(%q) = false, want true", err.Error())
}
}
for _, err := range []error{
errors.New("Error 1054: Unknown column 'daily_mileage_km'"),
errors.New("Error 1142: INSERT command denied"),
errors.New("validation failed"),
context.Canceled,
context.DeadlineExceeded,
nil,
} {
if isQuarantinableStatError(err) {
t.Fatalf("isQuarantinableStatError(%v) = true, want false", err)
}
}
}
func TestIsTransientMySQLStatError(t *testing.T) { func TestIsTransientMySQLStatError(t *testing.T) {
for _, err := range []error{ for _, err := range []error{
errors.New("dial tcp 127.0.0.1:3306: connection refused"), errors.New("dial tcp 127.0.0.1:3306: connection refused"),
@@ -903,6 +1071,9 @@ func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) {
if cfg.BaselineMissTTL != time.Minute { if cfg.BaselineMissTTL != time.Minute {
t.Fatalf("BaselineMissTTL = %s, want 1m", cfg.BaselineMissTTL) t.Fatalf("BaselineMissTTL = %s, want 1m", cfg.BaselineMissTTL)
} }
if cfg.BaselineHitTTL != 5*time.Minute {
t.Fatalf("BaselineHitTTL = %s, want 5m", cfg.BaselineHitTTL)
}
if cfg.RetryAttempts != 3 { if cfg.RetryAttempts != 3 {
t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts) t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts)
} }
@@ -915,6 +1086,12 @@ func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) {
if cfg.NormalizePlatformSourcesTimeout != 30*time.Second { if cfg.NormalizePlatformSourcesTimeout != 30*time.Second {
t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 30s", cfg.NormalizePlatformSourcesTimeout) t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 30s", cfg.NormalizePlatformSourcesTimeout)
} }
if !cfg.ReconcileProjectionsOnStart {
t.Fatal("ReconcileProjectionsOnStart = false, want default true")
}
if cfg.ReconcileProjectionsTimeout != 30*time.Second {
t.Fatalf("ReconcileProjectionsTimeout = %s, want 30s", cfg.ReconcileProjectionsTimeout)
}
} }
func TestConfigValidateRejectsRawTopics(t *testing.T) { func TestConfigValidateRejectsRawTopics(t *testing.T) {
@@ -965,6 +1142,7 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
t.Setenv("STATS_CACHE_RETENTION_HOURS", "48") t.Setenv("STATS_CACHE_RETENTION_HOURS", "48")
t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300") t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300")
t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45") t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45")
t.Setenv("STATS_BASELINE_HIT_TTL_SECONDS", "180")
t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345") t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345")
cfg := loadConfig() cfg := loadConfig()
@@ -984,6 +1162,9 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
if cfg.BaselineMissTTL != 45*time.Second { if cfg.BaselineMissTTL != 45*time.Second {
t.Fatalf("BaselineMissTTL = %s, want 45s", cfg.BaselineMissTTL) t.Fatalf("BaselineMissTTL = %s, want 45s", cfg.BaselineMissTTL)
} }
if cfg.BaselineHitTTL != 3*time.Minute {
t.Fatalf("BaselineHitTTL = %s, want 3m", cfg.BaselineHitTTL)
}
if cfg.CacheMaxEntries != 12345 { if cfg.CacheMaxEntries != 12345 {
t.Fatalf("CacheMaxEntries = %d, want 12345", cfg.CacheMaxEntries) t.Fatalf("CacheMaxEntries = %d, want 12345", cfg.CacheMaxEntries)
} }
@@ -997,6 +1178,9 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) {
t.Setenv("STATS_RETRY_DELAY_MS", "33") t.Setenv("STATS_RETRY_DELAY_MS", "33")
t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "false") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "false")
t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", "17") t.Setenv("STATS_NORMALIZE_PLATFORM_SOURCES_TIMEOUT_SECONDS", "17")
t.Setenv("STATS_RECONCILE_PROJECTIONS_ON_START", "false")
t.Setenv("STATS_RECONCILE_PROJECTIONS_TIMEOUT_SECONDS", "19")
t.Setenv("STATS_QUARANTINE_DIR", "/tmp/stat-writer-quarantine-test")
cfg := loadConfig() cfg := loadConfig()
@@ -1021,6 +1205,15 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) {
if cfg.NormalizePlatformSourcesTimeout != 17*time.Second { if cfg.NormalizePlatformSourcesTimeout != 17*time.Second {
t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 17s", cfg.NormalizePlatformSourcesTimeout) t.Fatalf("NormalizePlatformSourcesTimeout = %s, want 17s", cfg.NormalizePlatformSourcesTimeout)
} }
if cfg.ReconcileProjectionsOnStart {
t.Fatal("ReconcileProjectionsOnStart = true, want env override false")
}
if cfg.ReconcileProjectionsTimeout != 19*time.Second {
t.Fatalf("ReconcileProjectionsTimeout = %s, want 19s", cfg.ReconcileProjectionsTimeout)
}
if cfg.QuarantineDir != "/tmp/stat-writer-quarantine-test" {
t.Fatalf("QuarantineDir = %q, want env override", cfg.QuarantineDir)
}
} }
type contextCheckingStatAppender struct { type contextCheckingStatAppender struct {
@@ -1070,6 +1263,18 @@ type contextCheckingStatCommitter struct {
failOnCount int failOnCount int
} }
type recordingStatQuarantiner struct {
count int
message kafka.Message
err error
}
func (q *recordingStatQuarantiner) Quarantine(_ context.Context, message kafka.Message, _ error) error {
q.count++
q.message = message
return q.err
}
func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error { func (c *contextCheckingStatCommitter) CommitMessages(ctx context.Context, messages ...kafka.Message) error {
c.ctxErr = ctx.Err() c.ctxErr = ctx.Err()
c.count++ c.count++
@@ -1089,6 +1294,19 @@ type discardStatLogger struct{}
func (discardStatLogger) Error(string, ...any) {} func (discardStatLogger) Error(string, ...any) {}
func (discardStatLogger) Warn(string, ...any) {} func (discardStatLogger) Warn(string, ...any) {}
type recordingPendingProjectionFlusher struct {
result stats.ProjectionFlushResult
err error
calls int
readyBefore time.Time
}
func (f *recordingPendingProjectionFlusher) FlushPendingProjections(_ context.Context, readyBefore time.Time) (stats.ProjectionFlushResult, error) {
f.calls++
f.readyBefore = readyBefore
return f.result, f.err
}
var errTestStatAppend = errors.New("test stat append failed") var errTestStatAppend = errors.New("test stat append failed")
func committedOffsets(messages []kafka.Message) []int64 { func committedOffsets(messages []kafka.Message) []int64 {

View File

@@ -35,7 +35,10 @@ type config struct {
Reset bool Reset bool
Debug bool Debug bool
EventTimeFullScan bool EventTimeFullScan bool
StationaryCarry bool
GPSFallback bool
ProgressEvery int64 ProgressEvery int64
BaselineLookback int
Location *time.Location Location *time.Location
} }
@@ -50,26 +53,31 @@ type rawFrameRow struct {
EventTimeMS int64 EventTimeMS int64
ReceivedAtMS int64 ReceivedAtMS int64
ParsedJSON string ParsedJSON string
RawText string
} }
type metricAgg struct { type metricAgg struct {
VIN string VIN string
Date string Date string
Protocol envelope.Protocol Protocol envelope.Protocol
FirstKM float64 FirstKM float64
LatestKM float64 LatestKM float64
Count int64 PureHydrogenMileageKM float64
SourceKey string Count int64
Phone string PureHydrogenSampleCount int64
DeviceID string SourceKey string
SourceEndpoint string Phone string
SourceCode string DeviceID string
PlatformName string SourceEndpoint string
SourceKind string SourceCode string
FirstEventTime time.Time PlatformName string
LatestEventTime time.Time SourceKind string
QualityStatus string FirstEventTime time.Time
QualityReason string LatestEventTime time.Time
LatestPureHydrogenActive bool
LatestPureHydrogenModeKnown bool
QualityStatus string
QualityReason string
} }
type dailySourceLast struct { type dailySourceLast struct {
@@ -147,6 +155,14 @@ func main() {
if err != nil { if err != nil {
fail("build realtime-location fallback aggregates", err) fail("build realtime-location fallback aggregates", err)
} }
gpsFallbacks, err := addGPSCoordinateFallbackAggregates(ctx, mysqlDB, td, cfg, aggregates)
if err != nil {
fail("build GPS-coordinate fallback aggregates", err)
}
subthresholdFound, subthresholdDeleted, err := cleanupSubthresholdGPSMileageCandidates(ctx, mysqlDB, cfg, !cfg.DryRun)
if err != nil {
fail("cleanup sub-threshold GPS mileage candidates", err)
}
var written int64 var written int64
var normalized int var normalized int
if !cfg.DryRun { if !cfg.DryRun {
@@ -159,7 +175,7 @@ func main() {
fail("normalize historical platform sources", err) fail("normalize historical platform sources", err)
} }
} }
slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "realtimeLocationFallbacks", fallbacks, "written", written, "platformSourcesNormalized", normalized) slog.Info("stats backfill complete", "method", cfg.Method, "dry_run", cfg.DryRun, "aggregates", len(aggregates), "realtimeLocationFallbacks", fallbacks, "jt808GPSFallbacks", gpsFallbacks, "subthresholdGPSCandidatesFound", subthresholdFound, "subthresholdGPSCandidatesDeleted", subthresholdDeleted, "written", written, "platformSourcesNormalized", normalized)
return return
} }
@@ -188,6 +204,9 @@ func main() {
} }
} }
fields := fieldsForStats(row.Protocol, row.VIN, text) fields := fieldsForStats(row.Protocol, row.VIN, text)
if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT {
fields = fieldsForStats(row.Protocol, row.VIN, row.RawText)
}
if cfg.Debug && scanned <= 5 { if cfg.Debug && scanned <= 5 {
slog.Info("debug raw frame", slog.Info("debug raw frame",
"protocol", row.Protocol, "protocol", row.Protocol,
@@ -251,21 +270,24 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
agg, ok := aggregates[key] agg, ok := aggregates[key]
if !ok { if !ok {
aggregates[key] = &metricAgg{ aggregates[key] = &metricAgg{
VIN: sample.VIN, VIN: sample.VIN,
Date: sample.StatDate, Date: sample.StatDate,
Protocol: sample.Protocol, Protocol: sample.Protocol,
FirstKM: sample.TotalMileageKM, FirstKM: sample.TotalMileageKM,
LatestKM: sample.TotalMileageKM, LatestKM: sample.TotalMileageKM,
Count: 1, Count: 1,
SourceKey: sample.SourceKey, PureHydrogenSampleCount: activeModeSampleCount(sample),
Phone: sample.Phone, SourceKey: sample.SourceKey,
DeviceID: sample.DeviceID, Phone: sample.Phone,
SourceEndpoint: sample.SourceEndpoint, DeviceID: sample.DeviceID,
PlatformName: sample.PlatformName, SourceEndpoint: sample.SourceEndpoint,
FirstEventTime: sample.EventTime, PlatformName: sample.PlatformName,
LatestEventTime: sample.EventTime, FirstEventTime: sample.EventTime,
QualityStatus: stats.QualityOK, LatestEventTime: sample.EventTime,
QualityReason: "current_day_first_sample", LatestPureHydrogenActive: sample.PureHydrogenActive,
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
QualityStatus: stats.QualityOK,
QualityReason: "current_day_first_sample",
} }
continue continue
} }
@@ -274,8 +296,18 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
agg.FirstEventTime = sample.EventTime agg.FirstEventTime = sample.EventTime
} }
if sample.EventTime.After(agg.LatestEventTime) { if sample.EventTime.After(agg.LatestEventTime) {
agg.PureHydrogenMileageKM += stats.PureHydrogenMileageDelta(
agg.LatestKM,
sample.TotalMileageKM,
agg.LatestPureHydrogenActive,
agg.LatestPureHydrogenModeKnown,
sample.PureHydrogenActive,
sample.PureHydrogenModeKnown,
)
agg.LatestKM = sample.TotalMileageKM agg.LatestKM = sample.TotalMileageKM
agg.LatestEventTime = sample.EventTime agg.LatestEventTime = sample.EventTime
agg.LatestPureHydrogenActive = sample.PureHydrogenActive
agg.LatestPureHydrogenModeKnown = sample.PureHydrogenModeKnown
agg.SourceEndpoint = sample.SourceEndpoint agg.SourceEndpoint = sample.SourceEndpoint
if strings.TrimSpace(sample.Phone) != "" { if strings.TrimSpace(sample.Phone) != "" {
agg.Phone = sample.Phone agg.Phone = sample.Phone
@@ -288,9 +320,17 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
} }
} }
agg.Count++ agg.Count++
agg.PureHydrogenSampleCount += activeModeSampleCount(sample)
} }
} }
func activeModeSampleCount(sample stats.MetricSample) int64 {
if sample.PureHydrogenModeKnown && sample.PureHydrogenActive {
return 1
}
return 0
}
func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) { func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) {
if len(aggregates) == 0 { if len(aggregates) == 0 {
return 0, nil return 0, nil
@@ -316,28 +356,34 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
} }
clearedTargets[target] = struct{}{} clearedTargets[target] = struct{}{}
} }
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil { if agg.QualityReason != stats.QualityReasonGPSCoordinate {
return written, err if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
return written, err
}
} }
dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM) dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM)
candidate := stats.SourceMileageSample{ candidate := stats.SourceMileageSample{
VIN: agg.VIN, VIN: agg.VIN,
StatDate: agg.Date, StatDate: agg.Date,
Protocol: agg.Protocol, Protocol: agg.Protocol,
SourceKey: agg.SourceKey, SourceKey: agg.SourceKey,
SourceIP: identity.SourceIP, SourceIP: identity.SourceIP,
SourceEndpoint: agg.SourceEndpoint, SourceEndpoint: agg.SourceEndpoint,
Phone: agg.Phone, Phone: agg.Phone,
DeviceID: agg.DeviceID, DeviceID: agg.DeviceID,
PlatformName: agg.PlatformName, PlatformName: agg.PlatformName,
FirstTotalKM: agg.FirstKM, FirstTotalKM: agg.FirstKM,
LatestTotalKM: agg.LatestKM, LatestTotalKM: agg.LatestKM,
DailyKM: dailyKM, DailyKM: dailyKM,
SampleCount: agg.Count, PureHydrogenMileageKM: agg.PureHydrogenMileageKM,
FirstEventTime: agg.FirstEventTime, SampleCount: agg.Count,
LatestEventTime: agg.LatestEventTime, PureHydrogenSampleCount: agg.PureHydrogenSampleCount,
QualityStatus: agg.QualityStatus, FirstEventTime: agg.FirstEventTime,
QualityReason: agg.QualityReason, LatestEventTime: agg.LatestEventTime,
LatestPureHydrogenActive: agg.LatestPureHydrogenActive,
LatestPureHydrogenModeKnown: agg.LatestPureHydrogenModeKnown,
QualityStatus: agg.QualityStatus,
QualityReason: agg.QualityReason,
} }
if candidate.QualityStatus == "" { if candidate.QualityStatus == "" {
candidate.QualityStatus = stats.QualityOK candidate.QualityStatus = stats.QualityOK
@@ -427,7 +473,7 @@ func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB,
return aggregates, nil return aggregates, nil
} }
func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) { func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, _ *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
if aggregates == nil { if aggregates == nil {
return 0, fmt.Errorf("aggregates map is nil") return 0, fmt.Errorf("aggregates map is nil")
} }
@@ -441,11 +487,6 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
continue continue
} }
latestHistory := map[sourceHistoryID]dailySourceLast{} latestHistory := map[sourceHistoryID]dailySourceLast{}
preWindow, err := queryPreviousLastSourceRows(ctx, tdDB, cfg, protocol, targetDates[0])
if err != nil {
return added, err
}
rememberLatestSourceRows(latestHistory, preWindow)
aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol) aggregateRowsByDate := indexAggregateSourceRowsByDate(aggregates, protocol)
for _, date := range targetDates { for _, date := range targetDates {
current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date) current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date)
@@ -453,7 +494,8 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
return added, err return added, err
} }
for vin, currentSources := range current { for vin, currentSources := range current {
for _, currentRow := range currentSources { for i := range currentSources {
currentRow := currentSources[i]
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
if _, exists := aggregates[key]; exists { if _, exists := aggregates[key]; exists {
continue continue
@@ -462,8 +504,22 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
if err != nil { if err != nil {
return added, err return added, err
} }
if cfg.StationaryCarry && !hasPrevious {
continue
}
if cfg.StationaryCarry {
// A sparse Yutong location frame can retain an older
// realtime odometer than the durable daily baseline.
// Stationary carry-forward represents an explicit zero
// day, so both ends must use the latest trusted baseline.
currentRow.FirstTotalKM = previousRow.TotalKM
currentRow.TotalKM = previousRow.TotalKM
currentSources[i] = currentRow
}
agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious) agg := aggregateFromDailySource(date, protocol, currentRow, previousRow, hasPrevious)
if hasPrevious { if cfg.StationaryCarry {
agg.QualityReason = stats.QualityReasonStationaryCarry
} else if hasPrevious {
agg.QualityReason = "realtime_location_fallback_historical_baseline" agg.QualityReason = "realtime_location_fallback_historical_baseline"
} else { } else {
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline" agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
@@ -482,6 +538,307 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
return added, nil return added, nil
} }
type gpsCoordinateBackfillState struct {
previous stats.GPSMileagePoint
firstEventTime time.Time
latestEventTime time.Time
distanceKM float64
pointCount int64
usableSegments int64
badJumps int64
longGaps int64
}
type subthresholdGPSMileageTarget struct {
vin string
statDate string
protocol envelope.Protocol
rowCount int64
}
func cleanupSubthresholdGPSMileageCandidates(ctx context.Context, db *sql.DB, cfg config, apply bool) (int64, int64, error) {
if db == nil || len(cfg.Protocols) == 0 {
return 0, 0, nil
}
placeholders := make([]string, 0, len(cfg.Protocols))
args := []any{
cfg.DateFrom,
cfg.DateTo,
stats.QualityReasonGPSCoordinate,
stats.GPSMinimumDailyDistanceKM,
}
for _, protocol := range cfg.Protocols {
placeholders = append(placeholders, "?")
args = append(args, string(protocol))
}
query := `SELECT vin, DATE_FORMAT(stat_date, '%Y-%m-%d'), protocol, COUNT(*)
FROM vehicle_daily_mileage_source
WHERE stat_date >= ? AND stat_date <= ?
AND quality_reason = ?
AND daily_mileage_km >= 0 AND daily_mileage_km < ?
AND protocol IN (` + strings.Join(placeholders, ",") + `)
GROUP BY vin, stat_date, protocol
ORDER BY stat_date, protocol, vin`
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return 0, 0, err
}
targets := make([]subthresholdGPSMileageTarget, 0)
var found int64
for rows.Next() {
var target subthresholdGPSMileageTarget
var protocol string
if err := rows.Scan(&target.vin, &target.statDate, &protocol, &target.rowCount); err != nil {
_ = rows.Close()
return found, 0, err
}
target.protocol = envelope.Protocol(strings.TrimSpace(protocol))
targets = append(targets, target)
found += target.rowCount
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return found, 0, err
}
if err := rows.Close(); err != nil {
return found, 0, err
}
if !apply {
return found, 0, nil
}
var deleted int64
for _, target := range targets {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return found, deleted, err
}
result, err := tx.ExecContext(ctx, `DELETE FROM vehicle_daily_mileage_source
WHERE vin = ? AND stat_date = ? AND protocol = ?
AND quality_reason = ?
AND daily_mileage_km >= 0 AND daily_mileage_km < ?`,
target.vin,
target.statDate,
string(target.protocol),
stats.QualityReasonGPSCoordinate,
stats.GPSMinimumDailyDistanceKM,
)
if err == nil {
err = stats.ProjectDailyMileage(ctx, tx, target.vin, target.statDate, target.protocol)
}
if err != nil {
_ = tx.Rollback()
return found, deleted, err
}
affected, err := result.RowsAffected()
if err != nil {
_ = tx.Rollback()
return found, deleted, err
}
if err := tx.Commit(); err != nil {
return found, deleted, err
}
deleted += affected
}
return found, deleted, nil
}
func addGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg) (int, error) {
if !cfg.GPSFallback || tdDB == nil || aggregates == nil {
return 0, nil
}
totalAdded := 0
for _, protocol := range cfg.Protocols {
added, err := addProtocolGPSCoordinateFallbackAggregates(ctx, mysqlDB, tdDB, cfg, aggregates, protocol)
if err != nil {
return totalAdded, err
}
totalAdded += added
}
return totalAdded, nil
}
func addProtocolGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB, cfg config, aggregates map[string]*metricAgg, protocol envelope.Protocol) (int, error) {
if !supportsBackfillGPSFallback(protocol) {
return 0, nil
}
existing, err := existingPositiveOdometerTargets(ctx, mysqlDB, cfg, protocol)
if err != nil {
return 0, err
}
for _, agg := range aggregates {
if agg != nil && agg.Protocol == protocol && agg.QualityReason != stats.QualityReasonGPSCoordinate && agg.LatestKM > agg.FirstKM {
existing[agg.VIN+"|"+agg.Date] = struct{}{}
}
}
where := []string{
fmt.Sprintf("ts >= '%s 00:00:00'", quote(cfg.DateFrom)),
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(cfg.DateTo))),
fmt.Sprintf("protocol = '%s'", quote(string(protocol))),
"vin IS NOT NULL",
"vin <> ''",
"longitude BETWEEN -180 AND 180",
"latitude BETWEEN -90 AND 90",
"NOT (longitude = 0 AND latitude = 0)",
}
if protocol != envelope.ProtocolJT808 {
where = append(where, "speed_kmh > 0")
}
sqlText := fmt.Sprintf(`SELECT vin, ts, longitude, latitude
FROM %s.vehicle_locations
WHERE %s
ORDER BY ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
rows, err := tdDB.QueryContext(ctx, sqlText)
if err != nil {
return 0, err
}
defer rows.Close()
states := map[string]*gpsCoordinateBackfillState{}
for rows.Next() {
var vin string
var eventTime time.Time
var longitude, latitude float64
if err := rows.Scan(&vin, &eventTime, &longitude, &latitude); err != nil {
return 0, err
}
vin = strings.TrimSpace(vin)
eventTime = eventTime.In(cfg.Location)
date := eventTime.Format("2006-01-02")
target := vin + "|" + date
if _, ok := existing[target]; ok {
continue
}
current := stats.GPSMileagePoint{
VIN: vin,
StatDate: date,
Protocol: protocol,
EventTime: eventTime,
Longitude: longitude,
Latitude: latitude,
}
state := states[target]
if state == nil {
states[target] = &gpsCoordinateBackfillState{
previous: current,
firstEventTime: eventTime,
latestEventTime: eventTime,
pointCount: 1,
}
continue
}
segment := stats.CalculateGPSMileageSegment(state.previous, current)
if segment.OutOfOrder {
continue
}
state.previous = current
state.latestEventTime = eventTime
state.pointCount++
if segment.Usable {
state.distanceKM += segment.DistanceKM
state.usableSegments++
}
if segment.BadJump {
state.badJumps++
}
if segment.LongGap {
state.longGaps++
}
}
if err := rows.Err(); err != nil {
return 0, err
}
added := 0
for target, state := range states {
if !stats.GPSMileageCandidateEligible(state.distanceKM, state.usableSegments) {
continue
}
parts := strings.SplitN(target, "|", 2)
if len(parts) != 2 {
continue
}
vin, date := parts[0], parts[1]
sourceKey := string(protocol) + ":" + vin + "@GPS_COORDINATE"
key := vin + "|" + date + "|" + string(protocol) + "|" + sourceKey
aggregates[key] = &metricAgg{
VIN: vin,
Date: date,
Protocol: protocol,
FirstKM: 0,
LatestKM: state.distanceKM,
Count: state.pointCount,
SourceKey: sourceKey,
SourceEndpoint: "gps-coordinate",
PlatformName: backfillGPSPlatformName(protocol),
SourceKind: "UNKNOWN",
FirstEventTime: state.firstEventTime,
LatestEventTime: state.latestEventTime,
QualityStatus: stats.QualityOK,
QualityReason: stats.QualityReasonGPSCoordinate,
}
added++
}
if len(states) > 0 {
slog.Info("GPS-coordinate fallbacks loaded", "protocol", protocol, "states", len(states), "added", added)
}
return added, nil
}
func supportsBackfillGPSFallback(protocol envelope.Protocol) bool {
switch protocol {
case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT:
return true
default:
return false
}
}
func backfillGPSPlatformName(protocol envelope.Protocol) string {
switch protocol {
case envelope.ProtocolGB32960:
return "GB32960 GPS轨迹估算"
case envelope.ProtocolYutongMQTT:
return "宇通 GPS轨迹估算"
default:
return "JT808 GPS轨迹估算"
}
}
func existingPositiveOdometerTargets(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol) (map[string]struct{}, error) {
result := map[string]struct{}{}
if db == nil {
return result, nil
}
rows, err := db.QueryContext(ctx, `SELECT DISTINCT vin, DATE_FORMAT(stat_date, '%Y-%m-%d')
FROM vehicle_daily_mileage_source
WHERE protocol = ?
AND stat_date >= ? AND stat_date <= ?
AND quality_status = ?
AND COALESCE(quality_reason, '') <> ?
AND daily_mileage_km > 0
AND latest_total_mileage_km IS NOT NULL`, string(protocol), cfg.DateFrom, cfg.DateTo, stats.QualityOK, stats.QualityReasonGPSCoordinate)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var vin, date string
if err := rows.Scan(&vin, &date); err != nil {
return nil, err
}
result[strings.TrimSpace(vin)+"|"+strings.TrimSpace(date)] = struct{}{}
}
return result, rows.Err()
}
func containsProtocol(protocols []envelope.Protocol, target envelope.Protocol) bool {
for _, protocol := range protocols {
if protocol == target {
return true
}
}
return false
}
func indexAggregateSourceRowsByDate(aggregates map[string]*metricAgg, protocol envelope.Protocol) map[string]map[string][]dailySourceLast { func indexAggregateSourceRowsByDate(aggregates map[string]*metricAgg, protocol envelope.Protocol) map[string]map[string][]dailySourceLast {
indexed := map[string]map[string][]dailySourceLast{} indexed := map[string]map[string][]dailySourceLast{}
for _, agg := range aggregates { for _, agg := range aggregates {
@@ -656,7 +1013,9 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto
if predicate := mileageBearingFramePredicate(protocol); predicate != "" { if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
where = append(where, predicate) where = append(where, predicate)
} }
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), LAST(event_time), LAST(parsed_json), COUNT(*) sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
FIRST(event_time), FIRST(parsed_json), FIRST(raw_text),
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
FROM %s.raw_frames FROM %s.raw_frames
WHERE %s WHERE %s
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
@@ -668,7 +1027,7 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
// LAST aggregates the complete pre-window history once per source so empty // LAST aggregates the complete pre-window history once per source so empty
// calendar days do not make a backfill fall back to the current day's first // calendar days do not make a backfill fall back to the current day's first
// sample. // sample.
where := backfillBeforePredicates(date) where := backfillBeforePredicates(cfg, date)
where = append(where, where = append(where,
"parse_status = 'OK'", "parse_status = 'OK'",
"vin IS NOT NULL", "vin IS NOT NULL",
@@ -679,17 +1038,31 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
if predicate := mileageBearingFramePredicate(protocol); predicate != "" { if predicate := mileageBearingFramePredicate(protocol); predicate != "" {
where = append(where, predicate) where = append(where, predicate)
} }
sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, LAST(event_time), LAST(parsed_json), COUNT(*) sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint,
LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*)
FROM %s.raw_frames FROM %s.raw_frames
WHERE %s WHERE %s
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
return querySourceRows(ctx, db, cfg, protocol, sqlText, false) return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
} }
func backfillBeforePredicates(eventDateExclusive string) []string { func backfillBeforePredicates(cfg config, eventDateExclusive string) []string {
return []string{ lookbackDays := cfg.BaselineLookback
if lookbackDays <= 0 {
lookbackDays = 7
}
eventDateFrom := shiftDate(eventDateExclusive, -lookbackDays)
where := []string{
fmt.Sprintf("event_time >= '%s 00:00:00'", quote(eventDateFrom)),
fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)), fmt.Sprintf("event_time < '%s 00:00:00'", quote(eventDateExclusive)),
} }
if !cfg.EventTimeFullScan {
where = append([]string{
fmt.Sprintf("ts >= '%s 00:00:00'", quote(previousDate(eventDateFrom))),
fmt.Sprintf("ts < '%s 00:00:00'", quote(nextDate(eventDateExclusive))),
}, where...)
}
return where
} }
func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) {
@@ -700,15 +1073,28 @@ func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config,
if loc == nil { if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600) loc = time.FixedZone("Asia/Shanghai", 8*3600)
} }
sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), l.total_mileage_event_time, l.total_mileage_km eventTimeColumn := "l.total_mileage_event_time"
timePredicate := `l.total_mileage_event_time >= ?
AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)`
if cfg.StationaryCarry {
eventTimeColumn = "l.event_time"
timePredicate = `l.event_time >= ?
AND l.event_time < DATE_ADD(?, INTERVAL 1 DAY)
AND l.total_mileage_event_time < ?
AND COALESCE(l.speed_kmh, 0) = 0`
}
sqlText := `SELECT l.vin, COALESCE(NULLIF(s.peer, ''), ''), ` + eventTimeColumn + `, l.total_mileage_km
FROM vehicle_realtime_location l FROM vehicle_realtime_location l
LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin
WHERE l.protocol = ? WHERE l.protocol = ?
AND l.vin IS NOT NULL AND l.vin <> '' AND l.vin IS NOT NULL AND l.vin <> ''
AND l.total_mileage_km IS NOT NULL AND l.total_mileage_km > 0 AND l.total_mileage_km IS NOT NULL AND l.total_mileage_km > 0
AND l.total_mileage_event_time >= ? AND ` + timePredicate
AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)` args := []any{string(protocol), date, date}
rows, err := db.QueryContext(ctx, sqlText, string(protocol), date, date) if cfg.StationaryCarry {
args = append(args, date)
}
rows, err := db.QueryContext(ctx, sqlText, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -775,27 +1161,30 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
var sourceEndpoint string var sourceEndpoint string
var firstTS time.Time var firstTS time.Time
var firstParsedJSON string var firstParsedJSON string
var firstRawText string
var ts time.Time var ts time.Time
var parsedJSON string var parsedJSON string
var rawText string
var rawSampleCount int64 var rawSampleCount int64
if includeFirst { if includeFirst {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &ts, &parsedJSON, &rawSampleCount); err != nil { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
return nil, err return nil, err
} }
} else { } else {
if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawSampleCount); err != nil { if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil {
return nil, err return nil, err
} }
firstTS = ts firstTS = ts
firstParsedJSON = parsedJSON firstParsedJSON = parsedJSON
firstRawText = rawText
} }
latestTotalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, ts, cfg.Location) latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location)
if !ok { if !ok {
continue continue
} }
firstTotalKM := latestTotalKM firstTotalKM := latestTotalKM
if includeFirst { if includeFirst {
if parsedFirst, ok := mileageFromParsed(protocol, vin, firstParsedJSON, firstTS, cfg.Location); ok { if parsedFirst, ok := mileageFromEvidence(protocol, vin, firstParsedJSON, firstRawText, firstTS, cfg.Location); ok {
firstTotalKM = parsedFirst firstTotalKM = parsedFirst
} }
} }
@@ -852,6 +1241,16 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string
return samples[0].TotalMileageKM, true return samples[0].TotalMileageKM, true
} }
func mileageFromEvidence(protocol envelope.Protocol, vin string, parsedJSON string, rawText string, eventTime time.Time, loc *time.Location) (float64, bool) {
if totalKM, ok := mileageFromParsed(protocol, vin, parsedJSON, eventTime, loc); ok {
return totalKM, true
}
if protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(rawText) == "" {
return 0, false
}
return mileageFromParsed(protocol, vin, rawText, eventTime, loc)
}
func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string { func normalizedSourceKey(protocol string, phone string, deviceID string, endpoint string) string {
return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "") return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "")
} }
@@ -896,9 +1295,49 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
} }
fields[key] = value fields[key] = value
} }
promoteFuelCellWorkModeForStats(protocol, fields, text)
return fields return fields
} }
func promoteFuelCellWorkModeForStats(protocol envelope.Protocol, fields map[string]any, text string) {
if fields == nil {
return
}
if _, exists := fields[envelope.FieldFuelCellWorkMode]; exists {
return
}
var candidates []string
switch protocol {
case envelope.ProtocolGB32960:
candidates = []string{
"gb32960.gd_fc_stack.engine_work_state",
"engine_work_state",
}
case envelope.ProtocolYutongMQTT:
candidates = []string{
"yutong_mqtt.data.triangle_state",
"yutong_mqtt.root.data.triangle_state",
"TRIANGLE_STATE",
"triangle_state",
"triangleState",
}
default:
return
}
for _, key := range candidates {
if value, exists := fields[key]; exists {
fields[envelope.FieldFuelCellWorkMode] = value
return
}
}
for _, key := range candidates {
if value, ok := extractJSONStringField(text, key); ok {
fields[envelope.FieldFuelCellWorkMode] = value
return
}
}
}
func mileageKeys(protocol envelope.Protocol) []string { func mileageKeys(protocol envelope.Protocol) []string {
switch protocol { switch protocol {
case envelope.ProtocolGB32960: case envelope.ProtocolGB32960:
@@ -950,7 +1389,10 @@ func loadConfig() (config, error) {
Reset: envBool("BACKFILL_RESET", false), Reset: envBool("BACKFILL_RESET", false),
Debug: envBool("BACKFILL_DEBUG", false), Debug: envBool("BACKFILL_DEBUG", false),
EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false), EventTimeFullScan: envBool("BACKFILL_EVENT_TIME_FULL_SCAN", false),
StationaryCarry: envBool("BACKFILL_STATIONARY_CARRY_FORWARD", false),
GPSFallback: envBool("BACKFILL_GPS_FALLBACK", envBool("BACKFILL_JT808_GPS_FALLBACK", true)),
ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)), ProgressEvery: int64(envInt("BACKFILL_PROGRESS_EVERY", 100000)),
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
Location: loc, Location: loc,
}, nil }, nil
} }
@@ -1000,7 +1442,7 @@ func queryRawFrames(ctx context.Context, db *sql.DB, cfg config) (*sql.Rows, err
where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")") where = append(where, "protocol IN ("+strings.Join(quoted, ",")+")")
} }
where = append(where, realtimeMileageFramePredicate()) where = append(where, realtimeMileageFramePredicate())
sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json sqlText := fmt.Sprintf(`SELECT protocol, vin, phone, device_id, source_endpoint, event_id, message_id, event_time, received_at, parsed_json, raw_text
FROM %s.raw_frames FROM %s.raw_frames
WHERE %s WHERE %s
ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) ORDER BY event_time ASC, ts ASC`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
@@ -1044,6 +1486,9 @@ func mileageBearingFramePredicate(protocol envelope.Protocol) string {
conditions := make([]string, 0, len(tokens)) conditions := make([]string, 0, len(tokens))
for _, token := range tokens { for _, token := range tokens {
conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token))) conditions = append(conditions, fmt.Sprintf("parsed_json LIKE '%%%s%%'", quote(token)))
if protocol == envelope.ProtocolYutongMQTT {
conditions = append(conditions, fmt.Sprintf("raw_text LIKE '%%%s%%'", quote(token)))
}
} }
return "(" + strings.Join(conditions, " OR ") + ")" return "(" + strings.Join(conditions, " OR ") + ")"
} }
@@ -1071,10 +1516,10 @@ func mileageSearchTokens(protocol envelope.Protocol) []string {
} }
func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) { func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed string var protocol, vin, phone, deviceID, sourceEndpoint, eventID, parsed, rawText string
var messageID int64 var messageID int64
var eventTime, receivedAt time.Time var eventTime, receivedAt time.Time
if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed); err != nil { if err := rows.Scan(&protocol, &vin, &phone, &deviceID, &sourceEndpoint, &eventID, &messageID, &eventTime, &receivedAt, &parsed, &rawText); err != nil {
return rawFrameRow{}, err return rawFrameRow{}, err
} }
return rawFrameRow{ return rawFrameRow{
@@ -1088,6 +1533,7 @@ func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
EventTimeMS: eventTime.UnixMilli(), EventTimeMS: eventTime.UnixMilli(),
ReceivedAtMS: receivedAt.UnixMilli(), ReceivedAtMS: receivedAt.UnixMilli(),
ParsedJSON: parsed, ParsedJSON: parsed,
RawText: rawText,
}, nil }, nil
} }
@@ -1267,6 +1713,14 @@ func previousDate(date string) string {
return parsed.AddDate(0, 0, -1).Format("2006-01-02") return parsed.AddDate(0, 0, -1).Format("2006-01-02")
} }
func shiftDate(date string, days int) string {
parsed, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
return parsed.AddDate(0, 0, days).Format("2006-01-02")
}
func dateRangeWithPrevious(from string, to string) ([]string, error) { func dateRangeWithPrevious(from string, to string) ([]string, error) {
dates, err := dateRange(from, to) dates, err := dateRange(from, to)
if err != nil { if err != nil {

View File

@@ -50,6 +50,73 @@ func TestChooseTrustedSourceKeepsContinuingSourceAndRejectsNewJump(t *testing.T)
} }
} }
func TestCleanupSubthresholdGPSMileageCandidatesDryRunReportsWithoutDeleting(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT vin, DATE_FORMAT").
WithArgs("2026-07-20", "2026-07-20", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808").
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol", "row_count"}).
AddRow("LKLG7C4E9NA774739", "2026-07-20", "JT808", 1))
found, deleted, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, config{
DateFrom: "2026-07-20",
DateTo: "2026-07-20",
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
}, false)
if err != nil {
t.Fatalf("cleanupSubthresholdGPSMileageCandidates() error = %v", err)
}
if found != 1 || deleted != 0 {
t.Fatalf("cleanup result found=%d deleted=%d", found, deleted)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}
func TestCleanupSubthresholdGPSMileageCandidatesDeletesAndReprojectsAtomically(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
mock.ExpectQuery("SELECT vin, DATE_FORMAT").
WithArgs("2026-07-20", "2026-07-20", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808").
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol", "row_count"}).
AddRow("LKLG7C4E9NA774739", "2026-07-20", "JT808", 1))
mock.ExpectBegin()
mock.ExpectExec("DELETE FROM vehicle_daily_mileage_source").
WithArgs("LKLG7C4E9NA774739", "2026-07-20", "JT808", stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("UPDATE vehicle_daily_mileage_source").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
found, deleted, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, config{
DateFrom: "2026-07-20",
DateTo: "2026-07-20",
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
}, true)
if err != nil {
t.Fatalf("cleanupSubthresholdGPSMileageCandidates() error = %v", err)
}
if found != 1 || deleted != 1 {
t.Fatalf("cleanup result found=%d deleted=%d", found, deleted)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}
func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) { func TestChooseTrustedSourceAcceptsSmallNegativeMileageJitter(t *testing.T) {
previous := []dailySourceLast{{ previous := []dailySourceLast{{
VIN: "LB9A32A28R0LS1574", VIN: "LB9A32A28R0LS1574",
@@ -233,23 +300,23 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
currentRows := func() *sqlmock.Rows { currentRows := func() *sqlmock.Rows {
return sqlmock.NewRows([]string{ return sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}) })
} }
previousRows := func() *sqlmock.Rows { previousRows := func() *sqlmock.Rows {
return sqlmock.NewRows([]string{ return sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}) })
} }
mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(previousRows()) WillReturnRows(previousRows())
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, int64(4))) WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4)))
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'"). mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows()) WillReturnRows(currentRows())
mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'"). mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, int64(6))) WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6)))
aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{ aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{
TDengineDatabase: "lingniu_vehicle_ts", TDengineDatabase: "lingniu_vehicle_ts",
@@ -323,6 +390,95 @@ func TestQueryRealtimeLocationLastRowsBuildsYutongSourceFromPeer(t *testing.T) {
} }
} }
func TestQueryRealtimeLocationLastRowsCanCarryStationaryYutongMileage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
currentEventTime := time.Date(2026, 7, 19, 6, 5, 42, 0, loc)
mock.ExpectQuery("(?s)SELECT.*l\\.event_time.*l\\.total_mileage_event_time < \\?.*COALESCE\\(l\\.speed_kmh, 0\\) = 0").
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19").
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}).
AddRow("LMRKH9AC9R1004099", "mqtt://yutong/ytforward/shln/2", currentEventTime, 24245.0))
rows, err := queryRealtimeLocationLastRows(context.Background(), db, config{
Location: loc,
StationaryCarry: true,
}, envelope.ProtocolYutongMQTT, "2026-07-19")
if err != nil {
t.Fatalf("queryRealtimeLocationLastRows() error = %v", err)
}
sourceRows := rows["LMRKH9AC9R1004099"]
if len(sourceRows) != 1 {
t.Fatalf("rows = %d, want 1", len(sourceRows))
}
if !sourceRows[0].TS.Equal(currentEventTime) || sourceRows[0].TotalKM != 24245 {
t.Fatalf("stationary carry row = %#v", sourceRows[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAddRealtimeLocationFallbackStationaryCarryUsesTrustedHistoricalBaseline(t *testing.T) {
mysqlDB, mysqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("mysql sqlmock.New() error = %v", err)
}
defer mysqlDB.Close()
tdDB, tdMock, err := sqlmock.New()
if err != nil {
t.Fatalf("td sqlmock.New() error = %v", err)
}
defer tdDB.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
vin := "LMRKH9AC5R1004133"
currentTS := time.Date(2026, 7, 19, 10, 20, 20, 0, loc)
previousTS := time.Date(2026, 7, 17, 8, 49, 55, 0, loc)
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", vin, "mqtt", "PLATFORM", "yutong")
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l\\.event_time.*l\\.total_mileage_event_time < \\?").
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", "2026-07-19").
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "event_time", "total_mileage_km"}).
AddRow(vin, "mqtt://yutong/ytforward/shln/4", currentTS, 55451.0))
mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
WithArgs(vin, "2026-07-19", "YUTONG_MQTT", sourceKey).
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(55466.0, previousTS))
aggregates := map[string]*metricAgg{}
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
TDengineDatabase: "lingniu_vehicle_ts",
DateFrom: "2026-07-19",
DateTo: "2026-07-19",
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
Location: loc,
StationaryCarry: true,
}, aggregates)
if err != nil {
t.Fatalf("addRealtimeLocationFallbackAggregates() error = %v", err)
}
if added != 1 || len(aggregates) != 1 {
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
}
for _, agg := range aggregates {
if agg.FirstKM != 55466 || agg.LatestKM != 55466 {
t.Fatalf("stationary km range = %v -> %v", agg.FirstKM, agg.LatestKM)
}
if agg.QualityStatus != stats.QualityOK || agg.QualityReason != stats.QualityReasonStationaryCarry {
t.Fatalf("quality = %s/%s", agg.QualityStatus, agg.QualityReason)
}
}
if err := mysqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("mysql sql expectations: %v", err)
}
if err := tdMock.ExpectationsWereMet(); err != nil {
t.Fatalf("td sql expectations: %v", err)
}
}
func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) { func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.T) {
mysqlDB, mysqlMock, err := sqlmock.New() mysqlDB, mysqlMock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -342,18 +498,10 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12"). WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}). WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0)) AddRow("LMRKH9AC0R1004086", "mqtt://yutong/ytforward/shln/4", currentTS, 11578.0))
tdMock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-12 00:00:00'.*protocol = 'YUTONG_MQTT'.*TOTAL_MILEAGE"). sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
WillReturnRows(sqlmock.NewRows([]string{ mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey).
}).AddRow( WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS))
"LMRKH9AC0R1004086",
"",
"LMRKH9AC0R1004086",
"mqtt://yutong/ytforward/shln/4",
previousTS,
`{"data":{"TOTAL_MILEAGE":11500000}}`,
int64(12),
))
aggregates := map[string]*metricAgg{} aggregates := map[string]*metricAgg{}
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{ added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
@@ -373,7 +521,7 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
if agg.FirstKM != 11500 || agg.LatestKM != 11578 { if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM) t.Fatalf("km range = %v -> %v", agg.FirstKM, agg.LatestKM)
} }
if agg.SourceKey != stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong") { if agg.SourceKey != sourceKey {
t.Fatalf("source key = %q", agg.SourceKey) t.Fatalf("source key = %q", agg.SourceKey)
} }
if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" { if agg.SourceCode != "yutong" || agg.PlatformName != "宇通" || agg.SourceKind != "PLATFORM" {
@@ -410,10 +558,6 @@ func TestAddRealtimeLocationFallbackReusesAggregateHistoryAcrossEmptyDays(t *tes
dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc) dayOneTS := time.Date(2026, 7, 9, 23, 50, 0, 0, loc)
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
tdMock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'").
WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
}))
for _, date := range []string{"2026-07-09", "2026-07-10"} { for _, date := range []string{"2026-07-09", "2026-07-10"} {
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?"). mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
WithArgs("YUTONG_MQTT", date, date). WithArgs("YUTONG_MQTT", date, date).
@@ -483,16 +627,18 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc) lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow( }).AddRow(
"LMRKH9AC6R1004108", "LMRKH9AC6R1004108",
"", "",
"LMRKH9AC6R1004108", "LMRKH9AC6R1004108",
"mqtt://yutong/ytforward/shln/3", "mqtt://yutong/ytforward/shln/3",
firstTS, firstTS,
`{"yutong_mqtt.data.total_mileage":"65422000"}`, `{"yutong_mqtt.data.latitude":"30.0"}`,
`{"data":{"TOTAL_MILEAGE":65422000}}`,
lastTS, lastTS,
`{"yutong_mqtt.data.total_mileage":"65423000"}`, `{"yutong_mqtt.data.latitude":"30.1"}`,
`{"data":{"TOTAL_MILEAGE":65423000}}`,
int64(42), int64(42),
)) ))
@@ -526,14 +672,15 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'").
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow( }).AddRow(
"LMRKH9AC6R1004108", "LMRKH9AC6R1004108",
"", "",
"LMRKH9AC6R1004108", "LMRKH9AC6R1004108",
"mqtt://yutong/ytforward/shln/3", "mqtt://yutong/ytforward/shln/3",
lastTS, lastTS,
`{"yutong_mqtt.data.total_mileage":"65377000"}`, `{"yutong_mqtt.data.latitude":"30.0"}`,
`{"data":{"TOTAL_MILEAGE":65377000}}`,
int64(31), int64(31),
)) ))
@@ -556,13 +703,19 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
} }
} }
func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) { func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) {
where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ") where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ")
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") { if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where) t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
} }
if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") { for _, predicate := range []string{
t.Fatalf("pre-window predicate must not stop at the previous day: %s", where) "event_time >= '2026-07-01 00:00:00'",
"ts >= '2026-06-30 00:00:00'",
"ts < '2026-07-09 00:00:00'",
} {
if !strings.Contains(where, predicate) {
t.Fatalf("pre-window predicate missing %q: %s", predicate, where)
}
} }
} }
@@ -577,7 +730,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC) utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC)
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'"). mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)", "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)",
}).AddRow( }).AddRow(
"LMRKH9AC7R1004098", "LMRKH9AC7R1004098",
"", "",
@@ -585,6 +738,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
"mqtt://yutong/ytforward/shln/4", "mqtt://yutong/ytforward/shln/4",
utcInstant, utcInstant,
`{"yutong_mqtt.data.total_mileage":"41249000"}`, `{"yutong_mqtt.data.total_mileage":"41249000"}`,
"",
int64(1), int64(1),
)) ))
@@ -609,7 +763,8 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{ fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
"data": { "data": {
"TOTAL_MILEAGE": 65423000, "TOTAL_MILEAGE": 65423000,
"METER_SPEED": 12.3 "METER_SPEED": 12.3,
"TRIANGLE_STATE": 11
}, },
"root": { "root": {
"device": "LMRKH9AC6R1004108" "device": "LMRKH9AC6R1004108"
@@ -619,6 +774,9 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil { if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
t.Fatalf("fields missing raw yutong total mileage: %#v", fields) t.Fatalf("fields missing raw yutong total mileage: %#v", fields)
} }
if got := fields[envelope.FieldFuelCellWorkMode]; got == nil {
t.Fatalf("fields missing raw yutong fuel-cell work mode: %#v", fields)
}
env := envelope.FrameEnvelope{ env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT, Protocol: envelope.ProtocolYutongMQTT,
@@ -637,6 +795,32 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
if samples[0].TotalMileageKM != 65423 { if samples[0].TotalMileageKM != 65423 {
t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM) t.Fatalf("total mileage km = %v, want 65423", samples[0].TotalMileageKM)
} }
if !samples[0].PureHydrogenModeKnown || !samples[0].PureHydrogenActive {
t.Fatalf("pure-hydrogen mode = known:%v active:%v, want known active", samples[0].PureHydrogenModeKnown, samples[0].PureHydrogenActive)
}
}
func TestFieldsForStatsPromotesGB32960FuelCellEngineWorkState(t *testing.T) {
fields := fieldsForStats(envelope.ProtocolGB32960, "LTEST000000000001", `{
"data_units": [{
"type": "0x30",
"name": "gd_fc_stack",
"value": {
"summaries": [{"engine_work_state": 2}]
}
}]
}`)
if got := fields[envelope.FieldFuelCellWorkMode]; got == nil {
t.Fatalf("fields missing GB32960 fuel-cell work mode: %#v", fields)
}
active, known := stats.PureHydrogenModeFromEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
Fields: fields,
})
if !known || !active {
t.Fatalf("pure-hydrogen mode = known:%v active:%v, want known active", known, active)
}
} }
func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) { func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) {
@@ -709,9 +893,13 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
float64(4100), float64(4100),
float64(4101), float64(4101),
float64(1), float64(1),
float64(0),
int64(1), int64(1),
int64(0),
projNow, projNow,
projNow, projNow,
false,
false,
stats.QualityInvalidDelta, stats.QualityInvalidDelta,
"outside_daily_range", "outside_daily_range",
). ).
@@ -840,6 +1028,51 @@ func TestAddSamplesUsesCurrentDayFirstSampleBaseline(t *testing.T) {
} }
} }
func TestAddSamplesBackfillsOnlyContinuousPureHydrogenIntervals(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
base := stats.MetricSample{
VIN: "LTEST000000000001",
Protocol: envelope.ProtocolGB32960,
StatDate: "2026-07-08",
SourceKey: "GB32960|127.0.0.1",
PureHydrogenModeKnown: true,
}
samples := []stats.MetricSample{
base,
base,
base,
base,
}
samples[0].TotalMileageKM = 100
samples[0].EventTime = time.Date(2026, 7, 8, 8, 0, 0, 0, loc)
samples[1].TotalMileageKM = 102
samples[1].EventTime = time.Date(2026, 7, 8, 9, 0, 0, 0, loc)
samples[1].PureHydrogenActive = true
samples[2].TotalMileageKM = 107
samples[2].EventTime = time.Date(2026, 7, 8, 10, 0, 0, 0, loc)
samples[2].PureHydrogenActive = true
samples[3].TotalMileageKM = 110
samples[3].EventTime = time.Date(2026, 7, 8, 11, 0, 0, 0, loc)
aggregates := map[string]*metricAgg{}
addSamples(aggregates, samples)
if len(aggregates) != 1 {
t.Fatalf("aggregate count = %d, want 1", len(aggregates))
}
for _, agg := range aggregates {
if agg.PureHydrogenMileageKM != 5 {
t.Fatalf("pure-hydrogen mileage = %v, want 5", agg.PureHydrogenMileageKM)
}
if agg.PureHydrogenSampleCount != 2 {
t.Fatalf("pure-hydrogen sample count = %d, want 2", agg.PureHydrogenSampleCount)
}
if !agg.LatestPureHydrogenModeKnown || agg.LatestPureHydrogenActive {
t.Fatalf("latest mode = known:%v active:%v, want known inactive", agg.LatestPureHydrogenModeKnown, agg.LatestPureHydrogenActive)
}
}
}
func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) { func TestLoadConfigDefaultsBackfillMethodToLastDiff(t *testing.T) {
t.Setenv("BACKFILL_METHOD", "") t.Setenv("BACKFILL_METHOD", "")
t.Setenv("BACKFILL_DAYS_BACK", "") t.Setenv("BACKFILL_DAYS_BACK", "")
@@ -903,6 +1136,114 @@ func TestResolveBackfillDateRangeDefaultsToToday(t *testing.T) {
} }
} }
func TestAddGPSCoordinateFallbackAggregatesOnlyMissingPositiveOdometerVehicles(t *testing.T) {
mysqlDB, mysqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("mysql sqlmock.New() error = %v", err)
}
defer mysqlDB.Close()
tdDB, tdMock, err := sqlmock.New()
if err != nil {
t.Fatalf("tdengine sqlmock.New() error = %v", err)
}
defer tdDB.Close()
mysqlMock.ExpectQuery("SELECT DISTINCT vin, DATE_FORMAT").
WithArgs("JT808", "2026-07-19", "2026-07-19", stats.QualityOK, stats.QualityReasonGPSCoordinate).
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date"}).
AddRow("VIN-WITH-ODOMETER", "2026-07-19"))
loc := time.FixedZone("Asia/Shanghai", 8*3600)
start := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
tdMock.ExpectQuery("SELECT vin, ts, longitude, latitude").
WillReturnRows(sqlmock.NewRows([]string{"vin", "ts", "longitude", "latitude"}).
AddRow("VIN-GPS-FALLBACK", start, 121.4737, 31.2304).
AddRow("VIN-WITH-ODOMETER", start, 121.4737, 31.2304).
AddRow("VIN-GPS-FALLBACK", start.Add(5*time.Minute), 121.4837, 31.2304).
AddRow("VIN-WITH-ODOMETER", start.Add(5*time.Minute), 121.4837, 31.2304))
aggregates := map[string]*metricAgg{}
added, err := addGPSCoordinateFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
DateFrom: "2026-07-19",
DateTo: "2026-07-19",
Protocols: []envelope.Protocol{envelope.ProtocolJT808},
GPSFallback: true,
Location: loc,
TDengineDatabase: "vehicle_ts",
}, aggregates)
if err != nil {
t.Fatalf("addGPSCoordinateFallbackAggregates() error = %v", err)
}
if added != 1 || len(aggregates) != 1 {
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
}
for _, aggregate := range aggregates {
if aggregate.VIN != "VIN-GPS-FALLBACK" || aggregate.QualityReason != stats.QualityReasonGPSCoordinate {
t.Fatalf("aggregate = %+v", aggregate)
}
if aggregate.LatestKM < 0.9 || aggregate.LatestKM > 1.1 || aggregate.Count != 2 {
t.Fatalf("GPS aggregate distance/count = %.3f/%d", aggregate.LatestKM, aggregate.Count)
}
}
if err := mysqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("mysql expectations: %v", err)
}
if err := tdMock.ExpectationsWereMet(); err != nil {
t.Fatalf("tdengine expectations: %v", err)
}
}
func TestAddGPSCoordinateFallbackAggregatesUsesMovingYutongPoints(t *testing.T) {
mysqlDB, mysqlMock, err := sqlmock.New()
if err != nil {
t.Fatalf("mysql sqlmock.New() error = %v", err)
}
defer mysqlDB.Close()
tdDB, tdMock, err := sqlmock.New()
if err != nil {
t.Fatalf("tdengine sqlmock.New() error = %v", err)
}
defer tdDB.Close()
mysqlMock.ExpectQuery("SELECT DISTINCT vin, DATE_FORMAT").
WithArgs("YUTONG_MQTT", "2026-07-19", "2026-07-19", stats.QualityOK, stats.QualityReasonGPSCoordinate).
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date"}))
loc := time.FixedZone("Asia/Shanghai", 8*3600)
start := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
tdMock.ExpectQuery("protocol = 'YUTONG_MQTT'.*speed_kmh > 0").
WillReturnRows(sqlmock.NewRows([]string{"vin", "ts", "longitude", "latitude"}).
AddRow("VIN-YUTONG-GPS", start, 121.4737, 31.2304).
AddRow("VIN-YUTONG-GPS", start.Add(time.Minute), 121.4837, 31.2304))
aggregates := map[string]*metricAgg{}
added, err := addGPSCoordinateFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
DateFrom: "2026-07-19",
DateTo: "2026-07-19",
Protocols: []envelope.Protocol{envelope.ProtocolYutongMQTT},
GPSFallback: true,
Location: loc,
TDengineDatabase: "vehicle_ts",
}, aggregates)
if err != nil {
t.Fatalf("addGPSCoordinateFallbackAggregates() error = %v", err)
}
if added != 1 || len(aggregates) != 1 {
t.Fatalf("added=%d aggregates=%d", added, len(aggregates))
}
for _, aggregate := range aggregates {
if aggregate.Protocol != envelope.ProtocolYutongMQTT ||
aggregate.PlatformName != "宇通 GPS轨迹估算" ||
aggregate.QualityReason != stats.QualityReasonGPSCoordinate {
t.Fatalf("aggregate = %+v", aggregate)
}
}
if err := mysqlMock.ExpectationsWereMet(); err != nil {
t.Fatalf("mysql expectations: %v", err)
}
if err := tdMock.ExpectationsWereMet(); err != nil {
t.Fatalf("tdengine expectations: %v", err)
}
}
func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) { func TestResolveBackfillDateRangeUsesRelativeWindow(t *testing.T) {
t.Setenv("BACKFILL_DATE_FROM", "") t.Setenv("BACKFILL_DATE_FROM", "")
t.Setenv("BACKFILL_DATE_TO", "") t.Setenv("BACKFILL_DATE_TO", "")

View File

@@ -33,11 +33,12 @@ const (
) )
const ( const (
FieldSpeedKMH = "speed_kmh" FieldSpeedKMH = "speed_kmh"
FieldTotalMileageKM = "total_mileage_km" FieldTotalMileageKM = "total_mileage_km"
FieldLongitude = "longitude" FieldLongitude = "longitude"
FieldLatitude = "latitude" FieldLatitude = "latitude"
FieldSOCPercent = "soc_percent" FieldSOCPercent = "soc_percent"
FieldFuelCellWorkMode = "fuel_cell_work_mode"
) )
type FrameEnvelope struct { type FrameEnvelope struct {

View File

@@ -0,0 +1,57 @@
package feichibridge
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"time"
)
var captchaPattern = regexp.MustCompile(`^[0-9A-Z]{4}$`)
type CaptchaSolver interface {
Solve(context.Context, []byte) (string, error)
}
type DockerCaptchaSolver struct {
Image string
Timeout time.Duration
}
func (s DockerCaptchaSolver) Solve(ctx context.Context, image []byte) (string, error) {
if len(image) == 0 {
return "", errors.New("captcha image is empty")
}
if strings.TrimSpace(s.Image) == "" {
return "", errors.New("captcha OCR image is required")
}
timeout := s.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
solveCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := exec.CommandContext(
solveCtx,
"docker", "run", "--rm", "--network=none", "-i", strings.TrimSpace(s.Image),
)
command.Stdin = bytes.NewReader(image)
var stderr bytes.Buffer
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
if solveCtx.Err() != nil {
return "", fmt.Errorf("captcha OCR timed out: %w", solveCtx.Err())
}
return "", fmt.Errorf("captcha OCR failed: %w: %s", err, strings.TrimSpace(stderr.String()))
}
code := strings.ToUpper(strings.TrimSpace(string(output)))
if !captchaPattern.MatchString(code) {
return "", fmt.Errorf("captcha OCR returned invalid result %q", code)
}
return code, nil
}

View File

@@ -0,0 +1,211 @@
package feichibridge
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
var ErrUnauthorized = errors.New("feichi API session unauthorized")
type APIClient struct {
baseURL *url.URL
headers http.Header
client *http.Client
login *loginSession
}
func NewAPIClient(baseURL string, headers map[string]string, timeout time.Duration) (*APIClient, error) {
parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/")
if err != nil {
return nil, fmt.Errorf("parse FEICHI_BASE_URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("FEICHI_BASE_URL must use http or https")
}
if timeout <= 0 {
timeout = 15 * time.Second
}
out := &APIClient{
baseURL: parsed,
headers: make(http.Header),
client: &http.Client{Timeout: timeout},
}
for name, value := range headers {
if strings.TrimSpace(name) != "" && strings.TrimSpace(value) != "" {
out.headers.Set(name, value)
}
}
return out, nil
}
func NewAuthenticatedAPIClient(baseURL string, credentials LoginCredentials, solver CaptchaSolver, timeout time.Duration) (*APIClient, error) {
client, err := NewAPIClient(baseURL, nil, timeout)
if err != nil {
return nil, err
}
login, err := newLoginSession(credentials, solver)
if err != nil {
return nil, err
}
client.login = login
return client, nil
}
func (c *APIClient) Vehicles(ctx context.Context) ([]Vehicle, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "iccid", "value": ""},
{"name": "powerMode", "value": ""},
},
"sort": []map[string]string{{"name": "updateTime", "order": "desc"}},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[Vehicle]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/vehicleRealStatuss", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) Snapshot(ctx context.Context, vehicleID string) (Snapshot, error) {
path := "api/v1/sys/vehicleRealStatussByVId/" + url.PathEscape(vehicleID) + "/-1"
var envelope objectEnvelope[Snapshot]
if err := c.doJSON(ctx, http.MethodGet, path, nil, &envelope); err != nil {
return Snapshot{}, err
}
if !apiCodeOK(envelope.Code) {
return Snapshot{}, apiCodeError(envelope.Code)
}
return envelope.Data, nil
}
func (c *APIClient) History(ctx context.Context, vin string, begin, end time.Time) ([]Record, error) {
payload := map[string]any{
"conditions": []map[string]string{
{"name": "queryType", "value": "0"},
{"name": "queryContent", "value": vin},
{"name": "beginTime", "value": begin.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "endTime", "value": end.In(shanghai).Format("2006-01-02 15:04:05")},
{"name": "hisdataType", "value": "0"},
},
"start": 0,
"limit": 100,
}
var envelope collectionEnvelope[historySegment]
if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/hisdataQuerys", payload, &envelope); err != nil {
return nil, err
}
if !apiCodeOK(envelope.Code) {
return nil, apiCodeError(envelope.Code)
}
var records []Record
for _, segment := range envelope.Data {
records = append(records, segment.Subdata...)
}
return records, nil
}
func (c *APIClient) doJSON(ctx context.Context, method, path string, body any, output any) error {
if c.login != nil {
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
for attempt := 0; attempt < 2; attempt++ {
_, err := c.rawJSON(ctx, method, path, body, output)
if !errors.Is(err, ErrUnauthorized) || c.login == nil || attempt == 1 {
return err
}
c.login.invalidate()
if err := c.login.ensure(ctx, c); err != nil {
return err
}
}
return ErrUnauthorized
}
func (c *APIClient) rawJSON(ctx context.Context, method, path string, body any, output any) (http.Header, error) {
endpoint, err := c.baseURL.Parse(path)
if err != nil {
return nil, err
}
var reader io.Reader
if body != nil {
encoded, marshalErr := json.Marshal(body)
if marshalErr != nil {
return nil, marshalErr
}
reader = bytes.NewReader(encoded)
}
request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
if body != nil {
request.Header.Set("Content-Type", "application/json")
}
for name, values := range c.headers {
for _, value := range values {
request.Header.Add(name, value)
}
}
if c.login != nil {
if cookie := c.login.cookieHeader(); cookie != "" {
request.Header.Set("Cookie", cookie)
}
if csrf := c.login.csrfHeader(); csrf != "" {
request.Header.Set("x-api-csrf", csrf)
}
}
response, err := c.client.Do(request)
if err != nil {
return nil, fmt.Errorf("feichi %s %s: %w", method, path, err)
}
defer response.Body.Close()
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
return response.Header, ErrUnauthorized
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
snippet, _ := io.ReadAll(io.LimitReader(response.Body, 1024))
return response.Header, fmt.Errorf("feichi %s %s: HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(snippet)))
}
encoded, err := io.ReadAll(io.LimitReader(response.Body, 16<<20))
if err != nil {
return response.Header, fmt.Errorf("read feichi %s: %w", path, err)
}
var status struct {
Code any `json:"code"`
}
if err := json.Unmarshal(encoded, &status); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
if fmt.Sprint(status.Code) == "401" || fmt.Sprint(status.Code) == "403" {
return response.Header, ErrUnauthorized
}
if output != nil {
if err := json.Unmarshal(encoded, output); err != nil {
return response.Header, fmt.Errorf("decode feichi %s: %w", path, err)
}
}
return response.Header, nil
}
func apiCodeError(code any) error {
if fmt.Sprint(code) == "401" || fmt.Sprint(code) == "403" {
return ErrUnauthorized
}
return fmt.Errorf("feichi API returned code %v", code)
}

View File

@@ -0,0 +1,154 @@
package feichibridge
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fixedCaptchaSolver struct {
code string
calls int
}
func (s *fixedCaptchaSolver) Solve(_ context.Context, image []byte) (string, error) {
s.calls++
if string(image) != "captcha-image" {
return "", fmt.Errorf("unexpected captcha image")
}
return s.code, nil
}
func TestAPIClientUsesSessionHeadersAndExpectedEndpoints(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls++
if request.Header.Get("Cookie") != "R_SESS=test" || request.Header.Get("x-api-csrf") != "csrf" {
t.Errorf("missing session headers: %#v", request.Header)
}
response.Header().Set("Content-Type", "application/json")
switch {
case request.URL.Path == "/api/v1/sys/vehicleRealStatuss":
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": []map[string]string{{"vehicleId": "id-1", "vin": "LTEST32960VIN0001"}},
})
case strings.Contains(request.URL.Path, "vehicleRealStatussByVId"):
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": map[string]any{"vehicleId": "id-1", "vin": "LTEST32960VIN0001", "dataItems": map[string]string{"2000": "2026-07-17 12:00:00"}},
})
case request.URL.Path == "/api/v1/sys/hisdataQuerys":
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200,
"data": []map[string]any{{"subdata": []map[string]string{{"2000": "2026-07-17 11:59:50"}}}},
})
default:
http.NotFound(response, request)
}
}))
defer server.Close()
client, err := NewAPIClient(server.URL, map[string]string{"Cookie": "R_SESS=test", "x-api-csrf": "csrf"}, time.Second)
if err != nil {
t.Fatal(err)
}
vehicles, err := client.Vehicles(context.Background())
if err != nil || len(vehicles) != 1 {
t.Fatalf("vehicles = %#v, err = %v", vehicles, err)
}
if _, err := client.Snapshot(context.Background(), "id-1"); err != nil {
t.Fatal(err)
}
records, err := client.History(context.Background(), vehicles[0].VIN, time.Now().Add(-time.Hour), time.Now())
if err != nil || len(records) != 1 {
t.Fatalf("history = %#v, err = %v", records, err)
}
if calls != 3 {
t.Fatalf("calls = %d", calls)
}
}
func TestAPIClientMapsUnauthorizedEnvelope(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
_, _ = response.Write([]byte(`{"code":401,"data":[]}`))
}))
defer server.Close()
client, err := NewAPIClient(server.URL, nil, time.Second)
if err != nil {
t.Fatal(err)
}
if _, err := client.Vehicles(context.Background()); err != ErrUnauthorized {
t.Fatalf("error = %v", err)
}
}
func TestAuthenticatedAPIClientLogsInAndRetriesExpiredSession(t *testing.T) {
var loginCalls int
var vehicleCalls int
solver := &fixedCaptchaSolver{code: "12WN"}
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
switch request.URL.Path {
case "/api/v1/first-login":
response.Header().Set("x-api-csrf", "csrf-value")
response.Header().Add("Set-Cookie", "CSRF=csrf-value; Path=/")
_, _ = response.Write([]byte(`{"code":200}`))
case "/api/v1/login/randCode":
if request.Header.Get("x-api-csrf") != "csrf-value" {
t.Errorf("captcha CSRF = %q", request.Header.Get("x-api-csrf"))
}
src := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("captcha-image"))
_ = json.NewEncoder(response).Encode(map[string]any{"code": 200, "data": map[string]string{"src": src}})
case "/api/v1/login":
loginCalls++
var payload map[string]string
_ = json.NewDecoder(request.Body).Decode(&payload)
if payload["username"] != "JXQN0003" || payload["validCode"] != "12WN" {
t.Errorf("login payload = %#v", payload)
}
encrypted, err := base64.StdEncoding.DecodeString(payload["password"])
if err != nil || len(encrypted) != 128 || payload["password"] == "JXQN0003-" {
t.Errorf("password was not RSA encrypted: len=%d err=%v", len(encrypted), err)
}
_ = json.NewEncoder(response).Encode(map[string]any{
"code": 200, "data": map[string]string{"token": fmt.Sprintf("token-%d", loginCalls)},
})
case "/api/v1/sys/vehicleRealStatuss":
vehicleCalls++
if vehicleCalls == 1 {
_, _ = response.Write([]byte(`{"code":401,"data":[]}`))
return
}
if request.Header.Get("Cookie") != "CSRF=csrf-value; R_SESS=token-2" {
t.Errorf("session cookie = %q", request.Header.Get("Cookie"))
}
_, _ = response.Write([]byte(`{"code":200,"data":[]}`))
default:
http.NotFound(response, request)
}
}))
defer server.Close()
client, err := NewAuthenticatedAPIClient(
server.URL,
LoginCredentials{Username: "JXQN0003", Password: "JXQN0003-", MaxAttempts: 2},
solver,
time.Second,
)
if err != nil {
t.Fatal(err)
}
if _, err := client.Vehicles(context.Background()); err != nil {
t.Fatal(err)
}
if loginCalls != 2 || solver.calls != 2 || vehicleCalls != 2 {
t.Fatalf("login=%d solver=%d vehicles=%d", loginCalls, solver.calls, vehicleCalls)
}
}

View File

@@ -0,0 +1,481 @@
package feichibridge
import (
"encoding/binary"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
)
const (
CommandRealtime = byte(0x02)
CommandReissue = byte(0x03)
CommandLogin = byte(0x05)
)
type Encoder struct{}
func (e Encoder) DataFrame(command byte, vin string, at time.Time, record Record) ([]byte, error) {
if command != CommandRealtime && command != CommandReissue {
return nil, fmt.Errorf("unsupported data command 0x%02X", command)
}
body := encodeTime(at)
units := 0
if unit, ok := wholeVehicleUnit(record); ok {
body = append(body, 0x01)
body = append(body, unit...)
units++
}
if unit, ok := motorUnit(record); ok {
body = append(body, 0x02)
body = append(body, unit...)
units++
}
if unit, ok := fuelCellUnit(record); ok {
body = append(body, 0x03)
body = append(body, unit...)
units++
}
if unit, ok := positionUnit(record); ok {
body = append(body, 0x05)
body = append(body, unit...)
units++
}
if unit, ok := extremeUnit(record); ok {
body = append(body, 0x06)
body = append(body, unit...)
units++
}
if unit, ok := alarmUnit(record); ok {
body = append(body, 0x07)
body = append(body, unit...)
units++
}
for _, unit := range voltageUnits(record) {
body = append(body, 0x08)
body = append(body, unit...)
units++
}
for _, unit := range temperatureUnits(record) {
body = append(body, 0x09)
body = append(body, unit...)
units++
}
if units == 0 {
return nil, errors.New("source record has no mappable GB/T 32960 units")
}
return buildFrame('#', command, 0xFE, vin, body)
}
func LoginFrame(platformID, username, password string, serial uint16, now time.Time) ([]byte, error) {
if len(username) > 12 {
return nil, errors.New("GB/T 32960 platform username exceeds 12 bytes")
}
if len(password) > 20 {
return nil, errors.New("GB/T 32960 platform password exceeds 20 bytes")
}
body := encodeTime(now)
body = binary.BigEndian.AppendUint16(body, serial)
body = appendPaddedASCII(body, username, 12)
body = appendPaddedASCII(body, password, 20)
body = append(body, 0x01)
return buildFrame('#', CommandLogin, 0xFE, platformID, body)
}
func buildFrame(start, command, response byte, vin string, body []byte) ([]byte, error) {
if len(vin) != 17 {
return nil, fmt.Errorf("GB/T 32960 identifier must be exactly 17 bytes, got %d", len(vin))
}
if len(body) > math.MaxUint16 {
return nil, errors.New("GB/T 32960 body exceeds 65535 bytes")
}
frame := make([]byte, 24, 25+len(body))
frame[0], frame[1] = start, start
frame[2], frame[3] = command, response
copy(frame[4:21], vin)
frame[21] = 0x01
binary.BigEndian.PutUint16(frame[22:24], uint16(len(body)))
frame = append(frame, body...)
frame = append(frame, bcc(frame[2:]))
return frame, nil
}
func wholeVehicleUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2201", "2202", "3201", "7615") {
return nil, false
}
out := make([]byte, 20)
out[0] = enum(recordValue(record, "3201"), map[string]byte{
"启动": 1, "启动状态": 1, "行驶": 1, "熄火": 2, "熄火状态": 2, "其他": 3,
}, 0xFE)
out[1] = enum(recordValue(record, "2301"), map[string]byte{
"停车充电": 1, "行驶充电": 2, "未充电": 3, "未充电状态": 3, "充电完成": 4,
}, 0xFE)
out[2] = enum(recordValue(record, "2213"), map[string]byte{
"纯电": 1, "纯电动": 1, "混动": 2, "混合动力": 2, "燃油": 3,
}, 0xFE)
putScaledU16(out[3:5], recordValue(record, "2201"), 10, 0)
putScaledU32(out[5:9], recordValue(record, "2202"), 10)
putScaledU16(out[9:11], recordValue(record, "2613"), 10, 0)
putScaledU16(out[11:13], recordValue(record, "2614"), 10, 1000)
out[13] = byteValue(recordValue(record, "7615"))
out[14] = enum(recordValue(record, "2214"), map[string]byte{"工作": 1, "断开": 2}, 0xFE)
out[15] = gearValue(firstField(recordValue(record, "2203")))
putU16(out[16:18], recordValue(record, "2617"))
out[18] = byteValue(recordValue(record, "2208"))
out[19] = byteValue(recordValue(record, "2209"))
return out, true
}
func motorUnit(record Record) ([]byte, bool) {
// The source carries every motor as a key:value composite separated by "|".
// Fields are the GB/T 32960 sequence numbers exposed by the platform metadata.
composite := recordValue(record, "2308")
if composite == "" {
return nil, false
}
groups := parseCompositeGroups(composite)
if len(groups) == 0 || len(groups) > 253 {
return nil, false
}
out := []byte{byte(len(groups))}
for index, group := range groups {
out = append(out, byteValue(firstNonempty(group["2302"], strconv.Itoa(index+1))))
out = append(out, enum(group["2303"], map[string]byte{
"耗电": 1, "发电": 2, "关闭": 3, "准备": 4, "准备状态": 4,
}, 0xFE))
out = append(out, tempByte(group["2304"]))
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2305"], 1, 20000))
out = binary.BigEndian.AppendUint16(out, offsetU16(group["2306"], 10, 2000))
out = append(out, tempByte(group["2309"]))
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2311"], 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(group["2312"], 10, 1000))
}
return out, true
}
func fuelCellUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2110", "2111", "2112", "2117", "2119") {
return nil, false
}
temperatureGroups := parseSeriesGroups(recordValue(record, "2103"))
var temperatures []string
for _, group := range temperatureGroups {
temperatures = append(temperatures, group.values...)
}
if len(temperatures) > math.MaxUint16 {
temperatures = temperatures[:math.MaxUint16]
}
out := binary.BigEndian.AppendUint16(nil, scaledU16(recordValue(record, "2110"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2111"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2112"), 100, 0))
out = binary.BigEndian.AppendUint16(out, uint16(len(temperatures)))
for _, value := range temperatures {
out = append(out, tempByte(value))
}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2115"), 10, 40))
out = append(out, byteValue(recordValue(record, "2116")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2117"), 1, 0))
out = append(out, byteValue(recordValue(record, "2118")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2119"), 10, 0))
out = append(out, byteValue(recordValue(record, "2120")))
out = append(out, enum(recordValue(record, "2121"), map[string]byte{"工作": 1, "断开": 2}, 0xFE))
return out, true
}
func positionUnit(record Record) ([]byte, bool) {
longitude, lonOK := floatValue(recordValue(record, "2502"))
latitude, latOK := floatValue(recordValue(record, "2503"))
if !lonOK || !latOK || longitude < 0 || latitude < 0 {
return nil, false
}
status := byte(0)
text := strings.TrimSpace(recordValue(record, "2501"))
if text != "" && (strings.Contains(text, "无效") || text == "1") {
status = 1
}
out := []byte{status}
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(longitude*1_000_000)))
out = binary.BigEndian.AppendUint32(out, uint32(math.Round(latitude*1_000_000)))
return out, true
}
func extremeUnit(record Record) ([]byte, bool) {
if !hasAny(record, "2601", "2602", "2603", "2604", "2605", "2606", "2607", "2608", "2609", "2610", "2611", "2612") {
return nil, false
}
out := []byte{
byteValue(recordValue(record, "2601")),
byteValue(recordValue(record, "2602")),
}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2603"), 1000, 0))
out = append(out, byteValue(recordValue(record, "2604")), byteValue(recordValue(record, "2605")))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2606"), 1000, 0))
out = append(out,
byteValue(recordValue(record, "2607")),
byteValue(recordValue(record, "2608")),
tempByte(recordValue(record, "2609")),
byteValue(recordValue(record, "2610")),
byteValue(recordValue(record, "2611")),
tempByte(recordValue(record, "2612")),
)
return out, true
}
func alarmUnit(record Record) ([]byte, bool) {
levelRaw := recordValue(record, "2900", "2901")
var general uint32
for bit := 0; bit < 32; bit++ {
value := recordValue(record, strconv.Itoa(2901+bit))
if truthy(value) {
general |= 1 << bit
}
}
if strings.TrimSpace(levelRaw) == "" && general == 0 {
return nil, false
}
out := []byte{byteValue(levelRaw)}
out = binary.BigEndian.AppendUint32(out, general)
out = append(out, 0, 0, 0, 0)
return out, true
}
func voltageUnits(record Record) [][]byte {
raw := recordValue(record, "2003", "batteryVoltages")
groups := parseSeriesGroups(raw)
var units [][]byte
for _, group := range groups {
for start := 0; start < len(group.values); start += 255 {
end := start + 255
if end > len(group.values) {
end = len(group.values)
}
out := []byte{1, byte(group.id)}
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2613"), 10, 0))
out = binary.BigEndian.AppendUint16(out, scaledU16(recordValue(record, "2614"), 10, 1000))
out = binary.BigEndian.AppendUint16(out, uint16(len(group.values)))
out = binary.BigEndian.AppendUint16(out, uint16(start+1))
out = append(out, byte(end-start))
for _, value := range group.values[start:end] {
out = binary.BigEndian.AppendUint16(out, scaledU16(value, 1000, 0))
}
units = append(units, out)
}
}
return units
}
func temperatureUnits(record Record) [][]byte {
groups := parseSeriesGroups(recordValue(record, "2103", "batteryTemperatures"))
var units [][]byte
for _, group := range groups {
for start := 0; start < len(group.values); start += math.MaxUint16 {
end := start + math.MaxUint16
if end > len(group.values) {
end = len(group.values)
}
out := []byte{1, byte(group.id)}
out = binary.BigEndian.AppendUint16(out, uint16(end-start))
for _, value := range group.values[start:end] {
out = append(out, tempByte(value))
}
units = append(units, out)
}
}
return units
}
func parseCompositeGroups(raw string) []map[string]string {
var groups []map[string]string
for _, encoded := range strings.Split(raw, "|") {
group := map[string]string{}
for _, field := range strings.Split(encoded, ",") {
key, value, found := strings.Cut(field, ":")
if found {
group[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
}
if len(group) > 0 {
groups = append(groups, group)
}
}
return groups
}
type seriesGroup struct {
id int
values []string
}
func parseSeriesGroups(raw string) []seriesGroup {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var groups []seriesGroup
for index, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ';' || r == '|' }) {
group := seriesGroup{id: index + 1}
if before, after, found := strings.Cut(part, ":"); found {
if parsed, err := strconv.Atoi(strings.TrimSpace(before)); err == nil && parsed > 0 && parsed <= 255 {
group.id = parsed
}
part = after
}
for _, value := range strings.FieldsFunc(part, func(r rune) bool {
return r == '_' || r == ',' || r == ' ' || r == '[' || r == ']'
}) {
if strings.TrimSpace(value) != "" {
group.values = append(group.values, strings.TrimSpace(value))
}
}
if len(group.values) > 0 {
groups = append(groups, group)
}
}
return groups
}
func recordValue(record Record, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(record[key]); value != "" && value != "--" && value != "null" {
return value
}
}
return ""
}
func firstField(value string) string {
fields := strings.Fields(value)
if len(fields) == 0 {
return ""
}
return fields[0]
}
func firstNonempty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func hasAny(record Record, keys ...string) bool { return recordValue(record, keys...) != "" }
func floatValue(raw string) (float64, bool) {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "%"))
if raw == "" {
return 0, false
}
value, err := strconv.ParseFloat(raw, 64)
return value, err == nil && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func byteValue(raw string) byte {
value, ok := floatValue(raw)
if !ok || value < 0 || value > 253 {
return 0xFE
}
return byte(math.Round(value))
}
func tempByte(raw string) byte {
value, ok := floatValue(raw)
if !ok || value < -40 || value > 210 {
return 0xFE
}
return byte(math.Round(value + 40))
}
func putU16(out []byte, raw string) { binary.BigEndian.PutUint16(out, scaledU16(raw, 1, 0)) }
func putScaledU16(out []byte, raw string, scale, offset float64) {
binary.BigEndian.PutUint16(out, scaledU16(raw, scale, offset))
}
func putScaledU32(out []byte, raw string, scale float64) {
value, ok := floatValue(raw)
if !ok || value < 0 || value*scale > math.MaxUint32-2 {
binary.BigEndian.PutUint32(out, 0xFFFFFFFE)
return
}
binary.BigEndian.PutUint32(out, uint32(math.Round(value*scale)))
}
func scaledU16(raw string, scale, offset float64) uint16 {
value, ok := floatValue(raw)
encoded := (value + offset) * scale
if !ok || encoded < 0 || encoded > math.MaxUint16-2 {
return 0xFFFE
}
return uint16(math.Round(encoded))
}
func offsetU16(raw string, scale, offset float64) uint16 {
return scaledU16(raw, scale, offset)
}
func enum(raw string, values map[string]byte, missing byte) byte {
raw = strings.TrimSpace(raw)
if raw == "" {
return missing
}
if numeric, err := strconv.ParseUint(raw, 10, 8); err == nil {
return byte(numeric)
}
for label, value := range values {
if raw == label || strings.Contains(raw, label) {
return value
}
}
return missing
}
func gearValue(raw string) byte {
raw = strings.ToUpper(strings.TrimSpace(raw))
switch raw {
case "P", "P档":
return 15
case "R", "R档":
return 13
case "N", "N档":
return 0
case "D", "D档":
return 14
}
raw = strings.TrimSuffix(raw, "档")
raw = strings.TrimPrefix(raw, "D")
return byteValue(raw)
}
func truthy(raw string) bool {
raw = strings.ToLower(strings.TrimSpace(raw))
return raw != "" && raw != "0" && raw != "false" && raw != "无" && raw != "正常"
}
func appendPaddedASCII(out []byte, value string, width int) []byte {
start := len(out)
out = append(out, make([]byte, width)...)
copy(out[start:start+width], value)
return out
}
func encodeTime(value time.Time) []byte {
local := value.In(shanghai)
return []byte{
byte(local.Year() - 2000), byte(local.Month()), byte(local.Day()),
byte(local.Hour()), byte(local.Minute()), byte(local.Second()),
}
}
func bcc(value []byte) byte {
var result byte
for _, current := range value {
result ^= current
}
return result
}

View File

@@ -0,0 +1,96 @@
package feichibridge
import (
"encoding/binary"
"fmt"
"strings"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
)
func TestDataFrameMapsFeichiFields(t *testing.T) {
at := time.Date(2026, 7, 17, 14, 5, 6, 0, shanghai)
record := Record{
"2000": "2026-07-17 14:05:06",
"2201": "42.3", "2202": "12345.6", "2203": "D 0 0",
"2208": "35", "2209": "0", "2213": "纯电",
"2214": "工作", "2301": "未充电状态", "3201": "启动状态",
"2613": "550.2", "2614": "-20.5", "2617": "2200", "7615": "78",
"2501": "有效定位", "2502": "113.2644", "2503": "23.1291",
"2601": "1", "2602": "8", "2603": "3.955",
"2604": "1", "2605": "26", "2606": "3.821",
"2607": "1", "2608": "3", "2609": "48",
"2610": "1", "2611": "9", "2612": "37",
"2003": "1:3.900_3.901_3.902",
"2103": "1:35_36_37", "2115": "44", "2119": "6.0", "100016": "20.8",
}
frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", at, record)
if err != nil {
t.Fatal(err)
}
env, err := gb32960.ParseFrame(frame, at.UnixMilli(), "test")
if err != nil {
t.Fatal(err)
}
if env.MessageID != "0x02" || env.VIN != "LTEST32960VIN0001" {
t.Fatalf("unexpected envelope: %#v", env)
}
if got := env.Fields["speed_kmh"]; got != 42.3 {
t.Fatalf("speed = %#v", got)
}
if got := env.Fields["total_mileage_km"]; got != 12345.6 {
t.Fatalf("mileage = %#v", got)
}
if got := env.Fields["soc_percent"]; got != 78 {
t.Fatalf("soc = %#v", got)
}
if got := env.Fields["longitude"]; got != 113.2644 {
t.Fatalf("longitude = %#v", got)
}
if _, exists := env.Fields["gd_fc_vehicle_hydrogen_mass_kg"]; exists {
t.Fatal("bridge must not derive hydrogen mass from platform percentage")
}
}
func TestDataFrameSplitsMoreThan255CellVoltages(t *testing.T) {
values := make([]string, 288)
for index := range values {
values[index] = fmt.Sprintf("%.3f", 3.5+float64(index)/1000)
}
frame, err := (Encoder{}).DataFrame(CommandReissue, "LTEST32960VIN0002", time.Now(), Record{
"2003": "1:" + strings.Join(values, "_"),
"2613": "530",
"2614": "10",
})
if err != nil {
t.Fatal(err)
}
body := frame[24 : len(frame)-1]
if got := strings.Count(string(body), string([]byte{0x08})); got < 2 {
t.Fatalf("expected at least two voltage units, got %d", got)
}
if binary.BigEndian.Uint16(frame[22:24]) != uint16(len(body)) {
t.Fatal("body length mismatch")
}
if _, err := gb32960.ParseFrame(frame, time.Now().UnixMilli(), "test"); err != nil {
t.Fatal(err)
}
}
func TestLoginFrameLayout(t *testing.T) {
frame, err := LoginFrame("FEICHIBRIDGE00001", "bridge", "secret", 7, time.Date(2026, 7, 17, 1, 2, 3, 0, shanghai))
if err != nil {
t.Fatal(err)
}
if frame[2] != CommandLogin || frame[3] != 0xFE {
t.Fatalf("unexpected login header: %x", frame[:4])
}
if got := binary.BigEndian.Uint16(frame[30:32]); got != 7 {
t.Fatalf("serial = %d", got)
}
if got, want := frame[len(frame)-1], bcc(frame[2:len(frame)-1]); got != want {
t.Fatalf("BCC = %x, want %x", got, want)
}
}

View File

@@ -0,0 +1,209 @@
package feichibridge
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
const feichiPublicKey = `-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOwmFtEk1oJxDU0NI4kVO0Jx0X
nt+Abx+JKGUzcRzPwjUkd5z9Ice5rh87CCmj0XjZ5pPac6TtA3f0v5FqiK/kjQY5
XMLti4weJ4dcp1/q1O7PCYxRX8WgetGxwsjxGn+uoZOZkclN1PFS4wRnKEso6+G/
e60QGB29cZoo4jZnZwIDAQAB
-----END PUBLIC KEY-----`
type LoginCredentials struct {
Username string
Password string
MaxAttempts int
RetryDelay time.Duration
}
type loginSession struct {
credentials LoginCredentials
solver CaptchaSolver
mu sync.Mutex
ready atomic.Bool
csrf atomic.Value
token atomic.Value
}
type firstLoginEnvelope struct {
Code any `json:"code"`
}
type captchaPayload struct {
Src string `json:"src"`
}
type loginPayload struct {
Token string `json:"token"`
}
func newLoginSession(credentials LoginCredentials, solver CaptchaSolver) (*loginSession, error) {
credentials.Username = strings.TrimSpace(credentials.Username)
if credentials.Username == "" || credentials.Password == "" {
return nil, errors.New("Feichi username and password are required")
}
if solver == nil {
return nil, errors.New("captcha solver is required")
}
if credentials.MaxAttempts <= 0 {
credentials.MaxAttempts = 20
}
if credentials.RetryDelay <= 0 {
credentials.RetryDelay = time.Second
}
session := &loginSession{credentials: credentials, solver: solver}
session.csrf.Store("")
session.token.Store("")
return session, nil
}
func (s *loginSession) invalidate() {
s.ready.Store(false)
}
func (s *loginSession) cookieHeader() string {
var cookies []string
if csrf := s.csrf.Load().(string); csrf != "" {
cookies = append(cookies, "CSRF="+csrf)
}
if token := s.token.Load().(string); token != "" {
cookies = append(cookies, "R_SESS="+token)
}
return strings.Join(cookies, "; ")
}
func (s *loginSession) csrfHeader() string {
return s.csrf.Load().(string)
}
func (s *loginSession) ensure(ctx context.Context, client *APIClient) error {
if s.ready.Load() {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if s.ready.Load() {
return nil
}
var lastErr error
for attempt := 1; attempt <= s.credentials.MaxAttempts; attempt++ {
if err := s.loginAttempt(ctx, client); err == nil {
s.ready.Store(true)
return nil
} else {
lastErr = err
}
if attempt < s.credentials.MaxAttempts {
timer := time.NewTimer(s.credentials.RetryDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
return fmt.Errorf("Feichi automatic login failed after %d attempts: %w", s.credentials.MaxAttempts, lastErr)
}
func (s *loginSession) loginAttempt(ctx context.Context, client *APIClient) error {
s.ready.Store(false)
s.token.Store("")
var first firstLoginEnvelope
headers, err := client.rawJSON(ctx, http.MethodGet, "api/v1/first-login", nil, &first)
if err != nil {
return fmt.Errorf("initialize login session: %w", err)
}
if !apiCodeOK(first.Code) {
return apiCodeError(first.Code)
}
csrf := strings.TrimSpace(headers.Get("x-api-csrf"))
if csrf == "" {
for _, rawCookie := range headers.Values("Set-Cookie") {
cookie, parseErr := http.ParseSetCookie(rawCookie)
if parseErr == nil && cookie.Name == "CSRF" {
csrf = cookie.Value
break
}
}
}
if csrf == "" {
return errors.New("first-login response did not provide CSRF")
}
s.csrf.Store(csrf)
var captcha objectEnvelope[captchaPayload]
if _, err := client.rawJSON(ctx, http.MethodGet, "api/v1/login/randCode", nil, &captcha); err != nil {
return fmt.Errorf("request captcha: %w", err)
}
if !apiCodeOK(captcha.Code) {
return apiCodeError(captcha.Code)
}
encodedImage := captcha.Data.Src
if comma := strings.IndexByte(encodedImage, ','); comma >= 0 {
encodedImage = encodedImage[comma+1:]
}
image, err := base64.StdEncoding.DecodeString(encodedImage)
if err != nil {
return fmt.Errorf("decode captcha image: %w", err)
}
validCode, err := s.solver.Solve(ctx, image)
if err != nil {
return err
}
encrypted, err := encryptFeichiPassword(s.credentials.Password)
if err != nil {
return err
}
var login objectEnvelope[loginPayload]
if _, err := client.rawJSON(ctx, http.MethodPost, "api/v1/login", map[string]string{
"username": s.credentials.Username,
"password": encrypted,
"validCode": validCode,
}, &login); err != nil {
return fmt.Errorf("submit login: %w", err)
}
if !apiCodeOK(login.Code) {
return apiCodeError(login.Code)
}
token := strings.TrimSpace(login.Data.Token)
if token == "" {
return errors.New("login succeeded without R_SESS token")
}
s.token.Store(token)
return nil
}
func encryptFeichiPassword(password string) (string, error) {
block, _ := pem.Decode([]byte(feichiPublicKey))
if block == nil {
return "", errors.New("decode Feichi RSA public key")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("parse Feichi RSA public key: %w", err)
}
publicKey, ok := parsed.(*rsa.PublicKey)
if !ok {
return "", errors.New("Feichi public key is not RSA")
}
encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(password))
if err != nil {
return "", fmt.Errorf("encrypt Feichi password: %w", err)
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}

View File

@@ -0,0 +1,94 @@
package feichibridge
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
var shanghai = time.FixedZone("Asia/Shanghai", 8*60*60)
type Vehicle struct {
VehicleID string `json:"vehicleId"`
VIN string `json:"vin"`
OnlineStatus any `json:"onlineStatus"`
UpdateTime string `json:"updateTime"`
RuleTypeName string `json:"ruleTypeName"`
}
type Snapshot struct {
VehicleID string `json:"vehicleId"`
VIN string `json:"vin"`
DataItems map[string]string `json:"dataItems"`
}
type Record map[string]string
type collectionEnvelope[T any] struct {
Code any `json:"code"`
Data []T `json:"data"`
}
type objectEnvelope[T any] struct {
Code any `json:"code"`
Data T `json:"data"`
}
type historySegment struct {
Subdata []Record `json:"subdata"`
}
func apiCodeOK(code any) bool {
switch value := code.(type) {
case nil:
return true
case float64:
return value == 0 || value == 200
case string:
return value == "" || value == "0" || value == "200"
default:
return false
}
}
func recordTime(record Record) (time.Time, error) {
for _, key := range []string{"2000", "9999", "dataTime", "updateTime", "time"} {
if raw := strings.TrimSpace(record[key]); raw != "" {
return parseSourceTime(raw)
}
}
return time.Time{}, fmt.Errorf("source record has no timestamp")
}
func parseSourceTime(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
for _, layout := range []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
time.RFC3339Nano,
} {
if layout == time.RFC3339Nano {
if parsed, err := time.Parse(layout, raw); err == nil {
return parsed, nil
}
continue
}
if parsed, err := time.ParseInLocation(layout, raw, shanghai); err == nil {
return parsed, nil
}
}
if unixMS, err := strconv.ParseInt(raw, 10, 64); err == nil {
if unixMS < 10_000_000_000 {
return time.Unix(unixMS, 0).In(shanghai), nil
}
return time.UnixMilli(unixMS).In(shanghai), nil
}
return time.Time{}, fmt.Errorf("unsupported source timestamp %q", raw)
}
func canonicalHash(record Record) string {
encoded, _ := json.Marshal(record)
return hashBytes(encoded)
}

View File

@@ -0,0 +1,469 @@
package feichibridge
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
type Source interface {
Vehicles(context.Context) ([]Vehicle, error)
Snapshot(context.Context, string) (Snapshot, error)
History(context.Context, string, time.Time, time.Time) ([]Record, error)
}
type FrameTarget interface {
Connect(context.Context) error
Send(context.Context, []byte) error
Close() error
LastACK() time.Time
}
type ServiceConfig struct {
PollInterval time.Duration
DiscoveryInterval time.Duration
BackfillInterval time.Duration
BackfillLookback time.Duration
BackfillWindow time.Duration
BackfillSafetyLag time.Duration
SourceStaleAfter time.Duration
FetchConcurrency int
BackfillEnabled bool
StaleReissueEnabled bool
}
type Service struct {
config ServiceConfig
source Source
target FrameTarget
state *StateStore
encoder Encoder
logger *slog.Logger
metrics *metrics.Registry
mu sync.RWMutex
vehicles []Vehicle
lastSourceSuccess time.Time
lastError error
}
func NewService(config ServiceConfig, source Source, target FrameTarget, state *StateStore, logger *slog.Logger, registry *metrics.Registry) (*Service, error) {
if source == nil || target == nil || state == nil {
return nil, errors.New("source, target, and state are required")
}
if config.PollInterval <= 0 {
config.PollInterval = 10 * time.Second
}
if config.DiscoveryInterval <= 0 {
config.DiscoveryInterval = 5 * time.Minute
}
if config.BackfillInterval <= 0 {
config.BackfillInterval = time.Hour
}
if config.BackfillLookback <= 0 {
config.BackfillLookback = time.Hour
}
if config.BackfillWindow <= 0 {
config.BackfillWindow = 20 * time.Minute
}
if config.BackfillSafetyLag <= 0 {
config.BackfillSafetyLag = 30 * time.Second
}
if config.SourceStaleAfter <= 0 {
config.SourceStaleAfter = 2 * time.Minute
}
if config.FetchConcurrency <= 0 {
config.FetchConcurrency = 4
}
if logger == nil {
logger = slog.Default()
}
return &Service{
config: config, source: source, target: target, state: state,
logger: logger, metrics: registry,
}, nil
}
func (s *Service) Run(ctx context.Context) error {
if err := s.target.Connect(ctx); err != nil {
s.setError(err)
return fmt.Errorf("connect GB/T 32960 target: %w", err)
}
if err := s.discover(ctx); err != nil {
s.setError(err)
return fmt.Errorf("initial vehicle discovery: %w", err)
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
s.discoveryLoop(ctx)
}()
go func() {
defer wg.Done()
s.realtimeLoop(ctx)
}()
if s.config.BackfillEnabled {
wg.Add(1)
go func() {
defer wg.Done()
s.backfillLoop(ctx)
}()
}
<-ctx.Done()
_ = s.target.Close()
wg.Wait()
return nil
}
func (s *Service) Ready(context.Context) error {
s.mu.RLock()
lastSourceSuccess := s.lastSourceSuccess
lastErr := s.lastError
s.mu.RUnlock()
maxSourceAge := max(3*s.config.PollInterval, time.Minute)
if lastSourceSuccess.IsZero() || time.Since(lastSourceSuccess) > maxSourceAge {
if lastErr != nil {
return fmt.Errorf("source unavailable: %w", lastErr)
}
return errors.New("source has not completed a successful request")
}
lastACK := s.target.LastACK()
if lastACK.IsZero() || time.Since(lastACK) > max(3*s.config.DiscoveryInterval, 10*time.Minute) {
return errors.New("GB/T 32960 target has no recent ACK")
}
return nil
}
func (s *Service) discoveryLoop(ctx context.Context) {
ticker := time.NewTicker(s.config.DiscoveryInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := s.discover(ctx); err != nil {
s.recordFailure("discover", err)
}
}
}
}
func (s *Service) realtimeLoop(ctx context.Context) {
s.pollRealtime(ctx)
ticker := time.NewTicker(s.config.PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.pollRealtime(ctx)
}
}
}
func (s *Service) backfillLoop(ctx context.Context) {
s.runBackfill(ctx)
ticker := time.NewTicker(s.config.BackfillInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runBackfill(ctx)
}
}
}
func (s *Service) discover(ctx context.Context) error {
start := time.Now()
vehicles, err := s.source.Vehicles(ctx)
s.observeAPI("vehicles", start, err)
if err != nil {
return err
}
filtered := make([]Vehicle, 0, len(vehicles))
for _, vehicle := range vehicles {
vehicle.VIN = strings.TrimSpace(vehicle.VIN)
vehicle.VehicleID = strings.TrimSpace(vehicle.VehicleID)
if len(vehicle.VIN) != 17 || vehicle.VehicleID == "" {
continue
}
if vehicle.RuleTypeName != "" && !strings.Contains(strings.ToUpper(vehicle.RuleTypeName), "32960") {
continue
}
filtered = append(filtered, vehicle)
}
sort.Slice(filtered, func(i, j int) bool { return filtered[i].VIN < filtered[j].VIN })
s.mu.Lock()
s.vehicles = filtered
s.lastSourceSuccess = time.Now()
s.lastError = nil
s.mu.Unlock()
if s.metrics != nil {
s.metrics.SetGauge("vehicle_feichi_bridge_vehicles", nil, float64(len(filtered)))
}
s.logger.Info("feichi vehicles discovered", "count", len(filtered))
return nil
}
type fetchedSnapshot struct {
vehicle Vehicle
record Record
at time.Time
hash string
err error
duration time.Duration
}
func (s *Service) pollRealtime(ctx context.Context) {
vehicles := s.vehicleSnapshot()
jobs := make(chan Vehicle)
results := make(chan fetchedSnapshot, len(vehicles))
var wg sync.WaitGroup
for worker := 0; worker < min(s.config.FetchConcurrency, len(vehicles)); worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for vehicle := range jobs {
start := time.Now()
snapshot, err := s.source.Snapshot(ctx, vehicle.VehicleID)
result := fetchedSnapshot{vehicle: vehicle, duration: time.Since(start), err: err}
if err == nil {
result.record = Record(snapshot.DataItems)
result.at, result.err = recordTime(result.record)
result.hash = canonicalHash(result.record)
}
results <- result
}
}()
}
for _, vehicle := range vehicles {
jobs <- vehicle
}
close(jobs)
wg.Wait()
close(results)
var snapshots []fetchedSnapshot
for result := range results {
s.observeAPIWithDuration("snapshot", result.duration, result.err)
if result.err != nil {
s.recordFailure("snapshot", fmt.Errorf("VIN %s: %w", result.vehicle.VIN, result.err))
continue
}
snapshots = append(snapshots, result)
s.markSourceSuccess()
}
sort.Slice(snapshots, func(i, j int) bool {
if snapshots[i].at.Equal(snapshots[j].at) {
return snapshots[i].vehicle.VIN < snapshots[j].vehicle.VIN
}
return snapshots[i].at.Before(snapshots[j].at)
})
for _, snapshot := range snapshots {
if time.Since(snapshot.at) > s.config.SourceStaleAfter {
if s.config.StaleReissueEnabled {
s.reissueStaleSnapshot(ctx, snapshot)
}
continue
}
current := s.state.Vehicle(snapshot.vehicle.VIN)
if snapshot.at.Before(current.LastRealtimeTime) ||
(snapshot.at.Equal(current.LastRealtimeTime) && snapshot.hash == current.LastRealtimeHash) {
continue
}
frame, err := s.encoder.DataFrame(CommandRealtime, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
if err != nil {
s.recordFailure("encode", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
continue
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandRealtime, "error", snapshot.vehicle.VIN, snapshot.at)
s.recordFailure("send", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
continue
}
if err := s.state.CommitRealtime(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
s.recordFailure("state", err)
continue
}
s.recordFrame(CommandRealtime, "acked", snapshot.vehicle.VIN, snapshot.at)
}
}
func (s *Service) reissueStaleSnapshot(ctx context.Context, snapshot fetchedSnapshot) {
current := s.state.Vehicle(snapshot.vehicle.VIN)
if snapshot.at.Before(current.LastSnapshotReissueTime) ||
(snapshot.at.Equal(current.LastSnapshotReissueTime) && snapshot.hash == current.LastSnapshotReissueHash) {
return
}
frame, err := s.encoder.DataFrame(CommandReissue, snapshot.vehicle.VIN, snapshot.at, snapshot.record)
if err != nil {
s.recordFailure("encode_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
return
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandReissue, "error", snapshot.vehicle.VIN, snapshot.at)
s.recordFailure("send_stale_reissue", fmt.Errorf("VIN %s: %w", snapshot.vehicle.VIN, err))
return
}
if err := s.state.CommitSnapshotReissue(snapshot.vehicle.VIN, snapshot.at, snapshot.hash); err != nil {
s.recordFailure("state", err)
return
}
s.recordFrame(CommandReissue, "acked", snapshot.vehicle.VIN, snapshot.at)
}
func (s *Service) runBackfill(ctx context.Context) {
for _, vehicle := range s.vehicleSnapshot() {
if ctx.Err() != nil {
return
}
if err := s.backfillVehicle(ctx, vehicle); err != nil {
s.recordFailure("backfill", fmt.Errorf("VIN %s: %w", vehicle.VIN, err))
}
}
}
func (s *Service) backfillVehicle(ctx context.Context, vehicle Vehicle) error {
end := time.Now().Add(-s.config.BackfillSafetyLag)
cursor := s.state.Vehicle(vehicle.VIN).BackfillCursor
if cursor.IsZero() {
cursor = end.Add(-s.config.BackfillLookback)
}
for cursor.Before(end) {
windowEnd := cursor.Add(s.config.BackfillWindow)
if windowEnd.After(end) {
windowEnd = end
}
start := time.Now()
records, err := s.source.History(ctx, vehicle.VIN, cursor, windowEnd)
s.observeAPI("history", start, err)
if err != nil {
return err
}
sort.Slice(records, func(i, j int) bool {
left, _ := recordTime(records[i])
right, _ := recordTime(records[j])
return left.Before(right)
})
committed := cursor
for _, record := range records {
at, err := recordTime(record)
if err != nil || !at.After(cursor) || at.After(windowEnd) {
continue
}
frame, err := s.encoder.DataFrame(CommandReissue, vehicle.VIN, at, record)
if err != nil {
return err
}
if err := s.target.Send(ctx, frame); err != nil {
s.recordFrame(CommandReissue, "error", vehicle.VIN, at)
return err
}
if err := s.state.CommitBackfill(vehicle.VIN, at); err != nil {
return err
}
s.recordFrame(CommandReissue, "acked", vehicle.VIN, at)
committed = at
}
if !committed.After(cursor) || committed.Before(windowEnd) {
if err := s.state.AdvanceBackfillCursor(vehicle.VIN, windowEnd); err != nil {
return err
}
}
cursor = windowEnd
s.markSourceSuccess()
}
return nil
}
func (s *Service) vehicleSnapshot() []Vehicle {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]Vehicle(nil), s.vehicles...)
}
func (s *Service) markSourceSuccess() {
s.mu.Lock()
s.lastSourceSuccess = time.Now()
s.lastError = nil
s.mu.Unlock()
if s.metrics != nil {
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_source_last_success_unix_seconds", nil)
}
}
func (s *Service) setError(err error) {
s.mu.Lock()
s.lastError = err
s.mu.Unlock()
}
func (s *Service) recordFailure(operation string, err error) {
if err == nil {
return
}
s.setError(err)
if s.metrics != nil {
s.metrics.IncCounter("vehicle_feichi_bridge_errors_total", metrics.Labels{"operation": operation})
}
s.logger.Error("feichi bridge operation failed", "operation", operation, "error", err)
}
func (s *Service) observeAPI(operation string, start time.Time, err error) {
s.observeAPIWithDuration(operation, time.Since(start), err)
}
func (s *Service) observeAPIWithDuration(operation string, duration time.Duration, err error) {
if s.metrics == nil {
return
}
status := "ok"
if err != nil {
status = "error"
}
s.metrics.IncCounter("vehicle_feichi_bridge_api_requests_total", metrics.Labels{"operation": operation, "status": status})
s.metrics.ObserveHistogram(
"vehicle_feichi_bridge_api_request_duration_seconds",
metrics.Labels{"operation": operation},
[]float64{0.1, 0.25, 0.5, 1, 2, 5},
duration.Seconds(),
)
}
func (s *Service) recordFrame(command byte, status, vin string, sourceTime time.Time) {
if s.metrics != nil {
s.metrics.IncCounter("vehicle_feichi_bridge_frames_total", metrics.Labels{
"command": fmt.Sprintf("0x%02X", command),
"status": status,
})
}
if status == "acked" {
if s.metrics != nil {
metrics.RecordLastActivity(s.metrics, "vehicle_feichi_bridge_target_last_ack_unix_seconds", nil)
s.metrics.SetGauge(
"vehicle_feichi_bridge_vehicle_last_ack_unix_seconds",
metrics.Labels{"vin": vin, "command": fmt.Sprintf("0x%02X", command)},
float64(time.Now().Unix()),
)
}
s.logger.Info(
"GB/T 32960 vehicle frame acknowledged",
"vin", vin,
"command", fmt.Sprintf("0x%02X", command),
"source_time", sourceTime,
)
}
}

View File

@@ -0,0 +1,123 @@
package feichibridge
import (
"context"
"errors"
"log/slog"
"path/filepath"
"sync"
"testing"
"time"
)
type fakeSource struct {
vehicles []Vehicle
record Record
}
func (f *fakeSource) Vehicles(context.Context) ([]Vehicle, error) {
return append([]Vehicle(nil), f.vehicles...), nil
}
func (f *fakeSource) Snapshot(context.Context, string) (Snapshot, error) {
return Snapshot{DataItems: map[string]string(f.record)}, nil
}
func (f *fakeSource) History(context.Context, string, time.Time, time.Time) ([]Record, error) {
return nil, nil
}
type fakeTarget struct {
mu sync.Mutex
frames [][]byte
sendErr error
lastACK time.Time
}
func (f *fakeTarget) Connect(context.Context) error { return nil }
func (f *fakeTarget) Close() error { return nil }
func (f *fakeTarget) LastACK() time.Time { return f.lastACK }
func (f *fakeTarget) Send(_ context.Context, frame []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.sendErr != nil {
return f.sendErr
}
f.frames = append(f.frames, append([]byte(nil), frame...))
f.lastACK = time.Now()
return nil
}
func TestRealtimeCommitsOnlyAfterACKAndDeduplicates(t *testing.T) {
now := time.Now().In(shanghai).Truncate(time.Second)
source := &fakeSource{
vehicles: []Vehicle{{
VehicleID: "id-1", VIN: "LTEST32960VIN0001", RuleTypeName: "GB_T32960",
}},
record: Record{"2000": now.Format("2006-01-02 15:04:05"), "2201": "10"},
}
target := &fakeTarget{}
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
service, err := NewService(ServiceConfig{
SourceStaleAfter: time.Minute,
FetchConcurrency: 1,
}, source, target, store, slog.Default(), nil)
if err != nil {
t.Fatal(err)
}
if err := service.discover(context.Background()); err != nil {
t.Fatal(err)
}
service.pollRealtime(context.Background())
service.pollRealtime(context.Background())
if len(target.frames) != 1 {
t.Fatalf("frames = %d, want 1", len(target.frames))
}
if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) {
t.Fatalf("committed state = %#v", got)
}
source.record = Record{"2000": now.Add(time.Second).Format("2006-01-02 15:04:05"), "2201": "11"}
target.sendErr = errors.New("target down")
service.pollRealtime(context.Background())
if got := store.Vehicle("LTEST32960VIN0001"); !got.LastRealtimeTime.Equal(now) {
t.Fatalf("cursor advanced without ACK: %#v", got)
}
}
func TestStaleSnapshotIsReissuedOnlyOnce(t *testing.T) {
at := time.Now().In(shanghai).Add(-24 * time.Hour).Truncate(time.Second)
vin := "LTEST32960VIN0002"
source := &fakeSource{
vehicles: []Vehicle{{VehicleID: "id-2", VIN: vin, RuleTypeName: "GB_T32960"}},
record: Record{"2000": at.Format("2006-01-02 15:04:05"), "2201": "0"},
}
target := &fakeTarget{}
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
service, err := NewService(ServiceConfig{
SourceStaleAfter: time.Minute,
FetchConcurrency: 1,
StaleReissueEnabled: true,
}, source, target, store, slog.Default(), nil)
if err != nil {
t.Fatal(err)
}
if err := service.discover(context.Background()); err != nil {
t.Fatal(err)
}
service.pollRealtime(context.Background())
service.pollRealtime(context.Background())
if len(target.frames) != 1 || target.frames[0][2] != CommandReissue {
t.Fatalf("frames = %d command = %#v", len(target.frames), target.frames)
}
state := store.Vehicle(vin)
if !state.LastSnapshotReissueTime.Equal(at) || state.LastSnapshotReissueACKAt.IsZero() {
t.Fatalf("reissue state = %#v", state)
}
}

View File

@@ -0,0 +1,160 @@
package feichibridge
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type VehicleState struct {
LastRealtimeTime time.Time `json:"last_realtime_time,omitempty"`
LastRealtimeHash string `json:"last_realtime_hash,omitempty"`
LastRealtimeACKAt time.Time `json:"last_realtime_ack_at,omitempty"`
LastSnapshotReissueTime time.Time `json:"last_snapshot_reissue_time,omitempty"`
LastSnapshotReissueHash string `json:"last_snapshot_reissue_hash,omitempty"`
LastSnapshotReissueACKAt time.Time `json:"last_snapshot_reissue_ack_at,omitempty"`
BackfillCursor time.Time `json:"backfill_cursor,omitempty"`
LastBackfillACKAt time.Time `json:"last_backfill_ack_at,omitempty"`
}
func (s *StateStore) CommitSnapshotReissue(vin string, at time.Time, hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.LastSnapshotReissueTime = at
current.LastSnapshotReissueHash = hash
current.LastSnapshotReissueACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
type persistentState struct {
Version int `json:"version"`
PlatformSerial uint16 `json:"platform_serial"`
Vehicles map[string]VehicleState `json:"vehicles"`
}
type StateStore struct {
mu sync.Mutex
path string
state persistentState
}
func OpenStateStore(path string) (*StateStore, error) {
store := &StateStore{
path: path,
state: persistentState{
Version: 1,
Vehicles: map[string]VehicleState{},
},
}
encoded, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return store, nil
}
if err != nil {
return nil, fmt.Errorf("read bridge state: %w", err)
}
if err := json.Unmarshal(encoded, &store.state); err != nil {
return nil, fmt.Errorf("decode bridge state: %w", err)
}
if store.state.Vehicles == nil {
store.state.Vehicles = map[string]VehicleState{}
}
return store, nil
}
func (s *StateStore) Vehicle(vin string) VehicleState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state.Vehicles[vin]
}
func (s *StateStore) CommitRealtime(vin string, at time.Time, hash string) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.LastRealtimeTime = at
current.LastRealtimeHash = hash
current.LastRealtimeACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) CommitBackfill(vin string, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.BackfillCursor = at
current.LastBackfillACKAt = time.Now()
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) AdvanceBackfillCursor(vin string, at time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
current := s.state.Vehicles[vin]
current.BackfillCursor = at
s.state.Vehicles[vin] = current
return s.saveLocked()
}
func (s *StateStore) NextPlatformSerial() (uint16, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.PlatformSerial++
if s.state.PlatformSerial == 0 {
s.state.PlatformSerial = 1
}
if err := s.saveLocked(); err != nil {
return 0, err
}
return s.state.PlatformSerial, nil
}
func (s *StateStore) saveLocked() error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o750); err != nil {
return fmt.Errorf("create bridge state directory: %w", err)
}
encoded, err := json.MarshalIndent(s.state, "", " ")
if err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(s.path), ".state-*.json")
if err != nil {
return err
}
tempName := temp.Name()
defer os.Remove(tempName)
if err := temp.Chmod(0o600); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(encoded); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempName, s.path); err != nil {
return fmt.Errorf("replace bridge state: %w", err)
}
return nil
}
func hashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,38 @@
package feichibridge
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestStateStorePersistsAcknowledgedCursor(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "state.json")
store, err := OpenStateStore(path)
if err != nil {
t.Fatal(err)
}
at := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
if err := store.CommitRealtime("LTEST32960VIN0001", at, "hash"); err != nil {
t.Fatal(err)
}
if err := store.CommitBackfill("LTEST32960VIN0001", at.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
reopened, err := OpenStateStore(path)
if err != nil {
t.Fatal(err)
}
got := reopened.Vehicle("LTEST32960VIN0001")
if !got.LastRealtimeTime.Equal(at) || got.LastRealtimeHash != "hash" {
t.Fatalf("state = %#v", got)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("state mode = %o", info.Mode().Perm())
}
}

View File

@@ -0,0 +1,193 @@
package feichibridge
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
type TargetConfig struct {
Address string
PlatformID string
Username string
Password string
Timeout time.Duration
}
type Target struct {
mu sync.Mutex
config TargetConfig
state *StateStore
dialer net.Dialer
conn net.Conn
lastACK time.Time
}
func NewTarget(config TargetConfig, state *StateStore) (*Target, error) {
if strings.TrimSpace(config.Address) == "" {
return nil, errors.New("GB/T 32960 target address is required")
}
if len(config.PlatformID) != 17 {
return nil, fmt.Errorf("target platform ID must be exactly 17 bytes")
}
if state == nil {
return nil, errors.New("state store is required")
}
if config.Timeout <= 0 {
config.Timeout = 10 * time.Second
}
return &Target{
config: config,
state: state,
dialer: net.Dialer{Timeout: config.Timeout, KeepAlive: 30 * time.Second},
}, nil
}
func (t *Target) Send(ctx context.Context, frame []byte) error {
t.mu.Lock()
defer t.mu.Unlock()
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
if err := t.ensureConnected(ctx); err != nil {
lastErr = err
t.closeLocked()
continue
}
if err := t.sendAndACK(ctx, frame); err != nil {
lastErr = err
t.closeLocked()
continue
}
t.lastACK = time.Now()
return nil
}
return fmt.Errorf("send GB/T 32960 frame after reconnect: %w", lastErr)
}
func (t *Target) Connect(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if err := t.ensureConnected(ctx); err != nil {
t.closeLocked()
return err
}
return nil
}
func (t *Target) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return nil
}
err := t.conn.Close()
t.conn = nil
return err
}
func (t *Target) LastACK() time.Time {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastACK
}
func (t *Target) ensureConnected(ctx context.Context) error {
if t.conn != nil {
return nil
}
conn, err := t.dialer.DialContext(ctx, "tcp", t.config.Address)
if err != nil {
return fmt.Errorf("dial target %s: %w", t.config.Address, err)
}
t.conn = conn
serial, err := t.state.NextPlatformSerial()
if err != nil {
return fmt.Errorf("allocate platform login serial: %w", err)
}
login, err := LoginFrame(t.config.PlatformID, t.config.Username, t.config.Password, serial, time.Now())
if err != nil {
return err
}
if err := t.sendAndACK(ctx, login); err != nil {
return fmt.Errorf("GB/T 32960 platform login: %w", err)
}
t.lastACK = time.Now()
return nil
}
func (t *Target) sendAndACK(ctx context.Context, frame []byte) error {
if t.conn == nil {
return errors.New("target connection is closed")
}
deadline := time.Now().Add(t.config.Timeout)
if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) {
deadline = contextDeadline
}
if err := t.conn.SetDeadline(deadline); err != nil {
return err
}
if err := writeFull(t.conn, frame); err != nil {
return fmt.Errorf("write target frame: %w", err)
}
response, err := readFrame(t.conn)
if err != nil {
return fmt.Errorf("read target ACK: %w", err)
}
if response[2] != frame[2] {
return fmt.Errorf("target ACK command mismatch: got 0x%02X want 0x%02X", response[2], frame[2])
}
if response[3] != 0x01 {
return fmt.Errorf("target rejected command 0x%02X with response 0x%02X", response[2], response[3])
}
if string(response[4:21]) != string(frame[4:21]) {
return errors.New("target ACK identifier mismatch")
}
return nil
}
func (t *Target) closeLocked() {
if t.conn != nil {
_ = t.conn.Close()
t.conn = nil
}
}
func readFrame(reader io.Reader) ([]byte, error) {
header := make([]byte, 24)
if _, err := io.ReadFull(reader, header); err != nil {
return nil, err
}
if (header[0] != '#' || header[1] != '#') && (header[0] != '$' || header[1] != '$') {
return nil, fmt.Errorf("bad GB/T 32960 ACK start %q", header[:2])
}
bodyLength := int(binary.BigEndian.Uint16(header[22:24]))
tail := make([]byte, bodyLength+1)
if _, err := io.ReadFull(reader, tail); err != nil {
return nil, err
}
frame := append(header, tail...)
if got, want := bcc(frame[2:len(frame)-1]), frame[len(frame)-1]; got != want {
return nil, fmt.Errorf("bad GB/T 32960 ACK BCC: got 0x%02X want 0x%02X", got, want)
}
return frame, nil
}
func writeFull(writer io.Writer, value []byte) error {
for len(value) > 0 {
written, err := writer.Write(value)
if err != nil {
return err
}
if written == 0 {
return io.ErrShortWrite
}
value = value[written:]
}
return nil
}

View File

@@ -0,0 +1,83 @@
package feichibridge
import (
"context"
"net"
"path/filepath"
"sync"
"testing"
"time"
)
func TestTargetLogsInAndWaitsForACK(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
var commands []byte
var mu sync.Mutex
done := make(chan error, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
done <- acceptErr
return
}
defer conn.Close()
for count := 0; count < 2; count++ {
frame, readErr := readFrame(conn)
if readErr != nil {
done <- readErr
return
}
mu.Lock()
commands = append(commands, frame[2])
mu.Unlock()
body := []byte(nil)
if len(frame) >= 31 {
body = append(body, frame[24:30]...)
}
ack, buildErr := buildFrame('#', frame[2], 0x01, string(frame[4:21]), body)
if buildErr != nil {
done <- buildErr
return
}
if writeErr := writeFull(conn, ack); writeErr != nil {
done <- writeErr
return
}
}
done <- nil
}()
store, err := OpenStateStore(filepath.Join(t.TempDir(), "state.json"))
if err != nil {
t.Fatal(err)
}
target, err := NewTarget(TargetConfig{
Address: listener.Addr().String(), PlatformID: "FEICHIBRIDGE00001",
Username: "bridge", Password: "secret", Timeout: time.Second,
}, store)
if err != nil {
t.Fatal(err)
}
frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", time.Now(), Record{"2201": "1"})
if err != nil {
t.Fatal(err)
}
if err := target.Send(context.Background(), frame); err != nil {
t.Fatal(err)
}
if err := <-done; err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(commands) != 2 || commands[0] != CommandLogin || commands[1] != CommandRealtime {
t.Fatalf("commands = %x", commands)
}
if target.LastACK().IsZero() {
t.Fatal("last ACK was not recorded")
}
}

View File

@@ -151,16 +151,23 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
fields[envelope.FieldSOCPercent] = unit["soc_percent"] fields[envelope.FieldSOCPercent] = unit["soc_percent"]
cursor += size cursor += size
case 0x05: case 0x05:
if len(body[cursor:]) < 9 { size := 9
if version == "V2025" {
size = 10
}
if len(body[cursor:]) < size {
units = append(units, map[string]any{"type": "0x05", "error": "truncated"}) units = append(units, map[string]any{"type": "0x05", "error": "truncated"})
return eventTime, units return eventTime, units
} }
unit := parsePositionData(body[cursor : cursor+9]) unit := parsePositionData(version, body[cursor:cursor+size])
units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit}) units = append(units, map[string]any{"type": "0x05", "name": "position", "value": unit})
fields["position_status"] = unit["position_status"] fields["position_status"] = unit["position_status"]
if coordinateSystem, ok := unit["coordinate_system"]; ok {
fields["coordinate_system"] = coordinateSystem
}
fields[envelope.FieldLongitude] = unit["longitude"] fields[envelope.FieldLongitude] = unit["longitude"]
fields[envelope.FieldLatitude] = unit["latitude"] fields[envelope.FieldLatitude] = unit["latitude"]
cursor += 9 cursor += size
case 0x02: case 0x02:
if len(body[cursor:]) < 1 { if len(body[cursor:]) < 1 {
units = append(units, map[string]any{"type": "0x02", "error": "truncated"}) units = append(units, map[string]any{"type": "0x02", "error": "truncated"})
@@ -243,6 +250,7 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
unit := parseGdFCStackData(body[cursor : cursor+size]) unit := parseGdFCStackData(body[cursor : cursor+size])
units = append(units, map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": unit}) units = append(units, map[string]any{"type": "0x30", "name": "gd_fc_stack", "value": unit})
if summaries, ok := unit["summaries"].([]map[string]any); ok && len(summaries) > 0 { if summaries, ok := unit["summaries"].([]map[string]any); ok && len(summaries) > 0 {
fields[envelope.FieldFuelCellWorkMode] = summaries[0]["engine_work_state"]
fields["gd_fc_stack_hydrogen_inlet_pressure_kpa"] = summaries[0]["hydrogen_inlet_pressure_kpa"] fields["gd_fc_stack_hydrogen_inlet_pressure_kpa"] = summaries[0]["hydrogen_inlet_pressure_kpa"]
fields["gd_fc_stack_water_outlet_temp_c"] = summaries[0]["stack_water_outlet_temp_c"] fields["gd_fc_stack_water_outlet_temp_c"] = summaries[0]["stack_water_outlet_temp_c"]
fields["gd_fc_stack_cell_count"] = summaries[0]["cell_count"] fields["gd_fc_stack_cell_count"] = summaries[0]["cell_count"]
@@ -364,21 +372,27 @@ func parsePlatformLogin(body []byte) map[string]any {
} }
func parseVehicleData(data []byte) map[string]any { func parseVehicleData(data []byte) map[string]any {
return map[string]any{ out := map[string]any{
"vehicle_status": int(data[0]), "vehicle_status": int(data[0]),
"charge_status": int(data[1]), "charge_status": int(data[1]),
"running_mode": int(data[2]), "running_mode": int(data[2]),
"speed_kmh": scaledU16(data[3:5], 10), "speed_kmh": nullableScaledU16(data[3:5], 10),
"total_mileage_km": scaledU32(data[5:9], 10), "total_mileage_km": nullableScaledU32(data[5:9], 10),
"total_voltage_v": scaledU16(data[9:11], 10), "total_voltage_v": nullableScaledU16(data[9:11], 10),
"total_current_a": scaledU16WithOffset(data[11:13], 10, -1000), "total_current_a": nullableScaledU16WithOffset(data[11:13], 10, -1000),
"soc_percent": int(data[13]), "soc_percent": nullableByte(data[13]),
"dc_dc_status": int(data[14]), "dc_dc_status": int(data[14]),
"gear": int(data[15]), "gear": int(data[15]),
"insulation_kohm": binary.BigEndian.Uint16(data[16:18]), "insulation_kohm": nullableU16(data[16:18]),
"accelerator_pct": int(data[18]),
"brake_pct": int(data[19]),
} }
// GB/T 32960.3-2025 removes the 2016 accelerator and brake bytes from
// the 0x01 vehicle unit. Keep them only when the frame actually carries
// the 20-byte 2016 payload.
if len(data) >= 20 {
out["accelerator_pct"] = nullableByte(data[18])
out["brake_pct"] = nullableByte(data[19])
}
return out
} }
func parseDriveMotorData(data []byte) map[string]any { func parseDriveMotorData(data []byte) map[string]any {
@@ -389,12 +403,12 @@ func parseDriveMotorData(data []byte) map[string]any {
motors = append(motors, map[string]any{ motors = append(motors, map[string]any{
"serial_no": int(data[cursor]), "serial_no": int(data[cursor]),
"state": int(data[cursor+1]), "state": int(data[cursor+1]),
"controller_temperature_c": int(data[cursor+2]) - 40, "controller_temperature_c": nullableTempOffset40(data[cursor+2]),
"speed_rpm": int(binary.BigEndian.Uint16(data[cursor+3:cursor+5])) - 20000, "speed_rpm": nullableU16WithOffset(data[cursor+3:cursor+5], -20000),
"torque_nm": scaledIntWithOffset(int64(binary.BigEndian.Uint16(data[cursor+5:cursor+7])), 10, -20000), "torque_nm": nullableScaledU16WithOffset(data[cursor+5:cursor+7], 10, -20000),
"motor_temperature_c": int(data[cursor+7]) - 40, "motor_temperature_c": nullableTempOffset40(data[cursor+7]),
"controller_voltage_v": scaledU16(data[cursor+8:cursor+10], 10), "controller_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10),
"controller_current_a": scaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000), "controller_current_a": nullableScaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
}) })
cursor += 12 cursor += 12
} }
@@ -406,34 +420,45 @@ func parseDriveMotorData(data []byte) map[string]any {
func parseFuelCellData(data []byte) map[string]any { func parseFuelCellData(data []byte) map[string]any {
probeCount := int(binary.BigEndian.Uint16(data[6:8])) probeCount := int(binary.BigEndian.Uint16(data[6:8]))
probes := make([]int, 0, probeCount) probes := make([]any, 0, probeCount)
cursor := 8 cursor := 8
for i := 0; i < probeCount; i++ { for i := 0; i < probeCount; i++ {
probes = append(probes, int(data[cursor])-40) probes = append(probes, nullableTempOffset40(data[cursor]))
cursor++ cursor++
} }
out := map[string]any{ out := map[string]any{
"fuel_cell_voltage_v": scaledU16(data[0:2], 10), "fuel_cell_voltage_v": nullableScaledU16(data[0:2], 10),
"fuel_cell_current_a": scaledU16(data[2:4], 10), "fuel_cell_current_a": nullableScaledU16(data[2:4], 10),
"hydrogen_consumption_kg_per_100km": scaledU16(data[4:6], 100), "hydrogen_consumption_kg_per_100km": nullableScaledU16(data[4:6], 100),
"temperature_probe_count": probeCount, "temperature_probe_count": probeCount,
"temperature_probe_values_c": probes, "temperature_probe_values_c": probes,
"max_hydrogen_temperature_c": scaledU16WithOffset(data[cursor:cursor+2], 10, -40), "max_hydrogen_temperature_c": nullableScaledU16WithOffset(data[cursor:cursor+2], 10, -40),
"max_hydrogen_temperature_probe_id": int(data[cursor+2]), "max_hydrogen_temperature_probe_id": int(data[cursor+2]),
"max_hydrogen_concentration_fraction": scaledU16(data[cursor+3:cursor+5], 1_000_000),
"max_hydrogen_concentration_probe_id": int(data[cursor+5]), "max_hydrogen_concentration_probe_id": int(data[cursor+5]),
"max_hydrogen_pressure_mpa": scaledU16(data[cursor+6:cursor+8], 10), "max_hydrogen_pressure_mpa": nullableScaledU16(data[cursor+6:cursor+8], 10),
"max_hydrogen_pressure_probe_id": int(data[cursor+8]), "max_hydrogen_pressure_probe_id": int(data[cursor+8]),
"dc_dc_status": int(data[cursor+9]), "dc_dc_status": int(data[cursor+9]),
} }
if concentration := nullableU16(data[cursor+3 : cursor+5]); concentration != nil {
raw := concentration.(int)
out["max_hydrogen_concentration_fraction"] = float64(raw) / 1_000_000
out["max_hydrogen_concentration_ppm"] = raw
out["max_hydrogen_concentration_percent"] = float64(raw) / 10_000
} else {
out["max_hydrogen_concentration_fraction"] = nil
out["max_hydrogen_concentration_ppm"] = nil
out["max_hydrogen_concentration_percent"] = nil
}
return out return out
} }
func parseEngineData(data []byte) map[string]any { func parseEngineData(data []byte) map[string]any {
return map[string]any{ return map[string]any{
"engine_status": int(data[0]), "engine_status": int(data[0]),
"crank_speed_rpm": binary.BigEndian.Uint16(data[1:3]), "crank_speed_rpm": nullableU16(data[1:3]),
"fuel_rate": binary.BigEndian.Uint16(data[3:5]), // The supplied authoritative reference does not define this field's
// business unit or scale, so preserve the decoded protocol integer.
"fuel_rate": nullableU16(data[3:5]),
} }
} }
@@ -632,11 +657,13 @@ func parseGdFCStackData(data []byte) map[string]any {
"frame_cell_count": frameCellCount, "frame_cell_count": frameCellCount,
} }
cursor += 22 cursor += 22
frameVoltages := make([]float64, 0, frameCellCount)
maxCell := 0.0 maxCell := 0.0
minCell := 0.0 minCell := 0.0
seen := false seen := false
for c := 0; c < frameCellCount; c++ { for c := 0; c < frameCellCount; c++ {
voltage := scaledU16(data[cursor:cursor+2], 1000) voltage := scaledU16(data[cursor:cursor+2], 1000)
frameVoltages = append(frameVoltages, voltage)
if !seen || voltage > maxCell { if !seen || voltage > maxCell {
maxCell = voltage maxCell = voltage
} }
@@ -650,6 +677,7 @@ func parseGdFCStackData(data []byte) map[string]any {
summary["frame_max_cell_voltage_v"] = maxCell summary["frame_max_cell_voltage_v"] = maxCell
summary["frame_min_cell_voltage_v"] = minCell summary["frame_min_cell_voltage_v"] = minCell
} }
summary["frame_cell_voltages_v"] = frameVoltages
summaries = append(summaries, summary) summaries = append(summaries, summary)
} }
return map[string]any{ return map[string]any{
@@ -760,6 +788,13 @@ func nullableU16WithOffset(data []byte, offset int) any {
return int(value) + offset return int(value) + offset
} }
func nullableByte(value byte) any {
if value == 0xfe || value == 0xff {
return nil
}
return int(value)
}
func scaledU16(data []byte, divisor int64) float64 { func scaledU16(data []byte, divisor int64) float64 {
return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor) return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor)
} }
@@ -791,6 +826,14 @@ func nullableScaledU16(data []byte, divisor int64) any {
return scaledInt(int64(value), divisor) return scaledInt(int64(value), divisor)
} }
func nullableScaledU32(data []byte, divisor int64) any {
value := binary.BigEndian.Uint32(data)
if value == 0xfffffffe || value == 0xffffffff {
return nil
}
return scaledInt(int64(value), divisor)
}
func nullableScaledU16WithOffset(data []byte, divisor int64, offset int64) any { func nullableScaledU16WithOffset(data []byte, divisor int64, offset int64) any {
value := binary.BigEndian.Uint16(data) value := binary.BigEndian.Uint16(data)
if value == 0xfffe || value == 0xffff { if value == 0xfffe || value == 0xffff {
@@ -806,12 +849,16 @@ func nullableTempOffset40(value byte) any {
return int(value) - 40 return int(value) - 40
} }
func parsePositionData(data []byte) map[string]any { func parsePositionData(version string, data []byte) map[string]any {
return map[string]any{ offset := 1
"position_status": int(data[0]), out := map[string]any{"position_status": int(data[0])}
"longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000, if version == "V2025" {
"latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000, out["coordinate_system"] = int(data[1])
offset = 2
} }
out["longitude"] = float64(binary.BigEndian.Uint32(data[offset:offset+4])) / 1_000_000
out["latitude"] = float64(binary.BigEndian.Uint32(data[offset+4:offset+8])) / 1_000_000
return out
} }
func indexStart(data []byte) int { func indexStart(data []byte) int {

View File

@@ -197,6 +197,7 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
t.Fatalf("vin = %q", env.VIN) t.Fatalf("vin = %q", env.VIN)
} }
assertFloatField(t, env, envelope.FieldTotalMileageKM, 53490.9) assertFloatField(t, env, envelope.FieldTotalMileageKM, 53490.9)
assertIntField(t, env, envelope.FieldFuelCellWorkMode, 2)
assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8) assertFloatField(t, env, "fuel_cell_hydrogen_consumption_kg_per_100km", 1.8)
assertFloatField(t, env, envelope.FieldLongitude, 120.800326) assertFloatField(t, env, envelope.FieldLongitude, 120.800326)
assertFloatField(t, env, envelope.FieldLatitude, 31.634907) assertFloatField(t, env, envelope.FieldLatitude, 31.634907)
@@ -222,6 +223,13 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
if units[2]["name"] != "fuel_cell" { if units[2]["name"] != "fuel_cell" {
t.Fatalf("unexpected fuel cell unit: %#v", units[2]) t.Fatalf("unexpected fuel cell unit: %#v", units[2])
} }
fuelCell, ok := units[2]["value"].(map[string]any)
if !ok {
t.Fatalf("fuel cell payload missing: %#v", units[2])
}
if _, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok {
t.Fatalf("hydrogen concentration ppm missing: %#v", fuelCell["max_hydrogen_concentration_ppm"])
}
if units[9]["name"] != "gd_fc_stack" { if units[9]["name"] != "gd_fc_stack" {
t.Fatalf("unexpected gd stack unit: %#v", units[9]) t.Fatalf("unexpected gd stack unit: %#v", units[9])
} }
@@ -237,6 +245,69 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
} }
} }
func TestParseFuelCellDataExposesHydrogenConcentrationInReferencePercent(t *testing.T) {
data := make([]byte, 18)
data[11] = 0x01
data[12] = 0xf4
fuelCell := parseFuelCellData(data)
if concentration, ok := fuelCell["max_hydrogen_concentration_ppm"].(int); !ok || concentration != 500 {
t.Fatalf("hydrogen concentration ppm = %#v, want int(500)", fuelCell["max_hydrogen_concentration_ppm"])
}
if fraction, ok := fuelCell["max_hydrogen_concentration_fraction"].(float64); !ok || math.Abs(fraction-0.0005) > 0.0000001 {
t.Fatalf("hydrogen concentration fraction = %#v, want 0.0005", fuelCell["max_hydrogen_concentration_fraction"])
}
if percent, ok := fuelCell["max_hydrogen_concentration_percent"].(float64); !ok || math.Abs(percent-0.05) > 0.0000001 {
t.Fatalf("hydrogen concentration percent = %#v, want 0.05", fuelCell["max_hydrogen_concentration_percent"])
}
}
func TestParseFuelCellDataTreatsProtocolInvalidMarkersAsMissing(t *testing.T) {
data := make([]byte, 18)
for index := range data {
data[index] = 0xff
}
data[6], data[7] = 0, 0
fuelCell := parseFuelCellData(data)
for _, key := range []string{"fuel_cell_voltage_v", "fuel_cell_current_a", "hydrogen_consumption_kg_per_100km", "max_hydrogen_temperature_c", "max_hydrogen_concentration_percent", "max_hydrogen_pressure_mpa"} {
if fuelCell[key] != nil {
t.Fatalf("%s should be nil for protocol invalid marker: %#v", key, fuelCell[key])
}
}
}
func TestParseV2025VehicleAndPositionUsesCoordinateSystemByte(t *testing.T) {
body := []byte{0x1a, 0x07, 0x11, 0x0a, 0x00, 0x00, 0x01}
body = append(body,
0x01, 0x03, 0x01,
0x00, 0x64,
0x00, 0x00, 0x03, 0xe8,
0x13, 0x88,
0x27, 0x10,
80, 0x01, 0x00,
0x03, 0xe8)
body = append(body,
0x05,
0x00, 0x02,
0x07, 0x36, 0x50, 0x40,
0x01, 0xd2, 0x4f, 0x00)
fields := map[string]any{}
_, units := parseDataBody("V2025", body, fields)
if len(units) != 2 {
t.Fatalf("units = %#v", units)
}
vehicle := units[0]["value"].(map[string]any)
if _, exists := vehicle["accelerator_pct"]; exists {
t.Fatalf("2025 vehicle unit must not invent removed pedal fields: %#v", vehicle)
}
position := units[1]["value"].(map[string]any)
if position["coordinate_system"] != 2 {
t.Fatalf("coordinate system = %#v", position["coordinate_system"])
}
if longitude := position["longitude"].(float64); math.Abs(longitude-121) > 0.000001 {
t.Fatalf("longitude = %v", longitude)
}
}
func TestScaledDecimalFieldsDoNotLeakBinaryFloatTails(t *testing.T) { func TestScaledDecimalFieldsDoNotLeakBinaryFloatTails(t *testing.T) {
unit := parseDriveMotorData([]byte{ unit := parseDriveMotorData([]byte{
0x01, 0x01,

View File

@@ -115,6 +115,9 @@ func fieldsFromData(data map[string]any) map[string]any {
if gear, ok := firstFloat(data, "ON_GEAR", "gear"); ok { if gear, ok := firstFloat(data, "ON_GEAR", "gear"); ok {
fields["gear"] = gear fields["gear"] = gear
} }
if workMode, ok := firstFloat(data, "TRIANGLE_STATE", "triangleState", "triangle_state"); ok {
fields[envelope.FieldFuelCellWorkMode] = workMode
}
return fields return fields
} }

View File

@@ -18,7 +18,8 @@ func TestParseMessageMapsYutongPayloadToEnvelope(t *testing.T) {
"BATTERY_CAPACITY_SOC": 70, "BATTERY_CAPACITY_SOC": 70,
"LONGITUDE": 116397128, "LONGITUDE": 116397128,
"LATITUDE": 39916527, "LATITUDE": 39916527,
"GPSDirection": 88 "GPSDirection": 88,
"TRIANGLE_STATE": 11
} }
}`) }`)
@@ -41,6 +42,7 @@ func TestParseMessageMapsYutongPayloadToEnvelope(t *testing.T) {
assertFloatField(t, env, envelope.FieldLongitude, 116.397128) assertFloatField(t, env, envelope.FieldLongitude, 116.397128)
assertFloatField(t, env, envelope.FieldLatitude, 39.916527) assertFloatField(t, env, envelope.FieldLatitude, 39.916527)
assertFloatField(t, env, "direction_deg", 88) assertFloatField(t, env, "direction_deg", 88)
assertFloatField(t, env, envelope.FieldFuelCellWorkMode, 11)
if env.EventTimeMS == 1782745114999 { if env.EventTimeMS == 1782745114999 {
t.Fatal("event time should come from device time") t.Fatal("event time should come from device time")
} }

View File

@@ -6,6 +6,8 @@ import (
"testing" "testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/yutongmqtt"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
) )
func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) { func TestRealtimeKVFieldsFromGB32960ParsedDomains(t *testing.T) {
@@ -157,6 +159,37 @@ func TestBuildFieldsEnvelopeCopiesSourceMetadata(t *testing.T) {
} }
} }
func TestBuildFieldsEnvelopeKeepsYutongJSONNumberMileage(t *testing.T) {
env, err := yutongmqtt.ParseMessage(
"yutong",
"/ytforward/shln/1",
[]byte(`{"device":"LMRKH9AC2R1004106","time":"2026-07-17 11:50:13.021","data":{"LATITUDE":30.466027,"LONGITUDE":103.96096,"METER_SPEED":0,"TOTAL_MILEAGE":77081000}}`),
1784260213051,
)
if err != nil {
t.Fatalf("ParseMessage() error = %v", err)
}
if !EnsureParsedFields(&env) {
t.Fatal("EnsureParsedFields() should compute ingress fields")
}
fieldsEnv, ok := BuildFieldsEnvelope(env)
if !ok {
t.Fatal("BuildFieldsEnvelope() should emit Yutong fields")
}
for _, field := range []string{
"yutong_mqtt.data.total_mileage",
"yutong_mqtt.root.data.total_mileage",
} {
if got := fieldsEnv.Fields[field]; got != "77081000" {
t.Fatalf("%s = %#v, want 77081000", field, got)
}
}
mileageKM, found := telemetry.TotalMileageKM(fieldsEnv.Protocol, fieldsEnv.Fields)
if !found || mileageKM != 77081 {
t.Fatalf("TotalMileageKM() = %.3f, %v; want 77081, true", mileageKM, found)
}
}
func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) { func TestBuildFieldsEnvelopeDropsNonPositiveTotalMileage(t *testing.T) {
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{ fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808, Protocol: envelope.ProtocolJT808,

View File

@@ -468,49 +468,52 @@ func realtimeOpenAPISpec() map[string]any {
"DailyMetricRow": map[string]any{ "DailyMetricRow": map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"), "vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"), "stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"), "protocol": stringSchema("JT808"),
"source_id": integerSchema(3), "source_id": integerSchema(3),
"source_ip": stringSchema("115.231.168.135"), "source_ip": stringSchema("115.231.168.135"),
"latest_source_endpoint": stringSchema("115.231.168.135:41561"), "latest_source_endpoint": stringSchema("115.231.168.135:41561"),
"platform_name": stringSchema("G7 平台"), "platform_name": stringSchema("G7 平台"),
"source_code": stringSchema("G7S"), "source_code": stringSchema("G7S"),
"source_kind": stringSchema("PLATFORM"), "source_kind": stringSchema("PLATFORM"),
"daily_mileage_km": numberSchema(23.1), "daily_mileage_km": numberSchema(23.1),
"latest_total_mileage_km": numberSchema(4123.9), "pure_hydrogen_mileage_km": numberSchema(18.6),
"updated_at": stringSchema("2026-07-08 13:30:57"), "latest_total_mileage_km": numberSchema(4123.9),
"updated_at": stringSchema("2026-07-08 13:30:57"),
}, },
}, },
"DailyMetricSourceRow": map[string]any{ "DailyMetricSourceRow": map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"vin": stringSchema("LA9GG64L7PBAF4001"), "vin": stringSchema("LA9GG64L7PBAF4001"),
"stat_date": stringSchema("2026-07-08"), "stat_date": stringSchema("2026-07-08"),
"protocol": stringSchema("JT808"), "protocol": stringSchema("JT808"),
"source_key": stringSchema("JT808:13307795425@115.231.168.135"), "source_key": stringSchema("JT808:13307795425@115.231.168.135"),
"source_ip": stringSchema("115.231.168.135"), "source_ip": stringSchema("115.231.168.135"),
"source_endpoint": stringSchema("115.231.168.135:41561"), "source_endpoint": stringSchema("115.231.168.135:41561"),
"phone": stringSchema("13307795425"), "phone": stringSchema("13307795425"),
"platform_name": stringSchema("信达"), "platform_name": stringSchema("信达"),
"source_id": integerSchema(5), "source_id": integerSchema(5),
"source_code": stringSchema("xinda"), "source_code": stringSchema("xinda"),
"source_kind": stringSchema("PLATFORM"), "source_kind": stringSchema("PLATFORM"),
"source_enabled": map[string]any{"type": "boolean", "example": true}, "source_enabled": map[string]any{"type": "boolean", "example": true},
"trust_priority": integerSchema(10), "trust_priority": integerSchema(10),
"first_total_mileage_km": numberSchema(4100.8), "first_total_mileage_km": numberSchema(4100.8),
"latest_total_mileage_km": numberSchema(4123.9), "latest_total_mileage_km": numberSchema(4123.9),
"daily_mileage_km": numberSchema(23.1), "daily_mileage_km": numberSchema(23.1),
"sample_count": integerSchema(128), "pure_hydrogen_mileage_km": numberSchema(18.6),
"first_event_time": stringSchema("2026-07-08 00:01:00"), "pure_hydrogen_sample_count": integerSchema(42),
"latest_event_time": stringSchema("2026-07-08 23:59:00"), "sample_count": integerSchema(128),
"quality_status": stringSchema("OK"), "first_event_time": stringSchema("2026-07-08 00:01:00"),
"quality_reason": stringSchema("historical_source_baseline"), "latest_event_time": stringSchema("2026-07-08 23:59:00"),
"is_selected": map[string]any{"type": "boolean", "example": true}, "quality_status": stringSchema("OK"),
"selection_status": stringSchema("selected"), "quality_reason": stringSchema("historical_source_baseline"),
"selection_reason": stringSchema("selected_current_projection"), "is_selected": map[string]any{"type": "boolean", "example": true},
"selection_action": stringSchema("当前来源已被投影到最终日里程表"), "selection_status": stringSchema("selected"),
"updated_at": stringSchema("2026-07-08 23:59:10"), "selection_reason": stringSchema("selected_current_projection"),
"selection_action": stringSchema("当前来源已被投影到最终日里程表"),
"updated_at": stringSchema("2026-07-08 23:59:10"),
}, },
}, },
"DailyMetricSourceQualityRow": map[string]any{ "DailyMetricSourceQualityRow": map[string]any{

View File

@@ -825,12 +825,27 @@ func positiveNumber(value any) bool {
return typed > 0 return typed > 0
case int: case int:
return typed > 0 return typed > 0
case int8:
return typed > 0
case int16:
return typed > 0
case int32:
return typed > 0
case int64: case int64:
return typed > 0 return typed > 0
case uint:
return typed > 0
case uint8:
return typed > 0
case uint16: case uint16:
return typed > 0 return typed > 0
case uint32: case uint32:
return typed > 0 return typed > 0
case uint64:
return typed > 0
case json.Number:
parsed, err := typed.Float64()
return err == nil && parsed > 0
case string: case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return err == nil && parsed > 0 return err == nil && parsed > 0

View File

@@ -3,7 +3,9 @@ package stats
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -14,11 +16,13 @@ import (
) )
const ( const (
maxNegativeMileageJitterKM = 1.0 maxNegativeMileageJitterKM = 1.0
defaultCacheRetention = 72 * time.Hour defaultCacheRetention = 72 * time.Hour
defaultCacheCleanupInterval = 10 * time.Minute defaultCacheCleanupInterval = 10 * time.Minute
defaultBaselineMissTTL = time.Minute defaultBaselineMissTTL = time.Minute
defaultMaxCacheEntries = 1000000 defaultBaselineHitTTL = 5 * time.Minute
defaultMaxCacheEntries = 1000000
defaultGPSAccumulationInterval = 10 * time.Second
) )
type Execer interface { type Execer interface {
@@ -26,39 +30,46 @@ type Execer interface {
} }
type Writer struct { type Writer struct {
exec Execer exec Execer
query Queryer query Queryer
loc *time.Location loc *time.Location
sourceTouchInterval time.Duration sourceTouchInterval time.Duration
projectionInterval time.Duration projectionInterval time.Duration
cacheRetention time.Duration cacheRetention time.Duration
cacheCleanupInterval time.Duration cacheCleanupInterval time.Duration
baselineMissTTL time.Duration baselineMissTTL time.Duration
maxCacheEntries int baselineHitTTL time.Duration
lastCacheCleanup time.Time maxCacheEntries int
lastCacheCleanupStats cacheCleanupStats lastCacheCleanup time.Time
cacheEvictions cacheCleanupStats lastCacheCleanupStats cacheCleanupStats
mu sync.Mutex cacheEvictions cacheCleanupStats
lastTotalMileage map[string]float64 mu sync.Mutex
lastSourceSeen map[string]time.Time lastTotalMileage map[string]float64
lastProjection map[string]projectionCacheEntry lastSourceSeen map[string]time.Time
baselineCache map[string]sourceBaselineCacheEntry lastProjection map[string]projectionCacheEntry
mileageKeysByPrefix map[string]map[string]struct{} pendingProjection map[string]pendingProjectionEntry
projectionKeysByPrefix map[string]map[string]struct{} baselineCache map[string]sourceBaselineCacheEntry
baselineKeysByPrefix map[string]map[string]struct{} mileageKeysByPrefix map[string]map[string]struct{}
projectionKeysByPrefix map[string]map[string]struct{}
baselineKeysByPrefix map[string]map[string]struct{}
lastGPSAccumulation map[string]time.Time
hydrogenTankCapacities map[string]float64
gpsAccumulationInterval time.Duration
} }
type MetricSample struct { type MetricSample struct {
VIN string VIN string
Protocol envelope.Protocol Protocol envelope.Protocol
StatDate string StatDate string
TotalMileageKM float64 TotalMileageKM float64
EventTime time.Time EventTime time.Time
SourceKey string SourceKey string
Phone string Phone string
DeviceID string DeviceID string
SourceEndpoint string SourceEndpoint string
PlatformName string PlatformName string
PureHydrogenActive bool
PureHydrogenModeKnown bool
} }
type AppendResult struct { type AppendResult struct {
@@ -67,6 +78,8 @@ type AppendResult struct {
SamplesSkippedMissingFields int SamplesSkippedMissingFields int
SamplesSkippedMissingVIN int SamplesSkippedMissingVIN int
SamplesSkippedMissingMileage int SamplesSkippedMissingMileage int
SamplesRecoveredStationary int
SamplesRecoveredGPSCoordinate int
SamplesSkippedNonMileageFrame int SamplesSkippedNonMileageFrame int
SamplesSkippedNonPositiveMileage int SamplesSkippedNonPositiveMileage int
SamplesSkippedMissingTime int SamplesSkippedMissingTime int
@@ -81,23 +94,45 @@ type AppendResult struct {
ProjectionsAttempted int ProjectionsAttempted int
ProjectionsWritten int ProjectionsWritten int
ProjectionsSkippedThrottled int ProjectionsSkippedThrottled int
HydrogenSamplesFound int
HydrogenSamplesWritten int
HydrogenSamplesDuplicate int
HydrogenSamplesInvalid int
HydrogenSamplesNotCurrent int
}
type ProjectionFlushResult struct {
Attempted int
Written int
Failed int
}
type ProjectionReconcileResult struct {
Targets int
Attempted int
Written int
Failed int
} }
type CacheStats struct { type CacheStats struct {
LastTotalMileageEntries int LastTotalMileageEntries int
LastSourceSeenEntries int LastSourceSeenEntries int
LastProjectionEntries int LastProjectionEntries int
BaselineEntries int PendingProjectionEntries int
MaxEntries int BaselineEntries int
LastCleanupAt time.Time GPSAccumulationEntries int
LastCleanupTotalMileage int MaxEntries int
LastCleanupSourceSeen int LastCleanupAt time.Time
LastCleanupProjection int LastCleanupTotalMileage int
LastCleanupBaseline int LastCleanupSourceSeen int
TotalMileageEvictions int LastCleanupProjection int
SourceSeenEvictions int LastCleanupBaseline int
ProjectionEvictions int LastCleanupGPS int
BaselineEvictions int TotalMileageEvictions int
SourceSeenEvictions int
ProjectionEvictions int
BaselineEvictions int
GPSEvictions int
} }
type cacheCleanupStats struct { type cacheCleanupStats struct {
@@ -105,6 +140,7 @@ type cacheCleanupStats struct {
sourceSeen int sourceSeen int
projection int projection int
baseline int baseline int
gps int
} }
func NewWriter(exec Execer, loc *time.Location) *Writer { func NewWriter(exec Execer, loc *time.Location) *Writer {
@@ -115,21 +151,26 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
loc = time.FixedZone("Asia/Shanghai", 8*3600) loc = time.FixedZone("Asia/Shanghai", 8*3600)
} }
writer := &Writer{ writer := &Writer{
exec: exec, exec: exec,
loc: loc, loc: loc,
sourceTouchInterval: time.Minute, sourceTouchInterval: time.Minute,
projectionInterval: 15 * time.Second, projectionInterval: 15 * time.Second,
cacheRetention: defaultCacheRetention, cacheRetention: defaultCacheRetention,
cacheCleanupInterval: defaultCacheCleanupInterval, cacheCleanupInterval: defaultCacheCleanupInterval,
baselineMissTTL: defaultBaselineMissTTL, baselineMissTTL: defaultBaselineMissTTL,
maxCacheEntries: defaultMaxCacheEntries, baselineHitTTL: defaultBaselineHitTTL,
lastTotalMileage: map[string]float64{}, maxCacheEntries: defaultMaxCacheEntries,
lastSourceSeen: map[string]time.Time{}, lastTotalMileage: map[string]float64{},
lastProjection: map[string]projectionCacheEntry{}, lastSourceSeen: map[string]time.Time{},
baselineCache: map[string]sourceBaselineCacheEntry{}, lastProjection: map[string]projectionCacheEntry{},
mileageKeysByPrefix: map[string]map[string]struct{}{}, pendingProjection: map[string]pendingProjectionEntry{},
projectionKeysByPrefix: map[string]map[string]struct{}{}, baselineCache: map[string]sourceBaselineCacheEntry{},
baselineKeysByPrefix: map[string]map[string]struct{}{}, mileageKeysByPrefix: map[string]map[string]struct{}{},
projectionKeysByPrefix: map[string]map[string]struct{}{},
baselineKeysByPrefix: map[string]map[string]struct{}{},
lastGPSAccumulation: map[string]time.Time{},
hydrogenTankCapacities: map[string]float64{},
gpsAccumulationInterval: defaultGPSAccumulationInterval,
} }
if query, ok := exec.(Queryer); ok { if query, ok := exec.(Queryer); ok {
writer.query = query writer.query = query
@@ -137,6 +178,25 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
return writer return writer
} }
func (w *Writer) ReloadHydrogenTankCapacities(ctx context.Context) (int, error) {
capacities, err := LoadHydrogenTankCapacities(ctx, w.query)
if err != nil {
return 0, err
}
w.mu.Lock()
w.hydrogenTankCapacities = capacities
w.mu.Unlock()
return len(capacities), nil
}
func (w *Writer) HydrogenTankCapacityLiters(vin string) (float64, bool) {
vin = strings.ToUpper(strings.TrimSpace(vin))
w.mu.Lock()
defer w.mu.Unlock()
capacity, ok := w.hydrogenTankCapacities[vin]
return capacity, ok
}
func (w *Writer) SetSourceTouchInterval(interval time.Duration) { func (w *Writer) SetSourceTouchInterval(interval time.Duration) {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -182,6 +242,15 @@ func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
w.baselineMissTTL = ttl w.baselineMissTTL = ttl
} }
func (w *Writer) SetBaselineHitTTL(ttl time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if ttl < 0 {
ttl = 0
}
w.baselineHitTTL = ttl
}
func (w *Writer) SetMaxCacheEntries(maxEntries int) { func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -192,32 +261,48 @@ func (w *Writer) SetMaxCacheEntries(maxEntries int) {
w.enforceCacheLimitsLocked() w.enforceCacheLimitsLocked()
} }
func (w *Writer) SetGPSAccumulationInterval(interval time.Duration) {
w.mu.Lock()
defer w.mu.Unlock()
if interval < 0 {
interval = 0
}
w.gpsAccumulationInterval = interval
}
func (w *Writer) CacheStats() CacheStats { func (w *Writer) CacheStats() CacheStats {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
return CacheStats{ return CacheStats{
LastTotalMileageEntries: len(w.lastTotalMileage), LastTotalMileageEntries: len(w.lastTotalMileage),
LastSourceSeenEntries: len(w.lastSourceSeen), LastSourceSeenEntries: len(w.lastSourceSeen),
LastProjectionEntries: len(w.lastProjection), LastProjectionEntries: len(w.lastProjection),
BaselineEntries: len(w.baselineCache), PendingProjectionEntries: len(w.pendingProjection),
MaxEntries: w.maxCacheEntries, BaselineEntries: len(w.baselineCache),
LastCleanupAt: w.lastCacheCleanup, GPSAccumulationEntries: len(w.lastGPSAccumulation),
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage, MaxEntries: w.maxCacheEntries,
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen, LastCleanupAt: w.lastCacheCleanup,
LastCleanupProjection: w.lastCacheCleanupStats.projection, LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
LastCleanupBaseline: w.lastCacheCleanupStats.baseline, LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
TotalMileageEvictions: w.cacheEvictions.totalMileage, LastCleanupProjection: w.lastCacheCleanupStats.projection,
SourceSeenEvictions: w.cacheEvictions.sourceSeen, LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
ProjectionEvictions: w.cacheEvictions.projection, LastCleanupGPS: w.lastCacheCleanupStats.gps,
BaselineEvictions: w.cacheEvictions.baseline, TotalMileageEvictions: w.cacheEvictions.totalMileage,
SourceSeenEvictions: w.cacheEvictions.sourceSeen,
ProjectionEvictions: w.cacheEvictions.projection,
BaselineEvictions: w.cacheEvictions.baseline,
GPSEvictions: w.cacheEvictions.gps,
} }
} }
func (w *Writer) EnsureSchema(ctx context.Context) error { func (w *Writer) EnsureSchema(ctx context.Context) error {
for _, statement := range []string{ for _, statement := range []string{
DataSourceTableSQL, DataSourceTableSQL,
GPSMileageStateTableSQL,
DailyMileageSourceTableSQL, DailyMileageSourceTableSQL,
DailyMileageTableSQL, DailyMileageTableSQL,
HydrogenTankCapacityTableSQL,
HydrogenStreamStateTableSQL,
} { } {
if _, err := w.exec.ExecContext(ctx, statement); err != nil { if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err return err
@@ -245,6 +330,16 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
seenAt := w.sourceSeenAt(env) seenAt := w.sourceSeenAt(env)
w.maybeCleanupCaches(seenAt) w.maybeCleanupCaches(seenAt)
identity, hasSource := NewSourceIdentityFromEnvelope(env) identity, hasSource := NewSourceIdentityFromEnvelope(env)
capacityLiters, _ := w.HydrogenTankCapacityLiters(env.VIN)
hydrogen, err := AppendHydrogenStream(ctx, w.exec, env, w.loc, time.Now(), capacityLiters)
if err != nil {
return result, err
}
result.HydrogenSamplesFound = hydrogen.Found
result.HydrogenSamplesWritten = hydrogen.Written
result.HydrogenSamplesDuplicate = hydrogen.Duplicate
result.HydrogenSamplesInvalid = hydrogen.Invalid
result.HydrogenSamplesNotCurrent = hydrogen.NotCurrent
var sourceResult AppendResult var sourceResult AppendResult
if hasSource && ShouldManageDataSource(identity) { if hasSource && ShouldManageDataSource(identity) {
if w.shouldTouchSource(identity, seenAt) { if w.shouldTouchSource(identity, seenAt) {
@@ -261,6 +356,11 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
sourceResult.SourceTouchesSkippedUnmanaged = 1 sourceResult.SourceTouchesSkippedUnmanaged = 1
} }
samples, extractionResult, err := samplesFromEnvelopeWithResult(env, w.loc) samples, extractionResult, err := samplesFromEnvelopeWithResult(env, w.loc)
extractionResult.HydrogenSamplesFound = result.HydrogenSamplesFound
extractionResult.HydrogenSamplesWritten = result.HydrogenSamplesWritten
extractionResult.HydrogenSamplesDuplicate = result.HydrogenSamplesDuplicate
extractionResult.HydrogenSamplesInvalid = result.HydrogenSamplesInvalid
extractionResult.HydrogenSamplesNotCurrent = result.HydrogenSamplesNotCurrent
result = extractionResult result = extractionResult
result.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted result.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted
result.SourceTouchesWritten += sourceResult.SourceTouchesWritten result.SourceTouchesWritten += sourceResult.SourceTouchesWritten
@@ -270,6 +370,34 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
if err != nil { if err != nil {
return result, err return result, err
} }
var stationaryCandidate *SourceMileageSample
if len(samples) == 0 && hasSource && result.SamplesSkippedMissingMileage > 0 {
if point, ok := GPSMileagePointFromEnvelope(env, identity, w.loc); ok && w.shouldAccumulateGPS(point) {
_, recovered, recoverErr := AccumulateGPSMileage(ctx, w.exec, point)
if recoverErr != nil {
return result, recoverErr
}
w.markGPSAccumulated(point)
if recovered {
result.SamplesFound++
result.SamplesWritten++
result.SamplesRecoveredGPSCoordinate++
result.ProjectionsAttempted++
result.ProjectionsWritten++
return result, nil
}
}
sample, candidate, recovered, recoverErr := w.stationaryCarryForward(ctx, env, identity)
if recoverErr != nil {
return result, recoverErr
}
if recovered {
samples = []MetricSample{sample}
stationaryCandidate = &candidate
result.SamplesFound++
result.SamplesRecoveredStationary++
}
}
for _, sample := range samples { for _, sample := range samples {
if !hasSource { if !hasSource {
result.SamplesSkippedMissingSource++ result.SamplesSkippedMissingSource++
@@ -278,11 +406,33 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
} }
if w.seenSameMileage(sample) { if w.seenSameMileage(sample) {
result.SamplesSkippedSameMileage++ result.SamplesSkippedSameMileage++
// A repeated cumulative odometer is usually a harmless duplicate,
// but some upstream platforms keep reporting positions and speed
// while their instrument mileage remains stale for hours. Preserve
// the duplicate evidence and accumulate a speed-backed GPS
// candidate so real movement does not disappear from daily mileage.
if point, ok := GPSMileagePointFromEnvelope(env, identity, w.loc); ok && w.shouldAccumulateGPS(point) {
_, recovered, recoverErr := AccumulateGPSMileage(ctx, w.exec, point)
if recoverErr != nil {
return result, recoverErr
}
w.markGPSAccumulated(point)
if recovered {
result.SamplesWritten++
result.SamplesRecoveredGPSCoordinate++
result.ProjectionsAttempted++
result.ProjectionsWritten++
}
}
continue continue
} }
candidate := SourceMileageSampleFromMetric(sample, identity) candidate := SourceMileageSampleFromMetric(sample, identity)
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil { if stationaryCandidate != nil {
return result, err candidate = *stationaryCandidate
} else {
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
return result, err
}
} }
projectDaily := w.shouldProjectDailyMileage(sample) projectDaily := w.shouldProjectDailyMileage(sample)
if projectDaily { if projectDaily {
@@ -297,6 +447,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
if projectDaily { if projectDaily {
w.markProjected(sample) w.markProjected(sample)
result.ProjectionsWritten++ result.ProjectionsWritten++
} else {
w.markProjectionPending(sample, time.Now())
} }
w.markMileageWritten(sample) w.markMileageWritten(sample)
result.SamplesWritten++ result.SamplesWritten++
@@ -304,6 +456,72 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
return result, nil return result, nil
} }
func (w *Writer) shouldAccumulateGPS(point GPSMileagePoint) bool {
key := gpsMileageStateCacheKey(point)
w.mu.Lock()
defer w.mu.Unlock()
last, ok := w.lastGPSAccumulation[key]
if !ok || w.gpsAccumulationInterval == 0 {
return true
}
return point.EventTime.After(last.Add(w.gpsAccumulationInterval))
}
func (w *Writer) markGPSAccumulated(point GPSMileagePoint) {
key := gpsMileageStateCacheKey(point)
w.mu.Lock()
if previous, ok := w.lastGPSAccumulation[key]; !ok || point.EventTime.After(previous) {
w.lastGPSAccumulation[key] = point.EventTime
}
w.enforceCacheLimitsLocked()
w.mu.Unlock()
}
func gpsMileageStateCacheKey(point GPSMileagePoint) string {
return point.VIN + "|" + point.StatDate + "|" + string(point.Protocol) + "|" + point.SourceKey
}
func (w *Writer) stationaryCarryForward(ctx context.Context, env envelope.FrameEnvelope, identity SourceIdentity) (MetricSample, SourceMileageSample, bool, error) {
if env.Protocol != envelope.ProtocolYutongMQTT || strings.TrimSpace(env.VIN) == "" {
return MetricSample{}, SourceMileageSample{}, false, nil
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.Fields)
if !ok || location.SpeedKMH == nil || *location.SpeedKMH != 0 {
return MetricSample{}, SourceMileageSample{}, false, nil
}
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
return MetricSample{}, SourceMileageSample{}, false, nil
}
eventTime := time.UnixMilli(eventMS).In(w.loc)
sample := MetricSample{
VIN: strings.TrimSpace(env.VIN),
Protocol: env.Protocol,
StatDate: eventTime.Format("2006-01-02"),
EventTime: eventTime,
SourceKey: SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
PlatformName: strings.TrimSpace(env.PlatformName),
}
candidate := SourceMileageSampleFromMetric(sample, identity)
baseline, found, err := w.previousBaseline(ctx, candidate)
if err != nil || !found || baseline.LatestTotalKM <= 0 || baseline.LatestEventTime.IsZero() {
return MetricSample{}, SourceMileageSample{}, false, err
}
sample.TotalMileageKM = baseline.LatestTotalKM
candidate = SourceMileageSampleFromMetric(sample, identity)
candidate.FirstTotalKM = baseline.LatestTotalKM
candidate.LatestTotalKM = baseline.LatestTotalKM
candidate.DailyKM = 0
candidate.FirstEventTime = baseline.LatestEventTime
candidate.LatestEventTime = eventTime
candidate.QualityStatus = QualityOK
candidate.QualityReason = QualityReasonStationaryCarry
return sample, candidate, true, nil
}
func (w *Writer) writeMileageSample(ctx context.Context, sample MetricSample, candidate SourceMileageSample, projectDaily bool) error { func (w *Writer) writeMileageSample(ctx context.Context, sample MetricSample, candidate SourceMileageSample, projectDaily bool) error {
if projectDaily { if projectDaily {
if beginner, ok := w.exec.(txBeginner); ok { if beginner, ok := w.exec.(txBeginner); ok {
@@ -403,12 +621,168 @@ func (w *Writer) markProjected(sample MetricSample) {
entry.sourceKeys = map[string]struct{}{} entry.sourceKeys = map[string]struct{}{}
} }
entry.sourceKeys[sample.SourceKey] = struct{}{} entry.sourceKeys[sample.SourceKey] = struct{}{}
entry.projectedAt = sample.EventTime if entry.projectedAt.IsZero() || sample.EventTime.After(entry.projectedAt) {
entry.projectedAt = sample.EventTime
}
w.lastProjection[key] = entry w.lastProjection[key] = entry
if pending, ok := w.pendingProjection[key]; ok && !pending.sample.EventTime.After(sample.EventTime) {
delete(w.pendingProjection, key)
}
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key) w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked() w.enforceCacheLimitsLocked()
} }
func (w *Writer) markProjectionPending(sample MetricSample, queuedAt time.Time) {
key := projectionCacheKey(sample)
prefix := projectionCachePrefix(sample)
w.mu.Lock()
defer w.mu.Unlock()
current, ok := w.pendingProjection[key]
if !ok || sample.EventTime.After(current.sample.EventTime) ||
(sample.EventTime.Equal(current.sample.EventTime) && queuedAt.After(current.queuedAt)) {
w.pendingProjection[key] = pendingProjectionEntry{sample: sample, queuedAt: queuedAt}
}
w.addCacheKeyLocked(w.projectionKeysByPrefix, prefix, key)
w.enforceCacheLimitsLocked()
}
// FlushPendingProjections closes the eventual-consistency gap introduced by
// projection throttling. Source candidates are persisted immediately, while
// final projections are flushed after their throttle window even when a
// vehicle stops reporting before another message can trigger projection.
func (w *Writer) FlushPendingProjections(ctx context.Context, readyBefore time.Time) (ProjectionFlushResult, error) {
type pendingItem struct {
key string
entry pendingProjectionEntry
}
w.mu.Lock()
items := make([]pendingItem, 0, len(w.pendingProjection))
for key, entry := range w.pendingProjection {
if readyBefore.IsZero() || !entry.queuedAt.After(readyBefore) {
items = append(items, pendingItem{key: key, entry: entry})
}
}
w.mu.Unlock()
sort.Slice(items, func(i, j int) bool {
return items[i].key < items[j].key
})
var result ProjectionFlushResult
var flushErr error
for _, item := range items {
if err := ctx.Err(); err != nil {
return result, errors.Join(flushErr, err)
}
result.Attempted++
if err := w.projectPendingMileage(ctx, item.entry.sample); err != nil {
result.Failed++
flushErr = errors.Join(flushErr, fmt.Errorf("project pending mileage %s: %w", item.key, err))
continue
}
w.markProjected(item.entry.sample)
result.Written++
}
return result, flushErr
}
// ReconcileDateProjections rebuilds final daily rows whose durable source
// candidates are newer than the projection (or whose projection is missing).
// The pending queue is intentionally in-memory for the hot path; this durable
// sweep closes the crash window between a committed source candidate and its
// throttled final projection.
func (w *Writer) ReconcileDateProjections(ctx context.Context, statDate string) (ProjectionReconcileResult, error) {
var result ProjectionReconcileResult
if w.query == nil {
return result, errors.New("daily mileage projection reconciliation requires query support")
}
statDate = strings.TrimSpace(statDate)
if statDate == "" {
return result, errors.New("daily mileage projection reconciliation requires stat date")
}
rows, err := w.query.QueryContext(ctx, staleDailyMileageProjectionTargetsSQL, statDate)
if err != nil {
return result, err
}
type target struct {
vin string
protocol envelope.Protocol
}
var targets []target
for rows.Next() {
var item target
if err := rows.Scan(&item.vin, &item.protocol); err != nil {
_ = rows.Close()
return result, err
}
if strings.TrimSpace(item.vin) != "" && strings.TrimSpace(string(item.protocol)) != "" {
targets = append(targets, item)
}
}
if err := rows.Close(); err != nil {
return result, err
}
if err := rows.Err(); err != nil {
return result, err
}
result.Targets = len(targets)
var reconcileErr error
for _, item := range targets {
if err := ctx.Err(); err != nil {
return result, errors.Join(reconcileErr, err)
}
result.Attempted++
if err := ProjectDailyMileage(ctx, w.exec, item.vin, statDate, item.protocol); err != nil {
result.Failed++
reconcileErr = errors.Join(reconcileErr, fmt.Errorf("reconcile daily mileage %s/%s/%s: %w", item.vin, statDate, item.protocol, err))
continue
}
result.Written++
}
return result, reconcileErr
}
func (w *Writer) projectPendingMileage(ctx context.Context, sample MetricSample) error {
project := func(exec Execer) error {
if strings.Contains(sample.SourceKey, platformSourceKeyPrefix) {
if err := NormalizePlatformSourceMileage(ctx, exec, sample.VIN, sample.StatDate, sample.Protocol); err != nil {
return err
}
}
return projectDailyMileageWithExec(ctx, exec, sample.VIN, sample.StatDate, sample.Protocol)
}
if beginner, ok := w.exec.(txBeginner); ok {
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := project(tx); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
return project(w.exec)
}
const staleDailyMileageProjectionTargetsSQL = `
SELECT s.vin, s.protocol
FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_daily_mileage m
ON m.vin = s.vin
AND m.stat_date = s.stat_date
AND m.protocol = s.protocol
WHERE s.stat_date = ?
AND s.quality_status = '` + QualityOK + `'
AND (
m.vin IS NULL
OR s.updated_at > m.updated_at
OR s.latest_event_time > m.updated_at
)
GROUP BY s.vin, s.protocol
ORDER BY s.protocol ASC, s.vin ASC
`
func (w *Writer) seenSameMileage(sample MetricSample) bool { func (w *Writer) seenSameMileage(sample MetricSample) bool {
key := mileageCacheKey(sample) key := mileageCacheKey(sample)
w.mu.Lock() w.mu.Lock()
@@ -475,6 +849,12 @@ func (w *Writer) maybeCleanupCaches(now time.Time) {
cleanupStats.baseline++ cleanupStats.baseline++
} }
} }
for key, eventTime := range w.lastGPSAccumulation {
if eventTime.IsZero() || eventTime.Before(cutoff) {
delete(w.lastGPSAccumulation, key)
cleanupStats.gps++
}
}
w.lastCacheCleanupStats = cleanupStats w.lastCacheCleanupStats = cleanupStats
w.enforceCacheLimitsLocked() w.enforceCacheLimitsLocked()
} }
@@ -517,8 +897,12 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
now := time.Now() now := time.Now()
w.mu.Lock() w.mu.Lock()
if cached, ok := w.baselineCache[cacheKey]; ok { if cached, ok := w.baselineCache[cacheKey]; ok {
missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL) ttl := w.baselineMissTTL
if !missExpired { if cached.found {
ttl = w.baselineHitTTL
}
expired := ttl == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= ttl
if !expired {
w.mu.Unlock() w.mu.Unlock()
return cached.baseline, cached.found, nil return cached.baseline, cached.found, nil
} }
@@ -570,6 +954,16 @@ func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, e
return return
} }
w.mu.Lock() w.mu.Lock()
if previous, ok := w.baselineCache[cacheKey]; ok &&
previous.found && entry.found &&
previous.baseline.LatestTotalKM == entry.baseline.LatestTotalKM &&
previous.baseline.LatestEventTime.Equal(entry.baseline.LatestEventTime) &&
!previous.cachedAt.IsZero() {
// markBaselineWritten runs for every accepted sample. Preserve the
// original cache age when the durable day-boundary baseline has not
// changed, otherwise an active vehicle would keep stale history forever.
entry.cachedAt = previous.cachedAt
}
if entry.cachedAt.IsZero() { if entry.cachedAt.IsZero() {
entry.cachedAt = time.Now() entry.cachedAt = time.Now()
} }
@@ -634,6 +1028,7 @@ func (w *Writer) deleteBaselineKeyLocked(key string) {
func (w *Writer) deleteProjectionKeyLocked(key string) { func (w *Writer) deleteProjectionKeyLocked(key string) {
delete(w.lastProjection, key) delete(w.lastProjection, key)
delete(w.pendingProjection, key)
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key) w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
} }
@@ -687,6 +1082,14 @@ func (w *Writer) enforceCacheLimitsLocked() {
delete(w.lastSourceSeen, victim) delete(w.lastSourceSeen, victim)
w.cacheEvictions.sourceSeen++ w.cacheEvictions.sourceSeen++
} }
for len(w.lastGPSAccumulation) > w.maxCacheEntries {
victim := oldestSourceSeenKey(w.lastGPSAccumulation)
if victim == "" {
break
}
delete(w.lastGPSAccumulation, victim)
w.cacheEvictions.gps++
}
} }
func mileageCachePrefix(sample MetricSample) string { func mileageCachePrefix(sample MetricSample) string {
@@ -781,6 +1184,11 @@ type projectionCacheEntry struct {
sourceKeys map[string]struct{} sourceKeys map[string]struct{}
} }
type pendingProjectionEntry struct {
sample MetricSample
queuedAt time.Time
}
type sourceBaselineCacheEntry struct { type sourceBaselineCacheEntry struct {
baseline sourceBaseline baseline sourceBaseline
found bool found bool
@@ -828,18 +1236,21 @@ func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Locatio
result.SamplesAdjustedFutureEventTime = 1 result.SamplesAdjustedFutureEventTime = 1
} }
statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02") statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02")
pureHydrogenActive, pureHydrogenModeKnown := PureHydrogenModeFromEnvelope(env)
result.SamplesFound = 1 result.SamplesFound = 1
return []MetricSample{{ return []MetricSample{{
VIN: vin, VIN: vin,
Protocol: env.Protocol, Protocol: env.Protocol,
StatDate: statDate, StatDate: statDate,
TotalMileageKM: totalMileage, TotalMileageKM: totalMileage,
EventTime: time.UnixMilli(eventMS).In(loc), EventTime: time.UnixMilli(eventMS).In(loc),
SourceKey: sourceKey(env), SourceKey: sourceKey(env),
Phone: strings.TrimSpace(env.Phone), Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID), DeviceID: strings.TrimSpace(env.DeviceID),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint), SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
PlatformName: strings.TrimSpace(env.PlatformName), PlatformName: strings.TrimSpace(env.PlatformName),
PureHydrogenActive: pureHydrogenActive,
PureHydrogenModeKnown: pureHydrogenModeKnown,
}}, result, nil }}, result, nil
} }

View File

@@ -118,6 +118,40 @@ func TestSamplesFromEnvelopeMapsProtocolMileageFields(t *testing.T) {
} }
} }
func TestSamplesFromEnvelopeCarriesPureHydrogenWorkMode(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
tests := []struct {
name string
protocol envelope.Protocol
mode int
}{
{name: "gb32960 fuel cell engine running", protocol: envelope.ProtocolGB32960, mode: 2},
{name: "yutong fuel cell driving", protocol: envelope.ProtocolYutongMQTT, mode: 11},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fields := map[string]any{envelope.FieldFuelCellWorkMode: test.mode}
if test.protocol == envelope.ProtocolGB32960 {
fields["gb32960.vehicle.total_mileage_km"] = 12000.5
} else {
fields["yutong_mqtt.data.total_mileage"] = 12000500
}
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: test.protocol,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 23, 8, 30, 0, 0, loc).UnixMilli(),
Fields: fields,
}, loc)
if err != nil || len(samples) != 1 {
t.Fatalf("SamplesFromEnvelope() samples=%d error=%v", len(samples), err)
}
if !samples[0].PureHydrogenModeKnown || !samples[0].PureHydrogenActive {
t.Fatalf("pure hydrogen evidence missing: %#v", samples[0])
}
})
}
}
func TestSamplesFromEnvelopeFallsBackToReceivedTimeWhenEventTimeIsFuture(t *testing.T) { func TestSamplesFromEnvelopeFallsBackToReceivedTimeWhenEventTimeIsFuture(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600) loc := time.FixedZone("Asia/Shanghai", 8*3600)
received := time.Date(2026, 7, 8, 23, 59, 0, 0, loc) received := time.Date(2026, 7, 8, 23, 59, 0, 0, loc)
@@ -797,6 +831,142 @@ func TestWriterAppendThrottlesDailyProjectionPerVehicleProtocolDate(t *testing.T
} }
} }
func TestWriterFlushesFinalProjectionWhenVehicleStopsInsideThrottleWindow(t *testing.T) {
exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(exec, loc)
base := managedTestSource(envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
VIN: "LMRKH9AC7R1004120",
Phone: "64341233712",
SourceEndpoint: "115.159.85.149:7339",
Fields: map[string]any{
"jt808.location.total_mileage_km": 4732.6,
},
})
first := base
first.EventTimeMS = time.Date(2026, 7, 19, 15, 4, 45, 0, loc).UnixMilli()
if err := writer.Append(context.Background(), first); err != nil {
t.Fatalf("first Append() error = %v", err)
}
final := base
final.EventTimeMS = time.Date(2026, 7, 19, 15, 4, 52, 0, loc).UnixMilli()
final.Fields = map[string]any{"jt808.location.total_mileage_km": 4740.7}
if err := writer.Append(context.Background(), final); err != nil {
t.Fatalf("final Append() error = %v", err)
}
if got := writer.CacheStats().PendingProjectionEntries; got != 1 {
t.Fatalf("pending projections = %d, want final throttled sample queued", got)
}
beforeDue, err := writer.FlushPendingProjections(context.Background(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("early FlushPendingProjections() error = %v", err)
}
if beforeDue.Attempted != 0 || beforeDue.Written != 0 {
t.Fatalf("early flush result = %+v, want no eligible projection", beforeDue)
}
flushed, err := writer.FlushPendingProjections(context.Background(), time.Time{})
if err != nil {
t.Fatalf("FlushPendingProjections() error = %v", err)
}
if flushed.Attempted != 1 || flushed.Written != 1 {
t.Fatalf("flush result = %+v, want final projection written", flushed)
}
if got := countExecQueries(exec.calls, "INSERT INTO vehicle_daily_mileage\n"); got != 2 {
t.Fatalf("daily projections = %d, want initial plus eventual final projection", got)
}
if got := writer.CacheStats().PendingProjectionEntries; got != 0 {
t.Fatalf("pending projections after flush = %d, want 0", got)
}
key := projectionCacheKey(MetricSample{
VIN: "LMRKH9AC7R1004120",
Protocol: envelope.ProtocolJT808,
StatDate: "2026-07-19",
})
wantProjectedAt := time.UnixMilli(final.EventTimeMS).In(loc)
if got := writer.lastProjection[key].projectedAt; !got.Equal(wantProjectedAt) {
t.Fatalf("last projected event = %s, want %s", got, wantProjectedAt)
}
}
func TestWriterPendingProjectionFailureDoesNotBlockOtherVehicles(t *testing.T) {
exec := &selectiveFailingExec{failVIN: "FAIL-VIN"}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(exec, loc)
writer.projectionInterval = time.Hour
for _, vin := range []string{"FAIL-VIN", "OK-VIN"} {
sample := MetricSample{
VIN: vin,
Protocol: envelope.ProtocolJT808,
StatDate: "2026-07-19",
SourceKey: "jt808:test",
EventTime: time.Date(2026, 7, 19, 15, 4, 52, 0, loc),
TotalMileageKM: 100,
}
writer.markProjectionPending(sample, time.Now().Add(-time.Hour))
}
result, err := writer.FlushPendingProjections(context.Background(), time.Time{})
if err == nil {
t.Fatal("FlushPendingProjections() error = nil, want failed vehicle reported")
}
if result.Attempted != 2 || result.Written != 1 || result.Failed != 1 {
t.Fatalf("flush result = %+v, want one failure and one successful projection", result)
}
if got := writer.CacheStats().PendingProjectionEntries; got != 1 {
t.Fatalf("pending projections after partial failure = %d, want only failed vehicle retained", got)
}
}
func TestWriterReconcilesDurableProjectionAfterPendingQueueIsLost(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
writer := NewWriter(db, time.FixedZone("Asia/Shanghai", 8*3600))
vin := "LMRKH9AC7R1004120"
statDate := "2026-07-19"
protocol := envelope.ProtocolJT808
mock.ExpectQuery(staleDailyMileageProjectionTargetsSQL).
WithArgs(statDate).
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol"}).AddRow(vin, string(protocol)))
expectProjectDailyMileageSQL(mock, vin, statDate, protocol)
mock.ExpectCommit()
result, err := writer.ReconcileDateProjections(context.Background(), statDate)
if err != nil {
t.Fatalf("ReconcileDateProjections() error = %v", err)
}
if result.Targets != 1 || result.Attempted != 1 || result.Written != 1 || result.Failed != 0 {
t.Fatalf("reconcile result = %+v, want one recovered projection", result)
}
if got := writer.CacheStats().PendingProjectionEntries; got != 0 {
t.Fatalf("startup reconciliation should not depend on pending memory, got %d entries", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterProjectionReconciliationRequiresQueryableStore(t *testing.T) {
writer := NewWriter(&recordingExec{}, time.FixedZone("Asia/Shanghai", 8*3600))
result, err := writer.ReconcileDateProjections(context.Background(), "2026-07-19")
if err == nil || !strings.Contains(err.Error(), "query support") {
t.Fatalf("ReconcileDateProjections() error = %v, want query support failure", err)
}
if result != (ProjectionReconcileResult{}) {
t.Fatalf("reconcile result = %+v, want empty", result)
}
}
func TestWriterAppendProjectsImmediatelyForNewSource(t *testing.T) { func TestWriterAppendProjectsImmediatelyForNewSource(t *testing.T) {
exec := &recordingExec{} exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600) loc := time.FixedZone("Asia/Shanghai", 8*3600)
@@ -899,9 +1069,13 @@ func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(t *testing.T) {
float64(4123.9), float64(4123.9),
float64(4123.9), float64(4123.9),
float64(0), float64(0),
float64(0),
int64(1), int64(1),
int64(0),
eventTime, eventTime,
eventTime, eventTime,
false,
false,
QualityOK, QualityOK,
QualityReasonCurrentDayFirst, QualityReasonCurrentDayFirst,
). ).
@@ -979,9 +1153,13 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
float64(4123.9), float64(4123.9),
float64(4123.9), float64(4123.9),
float64(0), float64(0),
float64(0),
int64(1), int64(1),
int64(0),
firstTime, firstTime,
firstTime, firstTime,
false,
false,
QualityOK, QualityOK,
QualityReasonCurrentDayFirst, QualityReasonCurrentDayFirst,
). ).
@@ -1025,9 +1203,13 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
float64(4123.9), float64(4123.9),
float64(4124.4), float64(4124.4),
approxFloat64{want: 0.5, tolerance: 0.000001}, approxFloat64{want: 0.5, tolerance: 0.000001},
float64(0),
int64(1), int64(1),
int64(0),
firstTime, firstTime,
secondTime, secondTime,
false,
false,
QualityOK, QualityOK,
QualityReasonCurrentDayFirst, QualityReasonCurrentDayFirst,
). ).
@@ -1091,6 +1273,91 @@ func TestWriterRetriesExpiredMissingPreviousDayBaseline(t *testing.T) {
} }
} }
func TestWriterRefreshesExpiredFoundPreviousDayBaseline(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
writer.SetBaselineHitTTL(time.Minute)
candidate := SourceMileageSample{
VIN: "LMRKH9AC6R1004111",
StatDate: "2026-07-17",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
}
cacheKey := mileageCacheKey(MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
})
staleTime := time.Date(2026, 7, 12, 4, 12, 20, 0, loc)
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
baseline: sourceBaseline{LatestTotalKM: 90778, LatestEventTime: staleTime},
found: true,
cachedAt: time.Now().Add(-2 * time.Minute),
}
refreshedTime := time.Date(2026, 7, 16, 23, 59, 59, 0, loc)
mock.ExpectQuery(`SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source`).
WithArgs(candidate.VIN, candidate.StatDate, "YUTONG_MQTT", candidate.SourceKey).
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).
AddRow(91302.0, refreshedTime))
baseline, found, err := writer.previousBaseline(context.Background(), candidate)
if err != nil || !found {
t.Fatalf("previousBaseline() found=%v error=%v, want refreshed baseline", found, err)
}
if baseline.LatestTotalKM != 91302 || !baseline.LatestEventTime.Equal(refreshedTime) {
t.Fatalf("baseline = %+v, want nearest refreshed baseline", baseline)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestWriterMarkBaselineKeepsCacheAgeForUnchangedBoundary(t *testing.T) {
db, _, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
candidate := SourceMileageSample{
VIN: "LMRKH9AC6R1004111",
StatDate: "2026-07-17",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC6R1004111@PLATFORM:yutong",
FirstTotalKM: 91302,
FirstEventTime: time.Date(2026, 7, 16, 23, 59, 59, 0, loc),
}
sample := MetricSample{
VIN: candidate.VIN,
Protocol: candidate.Protocol,
StatDate: candidate.StatDate,
SourceKey: candidate.SourceKey,
}
cacheKey := mileageCacheKey(sample)
cachedAt := time.Now().Add(-4 * time.Minute)
writer.baselineCache[cacheKey] = sourceBaselineCacheEntry{
baseline: sourceBaseline{LatestTotalKM: candidate.FirstTotalKM, LatestEventTime: candidate.FirstEventTime},
found: true,
cachedAt: cachedAt,
}
writer.markBaselineWritten(sample, candidate)
if got := writer.baselineCache[cacheKey].cachedAt; !got.Equal(cachedAt) {
t.Fatalf("cachedAt = %s, want unchanged %s", got, cachedAt)
}
}
func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T) { func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -1135,9 +1402,13 @@ func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T
float64(4100.8), float64(4100.8),
float64(4123.9), float64(4123.9),
approxFloat64{want: 23.1, tolerance: 0.000001}, approxFloat64{want: 23.1, tolerance: 0.000001},
float64(0),
int64(1), int64(1),
int64(0),
previousTime, previousTime,
currentTime, currentTime,
false,
false,
QualityOK, QualityOK,
QualityReasonHistorical, QualityReasonHistorical,
). ).
@@ -1220,9 +1491,13 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
float64(120672.0), float64(120672.0),
float64(120788.0), float64(120788.0),
approxFloat64{want: 116.0, tolerance: 0.000001}, approxFloat64{want: 116.0, tolerance: 0.000001},
float64(0),
int64(1), int64(1),
int64(0),
historicalTime, historicalTime,
currentTime, currentTime,
false,
false,
QualityOK, QualityOK,
QualityReasonHistorical, QualityReasonHistorical,
). ).
@@ -1555,9 +1830,13 @@ func TestWriterAppendMarksCandidateNoPreviousBaselineWhenPreviousBaselineMissing
float64(4136.8), float64(4136.8),
float64(4136.8), float64(4136.8),
float64(0), float64(0),
float64(0),
int64(1), int64(1),
int64(0),
currentTime, currentTime,
currentTime, currentTime,
false,
false,
QualityOK, QualityOK,
QualityReasonCurrentDayFirst, QualityReasonCurrentDayFirst,
). ).
@@ -1615,34 +1894,37 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
t.Fatalf("Append() error = %v", err) t.Fatalf("Append() error = %v", err)
} }
schemaCalls := 3 + len(DailyMileageAlterSQL) schemaCalls := 6 + len(DailyMileageAlterSQL)
if len(exec.calls) != schemaCalls+5 { if len(exec.calls) != schemaCalls+5 {
t.Fatalf("exec calls = %d", len(exec.calls)) t.Fatalf("exec calls = %d", len(exec.calls))
} }
for i, want := range []string{ for i, want := range []string{
"CREATE TABLE IF NOT EXISTS vehicle_data_source", "CREATE TABLE IF NOT EXISTS vehicle_data_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_gps_mileage_state",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source", "CREATE TABLE IF NOT EXISTS vehicle_daily_mileage_source",
"CREATE TABLE IF NOT EXISTS vehicle_daily_mileage", "CREATE TABLE IF NOT EXISTS vehicle_daily_mileage",
"CREATE TABLE IF NOT EXISTS vehicle_hydrogen_tank_capacity",
"CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_stream_state",
} { } {
if !strings.Contains(exec.calls[i].query, want) { if !strings.Contains(exec.calls[i].query, want) {
t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want) t.Fatalf("schema call %d = %s, want %s", i, exec.calls[i].query, want)
} }
} }
for _, column := range []string{"vehicle_key", "AUTO_INCREMENT", "created_at", "first_total_mileage_km", "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "sample_count"} { for _, column := range []string{"vehicle_key", "AUTO_INCREMENT", "created_at", "first_total_mileage_km", "trusted_source_key", "trusted_phone", "trusted_source_endpoint", "sample_count"} {
if strings.Contains(exec.calls[2].query, column) { if strings.Contains(exec.calls[3].query, column) {
t.Fatalf("schema should not include %s: %s", column, exec.calls[2].query) t.Fatalf("schema should not include %s: %s", column, exec.calls[3].query)
} }
} }
for _, column := range []string{"source_id BIGINT NULL", "daily_mileage_km", "latest_total_mileage_km", "KEY idx_source_id (source_id)"} { for _, column := range []string{"source_id BIGINT NULL", "daily_mileage_km", "latest_total_mileage_km", "KEY idx_source_id (source_id)"} {
if !strings.Contains(exec.calls[2].query, column) { if !strings.Contains(exec.calls[3].query, column) {
t.Fatalf("schema should include %s: %s", column, exec.calls[2].query) t.Fatalf("schema should include %s: %s", column, exec.calls[3].query)
} }
} }
if !strings.Contains(exec.calls[2].query, "PRIMARY KEY (vin, stat_date, protocol)") { if !strings.Contains(exec.calls[3].query, "PRIMARY KEY (vin, stat_date, protocol)") {
t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[2].query) t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[3].query)
} }
if strings.Contains(exec.calls[2].query, "KEY idx_vin (vin)") { if strings.Contains(exec.calls[3].query, "KEY idx_vin (vin)") {
t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[2].query) t.Fatalf("daily mileage table should not keep redundant vin index covered by the primary key: %s", exec.calls[3].query)
} }
projectCall := exec.calls[schemaCalls+2] projectCall := exec.calls[schemaCalls+2]
if !strings.Contains(projectCall.query, "INSERT INTO vehicle_daily_mileage") { if !strings.Contains(projectCall.query, "INSERT INTO vehicle_daily_mileage") {
@@ -1660,7 +1942,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
if !strings.Contains(projectCall.query, "ds.id") { if !strings.Contains(projectCall.query, "ds.id") {
t.Fatalf("project sql should write source id: %s", projectCall.query) t.Fatalf("project sql should write source id: %s", projectCall.query)
} }
for _, forbidden := range []string{"trusted_source_key", "trusted_phone", "trusted_source_endpoint", "first_total_mileage_km", "sample_count)"} { for _, forbidden := range []string{"trusted_source_key", "trusted_phone", "trusted_source_endpoint", "first_total_mileage_km"} {
if strings.Contains(projectCall.query, forbidden) { if strings.Contains(projectCall.query, forbidden) {
t.Fatalf("project sql should not write final-table field %q: %s", forbidden, projectCall.query) t.Fatalf("project sql should not write final-table field %q: %s", forbidden, projectCall.query)
} }
@@ -1753,6 +2035,107 @@ func TestWriterSkipsConsecutiveDuplicateMileageSamples(t *testing.T) {
} }
} }
func TestWriterCarriesPreviousYutongMileageIntoStationaryDay(t *testing.T) {
exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(exec, loc)
eventTime := time.Date(2026, 7, 19, 6, 5, 42, 0, loc)
previousTime := time.Date(2026, 7, 18, 16, 48, 10, 0, loc)
vin := "LMRKH9AC9R1004099"
sourceKey := "YUTONG_MQTT:" + vin + "@PLATFORM:yutong"
sample := MetricSample{
VIN: vin,
Protocol: envelope.ProtocolYutongMQTT,
StatDate: "2026-07-19",
SourceKey: sourceKey,
}
writer.baselineCache[mileageCacheKey(sample)] = sourceBaselineCacheEntry{
baseline: sourceBaseline{
LatestTotalKM: 24245,
LatestEventTime: previousTime,
QualityReason: QualityReasonHistorical,
},
found: true,
cachedAt: time.Now(),
}
event := managedTestSource(envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: vin,
DeviceID: vin,
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
SourceCode: "yutong",
PlatformName: "宇通",
EventTimeMS: eventTime.UnixMilli(),
ReceivedAtMS: eventTime.UnixMilli(),
Fields: map[string]any{
"yutong_mqtt.data.latitude": "30.645438",
"yutong_mqtt.data.longitude": "114.435672",
"yutong_mqtt.data.meter_speed": "0",
},
})
result, err := writer.AppendWithResult(context.Background(), event)
if err != nil {
t.Fatalf("AppendWithResult() error = %v", err)
}
if result.SamplesRecoveredStationary != 1 || result.SamplesWritten != 1 || result.ProjectionsWritten != 1 {
t.Fatalf("stationary carry result = %+v", result)
}
if result.SamplesSkippedMissingMileage != 1 {
t.Fatalf("source-data evidence must retain the missing-mileage counter: %+v", result)
}
if len(exec.calls) != 7 {
t.Fatalf("exec calls = %d, want source touch plus six mileage writes", len(exec.calls))
}
sourceWrite := exec.calls[1]
if !strings.Contains(sourceWrite.query, "INSERT INTO vehicle_daily_mileage_source") {
t.Fatalf("source mileage write missing: %s", sourceWrite.query)
}
if got := sourceWrite.args[9]; got != float64(24245) {
t.Fatalf("first total mileage = %#v, want carried 24245", got)
}
if got := sourceWrite.args[10]; got != float64(24245) {
t.Fatalf("latest total mileage = %#v, want carried 24245", got)
}
if got := sourceWrite.args[11]; got != float64(0) {
t.Fatalf("daily mileage = %#v, want explicit zero", got)
}
if got := sourceWrite.args[20]; got != QualityReasonStationaryCarry {
t.Fatalf("quality reason = %#v, want %q", got, QualityReasonStationaryCarry)
}
}
func TestWriterDoesNotCarryMileageForMovingYutongFrame(t *testing.T) {
exec := &recordingExec{}
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(exec, loc)
eventTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
event := managedTestSource(envelope.FrameEnvelope{
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC9R1004099",
DeviceID: "LMRKH9AC9R1004099",
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
SourceCode: "yutong",
EventTimeMS: eventTime.UnixMilli(),
Fields: map[string]any{
"yutong_mqtt.data.latitude": "30.645438",
"yutong_mqtt.data.longitude": "114.435672",
"yutong_mqtt.data.meter_speed": "12",
},
})
result, err := writer.AppendWithResult(context.Background(), event)
if err != nil {
t.Fatalf("AppendWithResult() error = %v", err)
}
if result.SamplesRecoveredStationary != 0 || result.SamplesWritten != 0 || result.SamplesSkippedMissingMileage != 1 {
t.Fatalf("moving frame must remain missing-mileage evidence: %+v", result)
}
if len(exec.calls) != 1 || !strings.Contains(exec.calls[0].query, "INSERT INTO vehicle_data_source") {
t.Fatalf("moving frame writes = %+v, want source touch only", exec.calls)
}
}
func TestWriterAppendWithResultReportsDuplicateMileage(t *testing.T) { func TestWriterAppendWithResultReportsDuplicateMileage(t *testing.T) {
exec := &recordingExec{} exec := &recordingExec{}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
@@ -1786,6 +2169,78 @@ func TestWriterAppendWithResultReportsDuplicateMileage(t *testing.T) {
} }
} }
func TestWriterDuplicateYutongOdometerStillEntersGPSFallback(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
writer := NewWriter(db, loc)
eventTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
event := managedTestSource(envelope.FrameEnvelope{
EventID: "evt-yutong-stale-odometer",
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC9R1004099",
DeviceID: "LMRKH9AC9R1004099",
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
EventTimeMS: eventTime.UnixMilli(),
Fields: map[string]any{
"yutong_mqtt.data.total_mileage": 24245000,
"yutong_mqtt.data.longitude": 114.436672,
"yutong_mqtt.data.latitude": 30.645438,
"yutong_mqtt.data.meter_speed": 18,
},
})
samples, _, err := samplesFromEnvelopeWithResult(event, loc)
if err != nil || len(samples) != 1 {
t.Fatalf("samplesFromEnvelopeWithResult() samples=%+v err=%v", samples, err)
}
identity, ok := NewSourceIdentityFromEnvelope(event)
if !ok {
t.Fatal("expected managed Yutong source identity")
}
writer.markSourceTouched(identity, eventTime)
writer.markMileageWritten(samples[0])
mock.ExpectBegin()
mock.ExpectQuery(`SELECT first_event_time, latest_event_time`).
WillReturnRows(sqlmock.NewRows([]string{
"first_event_time", "latest_event_time", "latest_event_id",
"latest_longitude", "latest_latitude", "daily_mileage_km",
"point_count", "usable_segment_count", "bad_jump_count",
"long_gap_count", "out_of_order_count",
}).AddRow(eventTime.Add(-20*time.Second), eventTime.Add(-20*time.Second), "evt-before", 114.434672, 30.645438, 0, 1, 0, 0, 0, 0))
mock.ExpectExec(`UPDATE vehicle_daily_gps_mileage_state`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE vehicle_daily_mileage_source`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
result, err := writer.AppendWithResult(context.Background(), event)
if err != nil {
t.Fatalf("AppendWithResult() error = %v", err)
}
if result.SamplesFound != 1 || result.SamplesSkippedSameMileage != 1 ||
result.SamplesRecoveredGPSCoordinate != 1 || result.SamplesWritten != 1 ||
result.ProjectionsAttempted != 1 || result.ProjectionsWritten != 1 {
t.Fatalf("duplicate odometer evidence = %+v", result)
}
if got := writer.CacheStats().GPSAccumulationEntries; got != 1 {
t.Fatalf("GPS accumulation entries = %d, want stale moving odometer to enter fallback", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}
func TestWriterDoesNotCacheMileageWhenUpsertFails(t *testing.T) { func TestWriterDoesNotCacheMileageWhenUpsertFails(t *testing.T) {
exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}} exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}}
writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600)) writer := NewWriter(exec, time.FixedZone("Asia/Shanghai", 8*3600))
@@ -1854,9 +2309,13 @@ func TestWriterRollsBackMileageTransactionWhenProjectionFails(t *testing.T) {
float64(4123.9), float64(4123.9),
float64(4123.9), float64(4123.9),
float64(0), float64(0),
float64(0),
int64(1), int64(1),
int64(0),
eventTime, eventTime,
eventTime, eventTime,
false,
false,
QualityOK, QualityOK,
QualityReasonCurrentDayFirst, QualityReasonCurrentDayFirst,
). ).
@@ -2135,6 +2594,17 @@ type recordingExec struct {
errs []error errs []error
} }
type selectiveFailingExec struct {
failVIN string
}
func (e *selectiveFailingExec) ExecContext(_ context.Context, _ string, args ...any) (sql.Result, error) {
if len(args) > 0 && fmt.Sprint(args[0]) == e.failVIN {
return nil, errors.New("test projection failure")
}
return nil, nil
}
type concurrentExec struct { type concurrentExec struct {
mu sync.Mutex mu sync.Mutex
calls int calls int

View File

@@ -0,0 +1,442 @@
package stats
import (
"context"
"database/sql"
"errors"
"math"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
QualityReasonGPSCoordinate = "gps_coordinate_accumulation"
gpsCoordinateSourceSuffix = "#GPS_COORDINATE"
gpsMaxSegmentGap = 10 * time.Minute
gpsMaxImpliedSpeedKMH = 220.0
GPSMinimumDailyDistanceKM = 0.1
)
const GPSMileageStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_gps_mileage_state (
vin VARCHAR(32) NOT NULL,
stat_date DATE NOT NULL,
protocol VARCHAR(32) NOT NULL,
source_key VARCHAR(256) NOT NULL,
source_ip VARCHAR(64) NOT NULL,
source_endpoint VARCHAR(128) NULL,
phone VARCHAR(32) NULL,
device_id VARCHAR(64) NULL,
first_event_time DATETIME(3) NOT NULL,
latest_event_time DATETIME(3) NOT NULL,
latest_event_id VARCHAR(128) NULL,
latest_longitude DOUBLE NOT NULL,
latest_latitude DOUBLE NOT NULL,
daily_mileage_km DECIMAL(18,6) NOT NULL DEFAULT 0,
point_count BIGINT NOT NULL DEFAULT 1,
usable_segment_count BIGINT NOT NULL DEFAULT 0,
bad_jump_count BIGINT NOT NULL DEFAULT 0,
long_gap_count BIGINT NOT NULL DEFAULT 0,
out_of_order_count BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (vin, stat_date, protocol, source_key),
KEY idx_gps_mileage_date (stat_date, protocol, vin)
)`
type GPSMileagePoint struct {
VIN string
StatDate string
Protocol envelope.Protocol
SourceKey string
SourceIP string
SourceEndpoint string
Phone string
DeviceID string
EventID string
EventTime time.Time
Longitude float64
Latitude float64
}
type GPSMileageState struct {
FirstEventTime time.Time
LatestEventTime time.Time
LatestEventID string
LatestLongitude float64
LatestLatitude float64
DailyMileageKM float64
PointCount int64
UsableSegmentCount int64
BadJumpCount int64
LongGapCount int64
OutOfOrderCount int64
}
type GPSMileageSegment struct {
DistanceKM float64
Usable bool
LongGap bool
BadJump bool
OutOfOrder bool
}
func CalculateGPSMileageSegment(previous, current GPSMileagePoint) GPSMileageSegment {
if current.EventTime.IsZero() || previous.EventTime.IsZero() || !current.EventTime.After(previous.EventTime) {
return GPSMileageSegment{OutOfOrder: true}
}
elapsed := current.EventTime.Sub(previous.EventTime)
if elapsed > gpsMaxSegmentGap {
return GPSMileageSegment{LongGap: true}
}
distanceKM := haversineDistanceKM(previous.Latitude, previous.Longitude, current.Latitude, current.Longitude)
impliedSpeed := distanceKM / elapsed.Hours()
if !isFiniteNonNegative(distanceKM) || !isFiniteNonNegative(impliedSpeed) || impliedSpeed > gpsMaxImpliedSpeedKMH {
return GPSMileageSegment{BadJump: true}
}
return GPSMileageSegment{DistanceKM: distanceKM, Usable: true}
}
func GPSMileagePointFromEnvelope(env envelope.FrameEnvelope, identity SourceIdentity, loc *time.Location) (GPSMileagePoint, bool) {
if !supportsGPSMileageFallback(env.Protocol) || strings.TrimSpace(env.VIN) == "" {
return GPSMileagePoint{}, false
}
location, ok := telemetry.LocationProjectionForProtocol(env.Protocol, env.Fields)
if !ok || !validGPSCoordinate(location.Longitude, location.Latitude) {
return GPSMileagePoint{}, false
}
// JT808 terminals often omit speed together with odometer data, so keep the
// established coordinate-only fallback. GB32960 and Yutong report much more
// frequently; require positive device speed to prevent stationary GPS jitter
// from becoming mileage and to keep the MySQL state path inexpensive.
if env.Protocol != envelope.ProtocolJT808 && (location.SpeedKMH == nil || *location.SpeedKMH <= 0) {
return GPSMileagePoint{}, false
}
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
return GPSMileagePoint{}, false
}
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventTime := time.UnixMilli(eventMS).In(loc)
sourceKey := SourceKeyForSource(env.Protocol, env.Phone, env.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode)
return GPSMileagePoint{
VIN: strings.TrimSpace(env.VIN),
StatDate: eventTime.Format("2006-01-02"),
Protocol: env.Protocol,
SourceKey: sourceKey,
SourceIP: strings.TrimSpace(identity.SourceIP),
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
Phone: strings.TrimSpace(env.Phone),
DeviceID: strings.TrimSpace(env.DeviceID),
EventID: strings.TrimSpace(env.EventID),
EventTime: eventTime,
Longitude: location.Longitude,
Latitude: location.Latitude,
}, true
}
func supportsGPSMileageFallback(protocol envelope.Protocol) bool {
switch protocol {
case envelope.ProtocolGB32960, envelope.ProtocolJT808, envelope.ProtocolYutongMQTT:
return true
default:
return false
}
}
func AccumulateGPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) {
beginner, ok := exec.(txBeginner)
if !ok || strings.TrimSpace(point.VIN) == "" || strings.TrimSpace(point.SourceKey) == "" || point.EventTime.IsZero() {
return GPSMileageState{}, false, nil
}
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return GPSMileageState{}, false, err
}
state, found, err := selectGPSMileageState(ctx, tx, point)
if err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if !found {
baselineMileage, baselineFirstEvent, baselinePointCount, baselineSegmentCount, err := selectExistingGPSMileageBaseline(ctx, tx, point)
if err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
state = GPSMileageState{
FirstEventTime: point.EventTime,
LatestEventTime: point.EventTime,
LatestEventID: point.EventID,
LatestLongitude: point.Longitude,
LatestLatitude: point.Latitude,
DailyMileageKM: baselineMileage,
PointCount: baselinePointCount + 1,
UsableSegmentCount: baselineSegmentCount,
}
if !baselineFirstEvent.IsZero() && baselineFirstEvent.Before(point.EventTime) {
state.FirstEventTime = baselineFirstEvent
}
if _, err := tx.ExecContext(ctx, insertGPSMileageStateSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID,
state.FirstEventTime, point.EventTime, point.EventID, point.Longitude, point.Latitude,
state.DailyMileageKM, state.PointCount, state.UsableSegmentCount,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
segment := CalculateGPSMileageSegment(GPSMileagePoint{
EventTime: state.LatestEventTime,
Longitude: state.LatestLongitude,
Latitude: state.LatestLatitude,
}, point)
if segment.OutOfOrder {
state.OutOfOrderCount++
if _, err := tx.ExecContext(ctx, incrementGPSMileageOutOfOrderSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
state.LatestEventTime = point.EventTime
state.LatestEventID = point.EventID
state.LatestLongitude = point.Longitude
state.LatestLatitude = point.Latitude
state.PointCount++
if segment.Usable {
state.DailyMileageKM += segment.DistanceKM
state.UsableSegmentCount++
}
if segment.BadJump {
state.BadJumpCount++
}
if segment.LongGap {
state.LongGapCount++
}
if _, err := tx.ExecContext(ctx, updateGPSMileageStateSQL,
point.SourceIP, point.SourceEndpoint, point.Phone, point.DeviceID,
state.LatestEventTime, state.LatestEventID, state.LatestLongitude, state.LatestLatitude,
state.DailyMileageKM, state.PointCount, state.UsableSegmentCount,
state.BadJumpCount, state.LongGapCount, state.OutOfOrderCount,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if !GPSMileageCandidateEligible(state.DailyMileageKM, state.UsableSegmentCount) {
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, false, nil
}
if err := upsertGPSMileageCandidate(ctx, tx, point, state); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := projectDailyMileageWithExec(ctx, tx, point.VIN, point.StatDate, point.Protocol); err != nil {
_ = tx.Rollback()
return GPSMileageState{}, false, err
}
if err := tx.Commit(); err != nil {
return GPSMileageState{}, false, err
}
return state, true, nil
}
// GPSMileageCandidateEligible keeps sub-100-metre stationary drift and
// momentary speed noise in the durable accumulation state without exposing it
// as a business daily-mileage candidate. Real movement continues accumulating
// and becomes eligible once the evidence crosses the minimum distance.
func GPSMileageCandidateEligible(distanceKM float64, usableSegmentCount int64) bool {
return usableSegmentCount > 0 &&
isFiniteNonNegative(distanceKM) &&
distanceKM >= GPSMinimumDailyDistanceKM
}
// AccumulateJT808GPSMileage remains as a compatibility wrapper for callers and
// operational tooling compiled against the original JT808-only fallback.
func AccumulateJT808GPSMileage(ctx context.Context, exec Execer, point GPSMileagePoint) (GPSMileageState, bool, error) {
return AccumulateGPSMileage(ctx, exec, point)
}
func selectExistingGPSMileageBaseline(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (float64, time.Time, int64, int64, error) {
var mileage sql.NullFloat64
var firstEvent sql.NullTime
var pointCount sql.NullInt64
err := tx.QueryRowContext(ctx, selectExistingGPSMileageBaselineSQL,
point.VIN, point.StatDate, string(point.Protocol), QualityReasonGPSCoordinate,
).Scan(&mileage, &firstEvent, &pointCount)
if errors.Is(err, sql.ErrNoRows) {
return 0, time.Time{}, 0, 0, nil
}
if err != nil {
return 0, time.Time{}, 0, 0, err
}
segments := pointCount.Int64 - 1
if segments < 0 {
segments = 0
}
return mileage.Float64, firstEvent.Time, pointCount.Int64, segments, nil
}
func selectGPSMileageState(ctx context.Context, tx *sql.Tx, point GPSMileagePoint) (GPSMileageState, bool, error) {
var state GPSMileageState
err := tx.QueryRowContext(ctx, selectGPSMileageStateSQL,
point.VIN, point.StatDate, string(point.Protocol), point.SourceKey,
).Scan(
&state.FirstEventTime, &state.LatestEventTime, &state.LatestEventID,
&state.LatestLongitude, &state.LatestLatitude, &state.DailyMileageKM,
&state.PointCount, &state.UsableSegmentCount, &state.BadJumpCount,
&state.LongGapCount, &state.OutOfOrderCount,
)
if errors.Is(err, sql.ErrNoRows) {
return GPSMileageState{}, false, nil
}
return state, err == nil, err
}
func upsertGPSMileageCandidate(ctx context.Context, exec Execer, point GPSMileagePoint, state GPSMileageState) error {
sourceIP := strings.TrimSpace(point.SourceIP)
if len(sourceIP) > 56 {
sourceIP = sourceIP[:56]
}
sourceIP += "#gps"
_, err := exec.ExecContext(ctx, upsertGPSMileageCandidateSQL,
point.VIN,
point.StatDate,
string(point.Protocol),
gpsMileageCandidateSourceKey(point),
sourceIP,
point.SourceEndpoint,
point.Phone,
point.DeviceID,
gpsMileagePlatformName(point.Protocol),
0,
state.DailyMileageKM,
state.DailyMileageKM,
state.PointCount,
state.FirstEventTime,
state.LatestEventTime,
QualityOK,
QualityReasonGPSCoordinate,
)
return err
}
func gpsMileagePlatformName(protocol envelope.Protocol) string {
switch protocol {
case envelope.ProtocolGB32960:
return "GB32960 GPS轨迹估算"
case envelope.ProtocolYutongMQTT:
return "宇通 GPS轨迹估算"
default:
return "JT808 GPS轨迹估算"
}
}
func gpsMileageCandidateSourceKey(point GPSMileagePoint) string {
identity := strings.TrimSpace(point.Phone)
if identity == "" {
identity = strings.TrimSpace(point.DeviceID)
}
if identity == "" {
identity = strings.TrimSpace(point.VIN)
}
sourceIP := strings.TrimSpace(point.SourceIP)
if sourceIP != "" {
return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix + ":" + sourceIP
}
return string(point.Protocol) + ":" + identity + gpsCoordinateSourceSuffix
}
func validGPSCoordinate(longitude, latitude float64) bool {
return longitude >= -180 && longitude <= 180 &&
latitude >= -90 && latitude <= 90 &&
(longitude != 0 || latitude != 0) &&
!math.IsNaN(longitude) && !math.IsNaN(latitude) &&
!math.IsInf(longitude, 0) && !math.IsInf(latitude, 0)
}
func isFiniteNonNegative(value float64) bool {
return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
}
func haversineDistanceKM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKM = 6371.0088
toRadians := math.Pi / 180
lat1Rad := lat1 * toRadians
lat2Rad := lat2 * toRadians
dLat := (lat2 - lat1) * toRadians
dLon := (lon2 - lon1) * toRadians
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
}
const selectGPSMileageStateSQL = `SELECT first_event_time, latest_event_time, COALESCE(latest_event_id, ''),
latest_longitude, latest_latitude, daily_mileage_km, point_count, usable_segment_count,
bad_jump_count, long_gap_count, out_of_order_count
FROM vehicle_daily_gps_mileage_state
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?
FOR UPDATE`
const insertGPSMileageStateSQL = `INSERT INTO vehicle_daily_gps_mileage_state
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id,
first_event_time, latest_event_time, latest_event_id, latest_longitude, latest_latitude,
daily_mileage_km, point_count, usable_segment_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const selectExistingGPSMileageBaselineSQL = `SELECT daily_mileage_km, first_event_time, sample_count
FROM vehicle_daily_mileage_source
WHERE vin = ? AND stat_date = ? AND protocol = ? AND quality_reason = ?
ORDER BY latest_event_time DESC, sample_count DESC
LIMIT 1`
const incrementGPSMileageOutOfOrderSQL = `UPDATE vehicle_daily_gps_mileage_state
SET out_of_order_count = out_of_order_count + 1, updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?`
const updateGPSMileageStateSQL = `UPDATE vehicle_daily_gps_mileage_state
SET source_ip = ?, source_endpoint = ?, phone = ?, device_id = ?,
latest_event_time = ?, latest_event_id = ?, latest_longitude = ?, latest_latitude = ?,
daily_mileage_km = ?, point_count = ?, usable_segment_count = ?,
bad_jump_count = ?, long_gap_count = ?, out_of_order_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE vin = ? AND stat_date = ? AND protocol = ? AND source_key = ?`
const upsertGPSMileageCandidateSQL = `INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count,
first_event_time, latest_event_time, quality_status, quality_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint),
phone = VALUES(phone),
device_id = VALUES(device_id),
platform_name = VALUES(platform_name),
first_total_mileage_km = VALUES(first_total_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km),
daily_mileage_km = VALUES(daily_mileage_km),
sample_count = VALUES(sample_count),
first_event_time = VALUES(first_event_time),
latest_event_time = VALUES(latest_event_time),
quality_status = VALUES(quality_status),
quality_reason = VALUES(quality_reason),
updated_at = CURRENT_TIMESTAMP`

View File

@@ -0,0 +1,308 @@
package stats
import (
"context"
"math"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestCalculateGPSMileageSegmentMatchesTrackSafetyWindow(t *testing.T) {
start := time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
previous := GPSMileagePoint{EventTime: start, Longitude: 121.4737, Latitude: 31.2304}
valid := CalculateGPSMileageSegment(previous, GPSMileagePoint{
EventTime: start.Add(5 * time.Minute),
Longitude: 121.4837,
Latitude: 31.2304,
})
if !valid.Usable || valid.DistanceKM <= 0.9 || valid.DistanceKM >= 1.1 {
t.Fatalf("valid segment = %+v, want about 0.95 km", valid)
}
longGap := CalculateGPSMileageSegment(previous, GPSMileagePoint{
EventTime: start.Add(11 * time.Minute),
Longitude: 121.4837,
Latitude: 31.2304,
})
if !longGap.LongGap || longGap.Usable {
t.Fatalf("long gap segment = %+v", longGap)
}
jump := CalculateGPSMileageSegment(previous, GPSMileagePoint{
EventTime: start.Add(time.Minute),
Longitude: 121.5737,
Latitude: 31.2304,
})
if !jump.BadJump || jump.Usable {
t.Fatalf("jump segment = %+v", jump)
}
outOfOrder := CalculateGPSMileageSegment(previous, GPSMileagePoint{
EventTime: start,
Longitude: 121.4747,
Latitude: 31.2304,
})
if !outOfOrder.OutOfOrder || outOfOrder.Usable {
t.Fatalf("out-of-order segment = %+v", outOfOrder)
}
}
func TestGPSMileagePointFromEnvelopeUsesNaturalDayAndStableSource(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 19, 23, 59, 58, 0, loc)
env := envelope.FrameEnvelope{
EventID: "evt-gps-1",
Protocol: envelope.ProtocolJT808,
VIN: "LMRKH9AC7R1004120",
Phone: "64341233712",
SourceEndpoint: "122.152.221.156:33805",
EventTimeMS: eventTime.UnixMilli(),
Fields: map[string]any{
"jt808.location.longitude": 121.4737,
"jt808.location.latitude": 31.2304,
},
}
identity := SourceIdentity{
Protocol: envelope.ProtocolJT808,
SourceIP: "122.152.221.156",
SourceCode: "dongfang_beidou",
SourceKind: "PLATFORM",
PlatformName: "东方北斗",
}
point, ok := GPSMileagePointFromEnvelope(env, identity, loc)
if !ok {
t.Fatal("expected valid GPS point")
}
if point.StatDate != "2026-07-19" || point.SourceKey != "JT808:64341233712@PLATFORM:dongfang_beidou" {
t.Fatalf("point = %+v", point)
}
candidateKey := gpsMileageCandidateSourceKey(point)
if candidateKey != "JT808:64341233712#GPS_COORDINATE:122.152.221.156" || strings.Contains(candidateKey, platformSourceKeyPrefix) {
t.Fatalf("coordinate candidate key must remain lower-trust than an odometer platform source: %q", candidateKey)
}
}
func TestGPSMileagePointFromYutongEnvelopeRequiresMovement(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
env := envelope.FrameEnvelope{
EventID: "evt-yutong-gps",
Protocol: envelope.ProtocolYutongMQTT,
VIN: "LMRKH9AC9R1004099",
DeviceID: "LMRKH9AC9R1004099",
SourceEndpoint: "mqtt://yutong/ytforward/shln/2",
EventTimeMS: eventTime.UnixMilli(),
Fields: map[string]any{
"yutong_mqtt.data.longitude": 121.4737,
"yutong_mqtt.data.latitude": 31.2304,
"yutong_mqtt.data.meter_speed": 18,
},
}
identity := SourceIdentity{
Protocol: envelope.ProtocolYutongMQTT,
SourceIP: "mqtt",
SourceCode: "yutong",
SourceKind: "PLATFORM",
PlatformName: "宇通",
}
point, ok := GPSMileagePointFromEnvelope(env, identity, loc)
if !ok {
t.Fatal("moving Yutong location must be eligible for GPS mileage fallback")
}
if point.Protocol != envelope.ProtocolYutongMQTT || point.SourceKey != "YUTONG_MQTT:LMRKH9AC9R1004099@PLATFORM:yutong" {
t.Fatalf("point = %+v", point)
}
env.Fields["yutong_mqtt.data.meter_speed"] = 0
if _, ok := GPSMileagePointFromEnvelope(env, identity, loc); ok {
t.Fatal("stationary Yutong jitter must not enter GPS mileage fallback")
}
}
func TestWriterThrottlesHighFrequencyGPSMileageStateWrites(t *testing.T) {
writer := NewWriter(&recordingExec{}, time.FixedZone("Asia/Shanghai", 8*3600))
writer.SetGPSAccumulationInterval(10 * time.Second)
start := time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
point := GPSMileagePoint{
VIN: "LMRKH9AC9R1004099",
StatDate: "2026-07-19",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9AC9R1004099@PLATFORM:yutong",
EventTime: start,
}
if !writer.shouldAccumulateGPS(point) {
t.Fatal("first point must be accepted")
}
writer.markGPSAccumulated(point)
point.EventTime = start.Add(5 * time.Second)
if writer.shouldAccumulateGPS(point) {
t.Fatal("high-frequency point inside the 10s interval must be throttled")
}
point.EventTime = start.Add(11 * time.Second)
if !writer.shouldAccumulateGPS(point) {
t.Fatal("point after the 10s interval must be accepted")
}
if got := writer.CacheStats().GPSAccumulationEntries; got != 1 {
t.Fatalf("GPS cache entries = %d, want 1", got)
}
}
func TestGPSMileageCandidateEligibleRejectsStationaryDrift(t *testing.T) {
if GPSMileageCandidateEligible(0.099999, 10) {
t.Fatal("sub-100-metre GPS drift must not become a daily-mileage candidate")
}
if GPSMileageCandidateEligible(0.2, 0) {
t.Fatal("distance without a usable segment must not become a candidate")
}
if !GPSMileageCandidateEligible(0.1, 1) {
t.Fatal("100 metres with a usable segment should become eligible")
}
}
func TestAccumulateJT808GPSMileagePersistsEstimateAndProjects(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
firstTime := time.Date(2026, 7, 19, 8, 0, 0, 0, loc)
point := GPSMileagePoint{
VIN: "LMRKH9AC7R1004120",
StatDate: "2026-07-19",
Protocol: envelope.ProtocolJT808,
SourceKey: "JT808:64341233712@PLATFORM:dongfang_beidou",
SourceIP: "122.152.221.156",
SourceEndpoint: "122.152.221.156:33805",
Phone: "64341233712",
EventID: "evt-2",
EventTime: firstTime.Add(5 * time.Minute),
Longitude: 121.4837,
Latitude: 31.2304,
}
mock.ExpectBegin()
mock.ExpectQuery("SELECT first_event_time, latest_event_time").
WithArgs(point.VIN, point.StatDate, string(point.Protocol), point.SourceKey).
WillReturnRows(sqlmock.NewRows([]string{
"first_event_time", "latest_event_time", "latest_event_id",
"latest_longitude", "latest_latitude", "daily_mileage_km",
"point_count", "usable_segment_count", "bad_jump_count",
"long_gap_count", "out_of_order_count",
}).AddRow(firstTime, firstTime, "evt-1", 121.4737, 31.2304, 0, 1, 0, 0, 0, 0))
mock.ExpectExec("UPDATE vehicle_daily_gps_mileage_state").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE vehicle_daily_mileage_source").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
state, recovered, err := AccumulateJT808GPSMileage(context.Background(), db, point)
if err != nil {
t.Fatalf("AccumulateJT808GPSMileage() error = %v", err)
}
if !recovered || state.UsableSegmentCount != 1 || math.Abs(state.DailyMileageKM-0.9529) > 0.02 {
t.Fatalf("state = %+v recovered=%v", state, recovered)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}
func TestAccumulateJT808GPSMileageNeedsUsableSegmentBeforeProjection(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
point := GPSMileagePoint{
VIN: "LMRKH9AC7R1004120",
StatDate: "2026-07-19",
Protocol: envelope.ProtocolJT808,
SourceKey: "JT808:64341233712@PLATFORM:dongfang_beidou",
SourceIP: "122.152.221.156",
EventID: "evt-1",
EventTime: time.Date(2026, 7, 19, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
Longitude: 121.4737,
Latitude: 31.2304,
}
mock.ExpectBegin()
mock.ExpectQuery("SELECT first_event_time, latest_event_time").
WillReturnRows(sqlmock.NewRows([]string{
"first_event_time", "latest_event_time", "latest_event_id",
"latest_longitude", "latest_latitude", "daily_mileage_km",
"point_count", "usable_segment_count", "bad_jump_count",
"long_gap_count", "out_of_order_count",
}))
mock.ExpectQuery("SELECT daily_mileage_km, first_event_time, sample_count").
WillReturnRows(sqlmock.NewRows([]string{"daily_mileage_km", "first_event_time", "sample_count"}))
mock.ExpectExec("INSERT INTO vehicle_daily_gps_mileage_state").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
_, recovered, err := AccumulateJT808GPSMileage(context.Background(), db, point)
if err != nil {
t.Fatalf("AccumulateJT808GPSMileage() error = %v", err)
}
if recovered {
t.Fatal("first point must not project an estimated mileage")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}
func TestAccumulateGPSMileageKeepsSubThresholdDistanceAsStateOnly(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock.New() error = %v", err)
}
defer db.Close()
start := time.Date(2026, 7, 20, 0, 14, 56, 0, time.FixedZone("Asia/Shanghai", 8*3600))
point := GPSMileagePoint{
VIN: "LMRKH9ACXR1004130",
StatDate: "2026-07-20",
Protocol: envelope.ProtocolYutongMQTT,
SourceKey: "YUTONG_MQTT:LMRKH9ACXR1004130@PLATFORM:yutong",
SourceIP: "mqtt",
EventID: "evt-small-movement",
EventTime: start.Add(time.Second),
Longitude: 114.435573,
Latitude: 30.645503,
}
mock.ExpectBegin()
mock.ExpectQuery("SELECT first_event_time, latest_event_time").
WillReturnRows(sqlmock.NewRows([]string{
"first_event_time", "latest_event_time", "latest_event_id",
"latest_longitude", "latest_latitude", "daily_mileage_km",
"point_count", "usable_segment_count", "bad_jump_count",
"long_gap_count", "out_of_order_count",
}).AddRow(start, start, "evt-before", 114.435572, 30.645503, 0, 1, 0, 0, 0, 0))
mock.ExpectExec("UPDATE vehicle_daily_gps_mileage_state").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
state, recovered, err := AccumulateGPSMileage(context.Background(), db, point)
if err != nil {
t.Fatalf("AccumulateGPSMileage() error = %v", err)
}
if recovered || state.DailyMileageKM <= 0 || state.DailyMileageKM >= GPSMinimumDailyDistanceKM {
t.Fatalf("sub-threshold state = %+v recovered=%v", state, recovered)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet SQL expectations: %v", err)
}
}

View File

@@ -0,0 +1,149 @@
package stats
import (
"context"
"database/sql"
"fmt"
"regexp"
"strings"
)
const HydrogenTankCapacityTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_hydrogen_tank_capacity (
vin VARCHAR(64) NOT NULL,
source_vehicle_id BIGINT NOT NULL,
source_model_id BIGINT NULL,
plate_number VARCHAR(50) NOT NULL DEFAULT '',
brand_name VARCHAR(500) NOT NULL DEFAULT '',
model_name VARCHAR(500) NOT NULL DEFAULT '',
tank_capacity_l DECIMAL(12,2) NOT NULL,
active TINYINT(1) NOT NULL DEFAULT 1,
source_updated_at DATETIME NULL,
synced_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin),
KEY idx_hydrogen_capacity_model (source_model_id),
KEY idx_hydrogen_capacity_active (active,vin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
type HydrogenCapacitySyncResult struct {
Read int
Written int
Deactivated int64
}
type hydrogenCapacityRow struct {
VIN string
SourceVehicleID int64
SourceModelID sql.NullInt64
Plate string
Brand string
Model string
CapacityLiters float64
SourceUpdatedAt sql.NullTime
}
var mysqlIdentifier = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
func SyncHydrogenTankCapacities(ctx context.Context, db *sql.DB, sourceSchema string) (HydrogenCapacitySyncResult, error) {
result := HydrogenCapacitySyncResult{}
if db == nil {
return result, fmt.Errorf("mysql database is required")
}
sourceSchema = strings.TrimSpace(sourceSchema)
if !mysqlIdentifier.MatchString(sourceSchema) {
return result, fmt.Errorf("invalid asset schema %q", sourceSchema)
}
query := fmt.Sprintf(`SELECT vi.id,vi.vehicle_model_id,UPPER(TRIM(vi.vin)),COALESCE(vi.plate_number,''),
COALESCE(vm.brand,''),COALESCE(vm.model,''),vm.tank_capacity,
CASE
WHEN vi.update_time IS NULL THEN vm.update_time
WHEN vm.update_time IS NULL THEN vi.update_time
ELSE GREATEST(vi.update_time,vm.update_time)
END
FROM %s.vehicle_info vi
JOIN %s.vehicle_model vm ON vm.id=vi.vehicle_model_id
WHERE COALESCE(vi.del_flag,'0')='0' AND COALESCE(vm.del_flag,'0')='0'
AND LENGTH(TRIM(vi.vin))=17 AND vm.tank_capacity>0`, sourceSchema, sourceSchema)
rows, err := db.QueryContext(ctx, query)
if err != nil {
return result, fmt.Errorf("read asset hydrogen capacities: %w", err)
}
byVIN := map[string]hydrogenCapacityRow{}
for rows.Next() {
var row hydrogenCapacityRow
if err := rows.Scan(&row.SourceVehicleID, &row.SourceModelID, &row.VIN, &row.Plate, &row.Brand, &row.Model, &row.CapacityLiters, &row.SourceUpdatedAt); err != nil {
rows.Close()
return result, err
}
result.Read++
if row.CapacityLiters <= 0 || row.CapacityLiters > 10000 {
continue
}
if previous, exists := byVIN[row.VIN]; !exists || newerCapacityRow(row, previous) {
byVIN[row.VIN] = row
}
}
if err := rows.Close(); err != nil {
return result, err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `UPDATE vehicle_hydrogen_tank_capacity SET active=0 WHERE active=1`)
if err != nil {
return result, err
}
const upsert = `INSERT INTO vehicle_hydrogen_tank_capacity(
vin,source_vehicle_id,source_model_id,plate_number,brand_name,model_name,tank_capacity_l,active,source_updated_at,synced_at
) VALUES(?,?,?,?,?,?,?,1,?,NOW(3))
ON DUPLICATE KEY UPDATE source_vehicle_id=VALUES(source_vehicle_id),source_model_id=VALUES(source_model_id),
plate_number=VALUES(plate_number),brand_name=VALUES(brand_name),model_name=VALUES(model_name),
tank_capacity_l=VALUES(tank_capacity_l),active=1,source_updated_at=VALUES(source_updated_at),synced_at=NOW(3)`
for _, row := range byVIN {
if _, err := tx.ExecContext(ctx, upsert, row.VIN, row.SourceVehicleID, row.SourceModelID, row.Plate, row.Brand, row.Model, row.CapacityLiters, row.SourceUpdatedAt); err != nil {
return result, err
}
result.Written++
}
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_hydrogen_tank_capacity WHERE active=0`).Scan(&result.Deactivated); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
return result, nil
}
func newerCapacityRow(left, right hydrogenCapacityRow) bool {
if left.SourceUpdatedAt.Valid != right.SourceUpdatedAt.Valid {
return left.SourceUpdatedAt.Valid
}
if left.SourceUpdatedAt.Valid && !left.SourceUpdatedAt.Time.Equal(right.SourceUpdatedAt.Time) {
return left.SourceUpdatedAt.Time.After(right.SourceUpdatedAt.Time)
}
return left.SourceVehicleID > right.SourceVehicleID
}
func LoadHydrogenTankCapacities(ctx context.Context, query Queryer) (map[string]float64, error) {
if query == nil {
return nil, fmt.Errorf("mysql queryer is required")
}
rows, err := query.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`)
if err != nil {
return nil, err
}
defer rows.Close()
capacities := map[string]float64{}
for rows.Next() {
var vin string
var capacity float64
if err := rows.Scan(&vin, &capacity); err != nil {
return nil, err
}
if len(vin) == 17 && capacity > 0 && capacity <= 10000 {
capacities[vin] = capacity
}
}
return capacities, rows.Err()
}

View File

@@ -0,0 +1,72 @@
package stats
import (
"context"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestSyncHydrogenTankCapacitiesCopiesAssetMasterData(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
updatedAt := time.Date(2026, 7, 21, 10, 0, 0, 0, time.Local)
mock.ExpectQuery("FROM ln_asset_management\\.vehicle_info vi").
WillReturnRows(sqlmock.NewRows([]string{"id", "vehicle_model_id", "vin", "plate", "brand", "model", "tank_capacity", "updated_at"}).
AddRow(1001, 51, "LA9GG64L0NBAF4175", "浙F06618F", "feichi", "49吨牵引车头", 1400.0, updatedAt))
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("UPDATE vehicle_hydrogen_tank_capacity SET active=0 WHERE active=1")).
WillReturnResult(sqlmock.NewResult(0, 20))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_hydrogen_tank_capacity(")).
WithArgs("LA9GG64L0NBAF4175", int64(1001), sqlmock.AnyArg(), "浙F06618F", "feichi", "49吨牵引车头", 1400.0, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM vehicle_hydrogen_tank_capacity WHERE active=0")).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectCommit()
result, err := SyncHydrogenTankCapacities(context.Background(), db, "ln_asset_management")
if err != nil {
t.Fatal(err)
}
if result.Read != 1 || result.Written != 1 || result.Deactivated != 0 {
t.Fatalf("result=%+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSyncHydrogenTankCapacitiesRejectsUnsafeSchema(t *testing.T) {
db, _, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := SyncHydrogenTankCapacities(context.Background(), db, "ln_asset_management;DROP"); err == nil {
t.Fatal("unsafe schema accepted")
}
}
func TestLoadHydrogenTankCapacitiesBuildsVINMap(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT UPPER\\(TRIM\\(vin\\)\\),tank_capacity_l").
WillReturnRows(sqlmock.NewRows([]string{"vin", "capacity"}).
AddRow("LA9GG64L0NBAF4175", 1400.0).
AddRow("LB9A32A24R0LS1037", 380.0))
capacities, err := LoadHydrogenTankCapacities(context.Background(), db)
if err != nil {
t.Fatal(err)
}
if capacities["LA9GG64L0NBAF4175"] != 1400 || capacities["LB9A32A24R0LS1037"] != 380 {
t.Fatalf("capacities=%v", capacities)
}
}

View File

@@ -0,0 +1,270 @@
package stats
import (
"context"
"database/sql"
"math"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/telemetry"
)
const (
defaultHydrogenNoiseKG = 0.05
defaultHydrogenMaxDropKG = 20.0
hydrogenMolarMassKGPerMol = 0.00201588
universalGasConstant = 8.314472
)
var hydrogenPressureFieldKeys = []string{
"gb32960.fuel_cell.max_hydrogen_pressure_mpa",
"fuel_cell_max_hydrogen_pressure_mpa",
}
var hydrogenTemperatureFieldKeys = []string{
"gb32960.fuel_cell.max_hydrogen_temperature_c",
"fuel_cell_max_hydrogen_temperature_c",
}
var hydrogenDensityA = [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
var hydrogenDensityB = [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
var hydrogenDensityC = [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
const HydrogenStreamStateTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_open_hydrogen_stream_state (
vin VARCHAR(64) NOT NULL,
stat_date DATE NOT NULL,
source_endpoint VARCHAR(128) NOT NULL DEFAULT '',
first_mass_kg DECIMAL(18,3) NOT NULL,
last_mass_kg DECIMAL(18,3) NOT NULL,
cycle_min_mass_kg DECIMAL(18,3) NOT NULL,
consumption_kg DECIMAL(18,3) NOT NULL DEFAULT 0,
sample_count BIGINT UNSIGNED NOT NULL DEFAULT 1,
refuel_count INT UNSIGNED NOT NULL DEFAULT 0,
abnormal_drop_count INT UNSIGNED NOT NULL DEFAULT 0,
tank_capacity_l DECIMAL(12,2) NOT NULL,
first_pressure_mpa DECIMAL(10,3) NOT NULL,
last_pressure_mpa DECIMAL(10,3) NOT NULL,
first_temperature_c DECIMAL(10,3) NOT NULL,
last_temperature_c DECIMAL(10,3) NOT NULL,
calculation_method VARCHAR(32) NOT NULL DEFAULT 'PRESSURE_NIST',
first_event_time DATETIME(3) NOT NULL,
last_event_time DATETIME(3) NOT NULL,
last_event_id VARCHAR(128) NOT NULL DEFAULT '',
quality_status VARCHAR(24) NOT NULL DEFAULT 'NO_DATA',
quality_reason VARCHAR(255) NOT NULL DEFAULT '有效压力质量样本不足2条',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (vin, stat_date, source_endpoint),
KEY idx_hydrogen_stream_date_quality (stat_date, quality_status, vin)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`
type HydrogenStreamSample struct {
VIN string
Date string
SourceEndpoint string
EventID string
EventTime time.Time
MassKG float64
TankCapacityLiters float64
PressureMPa float64
TemperatureC float64
NoiseKG float64
RefuelThresholdKG float64
}
type HydrogenStreamResult struct {
Found int
Written int
Duplicate int
Invalid int
NotCurrent int
}
func HydrogenDensityKGPerM3(pressureMPa, temperatureC float64) (float64, bool) {
temperatureK := temperatureC + 273.15
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 {
return 0, false
}
z := 1.0
for i := range hydrogenDensityA {
z += hydrogenDensityA[i] * math.Pow(100/temperatureK, hydrogenDensityB[i]) * math.Pow(pressureMPa, hydrogenDensityC[i])
}
if z <= 0 {
return 0, false
}
molPerLiter := pressureMPa * 1000 / (universalGasConstant * temperatureK * z)
return molPerLiter * hydrogenMolarMassKGPerMol * 1000, true
}
func PressureHydrogenMassKG(pressureMPa, temperatureC, tankCapacityLiters float64) (float64, bool) {
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
return 0, false
}
density, ok := HydrogenDensityKGPerM3(pressureMPa, temperatureC)
if !ok {
return 0, false
}
mass := density * tankCapacityLiters / 1000
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
}
func HydrogenStreamSampleFromEnvelope(env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64) (HydrogenStreamSample, string, bool) {
if env.Protocol != envelope.ProtocolGB32960 {
return HydrogenStreamSample{}, "unsupported_protocol", false
}
vin := strings.ToUpper(strings.TrimSpace(env.VIN))
if len(vin) != 17 {
return HydrogenStreamSample{}, "invalid_vin", false
}
pressure, pressureOK := firstHydrogenNumber(env.Fields, hydrogenPressureFieldKeys)
if !pressureOK {
return HydrogenStreamSample{}, "missing_pressure", false
}
temperature, temperatureOK := firstHydrogenNumber(env.Fields, hydrogenTemperatureFieldKeys)
if !temperatureOK {
return HydrogenStreamSample{}, "missing_temperature", false
}
if tankCapacityLiters <= 0 || tankCapacityLiters > 10000 {
return HydrogenStreamSample{}, "missing_tank_capacity", false
}
mass, ok := PressureHydrogenMassKG(pressure, temperature, tankCapacityLiters)
if !ok {
return HydrogenStreamSample{}, "invalid_pressure_mass", false
}
pressureStepMass, _ := PressureHydrogenMassKG(math.Max(0, pressure-0.2), temperature, tankCapacityLiters)
noiseKG := math.Max(defaultHydrogenNoiseKG, mass-pressureStepMass)
noiseKG = math.Min(noiseKG, 1.0)
refuelThresholdKG := math.Max(1.0, mass*0.05)
if loc == nil {
loc = time.FixedZone("Asia/Shanghai", 8*3600)
}
eventMS, _, ok := envelope.NormalizedEventTimeMSWithReason(env)
if !ok {
return HydrogenStreamSample{}, "missing_event_time", false
}
eventTime := time.UnixMilli(eventMS).In(loc)
if now.IsZero() {
now = time.Now()
}
date := eventTime.Format("2006-01-02")
if date != now.In(loc).Format("2006-01-02") {
return HydrogenStreamSample{}, "not_current_date", false
}
return HydrogenStreamSample{
VIN: vin, Date: date, SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
EventID: env.StableEventID(), EventTime: eventTime, MassKG: mass,
TankCapacityLiters: tankCapacityLiters, PressureMPa: pressure, TemperatureC: temperature,
NoiseKG: noiseKG, RefuelThresholdKG: refuelThresholdKG,
}, "", true
}
func firstHydrogenNumber(fields map[string]any, keys []string) (float64, bool) {
for _, key := range keys {
if value, ok := telemetry.Number(fields, key); ok {
return value, true
}
}
return 0, false
}
func AppendHydrogenStream(ctx context.Context, exec Execer, env envelope.FrameEnvelope, loc *time.Location, now time.Time, tankCapacityLiters float64) (HydrogenStreamResult, error) {
sample, reason, ok := HydrogenStreamSampleFromEnvelope(env, loc, now, tankCapacityLiters)
if !ok {
result := HydrogenStreamResult{}
switch reason {
case "unsupported_protocol", "missing_pressure", "missing_temperature":
return result, nil
case "not_current_date":
result.NotCurrent = 1
default:
result.Invalid = 1
}
return result, nil
}
result := HydrogenStreamResult{Found: 1}
beginner, ok := exec.(txBeginner)
if !ok {
return result, sql.ErrTxDone
}
tx, err := beginner.BeginTx(ctx, nil)
if err != nil {
return result, err
}
defer tx.Rollback()
write, err := tx.ExecContext(ctx, upsertHydrogenStreamStateSQL,
sample.VIN, sample.Date, sample.SourceEndpoint, sample.MassKG, sample.MassKG, sample.MassKG,
sample.TankCapacityLiters, sample.PressureMPa, sample.PressureMPa,
sample.TemperatureC, sample.TemperatureC, sample.EventTime, sample.EventTime, sample.EventID,
sample.NoiseKG, defaultHydrogenMaxDropKG, sample.RefuelThresholdKG,
defaultHydrogenMaxDropKG, defaultHydrogenMaxDropKG, defaultHydrogenMaxDropKG,
sample.RefuelThresholdKG, sample.NoiseKG, defaultHydrogenMaxDropKG,
)
if err != nil {
return result, err
}
affected, _ := write.RowsAffected()
if affected == 0 {
result.Duplicate = 1
return result, tx.Commit()
}
if _, err := tx.ExecContext(ctx, projectHydrogenStreamDailySQL, sample.VIN, sample.Date); err != nil {
return result, err
}
if err := tx.Commit(); err != nil {
return result, err
}
result.Written = 1
return result, nil
}
const upsertHydrogenStreamStateSQL = `
INSERT INTO vehicle_open_hydrogen_stream_state(
vin,stat_date,source_endpoint,first_mass_kg,last_mass_kg,cycle_min_mass_kg,consumption_kg,sample_count,
refuel_count,abnormal_drop_count,tank_capacity_l,first_pressure_mpa,last_pressure_mpa,
first_temperature_c,last_temperature_c,calculation_method,
first_event_time,last_event_time,last_event_id,quality_status,quality_reason
) VALUES(?,?,?,?,?,?,0,1,0,0,?,?,?,?,?,'PRESSURE_NIST',?,?,?,'NO_DATA','有效压力质量样本不足2条')
ON DUPLICATE KEY UPDATE
consumption_kg = consumption_kg + IF(VALUES(last_event_time)>last_event_time
AND cycle_min_mass_kg-VALUES(last_mass_kg)>? AND cycle_min_mass_kg-VALUES(last_mass_kg)<=?,
cycle_min_mass_kg-VALUES(last_mass_kg),0),
refuel_count = refuel_count + IF(VALUES(last_event_time)>last_event_time
AND VALUES(last_mass_kg)-cycle_min_mass_kg>?,1,0),
quality_status = IF(VALUES(last_event_time)<=last_event_time,quality_status,
IF(abnormal_drop_count+IF(cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0)>0,'SUSPECT','OK')),
quality_reason = IF(VALUES(last_event_time)<=last_event_time,quality_reason,
IF(abnormal_drop_count+IF(cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0)>0,'存在超过阈值的异常下降','')),
abnormal_drop_count = abnormal_drop_count + IF(VALUES(last_event_time)>last_event_time
AND cycle_min_mass_kg-VALUES(last_mass_kg)>?,1,0),
cycle_min_mass_kg = IF(VALUES(last_event_time)>last_event_time,
IF(VALUES(last_mass_kg)-cycle_min_mass_kg>?,VALUES(last_mass_kg),
IF(cycle_min_mass_kg-VALUES(last_mass_kg)>? AND cycle_min_mass_kg-VALUES(last_mass_kg)<=?,VALUES(last_mass_kg),cycle_min_mass_kg)),
cycle_min_mass_kg),
sample_count = sample_count + IF(VALUES(last_event_time)>last_event_time,1,0),
tank_capacity_l = IF(VALUES(last_event_time)>last_event_time,VALUES(tank_capacity_l),tank_capacity_l),
last_pressure_mpa = IF(VALUES(last_event_time)>last_event_time,VALUES(last_pressure_mpa),last_pressure_mpa),
last_temperature_c = IF(VALUES(last_event_time)>last_event_time,VALUES(last_temperature_c),last_temperature_c),
calculation_method = 'PRESSURE_NIST',
last_mass_kg = IF(VALUES(last_event_time)>last_event_time,VALUES(last_mass_kg),last_mass_kg),
last_event_id = IF(VALUES(last_event_time)>last_event_time,VALUES(last_event_id),last_event_id),
last_event_time = GREATEST(last_event_time,VALUES(last_event_time))`
const projectHydrogenStreamDailySQL = `
INSERT INTO vehicle_open_daily_energy(
vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg,
sample_count,refuel_count,quality_status,quality_reason,calculated_at
)
SELECT vin,stat_date,'HYDROGEN',source_endpoint,consumption_kg,'kg',first_mass_kg,last_mass_kg,
sample_count,refuel_count,quality_status,quality_reason,NOW(3)
FROM vehicle_open_hydrogen_stream_state
WHERE vin=? AND stat_date=?
ORDER BY CASE quality_status WHEN 'OK' THEN 0 WHEN 'SUSPECT' THEN 1 ELSE 2 END,
sample_count DESC,source_endpoint ASC
LIMIT 1
ON DUPLICATE KEY UPDATE
source_endpoint=VALUES(source_endpoint),consumption_kg=VALUES(consumption_kg),unit='kg',
first_mass_kg=VALUES(first_mass_kg),last_mass_kg=VALUES(last_mass_kg),
sample_count=VALUES(sample_count),refuel_count=VALUES(refuel_count),
quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),calculated_at=VALUES(calculated_at)`

View File

@@ -0,0 +1,138 @@
package stats
import (
"context"
"math"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func pressureEnvelope(at time.Time) envelope.FrameEnvelope {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960, VIN: "LA9GG64L0NBAF4175", EventID: "event-1",
EventTimeMS: at.UnixMilli(), SourceEndpoint: "10.0.0.1:9000",
Fields: map[string]any{
"gb32960.fuel_cell.max_hydrogen_pressure_mpa": 6.0,
"gb32960.fuel_cell.max_hydrogen_temperature_c": 44.0,
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg": 99.9,
},
}
}
func TestHydrogenStreamSampleUsesPressureTemperatureAndCapacity(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
sample, reason, ok := HydrogenStreamSampleFromEnvelope(pressureEnvelope(eventTime), loc, eventTime.Add(time.Minute), 2050)
if !ok || reason != "" {
t.Fatalf("sample rejected: reason=%q", reason)
}
if sample.Date != "2026-07-21" || sample.EventID != "event-1" || sample.TankCapacityLiters != 2050 {
t.Fatalf("unexpected sample: %#v", sample)
}
if math.Abs(sample.MassKG-9.092) > 0.01 {
t.Fatalf("pressure mass = %.3fkg, want about 9.092kg", sample.MassKG)
}
if sample.MassKG == 99.9 {
t.Fatal("reported Guangdong hydrogen mass must be ignored")
}
_, reason, ok = HydrogenStreamSampleFromEnvelope(pressureEnvelope(eventTime), loc, eventTime.AddDate(0, 0, 1), 2050)
if ok || reason != "not_current_date" {
t.Fatalf("historical sample accepted: ok=%v reason=%q", ok, reason)
}
}
func TestHydrogenStreamRejectsMissingCapacity(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
_, reason, ok := HydrogenStreamSampleFromEnvelope(pressureEnvelope(eventTime), loc, eventTime, 0)
if ok || reason != "missing_tank_capacity" {
t.Fatalf("missing capacity accepted: ok=%v reason=%q", ok, reason)
}
}
func TestNISTHydrogenDensityValidationPoint(t *testing.T) {
density, ok := HydrogenDensityKGPerM3(10, 26.85)
if !ok || math.Abs(density-7.625) > 0.01 {
t.Fatalf("density=%.6f ok=%v", density, ok)
}
}
func TestAppendHydrogenStreamPersistsPressureEvidenceAtomically(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Date(2026, 7, 21, 10, 30, 0, 0, loc)
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21", "10.0.0.1:9000", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(),
2050.0, 6.0, 6.0, 44.0, 44.0, eventTime, eventTime, "event-1",
sqlmock.AnyArg(), 20.0, sqlmock.AnyArg(), 20.0, 20.0, 20.0,
sqlmock.AnyArg(), sqlmock.AnyArg(), 20.0).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).
WithArgs("LA9GG64L0NBAF4175", "2026-07-21").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := AppendHydrogenStream(context.Background(), db, pressureEnvelope(eventTime), loc, eventTime, 2050)
if err != nil {
t.Fatal(err)
}
if result.Found != 1 || result.Written != 1 {
t.Fatalf("unexpected result: %#v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestWriterAppendWithResultUsesInMemoryCapacity(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
loc := time.FixedZone("Asia/Shanghai", 8*3600)
eventTime := time.Now().In(loc).Truncate(time.Millisecond)
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_hydrogen_stream_state")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy")).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
writer := NewWriter(db, loc)
writer.hydrogenTankCapacities["LA9GG64L0NBAF4175"] = 2050
result, err := writer.AppendWithResult(context.Background(), pressureEnvelope(eventTime))
if err != nil {
t.Fatalf("AppendWithResult() error = %v", err)
}
if result.HydrogenSamplesFound != 1 || result.HydrogenSamplesWritten != 1 {
t.Fatalf("hydrogen counters lost: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHydrogenStreamSQLGuardsDuplicatesAndRecordsPressure(t *testing.T) {
for _, want := range []string{
"VALUES(last_event_time)>last_event_time",
"cycle_min_mass_kg-VALUES(last_mass_kg)>?",
"tank_capacity_l",
"last_pressure_mpa",
"last_temperature_c",
"PRESSURE_NIST",
} {
if !regexp.MustCompile(regexp.QuoteMeta(want)).MatchString(upsertHydrogenStreamStateSQL) {
t.Fatalf("stream upsert missing %q", want)
}
}
}

View File

@@ -0,0 +1,120 @@
package stats
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
const (
gb32960FuelCellEngineWorkMode = 2
yutongFuelCellRunningMode = 4
yutongFuelCellDrivingMode = 11
)
// PureHydrogenModeFromEnvelope returns whether the reported vehicle is in a
// fuel-cell-only work mode and whether the source value is understood.
//
// The 32960 fuel-cell extension reports engine_work_state; production frames
// use mode 2 for normal fuel-cell operation. Yutong reports the equivalent
// state through TRIANGLE_STATE; production frames use 1 for inactive and 4/11
// for active fuel-cell operating modes.
func PureHydrogenModeFromEnvelope(env envelope.FrameEnvelope) (active bool, known bool) {
value, exists := env.Fields[envelope.FieldFuelCellWorkMode]
if !exists {
return false, false
}
mode, ok := integerMode(value)
if !ok {
return false, false
}
switch env.Protocol {
case envelope.ProtocolGB32960:
switch mode {
case gb32960FuelCellEngineWorkMode:
return true, true
case 0, 1:
return false, true
default:
return false, false
}
case envelope.ProtocolYutongMQTT:
switch mode {
case yutongFuelCellRunningMode, yutongFuelCellDrivingMode:
return true, true
case 1:
return false, true
default:
return false, false
}
default:
return false, false
}
}
// PureHydrogenMileageDelta returns the odometer delta that can be attributed
// to pure-hydrogen operation. Both endpoints must report a known active mode;
// this deliberately leaves transitions and missing mode data unclassified.
func PureHydrogenMileageDelta(
previousKM float64,
currentKM float64,
previousActive bool,
previousKnown bool,
currentActive bool,
currentKnown bool,
) float64 {
delta := currentKM - previousKM
if !previousKnown || !previousActive || !currentKnown || !currentActive {
return 0
}
if delta < 0 || delta > maxSelectedDailyMileageKM {
return 0
}
return delta
}
func integerMode(value any) (int, bool) {
switch typed := value.(type) {
case json.Number:
parsed, err := strconv.Atoi(typed.String())
return parsed, err == nil
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
return parsed, err == nil
case int:
return typed, true
case int8:
return int(typed), true
case int16:
return int(typed), true
case int32:
return int(typed), true
case int64:
return int(typed), true
case uint:
return int(typed), true
case uint8:
return int(typed), true
case uint16:
return int(typed), true
case uint32:
return int(typed), true
case uint64:
if typed > uint64(^uint(0)>>1) {
return 0, false
}
return int(typed), true
case float32:
parsed := int(typed)
return parsed, float32(parsed) == typed
case float64:
parsed := int(typed)
return parsed, float64(parsed) == typed
default:
parsed, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(value)))
return parsed, err == nil
}
}

View File

@@ -0,0 +1,134 @@
package stats
import (
"encoding/json"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestPureHydrogenModeFromEnvelope(t *testing.T) {
tests := []struct {
name string
env envelope.FrameEnvelope
active bool
known bool
}{
{
name: "gb32960 fuel cell mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 2}},
active: true,
known: true,
},
{
name: "gb32960 electric mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolGB32960, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 1}},
known: true,
},
{
name: "yutong active mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: json.Number("4")}},
active: true,
known: true,
},
{
name: "yutong driving mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: "11"}},
active: true,
known: true,
},
{
name: "yutong inactive mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: float64(1)}},
known: true,
},
{
name: "unknown yutong mode",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolYutongMQTT, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 9}},
},
{
name: "unsupported protocol",
env: envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Fields: map[string]any{envelope.FieldFuelCellWorkMode: 3}},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
active, known := PureHydrogenModeFromEnvelope(test.env)
if active != test.active || known != test.known {
t.Fatalf("PureHydrogenModeFromEnvelope() = (%v, %v), want (%v, %v)", active, known, test.active, test.known)
}
})
}
}
func TestPureHydrogenMileageDeltaRequiresContinuousKnownActiveMode(t *testing.T) {
tests := []struct {
name string
previousKM, currentKM float64
previousActive, previousKnown bool
currentActive, currentKnown bool
want float64
}{
{
name: "continuous active interval",
previousKM: 100,
currentKM: 106.5,
previousActive: true,
previousKnown: true,
currentActive: true,
currentKnown: true,
want: 6.5,
},
{
name: "transition into active is unclassified",
previousKM: 100,
currentKM: 106.5,
previousKnown: true,
currentActive: true,
currentKnown: true,
},
{
name: "unknown endpoint is unclassified",
previousKM: 100,
currentKM: 106.5,
previousActive: true,
previousKnown: true,
currentActive: true,
},
{
name: "odometer rollback is rejected",
previousKM: 106.5,
currentKM: 100,
previousActive: true,
previousKnown: true,
currentActive: true,
currentKnown: true,
},
{
name: "implausible jump is rejected",
previousKM: 100,
currentKM: 2601,
previousActive: true,
previousKnown: true,
currentActive: true,
currentKnown: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := PureHydrogenMileageDelta(
test.previousKM,
test.currentKM,
test.previousActive,
test.previousKnown,
test.currentActive,
test.currentKnown,
)
if got != test.want {
t.Fatalf("PureHydrogenMileageDelta() = %v, want %v", got, test.want)
}
})
}
}

View File

@@ -58,48 +58,51 @@ type MetricDiagnosisQuery struct {
} }
type MetricRow struct { type MetricRow struct {
VIN string `json:"vin"` VIN string `json:"vin"`
StatDate string `json:"stat_date"` StatDate string `json:"stat_date"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
SourceID *int64 `json:"source_id,omitempty"` SourceID *int64 `json:"source_id,omitempty"`
SourceIP string `json:"source_ip,omitempty"` SourceIP string `json:"source_ip,omitempty"`
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"` LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
PlatformName string `json:"platform_name,omitempty"` PlatformName string `json:"platform_name,omitempty"`
SourceCode string `json:"source_code,omitempty"` SourceCode string `json:"source_code,omitempty"`
SourceKind string `json:"source_kind,omitempty"` SourceKind string `json:"source_kind,omitempty"`
DailyMileageKM float64 `json:"daily_mileage_km"` DailyMileageKM float64 `json:"daily_mileage_km"`
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"` PureHydrogenMileageKM float64 `json:"pure_hydrogen_mileage_km"`
UpdatedAt string `json:"updated_at"` LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
UpdatedAt string `json:"updated_at"`
} }
type MetricSourceRow struct { type MetricSourceRow struct {
VIN string `json:"vin"` VIN string `json:"vin"`
StatDate string `json:"stat_date"` StatDate string `json:"stat_date"`
Protocol string `json:"protocol"` Protocol string `json:"protocol"`
SourceKey string `json:"source_key"` SourceKey string `json:"source_key"`
SourceIP string `json:"source_ip"` SourceIP string `json:"source_ip"`
SourceEndpoint string `json:"source_endpoint,omitempty"` SourceEndpoint string `json:"source_endpoint,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
DeviceID string `json:"device_id,omitempty"` DeviceID string `json:"device_id,omitempty"`
PlatformName string `json:"platform_name,omitempty"` PlatformName string `json:"platform_name,omitempty"`
SourceID *int64 `json:"source_id,omitempty"` SourceID *int64 `json:"source_id,omitempty"`
SourceCode string `json:"source_code,omitempty"` SourceCode string `json:"source_code,omitempty"`
SourceKind string `json:"source_kind,omitempty"` SourceKind string `json:"source_kind,omitempty"`
SourceEnabled *bool `json:"source_enabled,omitempty"` SourceEnabled *bool `json:"source_enabled,omitempty"`
TrustPriority *int `json:"trust_priority,omitempty"` TrustPriority *int `json:"trust_priority,omitempty"`
FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"` FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"`
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"` LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
DailyMileageKM float64 `json:"daily_mileage_km"` DailyMileageKM float64 `json:"daily_mileage_km"`
SampleCount int64 `json:"sample_count"` PureHydrogenMileageKM float64 `json:"pure_hydrogen_mileage_km"`
FirstEventTime string `json:"first_event_time,omitempty"` PureHydrogenSampleCount int64 `json:"pure_hydrogen_sample_count"`
LatestEventTime string `json:"latest_event_time,omitempty"` SampleCount int64 `json:"sample_count"`
QualityStatus string `json:"quality_status"` FirstEventTime string `json:"first_event_time,omitempty"`
QualityReason string `json:"quality_reason,omitempty"` LatestEventTime string `json:"latest_event_time,omitempty"`
IsSelected bool `json:"is_selected"` QualityStatus string `json:"quality_status"`
SelectionStatus string `json:"selection_status,omitempty"` QualityReason string `json:"quality_reason,omitempty"`
SelectionReason string `json:"selection_reason,omitempty"` IsSelected bool `json:"is_selected"`
SelectionAction string `json:"selection_action,omitempty"` SelectionStatus string `json:"selection_status,omitempty"`
UpdatedAt string `json:"updated_at"` SelectionReason string `json:"selection_reason,omitempty"`
SelectionAction string `json:"selection_action,omitempty"`
UpdatedAt string `json:"updated_at"`
} }
type MetricDiagnosisRow struct { type MetricDiagnosisRow struct {
@@ -241,6 +244,7 @@ func (r *MetricRepository) Query(ctx context.Context, query MetricQuery) ([]Metr
&sourceCode, &sourceCode,
&sourceKind, &sourceKind,
&row.DailyMileageKM, &row.DailyMileageKM,
&row.PureHydrogenMileageKM,
&latest, &latest,
&updatedAt, &updatedAt,
); err != nil { ); err != nil {
@@ -303,7 +307,9 @@ func (r *MetricRepository) QuerySources(ctx context.Context, query MetricSourceQ
&firstTotal, &firstTotal,
&latestTotal, &latestTotal,
&row.DailyMileageKM, &row.DailyMileageKM,
&row.PureHydrogenMileageKM,
&row.SampleCount, &row.SampleCount,
&row.PureHydrogenSampleCount,
&firstEvent, &firstEvent,
&latestEvent, &latestEvent,
&row.QualityStatus, &row.QualityStatus,
@@ -1108,7 +1114,7 @@ func buildMetricSQL(query MetricQuery) (string, []any) {
where, args := buildMetricWhere(query) where, args := buildMetricWhere(query)
sqlText := `SELECT m.vin, m.stat_date, m.protocol, m.source_id, sqlText := `SELECT m.vin, m.stat_date, m.protocol, m.source_id,
ds.source_ip, ds.latest_source_endpoint, ds.platform_name, ds.source_code, ds.source_kind, ds.source_ip, ds.latest_source_endpoint, ds.platform_name, ds.source_code, ds.source_kind,
m.daily_mileage_km, m.latest_total_mileage_km, m.updated_at m.daily_mileage_km, m.pure_hydrogen_mileage_km, m.latest_total_mileage_km, m.updated_at
FROM vehicle_daily_mileage m FROM vehicle_daily_mileage m
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id` LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id`
if len(where) > 0 { if len(where) > 0 {
@@ -1124,7 +1130,8 @@ func buildMetricSourceSQL(query MetricSourceQuery) (string, []any) {
sqlText := `SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip, s.source_endpoint, s.phone, s.device_id, sqlText := `SELECT s.vin, s.stat_date, s.protocol, s.source_key, s.source_ip, s.source_endpoint, s.phone, s.device_id,
COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name) AS platform_name, COALESCE(NULLIF(TRIM(s.platform_name), ''), ds.platform_name) AS platform_name,
ds.id, ds.source_code, ` + metricSourceKindSQL("s") + ` AS source_kind, ds.enabled, ds.trust_priority, ds.id, ds.source_code, ` + metricSourceKindSQL("s") + ` AS source_kind, ds.enabled, ds.trust_priority,
s.first_total_mileage_km, s.latest_total_mileage_km, s.daily_mileage_km, s.sample_count, s.first_total_mileage_km, s.latest_total_mileage_km, s.daily_mileage_km, s.pure_hydrogen_mileage_km,
s.sample_count, s.pure_hydrogen_sample_count,
s.first_event_time, s.latest_event_time, s.quality_status, s.quality_reason, s.is_selected, s.updated_at s.first_event_time, s.latest_event_time, s.quality_status, s.quality_reason, s.is_selected, s.updated_at
FROM vehicle_daily_mileage_source s FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip` LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip`

View File

@@ -32,10 +32,10 @@ func TestMetricRepositoryQueriesDailyMetricsWithFilters(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id", "vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at", "pure_hydrogen_mileage_km", "latest_total_mileage_km", "updated_at",
}).AddRow( }).AddRow(
"LKLG7C4E3NA774736", time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "JT808", 3, "LKLG7C4E3NA774736", time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "JT808", 3,
"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, 0.0,
12357.9, time.Date(2026, 7, 1, 23, 9, 36, 0, time.FixedZone("Asia/Shanghai", 8*3600)), 12357.9, time.Date(2026, 7, 1, 23, 9, 36, 0, time.FixedZone("Asia/Shanghai", 8*3600)),
)) ))
@@ -80,10 +80,10 @@ func TestMetricQueryReturnsSelectedSourceID(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id", "vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at", "pure_hydrogen_mileage_km", "latest_total_mileage_km", "updated_at",
}).AddRow( }).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", 2, "LA9GG64L7PBAF4001", "2026-07-08", "JT808", 2,
"117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "PLATFORM", 23.1, "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "PLATFORM", 23.1, 0.0,
4123.9, "2026-07-08 13:30:57", 4123.9, "2026-07-08 13:30:57",
)) ))
@@ -115,13 +115,13 @@ func TestMetricRepositoryQueriesDailyMetricSourcesWithEvidence(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id", "vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority", "platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count", "first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "pure_hydrogen_mileage_km", "sample_count", "pure_hydrogen_sample_count",
"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at", "first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
}).AddRow( }).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
"115.231.168.135:41561", "13307795425", nil, "115.231.168.135:41561", "13307795425", nil,
"信达", 5, "xinda", "PLATFORM", 1, 10, "信达", 5, "xinda", "PLATFORM", 1, 10,
4100.8, 4123.9, 23.1, 128, 4100.8, 4123.9, 23.1, 18.6, 128, 96,
"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10", "2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
)) ))
@@ -856,10 +856,10 @@ func TestMetricHandlerReturnsDailyMetrics(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id", "vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at", "pure_hydrogen_mileage_km", "latest_total_mileage_km", "updated_at",
}).AddRow( }).AddRow(
"LB9A32A21R0LS1707", "2020-07-01", "GB32960", 1, "LB9A32A21R0LS1707", "2020-07-01", "GB32960", 1,
"8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "PLATFORM", 0.0, "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "PLATFORM", 0.0, 0.0,
53490.9, "2026-07-01 22:28:25", 53490.9, "2026-07-01 22:28:25",
)) ))
@@ -900,13 +900,13 @@ func TestMetricHandlerReturnsDailyMetricSources(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id", "vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
"platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority", "platform_name", "source_id", "source_code", "source_kind", "enabled", "trust_priority",
"first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "sample_count", "first_total_mileage_km", "latest_total_mileage_km", "daily_mileage_km", "pure_hydrogen_mileage_km", "sample_count", "pure_hydrogen_sample_count",
"first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at", "first_event_time", "latest_event_time", "quality_status", "quality_reason", "is_selected", "updated_at",
}).AddRow( }).AddRow(
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135", "LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
"115.231.168.135:41561", "13307795425", nil, "115.231.168.135:41561", "13307795425", nil,
"信达", 5, "xinda", "PLATFORM", 1, 10, "信达", 5, "xinda", "PLATFORM", 1, 10,
4100.8, 4123.9, 23.1, 128, 4100.8, 4123.9, 23.1, 18.6, 128, 96,
"2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10", "2026-07-08 00:01:00", "2026-07-08 23:59:00", "OK", "historical_source_baseline", 1, "2026-07-08 23:59:10",
)) ))
@@ -1340,7 +1340,7 @@ func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id", "vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at", "pure_hydrogen_mileage_km", "latest_total_mileage_km", "updated_at",
})) }))
handler := NewMetricHandler(NewMetricRepository(db)) handler := NewMetricHandler(NewMetricRepository(db))
@@ -1372,10 +1372,10 @@ func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{ WillReturnRows(sqlmock.NewRows([]string{
"vin", "stat_date", "protocol", "source_id", "vin", "stat_date", "protocol", "source_id",
"source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "daily_mileage_km",
"latest_total_mileage_km", "updated_at", "pure_hydrogen_mileage_km", "latest_total_mileage_km", "updated_at",
}).AddRow( }).AddRow(
"LKLG7C4E3NA774736", "2026-07-02", "JT808", 3, "LKLG7C4E3NA774736", "2026-07-02", "JT808", 3,
"115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 12.3, 0.0,
8805.1, "2026-07-02 23:59:59", 8805.1, "2026-07-02 23:59:59",
)) ))

View File

@@ -6,6 +6,7 @@ const DailyMileageTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage (
protocol VARCHAR(32) NOT NULL, protocol VARCHAR(32) NOT NULL,
source_id BIGINT NULL, source_id BIGINT NULL,
daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0, daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
latest_total_mileage_km DECIMAL(18,3) NULL, latest_total_mileage_km DECIMAL(18,3) NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (vin, stat_date, protocol), PRIMARY KEY (vin, stat_date, protocol),
@@ -20,6 +21,7 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code (protocol, source_code)", "ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_code (protocol, source_code)",
"ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)", "ALTER TABLE vehicle_data_source ADD KEY idx_protocol_source_kind_seen (protocol, source_kind, latest_seen_at)",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN source_id BIGINT NULL AFTER protocol", "ALTER TABLE vehicle_daily_mileage ADD COLUMN source_id BIGINT NULL AFTER protocol",
"ALTER TABLE vehicle_daily_mileage ADD COLUMN pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0 AFTER daily_mileage_km",
"ALTER TABLE vehicle_daily_mileage ADD KEY idx_source_id (source_id)", "ALTER TABLE vehicle_daily_mileage ADD KEY idx_source_id (source_id)",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN first_total_mileage_km", "ALTER TABLE vehicle_daily_mileage DROP COLUMN first_total_mileage_km",
"ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_key", "ALTER TABLE vehicle_daily_mileage DROP COLUMN trusted_source_key",
@@ -30,6 +32,10 @@ var DailyMileageAlterSQL = []string{
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)", "ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality (protocol, stat_date, quality_status, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)", "ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_protocol_date_quality_reason (protocol, stat_date, quality_status, quality_reason, vin)",
"ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_source_ip_date (protocol, source_ip, stat_date)", "ALTER TABLE vehicle_daily_mileage_source ADD KEY idx_source_ip_date (protocol, source_ip, stat_date)",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0 AFTER daily_mileage_km",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN pure_hydrogen_sample_count BIGINT NOT NULL DEFAULT 0 AFTER sample_count",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN latest_pure_hydrogen_active TINYINT(1) NOT NULL DEFAULT 0 AFTER latest_event_time",
"ALTER TABLE vehicle_daily_mileage_source ADD COLUMN latest_pure_hydrogen_mode_known TINYINT(1) NOT NULL DEFAULT 0 AFTER latest_pure_hydrogen_active",
} }
const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source ( const DataSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_data_source (
@@ -67,9 +73,13 @@ const DailyMileageSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mil
first_total_mileage_km DECIMAL(18,3) NULL, first_total_mileage_km DECIMAL(18,3) NULL,
latest_total_mileage_km DECIMAL(18,3) NULL, latest_total_mileage_km DECIMAL(18,3) NULL,
daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0, daily_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0,
sample_count BIGINT NOT NULL DEFAULT 0, sample_count BIGINT NOT NULL DEFAULT 0,
pure_hydrogen_sample_count BIGINT NOT NULL DEFAULT 0,
first_event_time DATETIME NULL, first_event_time DATETIME NULL,
latest_event_time DATETIME NULL, latest_event_time DATETIME NULL,
latest_pure_hydrogen_active TINYINT(1) NOT NULL DEFAULT 0,
latest_pure_hydrogen_mode_known TINYINT(1) NOT NULL DEFAULT 0,
quality_status VARCHAR(32) NOT NULL DEFAULT 'OK', quality_status VARCHAR(32) NOT NULL DEFAULT 'OK',
quality_reason VARCHAR(255) NULL, quality_reason VARCHAR(255) NULL,
is_selected TINYINT(1) NOT NULL DEFAULT 0, is_selected TINYINT(1) NOT NULL DEFAULT 0,

View File

@@ -15,6 +15,7 @@ const (
QualityInvalidDelta = "INVALID_DELTA" QualityInvalidDelta = "INVALID_DELTA"
QualityReasonHistorical = "historical_source_baseline" QualityReasonHistorical = "historical_source_baseline"
QualityReasonCurrentDayFirst = "current_day_first_baseline" QualityReasonCurrentDayFirst = "current_day_first_baseline"
QualityReasonStationaryCarry = "stationary_carry_forward"
maxSelectedDailyMileageKM = 2500 maxSelectedDailyMileageKM = 2500
maxSelectedDailyMileageKMSQL = "2500" maxSelectedDailyMileageKMSQL = "2500"
maxNegativeMileageJitterKMSQL = "1" maxNegativeMileageJitterKMSQL = "1"
@@ -23,23 +24,27 @@ const (
) )
type SourceMileageSample struct { type SourceMileageSample struct {
VIN string VIN string
StatDate string StatDate string
Protocol envelope.Protocol Protocol envelope.Protocol
SourceKey string SourceKey string
SourceIP string SourceIP string
SourceEndpoint string SourceEndpoint string
Phone string Phone string
DeviceID string DeviceID string
PlatformName string PlatformName string
FirstTotalKM float64 FirstTotalKM float64
LatestTotalKM float64 LatestTotalKM float64
DailyKM float64 DailyKM float64
SampleCount int64 PureHydrogenMileageKM float64
FirstEventTime time.Time SampleCount int64
LatestEventTime time.Time PureHydrogenSampleCount int64
QualityStatus string FirstEventTime time.Time
QualityReason string LatestEventTime time.Time
LatestPureHydrogenActive bool
LatestPureHydrogenModeKnown bool
QualityStatus string
QualityReason string
} }
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string { func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
@@ -79,26 +84,37 @@ func SourceKeyForSource(protocol envelope.Protocol, phone string, deviceID strin
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample { func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
eventTime := sample.EventTime eventTime := sample.EventTime
return SourceMileageSample{ return SourceMileageSample{
VIN: sample.VIN, VIN: sample.VIN,
StatDate: sample.StatDate, StatDate: sample.StatDate,
Protocol: sample.Protocol, Protocol: sample.Protocol,
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode), SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
SourceIP: identity.SourceIP, SourceIP: identity.SourceIP,
SourceEndpoint: identity.SourceEndpoint, SourceEndpoint: identity.SourceEndpoint,
Phone: sample.Phone, Phone: sample.Phone,
DeviceID: sample.DeviceID, DeviceID: sample.DeviceID,
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName), PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
FirstTotalKM: sample.TotalMileageKM, FirstTotalKM: sample.TotalMileageKM,
LatestTotalKM: sample.TotalMileageKM, LatestTotalKM: sample.TotalMileageKM,
DailyKM: 0, DailyKM: 0,
SampleCount: 1, PureHydrogenMileageKM: 0,
FirstEventTime: eventTime, SampleCount: 1,
LatestEventTime: eventTime, PureHydrogenSampleCount: boolCount(sample.PureHydrogenActive && sample.PureHydrogenModeKnown),
QualityStatus: QualityOK, FirstEventTime: eventTime,
QualityReason: "realtime_sample", LatestEventTime: eventTime,
LatestPureHydrogenActive: sample.PureHydrogenActive,
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
QualityStatus: QualityOK,
QualityReason: "realtime_sample",
} }
} }
func boolCount(value bool) int64 {
if value {
return 1
}
return 0
}
func firstNonEmpty(values ...string) string { func firstNonEmpty(values ...string) string {
for _, value := range values { for _, value := range values {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
@@ -202,9 +218,13 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
sample.FirstTotalKM, sample.FirstTotalKM,
sample.LatestTotalKM, sample.LatestTotalKM,
sample.DailyKM, sample.DailyKM,
sample.PureHydrogenMileageKM,
sample.SampleCount, sample.SampleCount,
sample.PureHydrogenSampleCount,
sample.FirstEventTime, sample.FirstEventTime,
sample.LatestEventTime, sample.LatestEventTime,
sample.LatestPureHydrogenActive,
sample.LatestPureHydrogenModeKnown,
sample.QualityStatus, sample.QualityStatus,
sample.QualityReason, sample.QualityReason,
) )
@@ -337,10 +357,30 @@ func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, s
return err return err
} }
const upsertSourceStatDateStartSQL = `CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)`
const upsertSourcePreferIncomingFirstSQL = `(
first_event_time IS NULL
OR (
first_event_time < ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) >= first_event_time
)
OR (
first_event_time >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) < ` + upsertSourceStatDateStartSQL + `
)
OR (
first_event_time >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) >= ` + upsertSourceStatDateStartSQL + `
AND VALUES(first_event_time) <= first_event_time
)
)`
const upsertSourceMergedFirstTotalSQL = `CASE const upsertSourceMergedFirstTotalSQL = `CASE
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0 WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
THEN VALUES(first_total_mileage_km) THEN VALUES(first_total_mileage_km)
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time WHEN ` + upsertSourcePreferIncomingFirstSQL + `
THEN VALUES(first_total_mileage_km) THEN VALUES(first_total_mileage_km)
ELSE first_total_mileage_km ELSE first_total_mileage_km
END` END`
@@ -354,7 +394,7 @@ const upsertSourceMergedLatestTotalSQL = `CASE
END` END`
const upsertSourceMergedFirstEventSQL = `CASE const upsertSourceMergedFirstEventSQL = `CASE
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time WHEN ` + upsertSourcePreferIncomingFirstSQL + `
THEN VALUES(first_event_time) THEN VALUES(first_event_time)
ELSE first_event_time ELSE first_event_time
END` END`
@@ -401,18 +441,44 @@ const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysS
const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)` const normalizePlatformOutsideDailyRangeSQL = `(` + normalizePlatformDailySQL + ` < 0 OR ` + normalizePlatformDailySQL + ` > ` + normalizePlatformMaxMileageSQL + `)`
const upsertSourcePureHydrogenIncrementSQL = `CASE
WHEN latest_event_time IS NOT NULL
AND VALUES(latest_event_time) > latest_event_time
AND latest_pure_hydrogen_mode_known = 1
AND latest_pure_hydrogen_active = 1
AND VALUES(latest_pure_hydrogen_mode_known) = 1
AND VALUES(latest_pure_hydrogen_active) = 1
AND VALUES(latest_total_mileage_km) >= latest_total_mileage_km
AND VALUES(latest_total_mileage_km) - latest_total_mileage_km <= ` + maxSelectedDailyMileageKMSQL + `
THEN VALUES(latest_total_mileage_km) - latest_total_mileage_km
ELSE 0
END`
const upsertSourceMileageSQL = ` const upsertSourceMileageSQL = `
INSERT INTO vehicle_daily_mileage_source INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name, (vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count, first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, pure_hydrogen_mileage_km,
first_event_time, latest_event_time, quality_status, quality_reason) sample_count, pure_hydrogen_sample_count, first_event_time, latest_event_time,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, quality_status, quality_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
source_ip = VALUES(source_ip), source_ip = VALUES(source_ip),
source_endpoint = VALUES(source_endpoint), source_endpoint = VALUES(source_endpoint),
phone = VALUES(phone), phone = VALUES(phone),
device_id = VALUES(device_id), device_id = VALUES(device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name), platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + ` + upsertSourcePureHydrogenIncrementSQL + `,
pure_hydrogen_sample_count = pure_hydrogen_sample_count + VALUES(pure_hydrogen_sample_count),
latest_pure_hydrogen_active = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_pure_hydrogen_active)
ELSE latest_pure_hydrogen_active
END,
latest_pure_hydrogen_mode_known = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time
THEN VALUES(latest_pure_hydrogen_mode_known)
ELSE latest_pure_hydrogen_mode_known
END,
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `, first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `, latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `, daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
@@ -435,8 +501,9 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
const normalizePlatformSourceMileageInsertSQL = ` const normalizePlatformSourceMileageInsertSQL = `
INSERT INTO vehicle_daily_mileage_source INSERT INTO vehicle_daily_mileage_source
(vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name, (vin, stat_date, protocol, source_key, source_ip, source_endpoint, phone, device_id, platform_name,
first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, sample_count, first_total_mileage_km, latest_total_mileage_km, daily_mileage_km, pure_hydrogen_mileage_km,
first_event_time, latest_event_time, quality_status, quality_reason) sample_count, pure_hydrogen_sample_count, first_event_time, latest_event_time,
latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, quality_status, quality_reason)
SELECT SELECT
s.vin, s.vin,
s.stat_date, s.stat_date,
@@ -450,9 +517,13 @@ SELECT
` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km, ` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km,
` + normalizePlatformLatestTotalSQL + ` AS latest_total_mileage_km, ` + normalizePlatformLatestTotalSQL + ` AS latest_total_mileage_km,
` + normalizePlatformDailySQL + ` AS daily_mileage_km, ` + normalizePlatformDailySQL + ` AS daily_mileage_km,
SUM(s.pure_hydrogen_mileage_km) AS pure_hydrogen_mileage_km,
SUM(s.sample_count) AS sample_count, SUM(s.sample_count) AS sample_count,
SUM(s.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count,
MIN(s.first_event_time) AS first_event_time, MIN(s.first_event_time) AS first_event_time,
MAX(s.latest_event_time) AS latest_event_time, MAX(s.latest_event_time) AS latest_event_time,
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_active ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_active,
CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_mode_known ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_mode_known,
CASE CASE
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `' WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
ELSE '` + QualityOK + `' ELSE '` + QualityOK + `'
@@ -490,6 +561,9 @@ WHERE s.vin = ?
AND s.stat_date = ? AND s.stat_date = ?
AND s.protocol = ? AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `') AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
AND s.first_total_mileage_km IS NOT NULL
AND s.latest_total_mileage_km IS NOT NULL
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
@@ -506,6 +580,16 @@ ON DUPLICATE KEY UPDATE
phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone), phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone),
device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id), device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id),
platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name), platform_name = COALESCE(NULLIF(TRIM(VALUES(platform_name)), ''), platform_name),
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
pure_hydrogen_sample_count = VALUES(pure_hydrogen_sample_count),
latest_pure_hydrogen_active = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_pure_hydrogen_active)
ELSE latest_pure_hydrogen_active
END,
latest_pure_hydrogen_mode_known = CASE
WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_pure_hydrogen_mode_known)
ELSE latest_pure_hydrogen_mode_known
END,
first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `, first_total_mileage_km = ` + upsertSourceMergedFirstTotalSQL + `,
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `, latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
daily_mileage_km = ` + upsertSourceMergedDailySQL + `, daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
@@ -558,6 +642,9 @@ WHERE s.vin = ?
AND s.stat_date = ? AND s.stat_date = ?
AND s.protocol = ? AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `') AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
AND s.first_total_mileage_km IS NOT NULL
AND s.latest_total_mileage_km IS NOT NULL
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
@@ -589,21 +676,71 @@ LEFT JOIN (
WHERE s.stat_date = ? WHERE s.stat_date = ?
AND s.protocol = ? AND s.protocol = ?
AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `') AND s.quality_status IN ('` + QualityOK + `', '` + QualityNoPreviousBaseline + `')
AND COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `'
AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL AND COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code) IS NOT NULL
AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL AND COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')) IS NOT NULL
AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code)) AND s.source_key <> CONCAT(s.protocol, ':', COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), '')), '` + platformSourceKeyPrefix + `', COALESCE(NULLIF(TRIM(ds.source_code), ''), vi.source_code))
ORDER BY s.vin ORDER BY s.vin
` `
// The same physical terminal can be forwarded by more than one platform.
// Preserve each forwarding source for audit, but rank the terminal as one
// evidence group: accumulated samples choose the established terminal and the
// freshest forwarding source keeps its projected mileage current.
const projectSourceIdentitySampleCountSQL = `(
SELECT COALESCE(SUM(identity_source.sample_count), 0)
FROM vehicle_daily_mileage_source identity_source
WHERE identity_source.vin = s.vin
AND identity_source.stat_date = s.stat_date
AND identity_source.protocol = s.protocol
AND identity_source.quality_status = '` + QualityOK + `'
AND COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)
= COALESCE(NULLIF(TRIM(s.phone), ''), NULLIF(TRIM(s.device_id), ''), s.source_key)
)`
// A fresh cumulative odometer is authoritative. When an upstream odometer is
// stale at zero but device-speed-backed coordinates prove movement, the
// coordinate estimate must beat the synthetic zero-day carry-forward. A true
// positive odometer still always outranks the estimate.
const projectMileageEvidenceRankSQL = `CASE
WHEN COALESCE(s.daily_mileage_km, 0) > 0
AND COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 0
WHEN COALESCE(s.daily_mileage_km, 0) > 0
AND COALESCE(s.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1
WHEN COALESCE(s.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 2
ELSE 3
END`
const selectedMileageEvidenceRankSQL = `CASE
WHEN COALESCE(s2.daily_mileage_km, 0) > 0
AND COALESCE(s2.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 0
WHEN COALESCE(s2.daily_mileage_km, 0) > 0
AND COALESCE(s2.quality_reason, '') = '` + QualityReasonGPSCoordinate + `' THEN 1
WHEN COALESCE(s2.quality_reason, '') <> '` + QualityReasonGPSCoordinate + `' THEN 2
ELSE 3
END`
const selectedSourceIdentitySampleCountSQL = `(
SELECT COALESCE(SUM(identity_source.sample_count), 0)
FROM vehicle_daily_mileage_source identity_source
WHERE identity_source.vin = s2.vin
AND identity_source.stat_date = s2.stat_date
AND identity_source.protocol = s2.protocol
AND identity_source.quality_status = '` + QualityOK + `'
AND COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)
= COALESCE(NULLIF(TRIM(s2.phone), ''), NULLIF(TRIM(s2.device_id), ''), s2.source_key)
)`
const projectDailyMileageSQL = ` const projectDailyMileageSQL = `
INSERT INTO vehicle_daily_mileage INSERT INTO vehicle_daily_mileage
(vin, stat_date, protocol, source_id, daily_mileage_km, latest_total_mileage_km) (vin, stat_date, protocol, source_id, daily_mileage_km, pure_hydrogen_mileage_km, latest_total_mileage_km)
SELECT SELECT
s.vin, s.vin,
s.stat_date, s.stat_date,
s.protocol, s.protocol,
ds.id, ds.id,
s.daily_mileage_km, s.daily_mileage_km,
LEAST(s.pure_hydrogen_mileage_km, s.daily_mileage_km),
s.latest_total_mileage_km s.latest_total_mileage_km
FROM vehicle_daily_mileage_source s FROM vehicle_daily_mileage_source s
LEFT JOIN vehicle_data_source ds LEFT JOIN vehicle_data_source ds
@@ -612,26 +749,30 @@ WHERE s.vin = ?
AND s.stat_date = ? AND s.stat_date = ?
AND s.protocol = ? AND s.protocol = ?
AND s.quality_status = '` + QualityOK + `' AND s.quality_status = '` + QualityOK + `'
AND s.latest_total_mileage_km IS NOT NULL
AND s.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time)))) AND s.daily_mileage_km BETWEEN 0 AND (? * GREATEST(1, DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))))
AND (ds.id IS NULL OR ds.enabled = 1 OR ( AND (ds.id IS NULL OR ds.enabled = 1 OR (
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN' COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '') AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '') AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
)) ))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END) ORDER BY ` + projectMileageEvidenceRankSQL + `,
CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0 WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1 WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2 WHEN 'UNKNOWN' THEN 2
ELSE 3 ELSE 3
END, END,
COALESCE(ds.trust_priority, 100000), COALESCE(ds.trust_priority, 100000),
s.sample_count DESC, ` + projectSourceIdentitySampleCountSQL + ` DESC,
s.latest_event_time DESC, s.latest_event_time DESC,
s.sample_count DESC,
s.source_key ASC s.source_key ASC
LIMIT 1 LIMIT 1
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
source_id = VALUES(source_id), source_id = VALUES(source_id),
daily_mileage_km = VALUES(daily_mileage_km), daily_mileage_km = VALUES(daily_mileage_km),
pure_hydrogen_mileage_km = VALUES(pure_hydrogen_mileage_km),
latest_total_mileage_km = VALUES(latest_total_mileage_km), latest_total_mileage_km = VALUES(latest_total_mileage_km),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
` `
@@ -657,15 +798,17 @@ LEFT JOIN (
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '') AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '') AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')
)) ))
ORDER BY CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END) ORDER BY ` + selectedMileageEvidenceRankSQL + `,
CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%` + platformSourceKeyPrefix + `%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%` + directSourceKeySuffix + `' THEN 'DIRECT' ELSE 'UNKNOWN' END)
WHEN 'PLATFORM' THEN 0 WHEN 'PLATFORM' THEN 0
WHEN 'DIRECT' THEN 1 WHEN 'DIRECT' THEN 1
WHEN 'UNKNOWN' THEN 2 WHEN 'UNKNOWN' THEN 2
ELSE 3 ELSE 3
END, END,
COALESCE(ds.trust_priority, 100000), COALESCE(ds.trust_priority, 100000),
s2.sample_count DESC, ` + selectedSourceIdentitySampleCountSQL + ` DESC,
s2.latest_event_time DESC, s2.latest_event_time DESC,
s2.sample_count DESC,
s2.source_key ASC s2.source_key ASC
LIMIT 1 LIMIT 1
) selected_source ) selected_source

View File

@@ -176,10 +176,10 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing
if len(exec.calls) != 1 { if len(exec.calls) != 1 {
t.Fatalf("exec calls = %d, want 1", len(exec.calls)) t.Fatalf("exec calls = %d, want 1", len(exec.calls))
} }
if got := exec.calls[0].args[13]; got != previous.Truncate(time.Second) { if got := exec.calls[0].args[15]; got != previous.Truncate(time.Second) {
t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second)) t.Fatalf("first event arg = %v, want %v", got, previous.Truncate(time.Second))
} }
if got := exec.calls[0].args[14]; got != current.Truncate(time.Second) { if got := exec.calls[0].args[16]; got != current.Truncate(time.Second) {
t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second)) t.Fatalf("latest event arg = %v, want %v", got, current.Truncate(time.Second))
} }
} }
@@ -187,7 +187,9 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) { func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"first_total_mileage_km = CASE", "first_total_mileage_km = CASE",
"VALUES(first_event_time) >= first_event_time",
"VALUES(first_event_time) <= first_event_time", "VALUES(first_event_time) <= first_event_time",
"VALUES(first_event_time) < CAST(CONCAT(VALUES(stat_date), ' 00:00:00') AS DATETIME)",
"latest_total_mileage_km = CASE", "latest_total_mileage_km = CASE",
"VALUES(latest_event_time) >= latest_event_time", "VALUES(latest_event_time) >= latest_event_time",
"daily_mileage_km = CASE", "daily_mileage_km = CASE",
@@ -209,6 +211,27 @@ func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
} }
} }
func TestUpsertSourceMileageAccumulatesOnlyContinuousPureHydrogenIntervals(t *testing.T) {
for _, want := range []string{
"pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE",
"VALUES(latest_event_time) > latest_event_time",
"latest_pure_hydrogen_mode_known = 1",
"latest_pure_hydrogen_active = 1",
"VALUES(latest_pure_hydrogen_mode_known) = 1",
"VALUES(latest_pure_hydrogen_active) = 1",
"VALUES(latest_total_mileage_km) - latest_total_mileage_km",
} {
if !strings.Contains(upsertSourceMileageSQL, want) {
t.Fatalf("pure hydrogen accumulation SQL missing %q:\n%s", want, upsertSourceMileageSQL)
}
}
pureUpdate := strings.Index(upsertSourceMileageSQL, "pure_hydrogen_mileage_km = pure_hydrogen_mileage_km + CASE")
latestTotalUpdate := strings.Index(upsertSourceMileageSQL, "latest_total_mileage_km = CASE")
if pureUpdate < 0 || latestTotalUpdate < 0 || pureUpdate > latestTotalUpdate {
t.Fatalf("pure hydrogen increment must use the previous odometer before MySQL updates latest_total_mileage_km:\n%s", upsertSourceMileageSQL)
}
}
func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) { func TestUpsertSourceMileageRechecksMergedDailyRange(t *testing.T) {
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" { if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL) t.Fatalf("SQL mileage limits drifted from Go quality constants: selected=%s jitter=%s", maxSelectedDailyMileageKMSQL, maxNegativeMileageJitterKMSQL)
@@ -334,6 +357,22 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) {
} }
} }
func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing.T) {
for name, query := range map[string]string{
"insert": normalizePlatformSourceMileageInsertSQL,
"delete": normalizePlatformSourceMileageDeleteSQL,
} {
for _, predicate := range []string{
"s.first_total_mileage_km IS NOT NULL",
"s.latest_total_mileage_km IS NOT NULL",
} {
if !strings.Contains(query, predicate) {
t.Fatalf("%s query missing incomplete-total guard %q:\n%s", name, predicate, query)
}
}
}
}
func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) { func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil { if err != nil {
@@ -487,17 +526,25 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"FROM vehicle_daily_mileage_source s", "FROM vehicle_daily_mileage_source s",
"LEFT JOIN vehicle_data_source ds", "LEFT JOIN vehicle_data_source ds",
"ds.id", "ds.id",
"WHEN COALESCE(s.daily_mileage_km, 0) > 0",
"AND COALESCE(s.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 0",
"AND COALESCE(s.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1",
"WHEN COALESCE(s.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 2",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)", "CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0", "WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1", "WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2", "WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority", "ds.trust_priority",
"s.quality_status = '" + QualityOK + "'", "s.quality_status = '" + QualityOK + "'",
"s.latest_total_mileage_km IS NOT NULL",
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'", "COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')", "AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')", "AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"s.daily_mileage_km BETWEEN 0 AND", "s.daily_mileage_km BETWEEN 0 AND",
"DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))", "DATEDIFF(DATE(s.latest_event_time), DATE(s.first_event_time))",
"SUM(identity_source.sample_count)",
"identity_source.vin = s.vin",
"COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)",
} { } {
if !strings.Contains(projectFinal, want) { if !strings.Contains(projectFinal, want) {
t.Fatalf("project query missing %q: %s", want, projectFinal) t.Fatalf("project query missing %q: %s", want, projectFinal)
@@ -508,7 +555,6 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"trusted_phone", "trusted_phone",
"trusted_source_endpoint", "trusted_source_endpoint",
"first_total_mileage_km", "first_total_mileage_km",
"sample_count)",
"ds.latest_source_endpoint", "ds.latest_source_endpoint",
} { } {
if strings.Contains(projectFinal, forbidden) { if strings.Contains(projectFinal, forbidden) {
@@ -533,11 +579,18 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'", "COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')", "AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')", "AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
"WHEN COALESCE(s2.daily_mileage_km, 0) > 0",
"AND COALESCE(s2.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 0",
"AND COALESCE(s2.quality_reason, '') = '" + QualityReasonGPSCoordinate + "' THEN 1",
"WHEN COALESCE(s2.quality_reason, '') <> '" + QualityReasonGPSCoordinate + "' THEN 2",
"CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)", "CASE COALESCE(NULLIF(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN'), CASE WHEN s2.source_key LIKE '%@PLATFORM:%' THEN 'PLATFORM' WHEN s2.source_key LIKE '%@DIRECT' THEN 'DIRECT' ELSE 'UNKNOWN' END)",
"WHEN 'PLATFORM' THEN 0", "WHEN 'PLATFORM' THEN 0",
"WHEN 'DIRECT' THEN 1", "WHEN 'DIRECT' THEN 1",
"WHEN 'UNKNOWN' THEN 2", "WHEN 'UNKNOWN' THEN 2",
"ds.trust_priority", "ds.trust_priority",
"SUM(identity_source.sample_count)",
"identity_source.vin = s2.vin",
"COALESCE(NULLIF(TRIM(identity_source.phone), ''), NULLIF(TRIM(identity_source.device_id), ''), identity_source.source_key)",
"LIMIT 1", "LIMIT 1",
} { } {
if !strings.Contains(markSelected, want) { if !strings.Contains(markSelected, want) {
@@ -555,6 +608,17 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden) t.Fatalf("source selection should prefer classified DIRECT before UNKNOWN, found %q", forbidden)
} }
} }
for queryName, query := range map[string]string{
"project": projectFinal,
"mark": markSelected,
} {
identityScore := strings.Index(query, "SUM(identity_source.sample_count)")
freshness := strings.Index(query, "latest_event_time DESC")
rowSamples := strings.LastIndex(query, "sample_count DESC")
if identityScore < 0 || freshness < identityScore || rowSamples < freshness {
t.Fatalf("%s query should rank terminal evidence before forwarding freshness and row samples: %s", queryName, query)
}
}
if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM { if got := exec.calls[1].args[3]; got != maxSelectedDailyMileageKM {
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM) t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
} }

View File

@@ -225,6 +225,26 @@ func TestDailyMileageSourceSchemaIncludesQueryIndexes(t *testing.T) {
} }
} }
func TestDailyMileageSchemaIncludesPureHydrogenEvidence(t *testing.T) {
for _, want := range []string{
"pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0",
} {
if !strings.Contains(DailyMileageTableSQL, want) {
t.Fatalf("daily mileage schema missing %q:\n%s", want, DailyMileageTableSQL)
}
}
for _, want := range []string{
"pure_hydrogen_mileage_km DECIMAL(18,3) NOT NULL DEFAULT 0",
"pure_hydrogen_sample_count BIGINT NOT NULL DEFAULT 0",
"latest_pure_hydrogen_active TINYINT(1) NOT NULL DEFAULT 0",
"latest_pure_hydrogen_mode_known TINYINT(1) NOT NULL DEFAULT 0",
} {
if !strings.Contains(DailyMileageSourceTableSQL, want) {
t.Fatalf("daily mileage source schema missing %q:\n%s", want, DailyMileageSourceTableSQL)
}
}
}
func containsStatement(statements []string, fragment string) bool { func containsStatement(statements []string, fragment string) bool {
for _, statement := range statements { for _, statement := range statements {
if strings.Contains(statement, fragment) { if strings.Contains(statement, fragment) {

View File

@@ -5,6 +5,7 @@ HOST=""
REMOTE_ROOT="/opt/lingniu-go-native" REMOTE_ROOT="/opt/lingniu-go-native"
DAYS_BACK=1 DAYS_BACK=1
WINDOW_DAYS=3 WINDOW_DAYS=3
BASELINE_LOOKBACK_DAYS=7
PROTOCOLS="GB32960,JT808,YUTONG_MQTT" PROTOCOLS="GB32960,JT808,YUTONG_MQTT"
ON_CALENDAR="*-*-* 01:30:00" ON_CALENDAR="*-*-* 01:30:00"
DRY_RUN="false" DRY_RUN="false"
@@ -19,6 +20,9 @@ Options:
--remote-root DIR Remote install root. Defaults to /opt/lingniu-go-native. --remote-root DIR Remote install root. Defaults to /opt/lingniu-go-native.
--days-back N Target end date relative to local day. Defaults to 1, meaning yesterday. --days-back N Target end date relative to local day. Defaults to 1, meaning yesterday.
--window-days N Number of days to recalculate ending at days-back. Defaults to 3. --window-days N Number of days to recalculate ending at days-back. Defaults to 3.
--baseline-lookback-days N
Maximum days searched before the target window for a mileage baseline.
Defaults to 7.
--protocols LIST Comma-separated protocols. Defaults to GB32960,JT808,YUTONG_MQTT. --protocols LIST Comma-separated protocols. Defaults to GB32960,JT808,YUTONG_MQTT.
--on-calendar SPEC systemd OnCalendar value. Defaults to "*-*-* 01:30:00". --on-calendar SPEC systemd OnCalendar value. Defaults to "*-*-* 01:30:00".
--dry-run Install timer in dry-run mode. --dry-run Install timer in dry-run mode.
@@ -44,6 +48,10 @@ while [[ $# -gt 0 ]]; do
WINDOW_DAYS="${2:-}" WINDOW_DAYS="${2:-}"
shift 2 shift 2
;; ;;
--baseline-lookback-days)
BASELINE_LOOKBACK_DAYS="${2:-}"
shift 2
;;
--protocols) --protocols)
PROTOCOLS="${2:-}" PROTOCOLS="${2:-}"
shift 2 shift 2
@@ -86,11 +94,17 @@ case "$WINDOW_DAYS" in
exit 2 exit 2
;; ;;
esac esac
case "$BASELINE_LOOKBACK_DAYS" in
''|*[!0-9]*|0)
echo "--baseline-lookback-days must be a positive integer" >&2
exit 2
;;
esac
remote_cmd="" remote_cmd=""
printf -v remote_cmd \ printf -v remote_cmd \
"REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \ "REMOTE_ROOT=%q DAYS_BACK=%q WINDOW_DAYS=%q BASELINE_LOOKBACK_DAYS=%q PROTOCOLS=%q ON_CALENDAR=%q DRY_RUN=%q bash -s" \
"$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN" "$REMOTE_ROOT" "$DAYS_BACK" "$WINDOW_DAYS" "$BASELINE_LOOKBACK_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN"
ssh "$HOST" "$remote_cmd" <<'REMOTE' ssh "$HOST" "$remote_cmd" <<'REMOTE'
set -euo pipefail set -euo pipefail
@@ -110,6 +124,7 @@ Environment=BACKFILL_METHOD=last_diff
Environment=BACKFILL_DRY_RUN=${DRY_RUN} Environment=BACKFILL_DRY_RUN=${DRY_RUN}
Environment=BACKFILL_DAYS_BACK=${DAYS_BACK} Environment=BACKFILL_DAYS_BACK=${DAYS_BACK}
Environment=BACKFILL_WINDOW_DAYS=${WINDOW_DAYS} Environment=BACKFILL_WINDOW_DAYS=${WINDOW_DAYS}
Environment=BACKFILL_BASELINE_LOOKBACK_DAYS=${BASELINE_LOOKBACK_DAYS}
Environment=BACKFILL_PROTOCOLS=${PROTOCOLS} Environment=BACKFILL_PROTOCOLS=${PROTOCOLS}
ExecStart=${REMOTE_ROOT}/current/stats-backfill ExecStart=${REMOTE_ROOT}/current/stats-backfill
TimeoutStartSec=45min TimeoutStartSec=45min

View File

@@ -0,0 +1,92 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cfg := config.Load()
if strings.TrimSpace(cfg.MySQLDSN) == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
gateways := map[string]platform.AlertNotificationGateway{}
for _, candidate := range []struct {
channel string
endpoint string
secret string
}{
{channel: "sms", endpoint: cfg.AlertNotificationSMSURL, secret: cfg.AlertNotificationSMSSecret},
{channel: "email", endpoint: cfg.AlertNotificationEmailURL, secret: cfg.AlertNotificationEmailSecret},
{channel: "wecom", endpoint: cfg.AlertNotificationWeComURL, secret: cfg.AlertNotificationWeComSecret},
} {
if strings.TrimSpace(candidate.endpoint) == "" && strings.TrimSpace(candidate.secret) == "" {
continue
}
gateway, err := platform.NewHTTPAlertNotificationGateway(candidate.endpoint, candidate.secret, cfg.AlertNotificationTimeout)
if err != nil {
return fmt.Errorf("%s gateway: %w", candidate.channel, err)
}
gateways[candidate.channel] = gateway
}
if len(gateways) == 0 {
return fmt.Errorf("at least one ALERT_NOTIFICATION_*_URL and matching signing secret are required")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
if err != nil {
return err
}
defer db.Close()
host, _ := os.Hostname()
dispatcher := platform.NewAlertNotificationDispatcher(
platform.NewProductionStore(db, nil, ""),
gateways,
cfg.AlertNotificationBatchSize,
cfg.AlertNotificationLease,
"notification-dispatcher-"+host,
)
poll := cfg.AlertNotificationPollInterval
if poll < 250*time.Millisecond {
poll = time.Second
}
if poll > time.Minute {
poll = time.Minute
}
ticker := time.NewTicker(poll)
defer ticker.Stop()
log.Printf("alert notification dispatcher started channels=%d batch=%d poll=%s lease=%s", len(gateways), cfg.AlertNotificationBatchSize, poll, cfg.AlertNotificationLease)
for {
started := time.Now()
result, dispatchErr := dispatcher.RunOnce(ctx)
if dispatchErr != nil {
log.Printf("alert notification dispatch failed claimed=%d sent=%d failed=%d duration=%s error=%v", result.Claimed, result.Sent, result.Failed, time.Since(started), dispatchErr)
} else if result.Claimed > 0 {
log.Printf("alert notification dispatch completed claimed=%d sent=%d failed=%d duration=%s", result.Claimed, result.Sent, result.Failed, time.Since(started))
}
select {
case <-ctx.Done():
log.Printf("alert notification dispatcher stopped")
return nil
case <-ticker.C:
}
}
}

View File

@@ -23,10 +23,6 @@ func main() {
} }
func run() error { func run() error {
sourceDSN, err := normalizedDSN("ONEOS_MYSQL_DSN", os.Getenv("ONEOS_MYSQL_DSN"), true)
if err != nil {
return err
}
targetDSN, err := normalizedDSN("MYSQL_DSN", os.Getenv("MYSQL_DSN"), false) targetDSN, err := normalizedDSN("MYSQL_DSN", os.Getenv("MYSQL_DSN"), false)
if err != nil { if err != nil {
return err return err
@@ -34,21 +30,12 @@ func run() error {
timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
source, err := openDB(ctx, sourceDSN, 2)
if err != nil {
return fmt.Errorf("open OneOS read-only database: %w", err)
}
defer source.Close()
target, err := openDB(ctx, targetDSN, 4) target, err := openDB(ctx, targetDSN, 4)
if err != nil { if err != nil {
return fmt.Errorf("open vehicle platform database: %w", err) return fmt.Errorf("open vehicle platform database: %w", err)
} }
defer target.Close() defer target.Close()
candidates, err := businessscope.ReadCandidates(ctx, source) snapshot, sourceKind, err := readSnapshot(ctx)
if err != nil {
return err
}
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
if err != nil { if err != nil {
return err return err
} }
@@ -64,11 +51,50 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
log.Printf("OneOS scope sync complete changed=%t candidates=%d accepted=%d rejected=%d version=%s", log.Printf("OneOS scope sync complete source=%s changed=%t candidates=%d accepted=%d rejected=%d version=%s",
result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion) sourceKind, result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
return nil return nil
} }
func readSnapshot(ctx context.Context) (businessscope.Snapshot, string, error) {
mode := strings.ToLower(strings.TrimSpace(os.Getenv("ONEOS_SCOPE_SOURCE")))
if mode == "" {
if strings.TrimSpace(os.Getenv("ONEOS_SCOPE_API_URL")) != "" {
mode = "api"
} else {
mode = "database"
}
}
switch mode {
case "api":
snapshot, err := businessscope.ReadAPISnapshot(ctx, businessscope.APIConfig{
URL: os.Getenv("ONEOS_SCOPE_API_URL"),
ServiceToken: os.Getenv("ONEOS_SCOPE_API_SERVICE_TOKEN"),
SigningSecret: os.Getenv("ONEOS_SCOPE_API_SIGNING_SECRET"),
MaxAttempts: envInt("ONEOS_SCOPE_API_MAX_ATTEMPTS", 3),
})
return snapshot, mode, err
case "database":
sourceDSN, err := normalizedDSN("ONEOS_MYSQL_DSN", os.Getenv("ONEOS_MYSQL_DSN"), true)
if err != nil {
return businessscope.Snapshot{}, mode, err
}
source, err := openDB(ctx, sourceDSN, 2)
if err != nil {
return businessscope.Snapshot{}, mode, fmt.Errorf("open OneOS read-only database: %w", err)
}
defer source.Close()
candidates, err := businessscope.ReadCandidates(ctx, source)
if err != nil {
return businessscope.Snapshot{}, mode, err
}
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
return snapshot, mode, err
default:
return businessscope.Snapshot{}, mode, fmt.Errorf("ONEOS_SCOPE_SOURCE must be api or database")
}
}
func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) { func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {

View File

@@ -0,0 +1,82 @@
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"strconv"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
)
func main() {
mysqlDSN := os.Getenv("MYSQL_DSN")
if mysqlDSN == "" {
log.Fatal("MYSQL_DSN is required")
}
db, err := sql.Open("mysql", mysqlDSN)
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("connect MySQL: %v", err)
}
tdengineDSN := os.Getenv("TDENGINE_DSN")
if tdengineDSN == "" {
log.Fatal("TDENGINE_DSN is required")
}
tdengineDriver := env("TDENGINE_DRIVER", "taosWS")
tdengineDB, err := sql.Open(tdengineDriver, tdengineDSN)
if err != nil {
log.Fatal(err)
}
defer tdengineDB.Close()
tdCtx, tdCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer tdCancel()
if err := tdengineDB.PingContext(tdCtx); err != nil {
log.Fatalf("connect TDengine: %v", err)
}
addr := env("OPEN_PLATFORM_HTTP_ADDR", ":20310")
handler := openplatform.NewStandaloneServer(db, openplatform.StandaloneConfig{
StaticDir: os.Getenv("OPEN_PLATFORM_STATIC_DIR"),
SessionTTL: time.Duration(envInt("OPEN_PLATFORM_SESSION_TTL_HOURS", 12)) * time.Hour,
RequestTimeout: time.Duration(envInt("OPEN_PLATFORM_REQUEST_TIMEOUT_MS", 10000)) * time.Millisecond,
Release: os.Getenv("OPEN_PLATFORM_RELEASE"),
TDengine: tdengineDB,
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
})
server := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("vehicle open platform listening on %s", addr)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil {
return fallback
}
return value
}

View File

@@ -0,0 +1,87 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"os"
"strconv"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/taosdata/driver-go/v3/taosWS"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
)
func main() {
date := flag.String("date", "", "last date to calculate in yyyy-MM-dd; defaults to yesterday in Asia/Shanghai")
lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date")
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
flag.Parse()
cfg := config.Load()
if cfg.MySQLDSN == "" || cfg.TDengineDSN == "" {
log.Fatal("MYSQL_DSN and TDENGINE_DSN are required")
}
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
if err != nil {
log.Fatal(err)
}
defer mysqlDB.Close()
tdengineDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
if err != nil {
log.Fatal(err)
}
defer tdengineDB.Close()
location := time.FixedZone("Asia/Shanghai", 8*60*60)
endDate := time.Now().In(location).AddDate(0, 0, -1)
if *date != "" {
endDate, err = time.ParseInLocation("2006-01-02", *date, location)
if err != nil {
log.Fatal("-date must use yyyy-MM-dd")
}
}
if *lookback < 1 || *lookback > 31 {
log.Fatal("-lookback-days must be between 1 and 31")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
if err != nil {
log.Fatalf("load hydrogen tank capacities: %v", err)
}
for offset := *lookback - 1; offset >= 0; offset-- {
start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset)
observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities)
if err != nil {
log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err)
}
stats := openplatform.BuildHydrogenDailyStats(observations, start.Format("2006-01-02"), *noise, *maxDrop)
if err := openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, start.Format("2006-01-02"), stats); err != nil {
log.Fatalf("persist hydrogen statistics for %s: %v", start.Format("2006-01-02"), err)
}
fmt.Printf("date=%s observations=%d vehicles=%d\n", start.Format("2006-01-02"), len(observations), len(stats))
}
}
func envInt(name string, fallback int) int {
value, err := strconv.Atoi(os.Getenv(name))
if err != nil {
return fallback
}
return value
}
func envFloat(name string, fallback float64) float64 {
value, err := strconv.ParseFloat(os.Getenv(name), 64)
if err != nil {
return fallback
}
return value
}

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
if err := run(); err != nil {
log.Printf("reconciliation evaluation failed: %v", err)
os.Exit(1)
}
}
func run() error {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
timeout := 5 * time.Minute
if value := strings.TrimSpace(os.Getenv("RECONCILIATION_TIMEOUT_SEC")); value != "" {
parsed, err := time.ParseDuration(value + "s")
if err != nil || parsed < 10*time.Second || parsed > 30*time.Minute {
return fmt.Errorf("RECONCILIATION_TIMEOUT_SEC must be between 10 and 1800 seconds")
}
timeout = parsed
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := platform.OpenSQL(ctx, "mysql", dsn)
if err != nil {
return fmt.Errorf("connect mysql: %w", err)
}
defer db.Close()
service := platform.NewService(platform.NewProductionStore(db, nil, ""))
started := time.Now()
result, err := service.EvaluateReconciliation(ctx)
if err != nil {
return err
}
log.Printf(
"reconciliation evaluation completed run=%s detected=%d new=%d active=%d recovered=%d rules=%v duration=%s",
result.RunID, result.Detected, result.New, result.Active, result.Recovered, result.RuleCounts, time.Since(started),
)
return nil
}

View File

@@ -0,0 +1,21 @@
package main
import (
"strings"
"testing"
)
func TestRunRequiresMySQLDSN(t *testing.T) {
t.Setenv("MYSQL_DSN", "")
if err := run(); err == nil || !strings.Contains(err.Error(), "MYSQL_DSN is required") {
t.Fatalf("run() error=%v", err)
}
}
func TestRunRejectsUnsafeTimeout(t *testing.T) {
t.Setenv("MYSQL_DSN", "ignored")
t.Setenv("RECONCILIATION_TIMEOUT_SEC", "1")
if err := run(); err == nil || !strings.Contains(err.Error(), "between 10 and 1800") {
t.Fatalf("run() error=%v", err)
}
}

View File

@@ -3,13 +3,15 @@ module lingniu/vehicle-data-platform/apps/api
go 1.26 go 1.26
require ( require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
github.com/segmentio/kafka-go v0.4.49
github.com/taosdata/driver-go/v3 v3.8.1 github.com/taosdata/driver-go/v3 v3.8.1
golang.org/x/crypto v0.49.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
@@ -17,6 +19,4 @@ require (
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/segmentio/kafka-go v0.4.49 // indirect
golang.org/x/crypto v0.49.0 // indirect
) )

View File

@@ -38,8 +38,18 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/taosdata/driver-go/v3 v3.8.1 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE= github.com/taosdata/driver-go/v3 v3.8.1 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE=
github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg= github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -30,6 +30,8 @@ type apiAuthenticator struct {
mode string mode string
tokens []tokenPrincipal tokens []tokenPrincipal
local *authStore local *authStore
oneOS *oneOSIdentityAdapter
demo *demoAuthDirectory
adapters []IdentityAdapter adapters []IdentityAdapter
} }
@@ -60,7 +62,17 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
if err != nil { if err != nil {
return nil, err return nil, err
} }
authenticator := &apiAuthenticator{mode: mode, local: local} oneOS, err := newOneOSIdentityAdapter(cfg)
if err != nil {
return nil, err
}
if oneOS != nil && local == nil {
return nil, fmt.Errorf("ONEOS_SSO_ENABLED requires MYSQL_DSN and the platform identity schema")
}
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS}
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
authenticator.demo = newDemoAuthDirectory()
}
configured := []configuredPrincipal{} configured := []configuredPrincipal{}
if strings.TrimSpace(cfg.AuthTokensJSON) != "" { if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil { if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
@@ -97,6 +109,18 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/auth/oneos/exchange" {
if r.Method != http.MethodPost {
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r))
return
}
if a.oneOS == nil || a.local == nil {
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_SSO_UNAVAILABLE", "OneOS 单点登录尚未启用", "", requestTraceID(r))
return
}
a.local.exchangeOneOSTicket(w, r, a.oneOS)
return
}
if r.URL.Path == "/api/v2/auth/login" { if r.URL.Path == "/api/v2/auth/login" {
if a.local == nil { if a.local == nil {
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r)) httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r))
@@ -146,7 +170,7 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r)) httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
return return
} }
if a.local != nil && principal.AuthProvider == "local" { if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
a.local.logout(r.Context(), bearerToken(r)) a.local.logout(r.Context(), bearerToken(r))
} }
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true}) httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
@@ -163,6 +187,9 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
if a.local != nil && a.local.handleAdmin(w, r, principal) { if a.local != nil && a.local.handleAdmin(w, r, principal) {
return return
} }
if a.demo != nil && a.demo.handleAdmin(w, r, principal) {
return
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -220,9 +247,9 @@ func requiredMenu(r *http.Request) string {
return "shared" return "shared"
case path == "/api/v2/tracks": case path == "/api/v2/tracks":
return "tracks" return "tracks"
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary": case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
return "statistics" return "statistics"
case path == "/api/vehicles", path == "/api/vehicles/resolve": case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
return "shared" return "shared"
case strings.HasPrefix(path, "/api/v2/vehicles/"): case strings.HasPrefix(path, "/api/v2/vehicles/"):
return "vehicles" return "vehicles"
@@ -232,6 +259,25 @@ func requiredMenu(r *http.Request) string {
} }
func requiredRole(r *http.Request) string { func requiredRole(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
return "admin"
}
if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") {
if r.Method == http.MethodPost && (strings.HasSuffix(r.URL.Path, "/archive") || strings.HasSuffix(r.URL.Path, "/restore") ||
strings.HasSuffix(r.URL.Path, "/batch-archive") || strings.HasSuffix(r.URL.Path, "/batch-restore")) {
return "admin"
}
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost {
return "operator"
}
return "admin"
}
if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return "operator"
}
return "admin"
}
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
return "viewer" return "viewer"
} }
@@ -241,7 +287,9 @@ func requiredRole(r *http.Request) string {
path := r.URL.Path path := r.URL.Path
if r.Method == http.MethodPost { if r.Method == http.MethodPost {
switch path { switch path {
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": case "/api/v2/auth/logout":
return "viewer"
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
return "viewer" return "viewer"
case "/api/v2/alerts/notifications/read": case "/api/v2/alerts/notifications/read":
return "operator" return "operator"
@@ -252,6 +300,9 @@ func requiredRole(r *http.Request) string {
if path == "/api/v2/alerts/rules" { if path == "/api/v2/alerts/rules" {
return "admin" return "admin"
} }
if strings.HasPrefix(path, "/api/v2/alerts/rules/") && strings.HasSuffix(path, "/rollback") {
return "admin"
}
} }
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) { if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
return "admin" return "admin"

View File

@@ -0,0 +1,260 @@
package app
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
// demoAuthDirectory keeps account governance operable in DATA_MODE=mock. It is
// intentionally isolated from production authentication and only exists when
// authentication is disabled and no SQL identity store is configured.
type demoAuthDirectory struct {
mu sync.Mutex
users []authUser
nextID uint64
}
func newDemoAuthDirectory() *demoAuthDirectory {
now := time.Now().Truncate(time.Second)
grant := func(vin, plate string, daysAgo int) authVehicleGrant {
return authVehicleGrant{VIN: vin, Plate: plate, ValidFrom: now.AddDate(0, 0, -daysAgo), SourceSystem: "manual", GrantedBy: "demo-admin"}
}
users := []authUser{
{ID: 101, Username: "customer-south", DisplayName: "华南运营中心", UserType: "customer", Status: "enabled", CustomerRef: "CUS-SOUTH", TenantRef: "tenant-south", AuthProvider: "local", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LB9A32A24R0LS1426", "粤AG18312", 120), grant("LB9A32A24P0LS1230", "粤AFF7936", 45)}, LastLoginAt: timePointer(now.Add(-18 * time.Minute)), CreatedAt: now.AddDate(0, -5, 0), UpdatedAt: now.Add(-2 * time.Hour)},
{ID: 102, Username: "oneos-east", DisplayName: "华东数据客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-EAST", TenantRef: "tenant-east", AuthProvider: "OneOS", ExternalSubject: "oneos:tenant-east:customer-002", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LMRKH9AC2R1004087", "豫A88888", 88)}, LastLoginAt: timePointer(now.Add(-3 * time.Hour)), CreatedAt: now.AddDate(0, -4, 0), UpdatedAt: now.Add(-25 * time.Minute)},
{ID: 103, Username: "ruoyi-west", DisplayName: "西区联营客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-WEST", TenantRef: "tenant-west", AuthProvider: "RuoYi", MenuKeys: []string{"monitor", "vehicles"}, Vehicles: []authVehicleGrant{grant("LNXNEGRR7SR318212", "川AHTWO1", 31)}, CreatedAt: now.AddDate(0, -2, 0), UpdatedAt: now.Add(-40 * time.Minute)},
{ID: 104, Username: "customer-archive", DisplayName: "历史合作客户", UserType: "customer", Status: "disabled", CustomerRef: "CUS-ARCHIVE", AuthProvider: "local", MenuKeys: []string{"monitor"}, Vehicles: []authVehicleGrant{grant("LB9A32A24P0LS1230", "粤AFF7936", 200)}, CreatedAt: now.AddDate(-1, 0, 0), UpdatedAt: now.AddDate(0, 0, -12)},
}
for index := range users {
users[index].VehicleVINs = grantVINsFromAuth(users[index].Vehicles)
}
return &demoAuthDirectory{users: users, nextID: 105}
}
var customerMenuKeys = []string{"monitor", "vehicles", "tracks", "statistics"}
func timePointer(value time.Time) *time.Time { return &value }
func grantVINsFromAuth(grants []authVehicleGrant) []string {
vins := make([]string, 0, len(grants))
for _, grant := range grants {
vins = append(vins, grant.VIN)
}
return vins
}
func cloneAuthUsers(users []authUser) []authUser {
result := make([]authUser, len(users))
for index, user := range users {
result[index] = user
result[index].MenuKeys = append([]string(nil), user.MenuKeys...)
result[index].VehicleVINs = append([]string(nil), user.VehicleVINs...)
result[index].Vehicles = append([]authVehicleGrant(nil), user.Vehicles...)
result[index].GrantHistory = append([]authVehicleGrantHistory(nil), user.GrantHistory...)
}
return result
}
func (d *demoAuthDirectory) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
return false
}
if principal.UserType != "admin" && principal.Role != "admin" {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "仅管理员可以管理账号与权限", "", requestTraceID(r))
return true
}
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
d.mu.Lock()
users := cloneAuthUsers(d.users)
d.mu.Unlock()
httpx.WriteOK(w, requestTraceID(r), users)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
d.batchCustomers(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
d.createCustomer(w, r)
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
d.updateCustomer(w, r)
default:
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r))
}
return true
}
func validateDemoCustomer(input userMutation, requirePassword bool) ([]string, []authVehicleGrant, error) {
if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) {
return nil, nil, demoValidationError("用户名需为 3-64 位字母、数字、点、下划线或短横线")
}
if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 {
return nil, nil, demoValidationError("客户名称不能为空且不能超过 48 个字符")
}
if input.Status != "enabled" && input.Status != "disabled" {
return nil, nil, demoValidationError("账号状态无效")
}
if requirePassword || input.Password != "" {
if err := validatePassword(input.Password); err != nil {
return nil, nil, err
}
}
menus := normalizeMenus(input.MenuKeys)
if len(menus) == 0 {
return nil, nil, demoValidationError("至少分配一个客户菜单")
}
mutations, err := normalizeVehicleGrantMutations(input)
if err != nil {
return nil, nil, err
}
if len(mutations) == 0 {
return nil, nil, demoValidationError("至少分配一辆可查看车辆")
}
now := time.Now().Truncate(time.Second)
plates := map[string]string{"LB9A32A24R0LS1426": "粤AG18312", "LNXNEGRR7SR318212": "川AHTWO1", "LMRKH9AC2R1004087": "豫A88888", "LB9A32A24P0LS1230": "粤AFF7936"}
grants := make([]authVehicleGrant, 0, len(mutations))
for _, item := range mutations {
if _, exists := plates[item.VIN]; !exists {
return nil, nil, demoValidationError("车辆不存在或尚未接入:" + item.VIN)
}
validFrom := now
if item.ValidFrom != nil {
validFrom = *item.ValidFrom
}
grants = append(grants, authVehicleGrant{VIN: item.VIN, Plate: plates[item.VIN], ValidFrom: validFrom, ValidTo: item.ValidTo, SourceSystem: "manual", GrantedBy: "local-developer"})
}
return menus, grants, nil
}
type demoValidationError string
func (e demoValidationError) Error() string { return string(e) }
func (d *demoAuthDirectory) createCustomer(w http.ResponseWriter, r *http.Request) {
var input userMutation
if !decodeAuthJSON(w, r, &input) {
return
}
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
menus, grants, err := validateDemoCustomer(input, true)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
return
}
d.mu.Lock()
defer d.mu.Unlock()
for _, user := range d.users {
if strings.EqualFold(user.Username, strings.TrimSpace(input.Username)) {
httpx.WriteError(w, http.StatusConflict, "USERNAME_EXISTS", "登录账号已经存在", input.Username, requestTraceID(r))
return
}
}
now := time.Now().Truncate(time.Second)
id := d.nextID
d.nextID++
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(input.Username), DisplayName: strings.TrimSpace(input.DisplayName), UserType: "customer", Status: input.Status, CustomerRef: strings.TrimSpace(input.CustomerRef), TenantRef: strings.TrimSpace(input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
}
func (d *demoAuthDirectory) updateCustomer(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/"), 10, 64)
if err != nil || id == 0 {
httpx.WriteError(w, http.StatusBadRequest, "USER_ID_INVALID", "账号编号无效", "", requestTraceID(r))
return
}
var input userMutation
if !decodeAuthJSON(w, r, &input) {
return
}
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
menus, grants, err := validateDemoCustomer(input, false)
if err != nil {
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
return
}
d.mu.Lock()
defer d.mu.Unlock()
for index := range d.users {
user := &d.users[index]
if user.ID != id || user.UserType != "customer" {
continue
}
if !strings.EqualFold(user.AuthProvider, "local") && strings.TrimSpace(user.AuthProvider) != "" && input.Password != "" {
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", user.AuthProvider, requestTraceID(r))
return
}
user.DisplayName = strings.TrimSpace(input.DisplayName)
user.Status = input.Status
user.CustomerRef = strings.TrimSpace(input.CustomerRef)
user.TenantRef = strings.TrimSpace(input.TenantRef)
user.MenuKeys = menus
user.Vehicles = grants
user.VehicleVINs = grantVINsFromAuth(grants)
user.UpdatedAt = time.Now().Truncate(time.Second)
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
return
}
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
}
func (d *demoAuthDirectory) batchCustomers(w http.ResponseWriter, r *http.Request) {
var input userBatchRequest
if !decodeAuthJSON(w, r, &input) {
return
}
if input.Mode != "preview" && input.Mode != "create" {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量处理模式无效", "", requestTraceID(r))
return
}
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", "每批需要包含 1 至 50 个账号", "", requestTraceID(r))
return
}
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
seen := map[string]bool{}
for _, item := range input.Items {
entry := userBatchResultItem{Row: item.Row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
key := strings.ToLower(strings.TrimSpace(item.Input.Username))
if seen[key] {
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内账号重复"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
seen[key] = true
if _, _, err := validateDemoCustomer(item.Input, true); err != nil {
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
d.mu.Lock()
exists := false
for _, user := range d.users {
exists = exists || strings.EqualFold(user.Username, key)
}
d.mu.Unlock()
if exists {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "登录账号已经存在"
result.Summary.Failed++
} else if input.Mode == "preview" {
entry.Status, entry.Message = "ready", "校验通过,可以创建"
result.Summary.Ready++
} else {
menus, grants, _ := validateDemoCustomer(item.Input, true)
d.mu.Lock()
id := d.nextID
d.nextID++
now := time.Now().Truncate(time.Second)
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(item.Input.Username), DisplayName: strings.TrimSpace(item.Input.DisplayName), UserType: "customer", Status: firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled"), CustomerRef: strings.TrimSpace(item.Input.CustomerRef), TenantRef: strings.TrimSpace(item.Input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
d.mu.Unlock()
entry.ID, entry.Status, entry.Message = int64(id), "created", "创建成功"
result.Summary.Created++
}
result.Items = append(result.Items, entry)
}
httpx.WriteOK(w, requestTraceID(r), result)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,177 @@
package app package app
import ( import (
"bytes"
"context" "context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp" "regexp"
"strings"
"testing" "testing"
"time"
"github.com/DATA-DOG/go-sqlmock" "github.com/DATA-DOG/go-sqlmock"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
) )
func TestBatchCustomersPreviewsReadyRowsAndFileDuplicatesIndependently(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
input := userBatchRequest{Mode: "preview", Items: []userBatchItem{
{Row: 2, Input: userMutation{Username: "customer-east", DisplayName: "华东客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
{Row: 3, Input: userMutation{Username: "CUSTOMER-EAST", DisplayName: "重复客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
}}
body, _ := json.Marshal(input)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`)).
WithArgs("customer-east").WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`)).
WillReturnResult(sqlmock.NewResult(1, 1))
request := httptest.NewRequest(http.MethodPost, "/api/v2/admin/users/batch", bytes.NewReader(body))
response := httptest.NewRecorder()
store.batchCustomers(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
if response.Code != http.StatusOK {
t.Fatalf("batch preview failed: status=%d body=%s", response.Code, response.Body.String())
}
var envelope struct {
Data userBatchResult `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.Summary.Ready != 1 || envelope.Data.Summary.Failed != 1 || len(envelope.Data.Items) != 2 {
t.Fatalf("unexpected batch summary: %+v", envelope.Data)
}
if envelope.Data.Items[0].Status != "ready" || envelope.Data.Items[1].Code != "DUPLICATE_IN_FILE" {
t.Fatalf("unexpected batch items: %+v", envelope.Data.Items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSDepartmentRoleUsesDepartmentVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("department", "40002,40001", "50001"))
mock.ExpectQuery("SELECT DISTINCT UPPER").
WithArgs("40001", "40002").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN-A").AddRow("VIN-B"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 7, DisplayName: "部门负责人", Username: "leader", UserType: "customer", AuthProvider: "OneOS",
})
if err != nil {
t.Fatal(err)
}
if principal.BusinessScopeLevel != "department" || strings.Join(principal.DepartmentIDs, ",") != "40001,40002" || strings.Join(principal.VehicleVINs, ",") != "VIN-A,VIN-B" {
t.Fatalf("unexpected OneOS department principal: %+v", principal)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}).AddRow("MANUAL-VIN", time.Now(), nil))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("responsible", "", "50008"))
mock.ExpectQuery("SELECT DISTINCT UPPER").
WithArgs("50008").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("OWN-VIN"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 8, DisplayName: "普通业务", Username: "seller", UserType: "customer", AuthProvider: "oneos",
})
if err != nil {
t.Fatal(err)
}
if principal.BusinessScopeLevel != "responsible" || principal.ResponsibleUserID != "50008" || strings.Join(principal.VehicleVINs, ",") != "OWN-VIN" {
t.Fatalf("ordinary OneOS user must only see responsible vehicles: %+v", principal)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestSyncOneOSIdentityCreatesCustomerMenusAndResponsibleScopeAtomically(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
issuedAt := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`)).
WithArgs("50008").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user(
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`)).
WithArgs("oneos-50008", "业务人员", "", "000000", "50008").
WillReturnResult(sqlmock.NewResult(18, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).
WithArgs(uint64(18)).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`)).
WithArgs(uint64(18), "vehicles").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_business_scope(
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
) VALUES(?,?,?,?,1,'oneos',?)
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
source_updated_at=VALUES(source_updated_at)`)).
WithArgs(uint64(18), "responsible", "40002,40001", "50008", issuedAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
user, err := store.syncOneOSIdentity(context.Background(), oneOSIdentity{
Subject: "50008", Username: "seller", DisplayName: "业务人员", TenantID: "000000",
DepartmentIDs: []string{"40002", "40001"}, ScopeLevel: "responsible",
ResponsibleUserID: "50008", IssuedAt: issuedAt,
}, []string{"vehicles"})
if err != nil {
t.Fatal(err)
}
if user.ID != 18 || user.Username != "oneos-50008" || user.AuthProvider != "oneos" || user.UserType != "customer" {
t.Fatalf("unexpected synced user: %+v", user)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) { func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
db, mock, err := sqlmock.New() db, mock, err := sqlmock.New()
if err != nil { if err != nil {
@@ -26,24 +190,33 @@ func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)). mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
WithArgs(uint64(7), "monitor", "admin-a"). WithArgs(uint64(7), "monitor", "admin-a").
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)). validFrom := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT p.vin,COALESCE(p.valid_from,p.granted_at),p.valid_to,
COALESCE(h.id,0),h.valid_from,h.valid_to
FROM platform_user_vehicle p
LEFT JOIN platform_user_vehicle_grant_history h
ON h.id=(SELECT MAX(latest.id) FROM platform_user_vehicle_grant_history latest WHERE latest.user_id=p.user_id AND latest.vin=p.vin)
WHERE p.user_id=? FOR UPDATE`)).
WithArgs(uint64(7)). WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001")) WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", validFrom, nil, 11, validFrom, nil))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)). mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history
WithArgs("admin-a", uint64(7), "VIN002"). SET valid_to=CASE WHEN valid_to IS NOT NULL AND valid_to<=NOW(3) THEN valid_to WHEN valid_from>NOW(3) THEN valid_from ELSE NOW(3) END,
revoked_by=?,updated_at=NOW(3) WHERE id=?`)).
WithArgs("admin-a", uint64(11)).
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)). mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
WithArgs(uint64(7), "VIN002"). WithArgs(uint64(7), "VIN002").
WillReturnResult(sqlmock.NewResult(0, 1)) WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`)). mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,valid_to,source_system,granted_by) VALUES(?,?,?,?,'manual',?)`)).
WithArgs(uint64(7), "VIN002", "admin-a"). WithArgs(uint64(7), "VIN002", sqlmock.AnyArg(), nil, "admin-a").
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`)). mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,valid_to,source_system,external_ref,granted_by)
WithArgs(uint64(7), "VIN002"). VALUES(?,?,?,?, 'manual','',?)`)).
WithArgs(uint64(7), "VIN002", sqlmock.AnyArg(), nil, "admin-a").
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit() mock.ExpectCommit()
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN002"}, "admin-a"); err != nil { if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []vehicleGrantMutation{{VIN: "VIN002"}}, "admin-a", nil); err != nil {
t.Fatalf("replaceGrants failed: %v", err) t.Fatalf("replaceGrants failed: %v", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -72,18 +245,21 @@ func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) {
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)). mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
WithArgs(uint64(7), "monitor", "admin-a"). WithArgs(uint64(7), "monitor", "admin-a").
WillReturnResult(sqlmock.NewResult(1, 1)) WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)). validFrom := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT p.vin,COALESCE(p.valid_from,p.granted_at),p.valid_to,
COALESCE(h.id,0),h.valid_from,h.valid_to
FROM platform_user_vehicle p
LEFT JOIN platform_user_vehicle_grant_history h
ON h.id=(SELECT MAX(latest.id) FROM platform_user_vehicle_grant_history latest WHERE latest.user_id=p.user_id AND latest.vin=p.vin)
WHERE p.user_id=? FOR UPDATE`)).
WithArgs(uint64(7)). WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001")) WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", validFrom, nil, 12, validFrom, nil))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)).
WithArgs("admin-a", uint64(7), "VIN001").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)). mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
WithArgs(uint64(7), "VIN001"). WithArgs(uint64(7), "VIN001").
WillReturnResult(sqlmock.NewResult(0, 0)) WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit() mock.ExpectCommit()
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN001"}, "admin-a"); err != nil { if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []vehicleGrantMutation{{VIN: "VIN001"}}, "admin-a", nil); err != nil {
t.Fatalf("replaceGrants failed: %v", err) t.Fatalf("replaceGrants failed: %v", err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -93,3 +269,140 @@ func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestReplaceGrantsUpdatesCurrentProjectionAndMatchingHistoryTogether(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
fromBefore := time.Date(2026, 7, 16, 14, 8, 18, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
fromAfter := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).WithArgs(uint64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).WithArgs(uint64(7), "statistics", "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT p.vin,COALESCE(p.valid_from,p.granted_at),p.valid_to,
COALESCE(h.id,0),h.valid_from,h.valid_to
FROM platform_user_vehicle p
LEFT JOIN platform_user_vehicle_grant_history h
ON h.id=(SELECT MAX(latest.id) FROM platform_user_vehicle_grant_history latest WHERE latest.user_id=p.user_id AND latest.vin=p.vin)
WHERE p.user_id=? FOR UPDATE`)).WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", fromBefore, nil, 19, fromAfter, nil))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).WithArgs(uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle SET valid_from=?,valid_to=?,source_system='manual',granted_by=? WHERE user_id=? AND vin=?`)).
WithArgs(fromAfter, nil, "admin-a", uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history
SET valid_from=?,valid_to=?,source_system='manual',granted_by=?,revoked_by='',updated_at=NOW(3) WHERE id=?`)).
WithArgs(fromAfter, nil, "admin-a", uint64(19)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
changes := []vehicleGrantAuditChange{}
if err := replaceGrants(context.Background(), tx, 7, []string{"statistics"}, []vehicleGrantMutation{{VIN: "VIN001", ValidFrom: &fromAfter}}, "admin-a", &changes); err != nil {
t.Fatalf("replaceGrants failed: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(changes) != 1 || changes[0].Before == nil || changes[0].After == nil ||
!changes[0].Before.ValidFrom.Equal(fromBefore) || !changes[0].After.ValidFrom.Equal(fromAfter) {
t.Fatalf("grant audit must retain before/after interval: %+v", changes)
}
}
func TestReplaceGrantsRepairsHistoryDriftEvenWhenProjectionDateIsUnchanged(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
projectionFrom := time.Date(2026, 7, 16, 14, 8, 18, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
driftedHistoryFrom := time.Date(2026, 6, 5, 19, 6, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).WithArgs(uint64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).WithArgs(uint64(7), "statistics", "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(regexp.QuoteMeta(`SELECT p.vin,COALESCE(p.valid_from,p.granted_at),p.valid_to,
COALESCE(h.id,0),h.valid_from,h.valid_to
FROM platform_user_vehicle p
LEFT JOIN platform_user_vehicle_grant_history h
ON h.id=(SELECT MAX(latest.id) FROM platform_user_vehicle_grant_history latest WHERE latest.user_id=p.user_id AND latest.vin=p.vin)
WHERE p.user_id=? FOR UPDATE`)).WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", projectionFrom, nil, 19, driftedHistoryFrom, nil))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).WithArgs(uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle SET valid_from=?,valid_to=?,source_system='manual',granted_by=? WHERE user_id=? AND vin=?`)).
WithArgs(projectionFrom, nil, "admin-a", uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history
SET valid_from=?,valid_to=?,source_system='manual',granted_by=?,revoked_by='',updated_at=NOW(3) WHERE id=?`)).
WithArgs(projectionFrom, nil, "admin-a", uint64(19)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
changes := []vehicleGrantAuditChange{}
if err := replaceGrants(context.Background(), tx, 7, []string{"statistics"}, []vehicleGrantMutation{{VIN: "VIN001", ValidFrom: &projectionFrom}}, "admin-a", &changes); err != nil {
t.Fatalf("replaceGrants failed: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(changes) != 1 {
t.Fatalf("history drift repair must be audited: %+v", changes)
}
}
func TestNormalizeVehicleGrantMutationsRequiresOrderedInterval(t *testing.T) {
_, err := normalizeVehicleGrantMutations(userMutation{VehicleGrants: []userVehicleGrantInput{{
VIN: "VIN001", ValidFrom: "2026-06-05T00:00", ValidTo: "2026-06-04T23:59",
}}})
if err == nil {
t.Fatal("invalid authorization interval must be rejected")
}
grants, err := normalizeVehicleGrantMutations(userMutation{VehicleGrants: []userVehicleGrantInput{{
VIN: "vin001", ValidFrom: "2026-06-05T00:00",
}}})
if err != nil || len(grants) != 1 || grants[0].VIN != "VIN001" || grants[0].ValidFrom == nil {
t.Fatalf("valid authorization interval was not normalized: grants=%+v err=%v", grants, err)
}
}
func TestUpdateCustomerRejectsPasswordResetForExternalIdentity(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
input := userMutation{
DisplayName: "华东外部客户",
Password: "ChangeMe2026!",
Status: "enabled",
MenuKeys: []string{"monitor"},
VehicleVINs: []string{"VIN001"},
}
body, _ := json.Marshal(input)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`)).
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"user_type", "auth_provider"}).AddRow("customer", "OneOS"))
mock.ExpectRollback()
request := httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/7", bytes.NewReader(body))
response := httptest.NewRecorder()
store.updateCustomer(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
t.Fatalf("external password reset should be rejected: status=%d body=%s", response.Code, response.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -1,6 +1,7 @@
package app package app
import ( import (
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -86,6 +87,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorRule.Code != http.StatusForbidden { if operatorRule.Code != http.StatusForbidden {
t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code) t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code)
} }
operatorRollback := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules/rule-1/rollback", operatorToken)
if operatorRollback.Code != http.StatusForbidden {
t.Fatalf("operator rule rollback should be forbidden, status=%d", operatorRollback.Code)
}
operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken) operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken)
if operatorProfile.Code != http.StatusForbidden { if operatorProfile.Code != http.StatusForbidden {
t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code) t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code)
@@ -94,6 +99,42 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorProfileSync.Code != http.StatusForbidden { if operatorProfileSync.Code != http.StatusForbidden {
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code) t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
} }
operatorIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", operatorToken)
if operatorIdentityClaim.Code != http.StatusForbidden {
t.Fatalf("operator identity claim should be forbidden, status=%d", operatorIdentityClaim.Code)
}
viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken)
if viewerSourceDiagnostic.Code != http.StatusForbidden {
t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code)
}
operatorSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", operatorToken)
if operatorSourceDiagnostic.Code != http.StatusNoContent {
t.Fatalf("operator source diagnostic status=%d", operatorSourceDiagnostic.Code)
}
operatorSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", operatorToken)
if operatorSourcePolicy.Code != http.StatusForbidden {
t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code)
}
viewerReconciliation := authRequest(t, cfg, http.MethodGet, "/api/v2/reconciliation/summary", viewerToken)
if viewerReconciliation.Code != http.StatusForbidden {
t.Fatalf("viewer reconciliation summary should be forbidden, status=%d", viewerReconciliation.Code)
}
operatorReconciliation := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/actions", operatorToken)
if operatorReconciliation.Code != http.StatusNoContent {
t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String())
}
operatorReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", operatorToken)
if operatorReconciliationArchive.Code != http.StatusForbidden {
t.Fatalf("operator reconciliation archive should be forbidden, status=%d", operatorReconciliationArchive.Code)
}
adminReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", adminToken)
if adminReconciliationArchive.Code != http.StatusNoContent {
t.Fatalf("admin reconciliation archive status=%d body=%s", adminReconciliationArchive.Code, adminReconciliationArchive.Body.String())
}
operatorOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", operatorToken)
if operatorOpenPlatform.Code != http.StatusForbidden {
t.Fatalf("operator open-platform management should be forbidden, status=%d", operatorOpenPlatform.Code)
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken) adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" { if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal")) t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))
@@ -106,6 +147,45 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if adminThreshold.Code != http.StatusNoContent { if adminThreshold.Code != http.StatusNoContent {
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String()) t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
} }
adminIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", adminToken)
if adminIdentityClaim.Code != http.StatusNoContent {
t.Fatalf("admin identity claim status=%d body=%s", adminIdentityClaim.Code, adminIdentityClaim.Body.String())
}
adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken)
if adminSourcePolicy.Code != http.StatusNoContent {
t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String())
}
adminOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", adminToken)
if adminOpenPlatform.Code != http.StatusNoContent {
t.Fatalf("admin open-platform management status=%d body=%s", adminOpenPlatform.Code, adminOpenPlatform.Body.String())
}
}
func TestDisabledMockModeProvidesOperableIdentityDirectory(t *testing.T) {
cfg := config.Config{AuthMode: "disabled", DataMode: "mock"}
handler := withAPIAuth(http.NotFoundHandler(), cfg)
listResponse := httptest.NewRecorder()
handler.ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil))
if listResponse.Code != http.StatusOK {
t.Fatalf("mock directory status=%d body=%s", listResponse.Code, listResponse.Body.String())
}
var listEnvelope struct {
Data []authUser `json:"data"`
}
if err := json.Unmarshal(listResponse.Body.Bytes(), &listEnvelope); err != nil {
t.Fatal(err)
}
if len(listEnvelope.Data) < 4 || listEnvelope.Data[1].AuthProvider != "OneOS" || listEnvelope.Data[1].ExternalSubject == "" || listEnvelope.Data[2].ExternalSubject != "" {
t.Fatalf("mock identity examples are incomplete: %+v", listEnvelope.Data)
}
updateBody := `{"displayName":"华东数据客户","password":"ChangeMe2026!","status":"enabled","customerRef":"CUS-EAST","tenantRef":"tenant-east","menuKeys":["monitor"],"vehicleVins":["LMRKH9AC2R1004087"]}`
updateResponse := httptest.NewRecorder()
handler.ServeHTTP(updateResponse, httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/102", strings.NewReader(updateBody)))
if updateResponse.Code != http.StatusBadRequest || !strings.Contains(updateResponse.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
t.Fatalf("mock external credential ownership was not enforced: status=%d body=%s", updateResponse.Code, updateResponse.Body.String())
}
} }
func TestAPIAuthSessionAndDisabledMode(t *testing.T) { func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
@@ -123,6 +203,27 @@ func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
} }
} }
func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
if role := requiredRole(httptest.NewRequest(http.MethodPost, "/api/v2/auth/logout", nil)); role != "viewer" {
t.Fatalf("logout should allow authenticated customers, required role=%s", role)
}
if role := requiredRole(httptest.NewRequest(http.MethodPut, "/api/v2/auth/password", nil)); role != "viewer" {
t.Fatalf("password change should allow authenticated customers, required role=%s", role)
}
}
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
if role := requiredRole(req); role != "viewer" {
t.Fatalf("%s should allow authenticated customers, required role=%s", path, role)
}
if menu := requiredMenu(req); menu != "statistics" {
t.Fatalf("%s should use statistics menu scope, required menu=%s", path, menu)
}
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) { func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{ cases := []config.Config{
{AuthMode: "enforce"}, {AuthMode: "enforce"},

View File

@@ -0,0 +1,215 @@
package app
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
type oneOSIdentity struct {
Subject string `json:"subject"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
TenantID string `json:"tenantId"`
DepartmentIDs []string `json:"departmentIds"`
DepartmentNames string `json:"departmentNames"`
Permissions []string `json:"permissions"`
ScopeLevel string `json:"scopeLevel"`
ResponsibleUserID string `json:"responsibleUserId"`
Audience string `json:"audience"`
ReturnTo string `json:"returnTo"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type oneOSIntrospectionEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data oneOSIdentity `json:"data"`
}
type oneOSIdentityAdapter struct {
endpoint *url.URL
serviceToken string
signingKey []byte
audience string
defaultMenus []string
client *http.Client
}
const oneOSIntrospectionCanonicalPath = "/inner/v1/sso/vehicle-platform/introspect"
func newOneOSIdentityAdapter(cfg config.Config) (*oneOSIdentityAdapter, error) {
if !cfg.OneOSSSOEnabled {
return nil, nil
}
rawEndpoint := strings.TrimSpace(cfg.OneOSIntrospectionURL)
endpoint, err := url.Parse(rawEndpoint)
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" {
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must be an absolute HTTP(S) URL")
}
if endpoint.Scheme != "https" && !isLoopbackHost(endpoint.Hostname()) {
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must use HTTPS outside localhost")
}
serviceToken := strings.TrimSpace(cfg.OneOSServiceToken)
signingSecret := strings.TrimSpace(cfg.OneOSSigningSecret)
if len(serviceToken) < 24 {
return nil, fmt.Errorf("ONEOS_SSO_SERVICE_TOKEN must contain at least 24 characters")
}
if len(signingSecret) < 32 {
return nil, fmt.Errorf("ONEOS_SSO_SIGNING_SECRET must contain at least 32 characters")
}
audience := strings.TrimSpace(cfg.OneOSAudience)
if audience == "" {
audience = "vehicle-platform"
}
timeout := cfg.OneOSRequestTimeout
if timeout <= 0 {
timeout = 3 * time.Second
}
defaultMenus := make([]string, 0, len(cfg.OneOSDefaultMenus))
for _, menu := range normalizeStringList(cfg.OneOSDefaultMenus, 4) {
if customerMenuSet[menu] {
defaultMenus = append(defaultMenus, menu)
}
}
if len(defaultMenus) == 0 {
defaultMenus = []string{"vehicles"}
}
return &oneOSIdentityAdapter{
endpoint: endpoint, serviceToken: serviceToken, signingKey: []byte(signingSecret),
audience: audience, defaultMenus: defaultMenus, client: &http.Client{Timeout: timeout},
}, nil
}
func (a *oneOSIdentityAdapter) ExchangeTicket(ctx context.Context, ticket string) (oneOSIdentity, error) {
ticket = strings.TrimSpace(ticket)
if len(ticket) < 32 || len(ticket) > 256 {
return oneOSIdentity{}, fmt.Errorf("invalid ticket")
}
body, err := json.Marshal(struct {
Ticket string `json:"ticket"`
Audience string `json:"audience"`
}{Ticket: ticket, Audience: a.audience})
if err != nil {
return oneOSIdentity{}, err
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
requestID, err := secureRequestID()
if err != nil {
return oneOSIdentity{}, err
}
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
mac := hmac.New(sha256.New, a.signingKey)
_, _ = mac.Write([]byte(canonical))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint.String(), bytes.NewReader(body))
if err != nil {
return oneOSIdentity{}, err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Service "+a.serviceToken)
request.Header.Set("X-OneOS-Timestamp", timestamp)
request.Header.Set("X-OneOS-Request-Id", requestID)
request.Header.Set("X-OneOS-Signature", hex.EncodeToString(mac.Sum(nil)))
response, err := a.client.Do(request)
if err != nil {
return oneOSIdentity{}, fmt.Errorf("call OneOS introspection: %w", err)
}
defer response.Body.Close()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return oneOSIdentity{}, fmt.Errorf("read OneOS introspection: %w", err)
}
var envelope oneOSIntrospectionEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil {
return oneOSIdentity{}, fmt.Errorf("decode OneOS introspection response: %w", err)
}
if response.StatusCode != http.StatusOK || envelope.Code != 200 {
return oneOSIdentity{}, fmt.Errorf("OneOS rejected ticket: status=%d code=%d message=%s", response.StatusCode, envelope.Code, envelope.Msg)
}
identity := envelope.Data
if strings.TrimSpace(identity.Subject) == "" || strings.TrimSpace(identity.Username) == "" {
return oneOSIdentity{}, fmt.Errorf("OneOS identity is incomplete")
}
if !hmac.Equal([]byte(identity.Audience), []byte(a.audience)) {
return oneOSIdentity{}, fmt.Errorf("OneOS identity audience mismatch")
}
now := time.Now()
if identity.ExpiresAt.IsZero() || !identity.ExpiresAt.After(now) || identity.IssuedAt.After(now.Add(time.Minute)) {
return oneOSIdentity{}, fmt.Errorf("OneOS ticket has expired or has an invalid issue time")
}
switch identity.ScopeLevel {
case "department":
if len(normalizeStringList(identity.DepartmentIDs, 100)) == 0 {
return oneOSIdentity{}, fmt.Errorf("OneOS department scope has no department")
}
case "responsible":
if strings.TrimSpace(identity.ResponsibleUserID) == "" {
return oneOSIdentity{}, fmt.Errorf("OneOS responsible scope has no responsible user")
}
default:
return oneOSIdentity{}, fmt.Errorf("OneOS identity has unsupported scope")
}
identity.DepartmentIDs = normalizeStringList(identity.DepartmentIDs, 100)
identity.ReturnTo = safePlatformReturnTo(identity.ReturnTo)
return identity, nil
}
func secureRequestID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}
func normalizeStringList(values []string, limit int) []string {
result := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] || len(result) >= limit {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func safePlatformReturnTo(value string) string {
value = strings.TrimSpace(value)
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") {
return "/vehicles"
}
for _, prefix := range []string{"/vehicles", "/monitor", "/tracks", "/statistics"} {
if value == prefix || strings.HasPrefix(value, prefix+"/") || strings.HasPrefix(value, prefix+"?") {
return value
}
}
return "/vehicles"
}
func isLoopbackHost(host string) bool {
switch strings.ToLower(strings.TrimSpace(host)) {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}

View File

@@ -0,0 +1,96 @@
package app
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestOneOSIdentityAdapterSignsAndValidatesIntrospection(t *testing.T) {
const serviceToken = "service-token-with-more-than-24-characters"
const signingSecret = "signing-secret-with-at-least-32-characters"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
timestamp := r.Header.Get("X-OneOS-Timestamp")
requestID := r.Header.Get("X-OneOS-Request-Id")
bodyHash := sha256.Sum256(body)
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
mac := hmac.New(sha256.New, []byte(signingSecret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("Authorization") != "Service "+serviceToken {
t.Errorf("unexpected service authorization")
}
if !hmac.Equal([]byte(r.Header.Get("X-OneOS-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
t.Errorf("unexpected HMAC signature")
}
_ = json.NewEncoder(w).Encode(map[string]any{
"code": 200,
"msg": "操作成功",
"data": map[string]any{
"subject": "50008", "username": "seller", "displayName": "业务人员",
"tenantId": "000000", "departmentIds": []string{"40002", "40001", "40001"},
"scopeLevel": "responsible", "responsibleUserId": "50008",
"audience": "vehicle-platform", "returnTo": "https://evil.example/",
"issuedAt": time.Now().Add(-time.Second), "expiresAt": time.Now().Add(time.Minute),
},
})
}))
defer server.Close()
adapter, err := newOneOSIdentityAdapter(config.Config{
OneOSSSOEnabled: true,
OneOSIntrospectionURL: server.URL + "/auth" + oneOSIntrospectionCanonicalPath,
OneOSServiceToken: serviceToken,
OneOSSigningSecret: signingSecret,
OneOSAudience: "vehicle-platform",
OneOSDefaultMenus: []string{"vehicles", "users", "vehicles"},
})
if err != nil {
t.Fatal(err)
}
identity, err := adapter.ExchangeTicket(context.Background(), strings.Repeat("a", 64))
if err != nil {
t.Fatal(err)
}
if identity.Subject != "50008" || identity.ReturnTo != "/vehicles" || strings.Join(identity.DepartmentIDs, ",") != "40002,40001" {
t.Fatalf("unexpected identity: %+v", identity)
}
if strings.Join(adapter.defaultMenus, ",") != "vehicles" {
t.Fatalf("unexpected menus: %v", adapter.defaultMenus)
}
}
func TestOneOSIdentityAdapterRequiresHTTPSOutsideLoopback(t *testing.T) {
_, err := newOneOSIdentityAdapter(config.Config{
OneOSSSOEnabled: true,
OneOSIntrospectionURL: "http://oneos.example.com/auth" + oneOSIntrospectionCanonicalPath,
OneOSServiceToken: strings.Repeat("t", 24),
OneOSSigningSecret: strings.Repeat("s", 32),
})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("expected HTTPS validation error, got %v", err)
}
}
func TestSafePlatformReturnToOnlyAllowsKnownApplicationRoutes(t *testing.T) {
for input, expected := range map[string]string{
"/tracks?vin=VIN001": "/tracks?vin=VIN001",
"//evil.example": "/vehicles",
"https://evil.test": "/vehicles",
"/users": "/vehicles",
} {
if actual := safePlatformReturnTo(input); actual != expected {
t.Fatalf("safePlatformReturnTo(%q)=%q want %q", input, actual, expected)
}
}
}

View File

@@ -12,6 +12,7 @@ import (
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -19,6 +20,7 @@ import (
"lingniu/vehicle-data-platform/apps/api/internal/config" "lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/httpx" "lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
"lingniu/vehicle-data-platform/apps/api/internal/platform" "lingniu/vehicle-data-platform/apps/api/internal/platform"
"lingniu/vehicle-data-platform/apps/api/internal/static" "lingniu/vehicle-data-platform/apps/api/internal/static"
) )
@@ -72,7 +74,12 @@ func NewServer(cfg config.Config) http.Handler {
log.Printf("production mysql store enabled") log.Printf("production mysql store enabled")
} }
} }
var api http.Handler = platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{ host, _ := os.Hostname()
workerHost := strings.TrimSpace(host)
if workerHost == "" {
workerHost = "api"
}
platformService := platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
DataMode: dataMode, DataMode: dataMode,
ExportDir: strings.TrimSpace(cfg.ExportDir), ExportDir: strings.TrimSpace(cfg.ExportDir),
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond), RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
@@ -84,13 +91,30 @@ func NewServer(cfg config.Config) http.Handler {
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease), PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode), AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode),
AlertStreamConsumerGroup: strings.TrimSpace(cfg.AlertStreamKafkaGroup), AlertStreamConsumerGroup: strings.TrimSpace(cfg.AlertStreamKafkaGroup),
})) AlertNotificationConfig: alertNotificationConfig(cfg, dataMode),
HistoryCleanupAutomation: cfg.HistoryCleanupAutomation,
HistoryCleanupPoll: cfg.HistoryCleanupPollInterval,
HistoryCleanupLease: cfg.HistoryCleanupLease,
HistoryCleanupWorkerID: "cleanup-scheduler-" + workerHost,
})
platformService.StartHistoryExportCleanupAutomation(context.Background())
var api http.Handler = platform.NewHandler(platformService)
if storeErr != nil { if storeErr != nil {
log.Printf("platform data store unavailable: %v", storeErr) log.Printf("platform data store unavailable: %v", storeErr)
api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r)) httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r))
}) })
} }
if authDB != nil && storeErr == nil {
openPlatformHandler := openplatform.NewAdminHandler(
openplatform.NewService(openplatform.NewMySQLRepository(authDB)),
openplatform.NewPortalService(authDB, cfg.SessionTTL),
)
internalMux := http.NewServeMux()
internalMux.Handle("/api/v2/open-platform/", openPlatformHandler)
internalMux.Handle("/", api)
api = internalMux
}
// Reverse geocoding consumes the server-side AMap credential, so it must // Reverse geocoding consumes the server-side AMap credential, so it must
// stay behind the same authentication boundary as the platform API. // stay behind the same authentication boundary as the platform API.
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient) api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
@@ -100,6 +124,32 @@ func NewServer(cfg config.Config) http.Handler {
return withRequestTimeout(handler, cfg.RequestTimeout) return withRequestTimeout(handler, cfg.RequestTimeout)
} }
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
channels := []platform.AlertNotificationChannelCapability{
{Channel: "in_app", Label: "站内信", Configured: true},
{Channel: "sms", Label: "短信", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationSMSURL) != "" && strings.TrimSpace(cfg.AlertNotificationSMSSecret) != ""},
{Channel: "email", Label: "邮件", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationEmailURL) != "" && strings.TrimSpace(cfg.AlertNotificationEmailSecret) != ""},
{Channel: "wecom", Label: "企业通讯", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationWeComURL) != "" && strings.TrimSpace(cfg.AlertNotificationWeComSecret) != ""},
}
targets := []platform.AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}
raw := strings.TrimSpace(cfg.AlertNotificationTargetsJSON)
if raw == "" && dataMode == "mock" {
targets = []platform.AlertNotificationTargetOption{
{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app", "email", "wecom"}},
{ID: "night-shift", Label: "夜班负责人", Channels: []string{"in_app", "sms", "wecom"}},
{ID: "data-platform", Label: "数据平台负责人", Channels: []string{"in_app", "email", "wecom"}},
}
} else if raw != "" {
var configured []platform.AlertNotificationTargetOption
if err := json.Unmarshal([]byte(raw), &configured); err != nil {
log.Printf("alert notification target catalog ignored: %v", err)
} else {
targets = configured
}
}
return platform.NormalizeAlertNotificationConfig(platform.AlertNotificationConfig{Targets: targets, Channels: channels})
}
func withAppConfig(next http.Handler, cfg config.Config) http.Handler { func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
type appConfig struct { type appConfig struct {
AMapWebJSKey string `json:"amapWebJsKey,omitempty"` AMapWebJSKey string `json:"amapWebJsKey,omitempty"`

View File

@@ -44,6 +44,40 @@ func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
} }
} }
func TestAlertNotificationConfigExposesOnlyReadyChannelsAndTargetReferences(t *testing.T) {
cfg := config.Config{
AlertNotificationTargetsJSON: `[{"id":"night-shift","label":"夜班负责人","channels":["sms","wecom"]}]`,
AlertNotificationSMSURL: "https://gateway.example.test/sms",
AlertNotificationSMSSecret: "signing-secret",
AlertNotificationEmailURL: "https://gateway.example.test/email",
}
result := alertNotificationConfig(cfg, "production")
if len(result.Channels) != 4 || !result.Channels[0].Configured || !result.Channels[1].Configured || result.Channels[2].Configured || result.Channels[3].Configured {
t.Fatalf("gateway readiness must require both endpoint and secret: %+v", result.Channels)
}
if len(result.Targets) != 2 || result.Targets[0].ID != "platform-operators" || result.Targets[1].ID != "night-shift" {
t.Fatalf("target catalog was not normalized: %+v", result.Targets)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "signing-secret") || strings.Contains(string(encoded), "gateway.example.test") {
t.Fatalf("public notification config leaked gateway credentials: %s", encoded)
}
}
func TestOpenPlatformDocsAreServedOnlyByStandaloneService(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
for _, path := range []string{"/open-api/docs/", "/open-api/swagger/", "/open-api/openapi.yaml"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("internal service unexpectedly served standalone docs: path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
}
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) { func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done() <-r.Context().Done()
@@ -380,3 +414,20 @@ func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) {
t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String())
} }
} }
func TestServerDoesNotExposeOpenPlatformPublicDataRoutes(t *testing.T) {
handler := NewServer(config.Config{
AuthMode: "enforce",
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
DataMode: "mock",
RequestTimeout: time.Second,
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/vehicles/hydrogen-consumption/query", nil)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("internal server should not expose anonymous open-platform data routes: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -0,0 +1,343 @@
package businessscope
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
apiPageLimit = 500
apiMaxPages = 100
apiMaxRows = 50000
apiMaxBodyBytes = 8 << 20
apiSourcePrefix = "oneos-api-v1:"
)
type APIConfig struct {
URL string
ServiceToken string
SigningSecret string
MaxAttempts int
HTTPClient *http.Client
Now func() time.Time
}
type apiEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data apiSnapshotPage `json:"data"`
RequestID string `json:"requestId"`
}
type apiSnapshotPage struct {
ScopeVersion string `json:"scopeVersion"`
GeneratedAt string `json:"generatedAt"`
Complete bool `json:"complete"`
NextCursor string `json:"nextCursor"`
Items []apiScopeItem `json:"items"`
Rejected []apiRejected `json:"rejected"`
}
type apiScopeItem struct {
VehicleID json.RawMessage `json:"vehicleId"`
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
CustomerID json.RawMessage `json:"customerId"`
CustomerName string `json:"customerName"`
ContractID json.RawMessage `json:"contractId"`
ContractCode string `json:"contractCode"`
ProjectName string `json:"projectName"`
DepartmentID json.RawMessage `json:"departmentId"`
DepartmentName string `json:"departmentName"`
ResponsibleUserID json.RawMessage `json:"responsibleUserId"`
ResponsibleName string `json:"responsibleUserName"`
OperationStatus string `json:"operationStatus"`
ScopeStartAt string `json:"scopeStartAt"`
SourceUpdatedAt string `json:"sourceUpdatedAt"`
}
type apiRejected struct {
VehicleID json.RawMessage `json:"vehicleId"`
VIN string `json:"vin"`
CustomerID json.RawMessage `json:"customerId"`
ContractID json.RawMessage `json:"contractId"`
Reason string `json:"reason"`
ReasonCode string `json:"reasonCode"`
}
func ReadAPISnapshot(ctx context.Context, config APIConfig) (Snapshot, error) {
endpoint, err := url.Parse(strings.TrimSpace(config.URL))
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" {
return Snapshot{}, fmt.Errorf("ONEOS_SCOPE_API_URL must be an absolute HTTP(S) URL")
}
if endpoint.Scheme != "https" && !isPrivateAPIHost(endpoint.Hostname()) {
return Snapshot{}, fmt.Errorf("OneOS scope API must use HTTPS outside private networks")
}
if strings.TrimSpace(config.ServiceToken) == "" || strings.TrimSpace(config.SigningSecret) == "" {
return Snapshot{}, fmt.Errorf("OneOS scope API service token and signing secret are required")
}
if config.MaxAttempts <= 0 {
config.MaxAttempts = 3
}
if config.MaxAttempts > 5 {
config.MaxAttempts = 5
}
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{Timeout: 10 * time.Second}
}
if config.Now == nil {
config.Now = time.Now
}
candidates := make([]Candidate, 0, 1024)
upstreamRejections := make([]Rejection, 0)
cursor := ""
seenCursors := map[string]bool{}
scopeVersion := ""
var generatedAt time.Time
for pageNumber := 0; pageNumber < apiMaxPages; pageNumber++ {
pageURL := *endpoint
query := pageURL.Query()
query.Set("limit", strconv.Itoa(apiPageLimit))
if cursor != "" {
query.Set("cursor", cursor)
} else {
query.Del("cursor")
}
pageURL.RawQuery = query.Encode()
page, err := fetchAPIPage(ctx, config, &pageURL)
if err != nil {
return Snapshot{}, err
}
if !page.Complete {
return Snapshot{}, fmt.Errorf("OneOS scope API returned an incomplete snapshot")
}
if strings.TrimSpace(page.ScopeVersion) == "" {
return Snapshot{}, fmt.Errorf("OneOS scope API omitted scopeVersion")
}
pageGeneratedAt, err := time.Parse(time.RFC3339Nano, page.GeneratedAt)
if err != nil {
return Snapshot{}, fmt.Errorf("OneOS scope API generatedAt is invalid: %w", err)
}
if scopeVersion == "" {
scopeVersion = page.ScopeVersion
generatedAt = pageGeneratedAt
} else if page.ScopeVersion != scopeVersion || !pageGeneratedAt.Equal(generatedAt) {
return Snapshot{}, fmt.Errorf("OneOS scope API pagination changed snapshot version")
}
for _, item := range page.Items {
candidate, err := candidateFromAPI(item, len(candidates)+len(upstreamRejections)+1)
if err != nil {
return Snapshot{}, err
}
candidates = append(candidates, candidate)
}
for _, rejected := range page.Rejected {
upstreamRejections = append(upstreamRejections, rejectionFromAPI(rejected, len(candidates)+len(upstreamRejections)+1))
}
if len(candidates)+len(upstreamRejections) > apiMaxRows {
return Snapshot{}, fmt.Errorf("OneOS scope API exceeded %d rows", apiMaxRows)
}
next := strings.TrimSpace(page.NextCursor)
if next == "" {
break
}
if seenCursors[next] {
return Snapshot{}, fmt.Errorf("OneOS scope API repeated pagination cursor")
}
seenCursors[next] = true
cursor = next
if pageNumber == apiMaxPages-1 {
return Snapshot{}, fmt.Errorf("OneOS scope API exceeded %d pages", apiMaxPages)
}
}
snapshot, err := BuildSnapshot(candidates, generatedAt)
if err != nil {
return Snapshot{}, err
}
snapshot.Candidates += len(upstreamRejections)
snapshot.Rejections = append(snapshot.Rejections, upstreamRejections...)
versionHash := sha256.Sum256([]byte(scopeVersion + "\n" + snapshot.Checksum))
snapshot.SourceVersion = apiSourcePrefix + hex.EncodeToString(versionHash[:])
return snapshot, nil
}
func fetchAPIPage(ctx context.Context, config APIConfig, pageURL *url.URL) (apiSnapshotPage, error) {
var lastErr error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
requestID, err := randomHex(16)
if err != nil {
return apiSnapshotPage{}, err
}
timestamp := strconv.FormatInt(config.Now().UTC().Unix(), 10)
canonical := http.MethodGet + "\n" + pageURL.RequestURI() + "\n" + timestamp + "\n" + requestID
mac := hmac.New(sha256.New, []byte(config.SigningSecret))
_, _ = mac.Write([]byte(canonical))
request, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL.String(), nil)
if err != nil {
return apiSnapshotPage{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Service "+config.ServiceToken)
request.Header.Set("X-Request-Id", requestID)
request.Header.Set("X-Request-Timestamp", timestamp)
request.Header.Set("X-Request-Signature", hex.EncodeToString(mac.Sum(nil)))
response, err := config.HTTPClient.Do(request)
if err != nil {
lastErr = err
} else {
page, retry, responseErr := decodeAPIResponse(response)
if responseErr == nil {
return page, nil
}
lastErr = responseErr
if !retry {
return apiSnapshotPage{}, responseErr
}
}
if attempt < config.MaxAttempts {
select {
case <-ctx.Done():
return apiSnapshotPage{}, ctx.Err()
case <-time.After(time.Duration(attempt*100) * time.Millisecond):
}
}
}
return apiSnapshotPage{}, fmt.Errorf("OneOS scope API failed after %d attempts: %w", config.MaxAttempts, lastErr)
}
func decodeAPIResponse(response *http.Response) (apiSnapshotPage, bool, error) {
defer response.Body.Close()
retry := response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return apiSnapshotPage{}, retry, fmt.Errorf("OneOS scope API returned HTTP %d", response.StatusCode)
}
var envelope apiEnvelope
decoder := json.NewDecoder(io.LimitReader(response.Body, apiMaxBodyBytes+1))
if err := decoder.Decode(&envelope); err != nil {
return apiSnapshotPage{}, false, fmt.Errorf("decode OneOS scope API response: %w", err)
}
if envelope.Code != 0 {
return apiSnapshotPage{}, false, fmt.Errorf("OneOS scope API returned business code %d", envelope.Code)
}
return envelope.Data, false, nil
}
func candidateFromAPI(item apiScopeItem, row int) (Candidate, error) {
vehicleID, err := requiredInt64ID(item.VehicleID, "vehicleId")
if err != nil {
return Candidate{}, err
}
customerID, err := requiredInt64ID(item.CustomerID, "customerId")
if err != nil {
return Candidate{}, err
}
contractID, err := requiredInt64ID(item.ContractID, "contractId")
if err != nil {
return Candidate{}, err
}
scopeStart, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(item.ScopeStartAt))
if err != nil {
return Candidate{}, fmt.Errorf("OneOS scope API item %d has invalid scopeStartAt", row)
}
var sourceUpdatedAt *time.Time
if value := strings.TrimSpace(item.SourceUpdatedAt); value != "" {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return Candidate{}, fmt.Errorf("OneOS scope API item %d has invalid sourceUpdatedAt", row)
}
sourceUpdatedAt = &parsed
}
return Candidate{
RowNumber: row, VehicleID: vehicleID, VehiclePresent: true,
VIN: item.VIN, PlateNumber: item.PlateNumber,
CustomerID: customerID, CustomerName: item.CustomerName, CustomerPresent: true, CustomerProfileExists: true,
ContractID: contractID, ContractPresent: true, ContractProfileExists: true,
EffectiveCustomerID: customerID, EffectiveCustomerSet: true,
ContractCode: item.ContractCode, ProjectName: item.ProjectName,
DepartmentID: rawIDString(item.DepartmentID), DepartmentName: item.DepartmentName,
ResponsibleUserID: rawIDString(item.ResponsibleUserID), ResponsibleUserName: item.ResponsibleName,
OperationStatus: item.OperationStatus, ScopeStartAt: scopeStart, SourceUpdatedAt: sourceUpdatedAt,
}, nil
}
func rejectionFromAPI(item apiRejected, row int) Rejection {
reason := strings.ToUpper(strings.TrimSpace(firstNonEmptyScope(item.ReasonCode, item.Reason)))
if reason == "" || len(reason) > 64 {
reason = "UPSTREAM_REJECTED"
}
rejection := Rejection{RowNumber: row, VIN: normalizeVIN(item.VIN), ReasonCode: reason}
if value, err := optionalInt64ID(item.VehicleID); err == nil && value > 0 {
rejection.VehicleID = int64Pointer(value)
}
if value, err := optionalInt64ID(item.CustomerID); err == nil && value > 0 {
rejection.CustomerID = int64Pointer(value)
}
if value, err := optionalInt64ID(item.ContractID); err == nil && value > 0 {
rejection.ContractID = int64Pointer(value)
}
return rejection
}
func requiredInt64ID(raw json.RawMessage, name string) (int64, error) {
value, err := optionalInt64ID(raw)
if err != nil || value <= 0 {
return 0, fmt.Errorf("OneOS scope API %s must be a positive integer string", name)
}
return value, nil
}
func optionalInt64ID(raw json.RawMessage) (int64, error) {
value := rawIDString(raw)
if value == "" {
return 0, nil
}
return strconv.ParseInt(value, 10, 64)
}
func rawIDString(raw json.RawMessage) string {
value := strings.TrimSpace(string(raw))
if value == "" || value == "null" {
return ""
}
if strings.HasPrefix(value, `"`) {
var decoded string
if json.Unmarshal(raw, &decoded) == nil {
return strings.TrimSpace(decoded)
}
return ""
}
return value
}
func isPrivateAPIHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "localhost" {
return true
}
address := net.ParseIP(host)
return address != nil && (address.IsPrivate() || address.IsLoopback())
}
func firstNonEmptyScope(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,99 @@
package businessscope
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestReadAPISnapshotAuthenticatesPaginatesAndPreservesDimensions(t *testing.T) {
const token = "service-token"
const secret = "signing-secret"
now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
if r.Header.Get("Authorization") != "Service "+token {
t.Fatalf("missing service token")
}
canonical := r.Method + "\n" + r.URL.RequestURI() + "\n" + r.Header.Get("X-Request-Timestamp") + "\n" + r.Header.Get("X-Request-Id")
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(canonical))
if r.Header.Get("X-Request-Signature") != hex.EncodeToString(mac.Sum(nil)) {
t.Fatalf("invalid request signature")
}
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("cursor") == "" {
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-42","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"page-2","items":[{"vehicleId":"10","vin":" lvin0001 ","plateNumber":"沪A00001","customerId":"100","customerName":"客户甲","contractId":"1010","contractCode":"HT-1","projectName":"项目甲","departmentId":"20","departmentName":"运营一部","responsibleUserId":"30","responsibleUserName":"张三","operationStatus":"active","scopeStartAt":"2026-07-01T08:00:00+08:00","sourceUpdatedAt":"2026-07-16T09:59:00Z"}],"rejected":[]}}`)
return
}
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-42","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"","items":[{"vehicleId":"11","vin":"LVIN0002","plateNumber":"沪A00002","customerId":"100","customerName":"客户甲","contractId":"1011","contractCode":"HT-2","projectName":"项目甲","departmentId":"20","departmentName":"运营一部","responsibleUserId":"31","responsibleUserName":"李四","operationStatus":"active","scopeStartAt":"2026-07-02T08:00:00+08:00"}],"rejected":[{"vehicleId":"12","vin":"","customerId":"100","contractId":"1012","reasonCode":"VIN_MISSING"}]}}`)
}))
defer server.Close()
snapshot, err := ReadAPISnapshot(context.Background(), APIConfig{
URL: server.URL, ServiceToken: token, SigningSecret: secret,
MaxAttempts: 1, HTTPClient: server.Client(), Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if requests.Load() != 2 || snapshot.Candidates != 3 || len(snapshot.Items) != 2 || len(snapshot.Rejections) != 1 {
t.Fatalf("unexpected API snapshot: requests=%d snapshot=%+v", requests.Load(), snapshot)
}
if !strings.HasPrefix(snapshot.SourceVersion, apiSourcePrefix) || len(snapshot.SourceVersion) > 96 {
t.Fatalf("unexpected source version %q", snapshot.SourceVersion)
}
item := snapshot.Items[0]
if item.VIN != "LVIN0001" || item.CustomerName != "客户甲" || item.DepartmentName != "运营一部" ||
item.ResponsibleUserName != "张三" {
t.Fatalf("business dimensions lost: %+v", item)
}
}
func TestReadAPISnapshotFailsClosedOnVersionDrift(t *testing.T) {
var request int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request++
version := "scope-1"
cursor := "next"
if request == 2 {
version = "scope-2"
cursor = ""
}
fmt.Fprintf(w, `{"code":0,"data":{"scopeVersion":%q,"generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":%q,"items":[],"rejected":[]}}`, version, cursor)
}))
defer server.Close()
_, err := ReadAPISnapshot(context.Background(), APIConfig{
URL: server.URL, ServiceToken: "token", SigningSecret: "secret", MaxAttempts: 1, HTTPClient: server.Client(),
})
if err == nil || !strings.Contains(err.Error(), "changed snapshot version") {
t.Fatalf("version drift must fail closed: %v", err)
}
}
func TestReadAPISnapshotRetriesOnlyTransientHTTPFailure(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requests.Add(1) == 1 {
http.Error(w, "temporary", http.StatusServiceUnavailable)
return
}
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-1","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"","items":[{"vehicleId":"10","vin":"LVIN0001","customerId":"100","contractId":"1010","scopeStartAt":"2026-07-01T00:00:00+08:00"}],"rejected":[]}}`)
}))
defer server.Close()
snapshot, err := ReadAPISnapshot(context.Background(), APIConfig{
URL: server.URL, ServiceToken: "token", SigningSecret: "secret", MaxAttempts: 2, HTTPClient: server.Client(),
})
if err != nil || requests.Load() != 2 || len(snapshot.Items) != 1 {
t.Fatalf("transient retry failed: requests=%d snapshot=%+v err=%v", requests.Load(), snapshot, err)
}
}

View File

@@ -33,6 +33,7 @@ type Candidate struct {
VIN string VIN string
PlateNumber string PlateNumber string
CustomerID int64 CustomerID int64
CustomerName string
CustomerPresent bool CustomerPresent bool
CustomerProfileExists bool CustomerProfileExists bool
ContractID int64 ContractID int64
@@ -42,22 +43,31 @@ type Candidate struct {
EffectiveCustomerSet bool EffectiveCustomerSet bool
ContractCode string ContractCode string
ProjectName string ProjectName string
DepartmentID string
DepartmentName string
ResponsibleUserID string
ResponsibleUserName string
OperationStatus string OperationStatus string
ScopeStartAt time.Time ScopeStartAt time.Time
SourceUpdatedAt *time.Time SourceUpdatedAt *time.Time
} }
type ScopeItem struct { type ScopeItem struct {
VehicleID int64 VehicleID int64
VIN string VIN string
PlateNumber string PlateNumber string
CustomerID int64 CustomerID int64
ContractID int64 CustomerName string
ContractCode string ContractID int64
ProjectName string ContractCode string
OperationStatus string ProjectName string
ScopeStartAt time.Time DepartmentID string
SourceUpdatedAt *time.Time DepartmentName string
ResponsibleUserID string
ResponsibleUserName string
OperationStatus string
ScopeStartAt time.Time
SourceUpdatedAt *time.Time
} }
type Rejection struct { type Rejection struct {
@@ -119,8 +129,10 @@ func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, err
} }
items = append(items, ScopeItem{ items = append(items, ScopeItem{
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber), VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
CustomerID: candidate.CustomerID, ContractID: candidate.ContractID, CustomerID: candidate.CustomerID, CustomerName: strings.TrimSpace(candidate.CustomerName), ContractID: candidate.ContractID,
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName), ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
DepartmentID: strings.TrimSpace(candidate.DepartmentID), DepartmentName: strings.TrimSpace(candidate.DepartmentName),
ResponsibleUserID: strings.TrimSpace(candidate.ResponsibleUserID), ResponsibleUserName: strings.TrimSpace(candidate.ResponsibleUserName),
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt, OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
SourceUpdatedAt: candidate.SourceUpdatedAt, SourceUpdatedAt: candidate.SourceUpdatedAt,
}) })

View File

@@ -61,8 +61,9 @@ run_id,source_system,source_version,status,candidate_count,accepted_count,reject
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err) return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
} }
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope( statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope(
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`) department_id,department_name,responsible_user_id,responsible_user_name,operation_status,scope_start_at,source_updated_at,published_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil { if err != nil {
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err) return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
} }
@@ -72,15 +73,16 @@ source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number
if item.ContractID > 0 { if item.ContractID > 0 {
contractID = item.ContractID contractID = item.ContractID
} }
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID, if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, contractID,
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil { item.ContractCode, item.PlateNumber, item.ProjectName, item.DepartmentID, item.DepartmentName,
item.ResponsibleUserID, item.ResponsibleUserName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err) return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
} }
} }
} }
if len(snapshot.Rejections) > 0 { if len(snapshot.Rejections) > 0 {
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection( statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection(
run_id,row_number,vehicle_id,vin,customer_id,contract_id,reason_code,created_at run_id,`+"`row_number`"+`,vehicle_id,vin,customer_id,contract_id,reason_code,created_at
) VALUES(?,?,?,?,?,?,?,?)`) ) VALUES(?,?,?,?,?,?,?,?)`)
if err != nil { if err != nil {
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err) return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)

View File

@@ -11,8 +11,9 @@ import (
) )
const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope( const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope(
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)` department_id,department_name,responsible_user_id,responsible_user_name,operation_status,scope_start_at,source_updated_at,published_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
func publishSnapshot(version string) Snapshot { func publishSnapshot(version string) Snapshot {
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC) generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
@@ -21,8 +22,9 @@ func publishSnapshot(version string) Snapshot {
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
GeneratedAt: generatedAt, Candidates: 1, GeneratedAt: generatedAt, Candidates: 1,
Items: []ScopeItem{{ Items: []ScopeItem{{
VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100, VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100, CustomerName: "示例客户",
ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1", ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
DepartmentID: "20", DepartmentName: "运营一部", ResponsibleUserID: "30", ResponsibleUserName: "负责人",
ScopeStartAt: generatedAt.Add(-time.Hour), ScopeStartAt: generatedAt.Add(-time.Hour),
}}, }},
} }
@@ -47,8 +49,9 @@ func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) {
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL)) prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
item := snapshot.Items[0] item := snapshot.Items[0]
prepared.ExpectExec().WithArgs( prepared.ExpectExec().WithArgs(
snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID, snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, item.ContractID,
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(), item.ContractCode, item.PlateNumber, item.ProjectName, item.DepartmentID, item.DepartmentName,
item.ResponsibleUserID, item.ResponsibleUserName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
).WillReturnResult(sqlmock.NewResult(1, 1)) ).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`UPDATE business_scope_state SET`). mock.ExpectExec(`UPDATE business_scope_state SET`).
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()). WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).

View File

@@ -24,6 +24,35 @@ const candidateQuery = `SELECT
COALESCE(co.other_customer_id, co.customer_id), COALESCE(co.other_customer_id, co.customer_id),
COALESCE(co.contract_code, r.contract_code, ''), COALESCE(co.contract_code, r.contract_code, ''),
COALESCE(co.project_name, r.project_name, ''), COALESCE(co.project_name, r.project_name, ''),
COALESCE(ci.customer_name, r.customer_name, ''),
COALESCE(CAST(
CASE WHEN co.other_customer_id IS NOT NULL
THEN co.other_business_department_id
ELSE co.business_department_id
END AS CHAR
), ''),
COALESCE(
CASE WHEN co.other_customer_id IS NOT NULL
THEN co.other_business_department_name
ELSE co.business_department_name
END,
r.business_dept,
''
),
COALESCE(CAST(
CASE WHEN co.other_customer_id IS NOT NULL
THEN co.other_business_manager_id
ELSE co.business_manager_id
END AS CHAR
), ''),
COALESCE(
CASE WHEN co.other_customer_id IS NOT NULL
THEN co.other_business_manager_name
ELSE co.business_manager_name
END,
r.business_manager,
''
),
COALESCE(vs.operation_status, ''), COALESCE(vs.operation_status, ''),
dv.delivery_time, dv.delivery_time,
dv.update_time dv.update_time
@@ -89,12 +118,14 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
for rows.Next() { for rows.Next() {
var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64 var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64
var contractID, contractProfileID, effectiveCustomerID sql.NullInt64 var contractID, contractProfileID, effectiveCustomerID sql.NullInt64
var vin, plate, contractCode, projectName, operationStatus string var vin, plate, contractCode, projectName, customerName string
var departmentID, departmentName, responsibleUserID, responsibleUserName, operationStatus string
var scopeStart time.Time var scopeStart time.Time
var updatedAt sql.NullTime var updatedAt sql.NullTime
if err := rows.Scan( if err := rows.Scan(
&vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID, &vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID,
&contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName, &contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName,
&customerName, &departmentID, &departmentName, &responsibleUserID, &responsibleUserName,
&operationStatus, &scopeStart, &updatedAt, &operationStatus, &scopeStart, &updatedAt,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan OneOS scope candidate: %w", err) return nil, fmt.Errorf("scan OneOS scope candidate: %w", err)
@@ -108,8 +139,11 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
ContractID: contractID.Int64, ContractPresent: contractID.Valid, ContractID: contractID.Int64, ContractPresent: contractID.Valid,
ContractProfileExists: contractProfileID.Valid, ContractProfileExists: contractProfileID.Valid,
EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid, EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid,
ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus, CustomerName: customerName, ContractCode: contractCode, ProjectName: projectName,
ScopeStartAt: scopeStart, DepartmentID: departmentID, DepartmentName: departmentName,
ResponsibleUserID: responsibleUserID, ResponsibleUserName: responsibleUserName,
OperationStatus: operationStatus,
ScopeStartAt: scopeStart,
} }
if updatedAt.Valid { if updatedAt.Valid {
value := updatedAt.Time value := updatedAt.Time

View File

@@ -13,6 +13,11 @@ func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t
"rt.status IN (2, 3, 5)", "rt.status IN (2, 3, 5)",
"FROM delivery_vehicle newer", "FROM delivery_vehicle newer",
"LEFT JOIN vehicle_lease_order_record r", "LEFT JOIN vehicle_lease_order_record r",
"ci.customer_name",
"co.business_department_name",
"co.other_business_department_name",
"co.business_manager_name",
"co.other_business_manager_name",
} }
for _, expected := range assertions { for _, expected := range assertions {
if !strings.Contains(candidateQuery, expected) { if !strings.Contains(candidateQuery, expected) {

View File

@@ -8,76 +8,120 @@ import (
) )
type Config struct { type Config struct {
HTTPAddr string HTTPAddr string
StaticDir string StaticDir string
MySQLDSN string MySQLDSN string
RedisAddr string RedisAddr string
RedisUsername string RedisUsername string
RedisPassword string RedisPassword string
RedisDB int RedisDB int
TDengineDriver string TDengineDriver string
TDengineDSN string TDengineDSN string
TDengineDatabase string TDengineDatabase string
CapacityCheckBin string CapacityCheckBin string
AuthToken string AuthToken string
AuthMode string AuthMode string
AuthTokensJSON string AuthTokensJSON string
BootstrapAdminUsername string BootstrapAdminUsername string
BootstrapAdminPassword string BootstrapAdminPassword string
SessionTTL time.Duration SessionTTL time.Duration
DataMode string OneOSSSOEnabled bool
ExportDir string OneOSIntrospectionURL string
RequestTimeout time.Duration OneOSServiceToken string
AMapWebJSKey string OneOSSigningSecret string
AMapAPIKey string OneOSAudience string
AMapSecurityCode string OneOSDefaultMenus []string
AMapServiceHost string OneOSRequestTimeout time.Duration
PlatformRelease string OneOSSessionTTL time.Duration
AlertEvaluationInterval time.Duration DataMode string
AlertStreamMode string ExportDir string
AlertStreamKafkaBrokers []string RequestTimeout time.Duration
AlertStreamKafkaTopics []string AMapWebJSKey string
AlertStreamKafkaGroup string AMapAPIKey string
AlertStreamBatchSize int AMapSecurityCode string
AlertStreamBatchWait time.Duration AMapServiceHost string
AlertStreamLateness time.Duration PlatformRelease string
AlertEvaluationInterval time.Duration
AlertStreamMode string
AlertStreamKafkaBrokers []string
AlertStreamKafkaTopics []string
AlertStreamKafkaGroup string
AlertStreamBatchSize int
AlertStreamBatchWait time.Duration
AlertStreamLateness time.Duration
AlertNotificationTargetsJSON string
AlertNotificationSMSURL string
AlertNotificationSMSSecret string
AlertNotificationEmailURL string
AlertNotificationEmailSecret string
AlertNotificationWeComURL string
AlertNotificationWeComSecret string
AlertNotificationBatchSize int
AlertNotificationPollInterval time.Duration
AlertNotificationLease time.Duration
AlertNotificationTimeout time.Duration
HistoryCleanupAutomation bool
HistoryCleanupPollInterval time.Duration
HistoryCleanupLease time.Duration
} }
func Load() Config { func Load() Config {
return Config{ return Config{
HTTPAddr: env("HTTP_ADDR", ":20300"), HTTPAddr: env("HTTP_ADDR", ":20300"),
StaticDir: env("STATIC_DIR", ""), StaticDir: env("STATIC_DIR", ""),
MySQLDSN: os.Getenv("MYSQL_DSN"), MySQLDSN: os.Getenv("MYSQL_DSN"),
RedisAddr: os.Getenv("REDIS_ADDR"), RedisAddr: os.Getenv("REDIS_ADDR"),
RedisUsername: os.Getenv("REDIS_USERNAME"), RedisUsername: os.Getenv("REDIS_USERNAME"),
RedisPassword: os.Getenv("REDIS_PASSWORD"), RedisPassword: os.Getenv("REDIS_PASSWORD"),
RedisDB: envInt("REDIS_DB", 50), RedisDB: envInt("REDIS_DB", 50),
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"), TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
TDengineDSN: os.Getenv("TDENGINE_DSN"), TDengineDSN: os.Getenv("TDENGINE_DSN"),
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"), TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"), CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
AuthToken: os.Getenv("AUTH_TOKEN"), AuthToken: os.Getenv("AUTH_TOKEN"),
AuthMode: authMode(), AuthMode: authMode(),
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"), AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"), BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"), BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour, SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
DataMode: dataMode(), OneOSSSOEnabled: envBool("ONEOS_SSO_ENABLED", false),
ExportDir: os.Getenv("EXPORT_DIR"), OneOSIntrospectionURL: os.Getenv("ONEOS_SSO_INTROSPECTION_URL"),
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, OneOSServiceToken: os.Getenv("ONEOS_SSO_SERVICE_TOKEN"),
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"), OneOSSigningSecret: os.Getenv("ONEOS_SSO_SIGNING_SECRET"),
AMapAPIKey: os.Getenv("AMAP_API_KEY"), OneOSAudience: env("ONEOS_SSO_AUDIENCE", "vehicle-platform"),
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"), OneOSDefaultMenus: splitCSV(env("ONEOS_SSO_DEFAULT_MENUS", "vehicles")),
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"), OneOSRequestTimeout: time.Duration(envInt("ONEOS_SSO_REQUEST_TIMEOUT_MS", 3000)) * time.Millisecond,
PlatformRelease: os.Getenv("PLATFORM_RELEASE"), OneOSSessionTTL: time.Duration(envInt("ONEOS_SSO_SESSION_TTL_MINUTES", 30)) * time.Minute,
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second, DataMode: dataMode(),
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")), ExportDir: os.Getenv("EXPORT_DIR"),
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")), RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")), AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"), AMapAPIKey: os.Getenv("AMAP_API_KEY"),
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200), AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond, AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second, PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
AlertNotificationTargetsJSON: os.Getenv("ALERT_NOTIFICATION_TARGETS_JSON"),
AlertNotificationSMSURL: os.Getenv("ALERT_NOTIFICATION_SMS_URL"),
AlertNotificationSMSSecret: os.Getenv("ALERT_NOTIFICATION_SMS_SECRET"),
AlertNotificationEmailURL: os.Getenv("ALERT_NOTIFICATION_EMAIL_URL"),
AlertNotificationEmailSecret: os.Getenv("ALERT_NOTIFICATION_EMAIL_SECRET"),
AlertNotificationWeComURL: os.Getenv("ALERT_NOTIFICATION_WECOM_URL"),
AlertNotificationWeComSecret: os.Getenv("ALERT_NOTIFICATION_WECOM_SECRET"),
AlertNotificationBatchSize: envInt("ALERT_NOTIFICATION_BATCH_SIZE", 20),
AlertNotificationPollInterval: time.Duration(envInt("ALERT_NOTIFICATION_POLL_INTERVAL_MS", 1000)) * time.Millisecond,
AlertNotificationLease: time.Duration(envInt("ALERT_NOTIFICATION_LEASE_SEC", 30)) * time.Second,
AlertNotificationTimeout: time.Duration(envInt("ALERT_NOTIFICATION_TIMEOUT_MS", 5000)) * time.Millisecond,
HistoryCleanupAutomation: envBool("HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED", false),
HistoryCleanupPollInterval: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_POLL_SEC", 60)) * time.Second,
HistoryCleanupLease: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_LEASE_SEC", 300)) * time.Second,
} }
} }
@@ -142,3 +186,15 @@ func envInt(key string, fallback int) int {
} }
return value return value
} }
func envBool(key string, fallback bool) bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
switch raw {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}

View File

@@ -50,3 +50,32 @@ func TestLoadKeepsExplicitMockDataMode(t *testing.T) {
t.Fatalf("DataMode = %q, want mock", cfg.DataMode) t.Fatalf("DataMode = %q, want mock", cfg.DataMode)
} }
} }
func TestLoadReadsAlertNotificationDispatcherSettings(t *testing.T) {
t.Setenv("ALERT_NOTIFICATION_TARGETS_JSON", `[{"id":"night-shift","label":"夜班负责人","channels":["sms"]}]`)
t.Setenv("ALERT_NOTIFICATION_SMS_URL", "https://gateway.example.test/sms")
t.Setenv("ALERT_NOTIFICATION_SMS_SECRET", "signing-secret")
t.Setenv("ALERT_NOTIFICATION_BATCH_SIZE", "48")
t.Setenv("ALERT_NOTIFICATION_POLL_INTERVAL_MS", "750")
t.Setenv("ALERT_NOTIFICATION_LEASE_SEC", "45")
t.Setenv("ALERT_NOTIFICATION_TIMEOUT_MS", "8000")
cfg := Load()
if cfg.AlertNotificationTargetsJSON == "" || cfg.AlertNotificationSMSURL != "https://gateway.example.test/sms" || cfg.AlertNotificationSMSSecret != "signing-secret" {
t.Fatalf("notification target or gateway settings were not loaded: %+v", cfg)
}
if cfg.AlertNotificationBatchSize != 48 || cfg.AlertNotificationPollInterval != 750*time.Millisecond || cfg.AlertNotificationLease != 45*time.Second || cfg.AlertNotificationTimeout != 8*time.Second {
t.Fatalf("notification worker timing settings were not loaded: %+v", cfg)
}
}
func TestLoadReadsHistoryCleanupAutomationSettings(t *testing.T) {
t.Setenv("HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED", "true")
t.Setenv("HISTORY_EXPORT_CLEANUP_POLL_SEC", "45")
t.Setenv("HISTORY_EXPORT_CLEANUP_LEASE_SEC", "420")
cfg := Load()
if !cfg.HistoryCleanupAutomation || cfg.HistoryCleanupPollInterval != 45*time.Second || cfg.HistoryCleanupLease != 7*time.Minute {
t.Fatalf("history cleanup automation settings were not loaded: %+v", cfg)
}
}

View File

@@ -0,0 +1,186 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>车辆数据开放平台</title>
<style>
:root{color-scheme:light;--ink:#13213c;--muted:#60708c;--line:#dce3ed;--blue:#1768e5;--soft:#f4f7fb}
*{box-sizing:border-box}body{margin:0;font:15px/1.65 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink);background:#fff}
main{max-width:980px;margin:auto;padding:48px 24px 80px}header{padding:34px;border:1px solid var(--line);border-radius:18px;background:linear-gradient(135deg,#f5f9ff,#eef4ff)}
h1{margin:0 0 10px;font-size:34px}h2{margin:42px 0 14px;font-size:22px}h3{margin:24px 0 10px;font-size:17px}
p{margin:8px 0;color:var(--muted)}a{color:var(--blue)}nav{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}
nav a{padding:8px 13px;border:1px solid #b9cef0;border-radius:9px;text-decoration:none;background:#fff}
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}pre{overflow:auto;padding:18px;border-radius:12px;background:#101827;color:#e9f1ff;font-size:13px}
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:11px;border-bottom:1px solid var(--line);vertical-align:top}th{background:var(--soft)}
.method{display:inline-block;margin-right:8px;padding:2px 8px;border-radius:6px;background:#dff3e5;color:#136b35;font-weight:700}
.note{padding:14px 16px;border-left:4px solid var(--blue);background:var(--soft);color:var(--muted)}
</style>
</head>
<body>
<main>
<header>
<h1>车辆数据开放平台</h1>
<p>面向合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。接口使用独立的 32 位 appKey 认证。</p>
<nav>
<a href="/open-api/swagger/">Swagger 在线调试</a>
<a href="/open-api/openapi.yaml">下载 OpenAPI 3.0</a>
</nav>
</header>
<h2>认证</h2>
<pre>Authorization: Bearer &lt;32位appKey&gt;
Content-Type: application/json</pre>
<p class="note">appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。</p>
<h2>开放接口</h2>
<h3><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</h3>
<p>查询指定车辆的单日用氢量,单位 kg。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/query</h3>
<p>查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。</p>
<p class="note">以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</h3>
<p>按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。</p>
<p class="note">两个里程接口均可传 protocolPriority唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议未列出的协议完全禁用。省略字段时保持现有默认选源行为。</p>
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0累计总里程、来源协议和数据时间沿用最近有效统计updatedAt 显示上一个统计周期的计算时间。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
<h2>总里程协议口径</h2>
<table>
<thead><tr><th>protocol 唯一规范值</th><th>总里程含义</th></tr></thead>
<tbody>
<tr><td>GB32960</td><td>车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程</td></tr>
<tr><td>YUTONG_MQTT</td><td>车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报</td></tr>
<tr><td>JT808</td><td>定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程</td></tr>
</tbody>
</table>
<p class="note">protocol 不传时严格按 GB32960 &gt; YUTONG_MQTT &gt; JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。</p>
<h2>请求示例</h2>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/hydrogen-consumption/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"plateNumbers": ["粤A12345", "粤B67890"],
"date": "2026-07-01"
}'</pre>
<h3>查询全部授权车辆的单日数据</h3>
<pre>{
"date": "2026-07-01"
}</pre>
<h3>按自定义协议优先级查询单日里程</h3>
<pre>{
"plateNumbers": ["粤A12345"],
"date": "2026-07-01",
"protocolPriority": ["JT808", "GB32960", "MQTT"]
}</pre>
<h3>指定时刻总里程</h3>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/total-mileage/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"vin": "LA9GG68L2PBAF4790",
"time": "2026-07-21 09:30:00",
"protocol": "GB32960"
}'</pre>
<h3>车辆区间日里程</h3>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/mileage/range/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"protocolPriority": ["GB32960", "MQTT"],
"pageSize": 5000
}'</pre>
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor同一次分页查询的 snapshotId 保持不变。</p>
<h2>响应示例</h2>
<h3>车辆单日里程</h3>
<pre>{
"code": "SUCCESS",
"message": "success",
"data": [{
"vin": "LNB00000000000001",
"plateNumber": "粤A12345",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679,
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"sourceProtocol": "GB32960",
"status": "NORMAL"
}],
"traceId": "b7ff5582ab1a4e13bfb4f10943685599"
}</pre>
<p class="note">里程状态为 NORMAL 时dailyMileageKm、totalMileageKm、dataTime 与 updatedAt 均有值NO_DATA 时相关数据字段为 null真实零里程仍为 NORMAL。</p>
<h3>车辆区间日里程响应</h3>
<pre>{
"code": "SUCCESS",
"message": "success",
"data": [{
"vin": "LNB00000000000001",
"plateNumber": "粤A12345",
"date": "2026-07-01",
"dailyMileageKm": 182.437,
"dataTime": "2026-07-01T23:58:45+08:00",
"updatedAt": "2026-07-02T05:10:00+08:00",
"status": "NORMAL"
}],
"snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
"nextCursor": null,
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
}</pre>
<h3>车辆单日用氢量</h3>
<pre>{
"code": "SUCCESS",
"message": "success",
"data": [{
"plateNumber": "粤A12345",
"date": "2026-07-01",
"hydrogenConsumptionKg": 12.315,
"status": "NORMAL"
}],
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
}</pre>
<h3>指定时刻总里程响应</h3>
<pre>{
"code": "SUCCESS",
"message": "success",
"data": {
"vin": "LA9GG68L2PBAF4790",
"queryTime": "2026-07-21 09:30:00",
"totalMileageKm": 12345.678,
"protocol": "GB32960",
"protocolInput": "GB32960",
"mileageMeaning": "车辆仪表盘累计总里程GB/T 32960整车数据累计里程",
"recordTime": "2026-07-21 09:29:45",
"timeDifferenceSeconds": 15,
"selectionPolicy": "GB32960 &gt; YUTONG_MQTT &gt; JT808",
"status": "NORMAL"
},
"traceId": "95bddca78133474fa2bf56ecdf758e22"
}</pre>
<h2>状态与错误码</h2>
<table>
<thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
<tbody>
<tr><td>200</td><td>SUCCESS</td><td>查询成功;无统计数据的车辆以 NO_DATA 返回</td></tr>
<tr><td>400</td><td>INVALID_REQUEST</td><td>请求格式、车牌或数量不正确</td></tr>
<tr><td>400</td><td>INVALID_DATE_FORMAT</td><td>日期不是 yyyy-MM-dd</td></tr>
<tr><td>400</td><td>INVALID_DATETIME_FORMAT</td><td>时间不是 yyyy-MM-dd HH:mm:ss</td></tr>
<tr><td>401</td><td>UNAUTHORIZED</td><td>appKey 不存在、停用或过期</td></tr>
<tr><td>403</td><td>FORBIDDEN</td><td>Key 或车辆授权未覆盖查询自然日</td></tr>
<tr><td>500</td><td>INTERNAL_ERROR</td><td>服务内部异常</td></tr>
</tbody>
</table>
</main>
</body>
</html>

View File

@@ -0,0 +1,743 @@
openapi: 3.0.3
info:
title: 车辆数据开放平台 API
version: 1.5.0
license:
name: Proprietary
description: |
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
appKey 和逐车授权必须完整覆盖查询自然日。
servers:
- url: /
description: 当前服务
tags:
- name: 合作方数据接口
description: 使用 appKey 查询已授权车辆的日统计数据
- name: 开放平台管理
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
paths:
/api/v1/vehicles/hydrogen-consumption/query:
post:
tags: [合作方数据接口]
summary: 查询车辆单日用氢量
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
operationId: queryDailyHydrogenConsumption
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VehicleDailyQuery'
example:
date: '2026-07-01'
responses:
'200':
description: 查询成功;无数据车辆仍保留在结果中
content:
application/json:
schema:
$ref: '#/components/schemas/HydrogenQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/mileage/query:
post:
tags: [合作方数据接口]
summary: 查询车辆单日里程
description: |
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。
当日无有效里程时,日里程补 0累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计updatedAt 仍为上一统计周期的计算时间。
operationId: queryDailyMileage
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MileageDailyQuery'
example:
date: '2026-07-01'
protocolPriority: [GB32960, MQTT, JT808]
responses:
'200':
description: 查询成功;无数据车辆仍保留在结果中
content:
application/json:
schema:
$ref: '#/components/schemas/MileageQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/mileage/range/query:
post:
tags: [合作方数据接口]
summary: 分页查询车辆区间日里程
description: |
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
首次请求返回 snapshotId 和 nextCursor后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
某日无有效里程时dailyMileageKm 补 0其余里程证据沿用此前最近的有效统计。
operationId: queryDailyMileageRange
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MileageRangeQuery'
example:
startDate: '2026-07-01'
endDate: '2026-07-23'
plateNumbers: [沪A00001, 沪A00002]
protocolPriority: [JT808, GB32960, MQTT]
cursor: null
pageSize: 5000
responses:
'200':
description: 查询成功;每辆授权车辆每天均有一条 NORMAL 或 NO_DATA 记录
content:
application/json:
schema:
$ref: '#/components/schemas/MileageRangeQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v1/vehicles/total-mileage/query:
post:
tags: [合作方数据接口]
summary: 查询指定时刻的车辆总里程
description: |
返回不晚于请求时间的最近一条有效总里程记录、实际记录时间和时间差秒数。
protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。
GB32960 和 YUTONG_MQTT 为车辆仪表盘或车端累计里程JT808 为定位终端/GPS侧累计里程。
operationId: queryTotalMileageAtTime
security:
- AppKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TotalMileageQuery'
example:
vin: LA9GG68L2PBAF4790
time: '2026-07-21 09:30:00'
protocol: GB32960
responses:
'200':
description: 查询成功;没有可用记录时返回 NO_DATA
content:
application/json:
schema:
$ref: '#/components/schemas/TotalMileageQueryResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalError'
/api/v2/open-platform/apps:
get:
tags: [开放平台管理]
summary: 查询开放平台应用
operationId: listOpenPlatformApps
security:
- AdminBearer: []
responses:
'200':
description: 应用列表
content:
application/json:
schema:
$ref: '#/components/schemas/AdminAppListResponse'
'401':
description: 未登录
'403':
description: 非管理员
post:
tags: [开放平台管理]
summary: 创建应用和 appKey
description: appKey 明文仅在本次响应中返回。
operationId: createOpenPlatformApp
security:
- AdminBearer: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AppInput'
responses:
'200':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/AdminAppCreatedResponse'
'400':
description: 参数错误
'401':
description: 未登录
'403':
description: 非管理员
/api/v2/open-platform/apps/{id}:
put:
tags: [开放平台管理]
summary: 更新应用状态和有效期
operationId: updateOpenPlatformApp
security:
- AdminBearer: []
parameters:
- $ref: '#/components/parameters/AppId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AppInput'
responses:
'200':
description: 更新成功
'400':
description: 参数错误
'404':
description: 应用不存在
/api/v2/open-platform/apps/{id}/rotate-key:
post:
tags: [开放平台管理]
summary: 轮换 appKey
description: 旧 Key 立即失效,新 Key 明文仅在本次响应中返回。
operationId: rotateOpenPlatformAppKey
security:
- AdminBearer: []
parameters:
- $ref: '#/components/parameters/AppId'
responses:
'200':
description: 轮换成功
content:
application/json:
schema:
$ref: '#/components/schemas/AdminAppCreatedResponse'
'404':
description: 应用不存在
/api/v2/open-platform/apps/{id}/vehicles:
get:
tags: [开放平台管理]
summary: 查询应用的车辆授权
operationId: listOpenPlatformVehicleGrants
security:
- AdminBearer: []
parameters:
- $ref: '#/components/parameters/AppId'
responses:
'200':
description: 授权列表
'404':
description: 应用不存在
put:
tags: [开放平台管理]
summary: 完整替换车辆授权
operationId: replaceOpenPlatformVehicleGrants
security:
- AdminBearer: []
parameters:
- $ref: '#/components/parameters/AppId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VehicleGrantRequest'
responses:
'200':
description: 替换成功
'400':
description: VIN 或有效期不正确
'404':
description: 应用不存在
components:
securitySchemes:
AppKeyAuth:
type: http
scheme: bearer
bearerFormat: 32-character appKey
description: 32 位无连字符 UUID appKey
AdminBearer:
type: http
scheme: bearer
bearerFormat: Platform session token
description: 车辆数据平台管理员令牌
parameters:
AppId:
name: id
in: path
required: true
schema:
type: integer
format: int64
minimum: 1
responses:
BadRequest:
description: 请求参数不正确
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidDate:
value:
code: INVALID_DATE_FORMAT
message: date格式必须为yyyy-MM-dd
traceId: 4ccf63c4e51d4d4ab9107d931783a53e
Unauthorized:
description: appKey 不存在、停用或过期
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Forbidden:
description: Key 或车辆授权未完整覆盖查询自然日
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
InternalError:
description: 服务内部异常
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
schemas:
VehicleDailyQuery:
type: object
additionalProperties: false
required: [date]
properties:
plateNumbers:
type: array
maxItems: 200
uniqueItems: true
items:
type: string
minLength: 1
maxLength: 32
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
date:
type: string
format: date
description: 查询自然日yyyy-MM-dd
MileageDailyQuery:
type: object
additionalProperties: false
required: [date]
properties:
plateNumbers:
type: array
maxItems: 200
uniqueItems: true
items:
type: string
minLength: 1
maxLength: 32
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
date:
type: string
format: date
description: 查询自然日yyyy-MM-dd
protocolPriority:
$ref: '#/components/schemas/ProtocolPriority'
MileageRangeQuery:
type: object
additionalProperties: false
required: [startDate, endDate]
properties:
startDate:
type: string
format: date
description: 区间开始自然日Asia/Shanghai
endDate:
type: string
format: date
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
plateNumbers:
type: array
maxItems: 5000
uniqueItems: true
items:
type: string
minLength: 1
maxLength: 32
description: 可选;省略或传空数组时查询整个区间均有效授权的全部车辆
protocolPriority:
$ref: '#/components/schemas/ProtocolPriority'
cursor:
type: string
nullable: true
description: 首次查询传 null 或省略;翻页时原样传入上一页 nextCursor
pageSize:
type: integer
minimum: 1
maximum: 5000
default: 5000
ProtocolPriority:
type: array
minItems: 1
maxItems: 3
uniqueItems: true
items:
type: string
enum: [GB32960, MQTT, JT808]
description: 可选;按数组顺序逐车逐日选择第一个有效协议。未列出的协议被禁用且不会作为兜底;省略字段时保持平台默认选源行为
TotalMileageQuery:
type: object
additionalProperties: false
required: [vin, time]
properties:
vin:
type: string
pattern: '^[A-HJ-NPR-Z0-9]{17}$'
description: 已授权车辆 VIN
time:
type: string
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
description: 请求时刻,北京时间,固定格式 yyyy-MM-dd HH:mm:ss
protocol:
type: string
enum: [GB32960, YUTONG_MQTT, JT808]
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
HydrogenResult:
type: object
required: [plateNumber, date, hydrogenConsumptionKg, status]
properties:
plateNumber:
type: string
date:
type: string
format: date
hydrogenConsumptionKg:
type: number
format: double
nullable: true
description: 单日用氢量kg无数据时为 null
status:
$ref: '#/components/schemas/DataStatus'
MileageResult:
type: object
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
properties:
vin:
type: string
plateNumber:
type: string
date:
type: string
format: date
dailyMileageKm:
type: number
format: double
nullable: true
description: 单日里程km当日无记录但存在历史累计里程时为 0
totalMileageKm:
type: number
format: double
nullable: true
description: 当日所选协议最后有效累计总里程kmstatus=NORMAL 时必定有值NO_DATA 时为 null
dataTime:
type: string
format: date-time
nullable: true
description: 本次统计实际采用的最后一条车辆源数据时间status=NO_DATA 时为 null
updatedAt:
type: string
format: date-time
nullable: true
description: 本行所依据统计周期的计算时间前向填充时为上一统计周期计算时间status=NO_DATA 时为 null
sourceProtocol:
type: string
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议status=NO_DATA 时为 null
status:
$ref: '#/components/schemas/DataStatus'
MileageRangeResult:
type: object
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
properties:
vin:
type: string
plateNumber:
type: string
date:
type: string
format: date
dailyMileageKm:
type: number
format: double
nullable: true
minimum: 0
description: 当日无记录但存在历史累计里程时为 0
totalMileageKm:
type: number
format: double
nullable: true
minimum: 0
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
dataTime:
type: string
format: date-time
nullable: true
updatedAt:
type: string
format: date-time
nullable: true
description: 本行所依据统计周期的计算时间;缺日补齐时为上一统计周期计算时间
sourceProtocol:
type: string
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议status=NO_DATA 时为 null
status:
$ref: '#/components/schemas/DataStatus'
DataStatus:
type: string
enum: [NORMAL, NO_DATA]
HydrogenQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/HydrogenResult'
MileageQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/MileageResult'
MileageRangeQueryResponse:
type: object
required: [code, message, data, snapshotId, nextCursor, traceId]
properties:
code:
type: string
enum: [SUCCESS]
message:
type: string
example: success
data:
type: array
items:
$ref: '#/components/schemas/MileageRangeResult'
snapshotId:
type: string
description: 同一次分页查询保持不变的授权车辆快照标识
nextCursor:
type: string
nullable: true
description: 下一页游标;最后一页为 null
traceId:
type: string
TotalMileageResult:
type: object
required: [vin, queryTime, totalMileageKm, selectionPolicy, status]
properties:
vin:
type: string
queryTime:
type: string
description: 请求时刻,北京时间
totalMileageKm:
type: number
format: double
nullable: true
description: 总里程km无数据时为 null
protocol:
type: string
enum: [GB32960, YUTONG_MQTT, JT808]
description: 实际采用的采集协议
protocolInput:
type: string
description: 请求中指定的协议原值;未指定时省略
mileageMeaning:
type: string
description: 当前协议总里程的业务含义
recordTime:
type: string
description: 命中的实际采集记录时间,北京时间
timeDifferenceSeconds:
type: integer
format: int64
minimum: 0
description: 请求时间减实际记录时间,单位秒
selectionPolicy:
type: string
example: GB32960 > YUTONG_MQTT > JT808
status:
$ref: '#/components/schemas/DataStatus'
TotalMileageQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
$ref: '#/components/schemas/TotalMileageResult'
SuccessEnvelope:
type: object
required: [code, message, traceId]
properties:
code:
type: string
enum: [SUCCESS]
message:
type: string
example: success
traceId:
type: string
example: 4ccf63c4e51d4d4ab9107d931783a53e
ErrorResponse:
type: object
required: [code, message, traceId]
properties:
code:
type: string
enum:
- INVALID_REQUEST
- INVALID_DATE_FORMAT
- UNAUTHORIZED
- FORBIDDEN
- INTERNAL_ERROR
message:
type: string
traceId:
type: string
AppInput:
type: object
additionalProperties: false
required: [name, status, validFrom]
properties:
name:
type: string
maxLength: 96
example: 示例合作方
status:
type: string
enum: [enabled, disabled]
validFrom:
type: string
format: date-time
example: '2026-07-01T00:00:00+08:00'
validTo:
type: string
format: date-time
nullable: true
example: '2027-07-01T00:00:00+08:00'
App:
type: object
required: [id, name, appKeyPrefix, status, validFrom]
properties:
id:
type: integer
format: int64
name:
type: string
appKeyPrefix:
type: string
minLength: 8
maxLength: 8
status:
type: string
enum: [enabled, disabled]
validFrom:
type: string
format: date-time
validTo:
type: string
format: date-time
nullable: true
AppCreated:
allOf:
- $ref: '#/components/schemas/App'
- type: object
required: [appKey]
properties:
appKey:
type: string
minLength: 32
maxLength: 32
pattern: '^[0-9a-f]{32}$'
description: 仅本次响应返回
VehicleGrantRequest:
type: object
additionalProperties: false
required: [vehicles]
properties:
vehicles:
type: array
maxItems: 500
items:
$ref: '#/components/schemas/VehicleGrantInput'
VehicleGrantInput:
type: object
additionalProperties: false
required: [vin, validFrom]
properties:
vin:
type: string
minLength: 17
maxLength: 17
validFrom:
type: string
format: date-time
validTo:
type: string
format: date-time
nullable: true
AdminAppListResponse:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/App'
traceId:
type: string
AdminAppCreatedResponse:
type: object
properties:
data:
$ref: '#/components/schemas/AppCreated'
traceId:
type: string

View File

@@ -0,0 +1,17 @@
window.addEventListener("load", function () {
window.ui = SwaggerUIBundle({
url: "/open-api/openapi.yaml",
dom_id: "#swagger-ui",
deepLinking: true,
displayRequestDuration: true,
filter: true,
persistAuthorization: false,
tryItOutEnabled: false,
validatorUrl: null,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
layout: "StandaloneLayout"
});
});

View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>车辆数据开放平台 Swagger</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
<script src="/open-api/swagger-init.js"></script>
</body>
</html>

View File

@@ -0,0 +1,83 @@
package openplatform
import (
_ "embed"
"net/http"
"strconv"
"strings"
)
const (
OpenAPISpecPath = "/open-api/openapi.yaml"
SwaggerUIPath = "/open-api/swagger/"
SimpleDocsPath = "/open-api/docs/"
swaggerInitJSPath = "/open-api/swagger-init.js"
)
//go:embed assets/openapi.yaml
var openAPISpec []byte
//go:embed assets/swagger.html
var swaggerHTML []byte
//go:embed assets/swagger-init.js
var swaggerInitJS []byte
//go:embed assets/docs.html
var simpleDocsHTML []byte
// WithDocs exposes version-matched API documentation without requiring a
// production database connection or an authenticated platform session.
func WithDocs(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/open-api":
redirectDocs(w, r, SimpleDocsPath)
case "/open-api/swagger":
redirectDocs(w, r, SwaggerUIPath)
case "/open-api/docs":
redirectDocs(w, r, SimpleDocsPath)
case OpenAPISpecPath:
serveDocsAsset(w, r, "application/yaml; charset=utf-8", openAPISpec, "")
case SwaggerUIPath:
serveDocsAsset(w, r, "text/html; charset=utf-8", swaggerHTML,
"default-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self'; font-src https://cdn.jsdelivr.net; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
case swaggerInitJSPath:
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
case SimpleDocsPath:
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
default:
next.ServeHTTP(w, r)
}
})
}
func redirectDocs(w http.ResponseWriter, r *http.Request, target string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
http.Redirect(w, r, target, http.StatusPermanentRedirect)
}
func serveDocsAsset(w http.ResponseWriter, r *http.Request, contentType string, body []byte, csp string) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
if strings.TrimSpace(csp) != "" {
w.Header().Set("Content-Security-Policy", csp)
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
_, _ = w.Write(body)
}
}

View File

@@ -0,0 +1,76 @@
package openplatform
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
handler := WithDocs(http.NotFoundHandler())
tests := []struct {
path string
contentType string
want string
}{
{OpenAPISpecPath, "application/yaml", "openapi: 3.0.3"},
{SwaggerUIPath, "text/html", "swagger-ui-bundle.js"},
{SimpleDocsPath, "text/html", "车辆数据开放平台"},
{swaggerInitJSPath, "application/javascript", `persistAuthorization: false`},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), test.contentType) {
t.Fatalf("content-type=%q", recorder.Header().Get("Content-Type"))
}
if !strings.Contains(recorder.Body.String(), test.want) {
t.Fatalf("body does not contain %q", test.want)
}
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("documentation asset must set nosniff")
}
})
}
}
func TestDocsRedirectsAndRejectsMutatingMethods(t *testing.T) {
handler := WithDocs(http.NotFoundHandler())
redirect := httptest.NewRecorder()
handler.ServeHTTP(redirect, httptest.NewRequest(http.MethodGet, "/open-api/docs", nil))
if redirect.Code != http.StatusPermanentRedirect || redirect.Header().Get("Location") != SimpleDocsPath {
t.Fatalf("status=%d location=%q", redirect.Code, redirect.Header().Get("Location"))
}
post := httptest.NewRecorder()
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, OpenAPISpecPath, nil))
if post.Code != http.StatusMethodNotAllowed || post.Header().Get("Allow") != "GET, HEAD" {
t.Fatalf("status=%d allow=%q", post.Code, post.Header().Get("Allow"))
}
}
func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
spec := string(openAPISpec)
for _, want := range []string{
"openapi: 3.0.3",
HydrogenQueryPath + ":",
MileageQueryPath + ":",
MileageRangeQueryPath + ":",
"/api/v2/open-platform/apps:",
"AppKeyAuth:",
"AdminBearer:",
"省略或传空数组时",
"protocolPriority:",
"sourceProtocol:",
"enum: [GB32960, MQTT, JT808]",
} {
if !strings.Contains(spec, want) {
t.Fatalf("OpenAPI spec missing %q", want)
}
}
}

View File

@@ -0,0 +1,729 @@
package openplatform
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
const (
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
MileageQueryPath = "/api/v1/vehicles/mileage/query"
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
)
type Handler struct {
service *Service
portal *PortalService
mux *http.ServeMux
}
func NewHandler(service *Service) *Handler {
handler := &Handler{service: service, mux: http.NewServeMux()}
handler.registerExternalDataRoutes()
handler.registerAdminAppRoutes()
return handler
}
func NewAdminHandler(service *Service, portal *PortalService) *Handler {
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
handler.registerAdminAppRoutes()
if portal != nil {
handler.registerAdminUserRoutes()
}
return handler
}
func NewExternalHandler(service *Service, portal *PortalService) *Handler {
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
handler.registerExternalDataRoutes()
if portal != nil {
handler.registerPortalRoutes()
}
return handler
}
func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
}
func (h *Handler) registerAdminAppRoutes() {
h.mux.HandleFunc("GET /api/v2/open-platform/apps", h.listApps)
h.mux.HandleFunc("POST /api/v2/open-platform/apps", h.createApp)
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}", h.updateApp)
h.mux.HandleFunc("POST /api/v2/open-platform/apps/{id}/rotate-key", h.rotateKey)
h.mux.HandleFunc("GET /api/v2/open-platform/apps/{id}/vehicles", h.listVehicleGrants)
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}/vehicles", h.replaceVehicleGrants)
}
func (h *Handler) registerAdminUserRoutes() {
h.mux.HandleFunc("GET /api/v2/open-platform/users", h.listPortalUsers)
h.mux.HandleFunc("POST /api/v2/open-platform/users", h.createPortalUser)
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}", h.updatePortalUser)
h.mux.HandleFunc("GET /api/v2/open-platform/users/{id}/apps", h.listPortalUserApps)
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}/apps", h.replacePortalUserApps)
}
func (h *Handler) registerPortalRoutes() {
h.mux.HandleFunc("POST /portal-api/auth/login", h.portalLogin)
h.mux.HandleFunc("POST /portal-api/auth/logout", h.portalLogout)
h.mux.HandleFunc("GET /portal-api/session", h.portalSession)
h.mux.HandleFunc("GET /portal-api/catalog", h.portalCatalog)
h.mux.HandleFunc("GET /portal-api/apps", h.portalApps)
h.mux.HandleFunc("GET /portal-api/apps/{id}/vehicles", h.portalVehicles)
h.mux.HandleFunc("GET /portal-api/apps/{id}/audit", h.portalAudit)
h.mux.HandleFunc("POST /portal-api/apps/{id}/rotate-key", h.portalRotateKey)
h.mux.HandleFunc("PUT /portal-api/account/password", h.portalChangePassword)
h.mux.HandleFunc("GET /portal-api/admin/apps", h.portalAdminListApps)
h.mux.HandleFunc("POST /portal-api/admin/apps", h.portalAdminCreateApp)
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}", h.portalAdminUpdateApp)
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}/vehicles", h.portalAdminReplaceVehicles)
h.mux.HandleFunc("GET /portal-api/admin/vehicles", h.portalAdminVehicleCatalog)
h.mux.HandleFunc("GET /portal-api/admin/users", h.portalAdminListUsers)
h.mux.HandleFunc("POST /portal-api/admin/users", h.portalAdminCreateUser)
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}", h.portalAdminUpdateUser)
h.mux.HandleFunc("GET /portal-api/admin/users/{id}/apps", h.portalAdminListUserApps)
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}/apps", h.portalAdminReplaceUserApps)
}
func NewDataHandler(service *Service) *Handler {
handler := &Handler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
return handler
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func IsPublicPath(path string) bool {
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
}
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request QueryRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryHydrogen(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) mileage(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request QueryRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryMileage(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) mileageRange(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request MileageRangeRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
response, err := h.service.QueryMileageRange(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, response)
}
func (h *Handler) totalMileage(w http.ResponseWriter, r *http.Request) {
traceID := externalTraceID(r)
var request TotalMileageQueryRequest
if !decodeExternalBody(w, r, traceID, &request) {
return
}
data, err := h.service.QueryTotalMileage(r.Context(), externalBearer(r), traceID, request)
if err != nil {
writeExternalError(w, traceID, err)
return
}
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
}
func (h *Handler) listApps(w http.ResponseWriter, r *http.Request) {
data, err := h.service.ListApps(r.Context())
h.writeAdmin(w, r, data, err)
}
func (h *Handler) createApp(w http.ResponseWriter, r *http.Request) {
var input AppInput
if !decodeAdminBody(w, r, &input) {
return
}
data, err := h.service.CreateApp(r.Context(), input, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) updateApp(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
var input AppInput
if !decodeAdminBody(w, r, &input) {
return
}
data, err := h.service.UpdateApp(r.Context(), id, input, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
data, err := h.service.RotateKey(r.Context(), id, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) listVehicleGrants(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
data, err := h.service.ListVehicleGrants(r.Context(), id)
h.writeAdmin(w, r, data, err)
}
func (h *Handler) replaceVehicleGrants(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
var request VehicleGrantRequest
if !decodeAdminBody(w, r, &request) {
return
}
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, request, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) listPortalUsers(w http.ResponseWriter, r *http.Request) {
data, err := h.portal.ListUsers(r.Context())
h.writeAdmin(w, r, data, err)
}
func (h *Handler) createPortalUser(w http.ResponseWriter, r *http.Request) {
var input PortalUserInput
if !decodeAdminBody(w, r, &input) {
return
}
data, err := h.portal.CreateUser(r.Context(), input, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) updatePortalUser(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
var input PortalUserInput
if !decodeAdminBody(w, r, &input) {
return
}
data, err := h.portal.UpdateUser(r.Context(), id, input, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) listPortalUserApps(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
data, err := h.portal.ListUserApps(r.Context(), id)
h.writeAdmin(w, r, data, err)
}
func (h *Handler) replacePortalUserApps(w http.ResponseWriter, r *http.Request) {
id, ok := parseID(w, r)
if !ok {
return
}
var request PortalUserAppRequest
if !decodeAdminBody(w, r, &request) {
return
}
data, err := h.portal.ReplaceUserApps(r.Context(), id, request, platform.ActorFromContext(r.Context()))
h.writeAdmin(w, r, data, err)
}
func (h *Handler) portalLogin(w http.ResponseWriter, r *http.Request) {
var input PortalLoginRequest
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.portal.Login(r.Context(), input, requestRemoteAddress(r), r.UserAgent())
h.writePortal(w, r, data, err)
}
func (h *Handler) portalLogout(w http.ResponseWriter, r *http.Request) {
h.portal.Logout(r.Context(), externalBearer(r))
h.writePortal(w, r, map[string]bool{"loggedOut": true}, nil)
}
func (h *Handler) portalSession(w http.ResponseWriter, r *http.Request) {
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
h.writePortal(w, r, session, err)
}
func (h *Handler) portalCatalog(w http.ResponseWriter, r *http.Request) {
h.writePortal(w, r, dataProducts(), nil)
}
func (h *Handler) portalApps(w http.ResponseWriter, r *http.Request) {
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err != nil {
h.writePortal(w, r, nil, err)
return
}
data, err := h.portal.Apps(r.Context(), session)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalVehicles(w http.ResponseWriter, r *http.Request) {
appID, ok := parsePortalID(w, r)
if !ok {
return
}
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err != nil {
h.writePortal(w, r, nil, err)
return
}
data, err := h.portal.Vehicles(r.Context(), session, appID)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAudit(w http.ResponseWriter, r *http.Request) {
appID, ok := parsePortalID(w, r)
if !ok {
return
}
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err != nil {
h.writePortal(w, r, nil, err)
return
}
data, err := h.portal.Audit(r.Context(), session, appID)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalRotateKey(w http.ResponseWriter, r *http.Request) {
appID, ok := parsePortalID(w, r)
if !ok {
return
}
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err == nil {
err = h.portal.CanRotateKey(r.Context(), session, appID)
}
if err != nil {
h.writePortal(w, r, nil, err)
return
}
data, err := h.service.RotateKey(r.Context(), appID, "portal:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalChangePassword(w http.ResponseWriter, r *http.Request) {
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err != nil {
h.writePortal(w, r, nil, err)
return
}
var input struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if !decodePortalBody(w, r, &input) {
return
}
err = h.portal.ChangePassword(r.Context(), session, input.CurrentPassword, input.NewPassword)
h.writePortal(w, r, map[string]bool{"changed": err == nil}, err)
}
func (h *Handler) portalAdminListApps(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePortalAdmin(w, r); !ok {
return
}
data, err := h.service.ListApps(r.Context())
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminCreateApp(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
var input AppInput
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.service.CreateApp(r.Context(), input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminUpdateApp(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
id, ok := parsePortalID(w, r)
if !ok {
return
}
var input AppInput
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.service.UpdateApp(r.Context(), id, input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminReplaceVehicles(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
id, ok := parsePortalID(w, r)
if !ok {
return
}
var input VehicleGrantRequest
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminVehicleCatalog(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePortalAdmin(w, r); !ok {
return
}
data, err := h.portal.VehicleCatalog(r.Context())
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePortalAdmin(w, r); !ok {
return
}
data, err := h.portal.ListUsers(r.Context())
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminCreateUser(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
var input PortalUserInput
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.portal.CreateUser(r.Context(), input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
id, ok := parsePortalID(w, r)
if !ok {
return
}
var input PortalUserInput
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.portal.UpdateUser(r.Context(), id, input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminListUserApps(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requirePortalAdmin(w, r); !ok {
return
}
id, ok := parsePortalID(w, r)
if !ok {
return
}
data, err := h.portal.ListUserApps(r.Context(), id)
h.writePortal(w, r, data, err)
}
func (h *Handler) portalAdminReplaceUserApps(w http.ResponseWriter, r *http.Request) {
session, ok := h.requirePortalAdmin(w, r)
if !ok {
return
}
id, ok := parsePortalID(w, r)
if !ok {
return
}
var input PortalUserAppRequest
if !decodePortalBody(w, r, &input) {
return
}
data, err := h.portal.ReplaceUserApps(r.Context(), id, input, "portal-admin:"+session.Username)
h.writePortal(w, r, data, err)
}
func (h *Handler) requirePortalAdmin(w http.ResponseWriter, r *http.Request) (PortalSession, bool) {
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
if err == nil && session.UserType != "admin" {
err = ErrForbidden
}
if err != nil {
h.writePortal(w, r, nil, err)
return PortalSession{}, false
}
return session, true
}
func (h *Handler) writeAdmin(w http.ResponseWriter, r *http.Request, data any, err error) {
traceID := externalTraceID(r)
switch {
case err == nil:
httpx.WriteOK(w, traceID, data)
case errors.Is(err, ErrInvalidRequest):
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
case errors.Is(err, ErrNotFound):
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "开放平台应用不存在", "", traceID)
default:
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", err.Error(), traceID)
}
}
func (h *Handler) writePortal(w http.ResponseWriter, r *http.Request, data any, err error) {
traceID := externalTraceID(r)
w.Header().Set("Cache-Control", "no-store")
switch {
case err == nil:
httpx.WriteOK(w, traceID, data)
case errors.Is(err, ErrUnauthorized):
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-open-platform-portal"`)
httpx.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "登录状态无效或已过期", "", traceID)
case errors.Is(err, ErrForbidden):
httpx.WriteError(w, http.StatusForbidden, "FORBIDDEN", "当前账号无此操作权限", "", traceID)
case errors.Is(err, ErrInvalidRequest):
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
case errors.Is(err, ErrNotFound):
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "资源不存在", "", traceID)
default:
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", "", traceID)
}
}
func decodeExternalBody(w http.ResponseWriter, r *http.Request, traceID string, output any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
message := "请求参数不正确"
if strings.Contains(err.Error(), "protocolPriority") {
message = "protocolPriority必须是非空字符串数组"
}
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: message, TraceID: traceID})
return false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求体只能包含一个JSON对象", TraceID: traceID})
return false
}
return true
}
func decodeAdminBody(w http.ResponseWriter, r *http.Request, output any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), externalTraceID(r))
return false
}
return true
}
func decodePortalBody(w http.ResponseWriter, r *http.Request, output any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(output); err != nil {
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", portalDecodeDetail(err), externalTraceID(r))
return false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求体只能包含一个JSON对象", "", externalTraceID(r))
return false
}
return true
}
func portalDecodeDetail(err error) string {
var syntaxError *json.SyntaxError
var typeError *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxError):
return "请求内容不是有效的 JSON"
case errors.As(err, &typeError):
if typeError.Field != "" {
return fmt.Sprintf("字段 %s 的数据类型不正确", typeError.Field)
}
return "请求字段的数据类型不正确"
case errors.Is(err, io.EOF):
return "请求内容不能为空"
case strings.HasPrefix(err.Error(), "json: unknown field "):
return "请求包含未支持的字段 " + strings.TrimPrefix(err.Error(), "json: unknown field ")
default:
return "请求内容无法解析"
}
}
func writeExternalError(w http.ResponseWriter, traceID string, err error) {
response := ExternalResponse{TraceID: traceID}
status := http.StatusInternalServerError
switch {
case errors.Is(err, ErrUnauthorized):
status, response.Code, response.Message = http.StatusUnauthorized, "UNAUTHORIZED", "身份认证失败"
case errors.Is(err, ErrForbidden):
status, response.Code, response.Message = http.StatusForbidden, "FORBIDDEN", "无数据访问权限"
case errors.Is(err, ErrInvalidRequest):
status, response.Code, response.Message = http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确"
if strings.Contains(err.Error(), "invalid datetime") {
response.Code, response.Message = "INVALID_DATETIME_FORMAT", "time格式必须为yyyy-MM-dd HH:mm:ss"
} else if strings.Contains(err.Error(), "invalid date") {
response.Code, response.Message = "INVALID_DATE_FORMAT", "date格式必须为yyyy-MM-dd"
} else if strings.Contains(err.Error(), "protocolPriority must not be empty") {
response.Message = "protocolPriority不能为空"
} else if strings.Contains(err.Error(), "protocolPriority contains duplicate protocol") {
response.Message = "protocolPriority不能包含重复协议"
} else if strings.Contains(err.Error(), "protocolPriority contains unsupported protocol") {
response.Message = "protocolPriority仅允许GB32960、MQTT、JT808"
}
default:
response.Code, response.Message = "INTERNAL_ERROR", "服务内部异常"
}
if status == http.StatusUnauthorized {
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-open-platform"`)
}
writeExternal(w, status, response)
}
func writeExternal(w http.ResponseWriter, status int, response any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func externalBearer(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
return ""
}
return strings.TrimSpace(header[7:])
}
func externalTraceID(r *http.Request) string {
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
return traceID
}
var value [16]byte
if _, err := rand.Read(value[:]); err == nil {
return hex.EncodeToString(value[:])
}
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
func parseID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil || id == 0 {
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
return 0, false
}
return id, true
}
func parsePortalID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil || id == 0 {
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
return 0, false
}
return id, true
}
func requestRemoteAddress(r *http.Request) string {
for _, header := range []string{"X-Forwarded-For", "X-Real-Ip"} {
if value := strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0]); value != "" {
return value
}
}
return r.RemoteAddr
}
func dataProducts() []DataProduct {
return []DataProduct{
{
Code: "daily_hydrogen", Name: "单日用氢量",
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: HydrogenQueryPath, Unit: "kg",
},
{
Code: "daily_mileage", Name: "单日里程",
Description: "按车牌和自然日查询日里程、累计里程、实际来源协议与数据时间,支持自定义协议优先级。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: MileageQueryPath, Unit: "km",
},
{
Code: "mileage_range", Name: "区间日里程",
Description: "按最长366天区间分页查询逐日里程支持逐车逐日自定义协议优先级。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: MileageRangeQueryPath, Unit: "km",
},
{
Code: "total_mileage_at_time", Name: "指定时刻总里程",
Description: "按VIN和北京时间查询最近一条总里程、采集协议及记录时间差。",
Version: "v1", Status: "available", Method: http.MethodPost,
Path: TotalMileageQueryPath, Unit: "km",
},
}
}

View File

@@ -0,0 +1,330 @@
package openplatform
import (
"encoding/json"
"time"
)
const (
StatusNormal = "NORMAL"
StatusNoData = "NO_DATA"
)
type QueryRequest struct {
PlateNumbers []string `json:"plateNumbers"`
Date string `json:"date"`
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
}
type MileageRangeRequest struct {
StartDate string `json:"startDate"`
EndDate string `json:"endDate"`
PlateNumbers []string `json:"plateNumbers"`
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
Cursor string `json:"cursor"`
PageSize int `json:"pageSize"`
}
type ProtocolPriority struct {
Values []string
Present bool
}
func (p *ProtocolPriority) UnmarshalJSON(data []byte) error {
p.Present = true
if string(data) == "null" {
p.Values = []string{}
return nil
}
var values []string
if err := json.Unmarshal(data, &values); err != nil {
return err
}
p.Values = values
return nil
}
type HydrogenResult struct {
PlateNumber string `json:"plateNumber"`
Date string `json:"date"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
Status string `json:"status"`
}
type MileageResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
Date string `json:"date"`
DailyMileageKm *float64 `json:"dailyMileageKm"`
TotalMileageKm *float64 `json:"totalMileageKm"`
DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"`
Status string `json:"status"`
}
type MileageRangeResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
Date string `json:"date"`
DailyMileageKm *float64 `json:"dailyMileageKm"`
TotalMileageKm *float64 `json:"totalMileageKm"`
DataTime *string `json:"dataTime"`
UpdatedAt *string `json:"updatedAt"`
SourceProtocol *string `json:"sourceProtocol"`
Status string `json:"status"`
}
type TotalMileageQueryRequest struct {
VIN string `json:"vin"`
Time string `json:"time"`
Protocol string `json:"protocol,omitempty"`
}
type TotalMileageResult struct {
VIN string `json:"vin"`
QueryTime string `json:"queryTime"`
TotalMileageKm *float64 `json:"totalMileageKm"`
Protocol string `json:"protocol,omitempty"`
ProtocolInput string `json:"protocolInput,omitempty"`
MileageMeaning string `json:"mileageMeaning,omitempty"`
RecordTime string `json:"recordTime,omitempty"`
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
SelectionPolicy string `json:"selectionPolicy"`
Status string `json:"status"`
}
type TotalMileagePoint struct {
VIN string
Protocol string
ObservedAt time.Time
TotalMileageKm float64
}
type ExternalResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
TraceID string `json:"traceId"`
}
type MileageRangeResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Data []MileageRangeResult `json:"data"`
SnapshotID string `json:"snapshotId"`
NextCursor *string `json:"nextCursor"`
TraceID string `json:"traceId"`
}
type App struct {
ID uint64 `json:"id"`
Name string `json:"name"`
AppKeyPrefix string `json:"appKeyPrefix"`
Status string `json:"status"`
ValidFrom time.Time `json:"validFrom"`
ValidTo *time.Time `json:"validTo,omitempty"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type AppCreated struct {
App
AppKey string `json:"appKey"`
}
type AppInput struct {
Name string `json:"name"`
Status string `json:"status"`
ValidFrom string `json:"validFrom"`
ValidTo string `json:"validTo"`
}
type VehicleGrantInput struct {
VIN string `json:"vin"`
ValidFrom string `json:"validFrom"`
ValidTo string `json:"validTo"`
}
type VehicleGrantRequest struct {
Vehicles []VehicleGrantInput `json:"vehicles"`
}
type VehicleGrant struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
ValidFrom time.Time `json:"validFrom"`
ValidTo *time.Time `json:"validTo,omitempty"`
GrantedBy string `json:"grantedBy"`
UpdatedAt time.Time `json:"updatedAt"`
}
type VehicleCatalogItem struct {
VIN string `json:"vin"`
Plate string `json:"plate"`
OEM string `json:"oem"`
Status string `json:"status"`
Source string `json:"source"`
}
type AuthorizedVehicle struct {
VIN string
Plate string
}
type AppCredential struct {
ID uint64
Name string
}
type DailyHydrogen struct {
VIN string
Date string
ConsumptionKg float64
SampleCount int
QualityStatus string
}
type DailyMileage struct {
VIN string
Date string
Protocol string
MileageKm float64
TotalMileageKm float64
DataTime string
UpdatedAt string
}
type MileageSnapshot struct {
ID string
AppID uint64
RequestHash []byte
StartDate string
EndDate string
VehicleCount int
ExpiresAt time.Time
Vehicles []AuthorizedVehicle
}
type HydrogenObservation struct {
VIN string
Source string
ObservedAt time.Time
MassKg float64
TankCapacityLiter float64
PressureMPa float64
TemperatureC float64
NoiseKg float64
RefuelThresholdKg float64
}
type HydrogenRateObservation struct {
VIN string
Source string
ObservedAt time.Time
Rate float64
MileageKm float64
}
type HydrogenRateDailyStat struct {
VIN string
Source string
Date string
ConsumptionKg float64
SampleCount int
QualityStatus string
QualityReason string
}
type HydrogenDailyStat struct {
VIN string
Source string
Date string
ConsumptionKg float64
FirstMassKg float64
LastMassKg float64
SampleCount int
RefuelCount int
QualityStatus string
QualityReason string
}
type PortalUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
ValidFrom time.Time `json:"validFrom"`
ValidTo *time.Time `json:"validTo,omitempty"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PortalUserInput struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
Password string `json:"password"`
Status string `json:"status"`
ValidFrom string `json:"validFrom"`
ValidTo string `json:"validTo"`
}
type PortalUserAppInput struct {
AppID uint64 `json:"appId"`
Role string `json:"role"`
}
type PortalUserAppRequest struct {
Apps []PortalUserAppInput `json:"apps"`
}
type PortalUserApp struct {
AppID uint64 `json:"appId"`
AppName string `json:"appName"`
AppKeyPrefix string `json:"appKeyPrefix"`
AppStatus string `json:"appStatus"`
Role string `json:"role"`
ValidFrom time.Time `json:"validFrom"`
ValidTo *time.Time `json:"validTo,omitempty"`
}
type PortalSession struct {
UserID uint64 `json:"userId"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
UserType string `json:"userType"`
ExpiresAt time.Time `json:"expiresAt"`
}
type PortalLoginResponse struct {
AccessToken string `json:"accessToken"`
ExpiresAt time.Time `json:"expiresAt"`
Session PortalSession `json:"session"`
}
type PortalLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type PortalAuditItem struct {
TraceID string `json:"traceId"`
Endpoint string `json:"endpoint"`
Result string `json:"result"`
VehicleCount int `json:"vehicleCount"`
CreatedAt time.Time `json:"createdAt"`
}
type DataProduct struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Status string `json:"status"`
Method string `json:"method"`
Path string `json:"path"`
Unit string `json:"unit"`
}

View File

@@ -0,0 +1,611 @@
package openplatform
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
type MySQLRepository struct {
db *sql.DB
tdengine *sql.DB
tdDatabase string
}
func (r *MySQLRepository) WithTDengine(db *sql.DB, database string) *MySQLRepository {
if db == nil {
panic("open platform TDengine database is required")
}
if database == "" {
database = "lingniu_vehicle_ts"
}
for _, char := range database {
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '_' {
panic("invalid TDengine database identifier")
}
}
r.tdengine = db
r.tdDatabase = database
return r
}
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
if db == nil {
panic("open platform database is required")
}
return &MySQLRepository{db: db}
}
func (r *MySQLRepository) Authenticate(ctx context.Context, hash [sha256.Size]byte, now, dayStart, dayEnd time.Time) (AppCredential, error) {
var app AppCredential
err := r.db.QueryRowContext(ctx, `
SELECT id,name
FROM vehicle_open_app
WHERE app_key_hash=?
AND status='enabled'
AND valid_from<=?
AND (valid_to IS NULL OR valid_to>?)
AND valid_from<=?
AND (valid_to IS NULL OR valid_to>=?)`,
hash[:], now, now, dayStart, dayEnd,
).Scan(&app.ID, &app.Name)
if errors.Is(err, sql.ErrNoRows) {
return AppCredential{}, ErrUnauthorized
}
return app, err
}
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
args := make([]any, 0, len(plates)+3)
args = append(args, appID, dayStart, dayEnd)
query := `
SELECT UPPER(b.plate),MIN(g.vin)
FROM vehicle_open_app_vehicle g
JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE g.app_id=?
AND g.valid_from<=?
AND (g.valid_to IS NULL OR g.valid_to>=?)
AND TRIM(COALESCE(b.plate,''))<>''`
if len(plates) > 0 {
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
query += `
AND UPPER(b.plate) IN (` + placeholders + `)`
for _, plate := range plates {
args = append(args, plate)
}
}
query += `
GROUP BY UPPER(b.plate)
HAVING COUNT(DISTINCT g.vin)=1
ORDER BY UPPER(b.plate)`
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]AuthorizedVehicle, len(plates))
for rows.Next() {
var vehicle AuthorizedVehicle
if err := rows.Scan(&vehicle.Plate, &vehicle.VIN); err != nil {
return nil, err
}
out[vehicle.Plate] = vehicle
}
return out, rows.Err()
}
func (r *MySQLRepository) AuthorizedVIN(ctx context.Context, appID uint64, vin string, at time.Time) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM vehicle_open_app_vehicle
WHERE app_id=? AND BINARY vin=BINARY ?
AND valid_from<=?
AND (valid_to IS NULL OR valid_to>?)`, appID, vin, at, at).Scan(&count)
return count == 1, err
}
func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.Time, protocols []string) (*TotalMileagePoint, error) {
if r.tdengine == nil || r.tdDatabase == "" {
return nil, errors.New("TDengine is not configured for total mileage query")
}
table := r.tdDatabase + ".vehicle_locations"
timeLiteral := strings.ReplaceAll(at.Format(time.RFC3339), "'", "''")
for _, protocol := range protocols {
query := `SELECT CAST(ts AS BIGINT),total_mileage_km,protocol FROM ` + table +
` WHERE vin='` + vin + `' AND protocol='` + protocol + `'` +
` AND ts<='` + timeLiteral + `' AND total_mileage_km IS NOT NULL AND total_mileage_km>=0` +
` ORDER BY ts DESC LIMIT 1`
var timestampMS int64
var point TotalMileagePoint
err := r.tdengine.QueryRowContext(ctx, query).Scan(&timestampMS, &point.TotalMileageKm, &point.Protocol)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return nil, err
}
point.VIN = vin
point.ObservedAt = time.UnixMilli(timestampMS)
return &point, nil
}
return nil, nil
}
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
if len(vins) == 0 {
return map[string]DailyHydrogen{}, nil
}
query, args := inQuery(`
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() {
var value DailyHydrogen
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
return nil, err
}
out[value.VIN] = value
}
return out, rows.Err()
}
func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date string, protocols []string) (map[string]DailyMileage, error) {
values, err := r.DailyMileageRange(ctx, vins, date, date, protocols)
if err != nil {
return nil, err
}
out := make(map[string]DailyMileage, len(values))
for _, value := range values {
out[value.VIN] = value
}
return out, nil
}
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
query := `
SELECT
m.vin,
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
WHERE selected.vin=m.vin
AND selected.stat_date=m.stat_date
AND selected.protocol=m.protocol
AND selected.is_selected=1
AND selected.latest_event_time IS NOT NULL
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
FROM vehicle_daily_mileage m
WHERE m.stat_date BETWEEN ? AND ?
AND m.vin IN (` + placeholders + `)
AND m.latest_total_mileage_km IS NOT NULL
AND m.latest_total_mileage_km>=0
AND m.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+2+len(protocols)*2)
args = append(args, startDate, endDate)
for _, vin := range vins {
args = append(args, vin)
}
if len(protocols) > 0 {
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
query += "\n AND m.protocol IN (" + protocolPlaceholders + ")\nORDER BY m.stat_date,m.vin,CASE m.protocol"
for _, protocol := range protocols {
args = append(args, protocol)
}
for index, protocol := range protocols {
query += " WHEN ? THEN " + strconv.Itoa(index+1)
args = append(args, protocol)
}
query += " ELSE 99 END,m.protocol"
} else {
query += `
ORDER BY m.stat_date,m.vin,
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
m.protocol`
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DailyMileage, len(vins))
for rows.Next() {
var value DailyMileage
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
return nil, err
}
key := dailyMileageKey(value.VIN, value.Date)
if _, exists := out[key]; !exists {
out[key] = value
}
}
return out, rows.Err()
}
func (r *MySQLRepository) LatestMileageBefore(ctx context.Context, vins []string, beforeDate string, protocols []string) (map[string]DailyMileage, error) {
if len(vins) == 0 {
return map[string]DailyMileage{}, nil
}
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
query := `
SELECT
m.vin,
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
m.protocol,
m.daily_mileage_km,
m.latest_total_mileage_km,
COALESCE(DATE_FORMAT((
SELECT MAX(selected.latest_event_time)
FROM vehicle_daily_mileage_source selected
WHERE selected.vin=m.vin
AND selected.stat_date=m.stat_date
AND selected.protocol=m.protocol
AND selected.is_selected=1
AND selected.latest_event_time IS NOT NULL
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
FROM vehicle_daily_mileage m
JOIN (
SELECT prior.vin,prior.protocol,MAX(prior.stat_date) AS stat_date
FROM vehicle_daily_mileage prior
WHERE prior.stat_date<?
AND prior.vin IN (` + vinPlaceholders + `)
AND prior.latest_total_mileage_km IS NOT NULL
AND prior.latest_total_mileage_km>=0
AND prior.daily_mileage_km>=0`
args := make([]any, 0, len(vins)+1+len(protocols)*2)
args = append(args, beforeDate)
for _, vin := range vins {
args = append(args, vin)
}
if len(protocols) > 0 {
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
query += "\n AND prior.protocol IN (" + protocolPlaceholders + ")"
for _, protocol := range protocols {
args = append(args, protocol)
}
}
query += `
GROUP BY prior.vin,prior.protocol
) latest
ON latest.vin=m.vin
AND latest.stat_date=m.stat_date
AND latest.protocol=m.protocol`
if len(protocols) > 0 {
query += "\nORDER BY m.vin,CASE m.protocol"
for index, protocol := range protocols {
query += " WHEN ? THEN " + strconv.Itoa(index+1)
args = append(args, protocol)
}
query += " ELSE 99 END,m.protocol,m.updated_at DESC"
} else {
query += `
ORDER BY m.vin,
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
m.protocol,
m.updated_at DESC`
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]DailyMileage, len(vins))
for rows.Next() {
var value DailyMileage
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
return nil, err
}
if _, exists := out[value.VIN]; !exists {
out[value.VIN] = value
}
}
return out, rows.Err()
}
func (r *MySQLRepository) CreateMileageSnapshot(ctx context.Context, snapshot MileageSnapshot) error {
if len(snapshot.RequestHash) != sha256.Size || snapshot.ID == "" {
return fmt.Errorf("%w: invalid snapshot", ErrInvalidRequest)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_mileage_snapshot WHERE expires_at<=CURRENT_TIMESTAMP(3)`); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_mileage_snapshot
(snapshot_id,app_id,request_hash,start_date,end_date,vehicle_count,expires_at)
VALUES(?,?,?,?,?,?,?)`,
snapshot.ID, snapshot.AppID, snapshot.RequestHash, snapshot.StartDate, snapshot.EndDate, len(snapshot.Vehicles), snapshot.ExpiresAt); err != nil {
return err
}
const batchSize = 500
for start := 0; start < len(snapshot.Vehicles); start += batchSize {
end := start + batchSize
if end > len(snapshot.Vehicles) {
end = len(snapshot.Vehicles)
}
var query strings.Builder
query.WriteString(`INSERT INTO vehicle_open_mileage_snapshot_vehicle(snapshot_id,ordinal,vin,plate) VALUES `)
args := make([]any, 0, (end-start)*4)
for index := start; index < end; index++ {
if index > start {
query.WriteByte(',')
}
query.WriteString("(?,?,?,?)")
vehicle := snapshot.Vehicles[index]
args = append(args, snapshot.ID, index, vehicle.VIN, vehicle.Plate)
}
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
return err
}
}
return tx.Commit()
}
func (r *MySQLRepository) LoadMileageSnapshot(ctx context.Context, snapshotID string, appID uint64, now time.Time) (MileageSnapshot, error) {
snapshot := MileageSnapshot{ID: snapshotID, AppID: appID}
err := r.db.QueryRowContext(ctx, `
SELECT request_hash,DATE_FORMAT(start_date,'%Y-%m-%d'),DATE_FORMAT(end_date,'%Y-%m-%d'),vehicle_count
FROM vehicle_open_mileage_snapshot
WHERE snapshot_id=? AND app_id=? AND expires_at>?`, snapshotID, appID, now).
Scan(&snapshot.RequestHash, &snapshot.StartDate, &snapshot.EndDate, &snapshot.VehicleCount)
if errors.Is(err, sql.ErrNoRows) {
return MileageSnapshot{}, fmt.Errorf("%w: cursor expired or unavailable", ErrInvalidRequest)
}
if err != nil {
return MileageSnapshot{}, err
}
rows, err := r.db.QueryContext(ctx, `
SELECT vin,plate
FROM vehicle_open_mileage_snapshot_vehicle
WHERE snapshot_id=?
ORDER BY ordinal`, snapshotID)
if err != nil {
return MileageSnapshot{}, err
}
defer rows.Close()
snapshot.Vehicles = make([]AuthorizedVehicle, 0, snapshot.VehicleCount)
for rows.Next() {
var vehicle AuthorizedVehicle
if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil {
return MileageSnapshot{}, err
}
snapshot.Vehicles = append(snapshot.Vehicles, vehicle)
}
if err := rows.Err(); err != nil {
return MileageSnapshot{}, err
}
if len(snapshot.Vehicles) != snapshot.VehicleCount {
return MileageSnapshot{}, errors.New("mileage snapshot vehicle count mismatch")
}
return snapshot, nil
}
func (r *MySQLRepository) Audit(ctx context.Context, appID uint64, endpoint, result, traceID string, requested int, detail string) error {
var nullableApp any
if appID > 0 {
nullableApp = appID
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO vehicle_open_api_audit(trace_id,app_id,endpoint,result,requested_vehicle_count,detail)
VALUES(?,?,?,?,?,?)`, traceID, nullableApp, endpoint, result, requested, truncate(detail, 512))
return err
}
func (r *MySQLRepository) CreateApp(ctx context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
result, err := r.db.ExecContext(ctx, `
INSERT INTO vehicle_open_app(name,app_key_hash,app_key_prefix,status,valid_from,valid_to,created_by,updated_by)
VALUES(?,?,?,?,?,?,?,?)`, input.Name, hash[:], prefix, input.Status, from, nullableTime(to), actor, actor)
if err != nil {
return App{}, err
}
id, err := result.LastInsertId()
if err != nil {
return App{}, err
}
_ = r.adminAudit(ctx, uint64(id), actor, "create_app", map[string]any{"name": input.Name, "validFrom": from, "validTo": to})
return r.app(ctx, uint64(id))
}
func (r *MySQLRepository) ListApps(ctx context.Context) ([]App, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
FROM vehicle_open_app ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]App, 0)
for rows.Next() {
app, err := scanApp(rows)
if err != nil {
return nil, err
}
apps = append(apps, app)
}
return apps, rows.Err()
}
func (r *MySQLRepository) UpdateApp(ctx context.Context, id uint64, input AppInput, from time.Time, to *time.Time, actor string) (App, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE vehicle_open_app SET name=?,status=?,valid_from=?,valid_to=?,updated_by=? WHERE id=?`,
input.Name, input.Status, from, nullableTime(to), actor, id)
if err != nil {
return App{}, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return App{}, ErrNotFound
}
_ = r.adminAudit(ctx, id, actor, "update_app", map[string]any{"name": input.Name, "status": input.Status, "validFrom": from, "validTo": to})
return r.app(ctx, id)
}
func (r *MySQLRepository) RotateKey(ctx context.Context, id uint64, hash [sha256.Size]byte, prefix, actor string) (App, error) {
result, err := r.db.ExecContext(ctx, `UPDATE vehicle_open_app SET app_key_hash=?,app_key_prefix=?,updated_by=? WHERE id=?`, hash[:], prefix, actor, id)
if err != nil {
return App{}, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return App{}, ErrNotFound
}
_ = r.adminAudit(ctx, id, actor, "rotate_key", map[string]any{"appKeyPrefix": prefix})
return r.app(ctx, id)
}
func (r *MySQLRepository) ReplaceVehicleGrants(ctx context.Context, appID uint64, grants []parsedGrant, actor string) ([]VehicleGrant, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=? FOR UPDATE`, appID).Scan(&exists); err != nil {
return nil, err
}
if exists == 0 {
return nil, ErrNotFound
}
if len(grants) > 0 {
placeholders := strings.TrimRight(strings.Repeat("?,", len(grants)), ",")
args := make([]any, 0, len(grants))
for _, grant := range grants {
args = append(args, grant.VIN)
}
var vinCount int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IN (`+placeholders+`)`, args...).Scan(&vinCount); err != nil {
return nil, err
}
if vinCount != len(grants) {
return nil, fmt.Errorf("%w: one or more VINs do not exist", ErrInvalidRequest)
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_app_vehicle WHERE app_id=?`, appID); err != nil {
return nil, err
}
for _, grant := range grants {
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_app_vehicle(app_id,vin,valid_from,valid_to,granted_by)
VALUES(?,?,?,?,?)`, appID, grant.VIN, grant.ValidFrom, nullableTime(grant.ValidTo), actor); err != nil {
return nil, err
}
}
detail, _ := json.Marshal(map[string]any{"count": len(grants)})
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json)
VALUES(?,?,?,?)`, appID, actor, "replace_vehicle_grants", string(detail)); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return r.ListVehicleGrants(ctx, appID)
}
func (r *MySQLRepository) ListVehicleGrants(ctx context.Context, appID uint64) ([]VehicleGrant, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
FROM vehicle_open_app_vehicle g
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE g.app_id=?
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
ORDER BY g.vin`, appID)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make([]VehicleGrant, 0)
for rows.Next() {
var grant VehicleGrant
var validTo sql.NullTime
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
return nil, err
}
if validTo.Valid {
grant.ValidTo = &validTo.Time
}
grants = append(grants, grant)
}
return grants, rows.Err()
}
func (r *MySQLRepository) app(ctx context.Context, id uint64) (App, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
FROM vehicle_open_app WHERE id=?`, id)
app, err := scanApp(row)
if errors.Is(err, sql.ErrNoRows) {
return App{}, ErrNotFound
}
return app, err
}
type scanner interface {
Scan(...any) error
}
func scanApp(row scanner) (App, error) {
var app App
var validTo sql.NullTime
err := row.Scan(&app.ID, &app.Name, &app.AppKeyPrefix, &app.Status, &app.ValidFrom, &validTo, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt)
if validTo.Valid {
app.ValidTo = &validTo.Time
}
return app, err
}
func (r *MySQLRepository) adminAudit(ctx context.Context, appID uint64, actor, action string, detail any) error {
encoded, _ := json.Marshal(detail)
_, err := r.db.ExecContext(ctx, `INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json) VALUES(?,?,?,?)`, appID, actor, action, encoded)
return err
}
func inQuery(template, first string, values []string) (string, []any) {
placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",")
args := make([]any, 0, len(values)+1)
args = append(args, first)
for _, value := range values {
args = append(args, value)
}
return strings.Replace(template, "%s", placeholders, 1), args
}
func nullableTime(value *time.Time) any {
if value == nil {
return nil
}
return *value
}
func truncate(value string, size int) string {
runes := []rune(value)
if len(runes) <= size {
return value
}
return string(runes[:size])
}

View File

@@ -0,0 +1,212 @@
package openplatform
import (
"context"
"regexp"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
t.Fatalf("date format was corrupted: %s", query)
}
if !strings.Contains(query, "vin IN (?,?)") {
t.Fatalf("VIN placeholders missing: %s", query)
}
if len(args) != 3 || args[0] != "2026-07-21" || args[1] != "VIN1" || args[2] != "VIN2" {
t.Fatalf("unexpected args: %#v", args)
}
}
func TestAuthorizedVehiclesUsesBinaryVINJoin(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
end := start.Add(24*time.Hour - time.Nanosecond)
mock.ExpectQuery(regexp.QuoteMeta("JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
WithArgs(uint64(1), start, end, "辽A00001").
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).AddRow("辽A00001", "LTEST000000000001"))
repository := NewMySQLRepository(db)
vehicles, err := repository.AuthorizedVehicles(context.Background(), 1, []string{"辽A00001"}, start, end)
if err != nil {
t.Fatal(err)
}
if vehicles["辽A00001"].VIN != "LTEST000000000001" {
t.Fatalf("unexpected vehicles: %#v", vehicles)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
end := start.Add(24 * time.Hour)
mock.ExpectQuery("JOIN vehicle_identity_binding.*TRIM.*ORDER BY UPPER").
WithArgs(uint64(7), start, end).
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).
AddRow("粤A12345", "LTEST32960VIN0001").
AddRow("粤B67890", "LTEST32960VIN0002"))
vehicles, err := NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, nil, start, end)
if err != nil {
t.Fatal(err)
}
if len(vehicles) != 2 || vehicles["粤A12345"].VIN != "LTEST32960VIN0001" || vehicles["粤B67890"].VIN != "LTEST32960VIN0002" {
t.Fatalf("vehicles=%#v", vehicles)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0").
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
if err != nil {
t.Fatal(err)
}
first := values["LTEST32960VIN0001"]
if first.MileageKm != 101.235 || first.TotalMileageKm != 12345.679 || first.DataTime != "2026-07-21T23:58:45+08:00" || first.UpdatedAt == "" {
t.Fatalf("first=%#v", first)
}
if _, ok := values["LTEST32960VIN0002"]; ok {
t.Fatalf("vehicle without a cumulative total must not be returned: %#v", values)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
if err != nil {
t.Fatal(err)
}
selected := values["LTEST32960VIN0001"]
if selected.Protocol != "JT808" || selected.MileageKm != 0 {
t.Fatalf("selected=%#v", selected)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestLatestMileageBeforeUsesRequestedProtocolPriority(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*SELECT prior.vin,prior.protocol,MAX\\(prior.stat_date\\).*FROM vehicle_daily_mileage prior\\s+WHERE prior.stat_date<\\?.*prior.protocol IN \\(\\?,\\?\\).*ORDER BY m.vin,CASE m.protocol").
WithArgs("2026-07-22", "LTEST32960VIN0001", "JT808", "YUTONG_MQTT", "JT808", "YUTONG_MQTT").
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
AddRow("LTEST32960VIN0001", "2026-07-20", "JT808", 18.5, 9008.5, "2026-07-20T22:00:00+08:00", "2026-07-21T01:00:00+08:00").
AddRow("LTEST32960VIN0001", "2026-07-21", "YUTONG_MQTT", 20.0, 12020.0, "2026-07-21T23:00:00+08:00", "2026-07-22T01:00:00+08:00"))
values, err := NewMySQLRepository(db).LatestMileageBefore(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-22", []string{"JT808", "YUTONG_MQTT"})
if err != nil {
t.Fatal(err)
}
selected := values["LTEST32960VIN0001"]
if selected.Protocol != "JT808" ||
selected.MileageKm != 18.5 ||
selected.TotalMileageKm != 9008.5 ||
selected.DataTime != "2026-07-20T22:00:00+08:00" ||
selected.UpdatedAt != "2026-07-21T01:00:00+08:00" {
t.Fatalf("selected=%#v", selected)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testing.T) {
mysqlDB, _, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer mysqlDB.Close()
tdDB, tdMock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer tdDB.Close()
at := time.Date(2026, 7, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
tdMock.ExpectQuery("protocol='GB32960'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}))
tdMock.ExpectQuery("protocol='YUTONG_MQTT'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}).AddRow(at.Add(-15*time.Second).UnixMilli(), 12345.678, "YUTONG_MQTT"))
repository := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts")
point, err := repository.TotalMileage(context.Background(), "LA9GG68L2PBAF4790", at, []string{"GB32960", "YUTONG_MQTT", "JT808"})
if err != nil {
t.Fatal(err)
}
if point == nil || point.Protocol != "YUTONG_MQTT" || point.TotalMileageKm != 12345.678 || !point.ObservedAt.Equal(at.Add(-15*time.Second)) {
t.Fatalf("point=%#v", point)
}
if err := tdMock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
mock.ExpectQuery(regexp.QuoteMeta("LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
WithArgs(uint64(1)).
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "valid_from", "valid_to", "granted_by", "updated_at"}).
AddRow("LTEST000000000001", "辽A00001", now, now.AddDate(1, 0, 0), "admin", now))
repository := NewMySQLRepository(db)
grants, err := repository.ListVehicleGrants(context.Background(), 1)
if err != nil {
t.Fatal(err)
}
if len(grants) != 1 || grants[0].Plate != "辽A00001" {
t.Fatalf("unexpected grants: %#v", grants)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,675 @@
package openplatform
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
portalMaxLoginFailures = 5
portalLoginLock = 15 * time.Minute
)
var portalUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{3,64}$`)
type PortalService struct {
db *sql.DB
sessionTTL time.Duration
now func() time.Time
}
type portalCredential struct {
PortalUser
PasswordHash string
FailedLoginCount int
LockedUntil sql.NullTime
UserType string
}
func NewPortalService(db *sql.DB, sessionTTL time.Duration) *PortalService {
if db == nil {
panic("open platform portal database is required")
}
if sessionTTL <= 0 {
sessionTTL = 12 * time.Hour
}
return &PortalService{db: db, sessionTTL: sessionTTL, now: time.Now}
}
func (s *PortalService) CreateUser(ctx context.Context, input PortalUserInput, actor string) (PortalUser, error) {
from, to, err := validatePortalUserInput(&input, true)
if err != nil {
return PortalUser{}, err
}
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
return PortalUser{}, err
} else if reserved {
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return PortalUser{}, err
}
result, err := s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user(
username,display_name,password_hash,status,valid_from,valid_to,created_by,updated_by
) VALUES(?,?,?,?,?,?,?,?)`,
input.Username, input.DisplayName, string(hash), input.Status, from, nullableTime(to), actor, actor)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
}
return PortalUser{}, err
}
id, err := result.LastInsertId()
if err != nil {
return PortalUser{}, err
}
_ = s.userAudit(ctx, uint64(id), actor, "user.create", "success", map[string]any{"username": input.Username}, "")
return s.user(ctx, uint64(id))
}
func (s *PortalService) ListUsers(ctx context.Context) ([]PortalUser, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]PortalUser, 0)
for rows.Next() {
user, err := scanPortalUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s *PortalService) UpdateUser(ctx context.Context, id uint64, input PortalUserInput, actor string) (PortalUser, error) {
from, to, err := validatePortalUserInput(&input, false)
if err != nil {
return PortalUser{}, err
}
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
return PortalUser{}, err
} else if reserved {
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
}
args := []any{input.Username, input.DisplayName, input.Status, from, nullableTime(to), actor}
query := `UPDATE vehicle_open_user SET username=?,display_name=?,status=?,valid_from=?,valid_to=?,updated_by=?`
if input.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return PortalUser{}, err
}
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
args = append(args, string(hash))
}
query += ` WHERE id=?`
args = append(args, id)
result, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
}
return PortalUser{}, err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return PortalUser{}, ErrNotFound
}
if input.Status == "disabled" || input.Password != "" {
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id)
}
_ = s.userAudit(ctx, id, actor, "user.update", "success", map[string]any{"status": input.Status, "passwordReset": input.Password != ""}, "")
return s.user(ctx, id)
}
func (s *PortalService) ReplaceUserApps(ctx context.Context, userID uint64, request PortalUserAppRequest, actor string) ([]PortalUserApp, error) {
if len(request.Apps) > 100 {
return nil, fmt.Errorf("%w: app memberships exceed 100", ErrInvalidRequest)
}
seen := map[uint64]bool{}
for _, item := range request.Apps {
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
if item.AppID == 0 || seen[item.AppID] || !validPortalRole(item.Role) {
return nil, fmt.Errorf("%w: invalid or duplicate app membership", ErrInvalidRequest)
}
seen[item.AppID] = true
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var exists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_user WHERE id=? FOR UPDATE`, userID).Scan(&exists); err != nil {
return nil, err
}
if exists == 0 {
return nil, ErrNotFound
}
for _, item := range request.Apps {
var appExists int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, item.AppID).Scan(&appExists); err != nil {
return nil, err
}
if appExists == 0 {
return nil, fmt.Errorf("%w: app %d", ErrInvalidRequest, item.AppID)
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_user_app WHERE user_id=?`, userID); err != nil {
return nil, err
}
for _, item := range request.Apps {
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_user_app(user_id,app_id,role,granted_by) VALUES(?,?,?,?)`,
userID, item.AppID, strings.ToLower(strings.TrimSpace(item.Role)), actor); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
_ = s.userAudit(ctx, userID, actor, "user.apps.replace", "success", map[string]any{"count": len(request.Apps)}, "")
return s.ListUserApps(ctx, userID)
}
func (s *PortalService) ListUserApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
return s.portalApps(ctx, userID)
}
func (s *PortalService) Login(ctx context.Context, input PortalLoginRequest, remoteAddr, userAgent string) (PortalLoginResponse, error) {
input.Username = strings.TrimSpace(input.Username)
if !portalUsernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
return PortalLoginResponse{}, ErrUnauthorized
}
credential, err := s.loginCredential(ctx, input.Username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
_ = s.userAudit(ctx, 0, input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddr)
return PortalLoginResponse{}, ErrUnauthorized
}
return PortalLoginResponse{}, err
}
now := s.now()
if credential.Status != "enabled" ||
(credential.UserType != "admin" && (credential.ValidFrom.After(now) || (credential.ValidTo != nil && !credential.ValidTo.After(now)))) {
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "disabled_or_expired"}, remoteAddr)
return PortalLoginResponse{}, ErrForbidden
}
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
return PortalLoginResponse{}, fmt.Errorf("%w: account locked", ErrForbidden)
}
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
failures := credential.FailedLoginCount + 1
var lockedUntil any
if failures >= portalMaxLoginFailures {
lockedUntil = now.Add(portalLoginLock)
}
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=?,locked_until=? WHERE id=?`, failures, lockedUntil, credential.ID)
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddr)
return PortalLoginResponse{}, ErrUnauthorized
}
rawToken, tokenHash, err := newPortalSessionToken()
if err != nil {
return PortalLoginResponse{}, err
}
sessionID, err := randomHexBytes(16)
if err != nil {
return PortalLoginResponse{}, err
}
expiresAt := now.Add(s.sessionTTL)
var portalUserID, platformUserID any
if credential.UserType == "admin" {
platformUserID = credential.ID
} else {
portalUserID = credential.ID
}
_, err = s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user_session(
id,user_id,platform_user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent
) VALUES(?,?,?,?,?,?,?,?,?)`,
sessionID, portalUserID, platformUserID, tokenHash[:], now, expiresAt, now, truncate(remoteAddr, 96), truncate(userAgent, 255))
if err != nil {
return PortalLoginResponse{}, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
_ = s.userAudit(ctx, auditPortalUserID(credential), credential.Username, "login", "success", map[string]any{"userType": credential.UserType}, remoteAddr)
session := PortalSession{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, UserType: credential.UserType, ExpiresAt: expiresAt}
return PortalLoginResponse{AccessToken: rawToken, ExpiresAt: expiresAt, Session: session}, nil
}
func (s *PortalService) Authenticate(ctx context.Context, rawToken string) (PortalSession, error) {
if len(rawToken) != 64 {
return PortalSession{}, ErrUnauthorized
}
hash := sha256.Sum256([]byte(rawToken))
var session PortalSession
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(u.id,p.id),COALESCE(u.username,p.username),COALESCE(u.display_name,p.display_name),
CASE WHEN p.id IS NOT NULL THEN 'admin' ELSE 'partner' END,se.expires_at
FROM vehicle_open_user_session se
LEFT JOIN vehicle_open_user u ON u.id=se.user_id
LEFT JOIN platform_user p ON p.id=se.platform_user_id
WHERE se.token_hash=?
AND se.revoked_at IS NULL
AND se.expires_at>NOW(3)
AND ((u.id IS NOT NULL AND u.status='enabled' AND u.valid_from<=NOW(3)
AND (u.valid_to IS NULL OR u.valid_to>NOW(3)))
OR (p.id IS NOT NULL AND p.user_type='admin' AND p.status='enabled'
AND p.auth_provider='local'))`, hash[:]).
Scan(&session.UserID, &session.Username, &session.DisplayName, &session.UserType, &session.ExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return PortalSession{}, ErrUnauthorized
}
if err != nil {
return PortalSession{}, err
}
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET last_seen_at=NOW(3) WHERE token_hash=?`, hash[:])
return session, nil
}
func (s *PortalService) Logout(ctx context.Context, rawToken string) {
hash := sha256.Sum256([]byte(rawToken))
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
}
func (s *PortalService) ChangePassword(ctx context.Context, session PortalSession, currentPassword, newPassword string) error {
if session.UserType == "admin" {
return fmt.Errorf("%w: platform administrators must change passwords on the internal platform", ErrInvalidRequest)
}
if err := validatePortalPassword(newPassword); err != nil {
return err
}
var currentHash string
table := credentialTable(session.UserType)
if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM `+table+` WHERE id=? AND status='enabled'`, session.UserID).Scan(&currentHash); err != nil {
return ErrUnauthorized
}
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(currentPassword)) != nil {
return ErrUnauthorized
}
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(newPassword)) == nil {
return fmt.Errorf("%w: password unchanged", ErrInvalidRequest)
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET password_hash=?,password_changed_at=NOW(3),updated_by=? WHERE id=?`, string(hash), session.Username, session.UserID); err != nil {
return err
}
identityColumn := "user_id"
if session.UserType == "admin" {
identityColumn = "platform_user_id"
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, session.UserID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE `+identityColumn+`=? AND revoked_at IS NULL`, session.UserID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
auditUserID := session.UserID
if session.UserType == "admin" {
auditUserID = 0
}
_ = s.userAudit(ctx, auditUserID, session.Username, "password.change", "success", map[string]any{"userType": session.UserType}, "")
return nil
}
func (s *PortalService) Apps(ctx context.Context, session PortalSession) ([]PortalUserApp, error) {
if session.UserType == "admin" {
return s.adminApps(ctx)
}
return s.portalApps(ctx, session.UserID)
}
func (s *PortalService) Vehicles(ctx context.Context, session PortalSession, appID uint64) ([]VehicleGrant, error) {
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
FROM vehicle_open_app_vehicle g
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE g.app_id=?
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
ORDER BY g.vin`, appID)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make([]VehicleGrant, 0)
for rows.Next() {
var grant VehicleGrant
var validTo sql.NullTime
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
return nil, err
}
if validTo.Valid {
grant.ValidTo = &validTo.Time
}
grants = append(grants, grant)
}
return grants, rows.Err()
}
func (s *PortalService) VehicleCatalog(ctx context.Context) ([]VehicleCatalogItem, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT b.vin,
COALESCE(MAX(NULLIF(b.plate,'')),''),
COALESCE(MAX(NULLIF(b.oem,'')),'')
FROM vehicle_identity_binding b
WHERE b.vin IS NOT NULL AND b.vin<>''
GROUP BY b.vin
ORDER BY COALESCE(MAX(NULLIF(b.plate,'')),''),b.vin`)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]VehicleCatalogItem, 0)
for rows.Next() {
var item VehicleCatalogItem
if err := rows.Scan(&item.VIN, &item.Plate, &item.OEM); err != nil {
return nil, err
}
item.Status = "available"
item.Source = "车辆主数据"
items = append(items, item)
}
return items, rows.Err()
}
func (s *PortalService) Audit(ctx context.Context, session PortalSession, appID uint64) ([]PortalAuditItem, error) {
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT trace_id,endpoint,result,requested_vehicle_count,created_at
FROM vehicle_open_api_audit WHERE app_id=? ORDER BY id DESC LIMIT 100`, appID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]PortalAuditItem, 0)
for rows.Next() {
var item PortalAuditItem
if err := rows.Scan(&item.TraceID, &item.Endpoint, &item.Result, &item.VehicleCount, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *PortalService) CanRotateKey(ctx context.Context, session PortalSession, appID uint64) error {
role, err := s.MembershipRole(ctx, session, appID)
if err != nil {
return err
}
if role != "owner" {
return ErrForbidden
}
return nil
}
func (s *PortalService) user(ctx context.Context, id uint64) (PortalUser, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user WHERE id=?`, id)
user, err := scanPortalUser(row)
if errors.Is(err, sql.ErrNoRows) {
return PortalUser{}, ErrNotFound
}
return user, err
}
func (s *PortalService) credential(ctx context.Context, username string) (portalCredential, error) {
var credential portalCredential
var validTo, lastLogin, locked sql.NullTime
err := s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,password_hash,status,valid_from,valid_to,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
FROM vehicle_open_user WHERE username=?`, username).Scan(
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash,
&credential.Status, &credential.ValidFrom, &validTo, &credential.FailedLoginCount,
&locked, &lastLogin, &credential.CreatedBy, &credential.CreatedAt, &credential.UpdatedAt,
)
if validTo.Valid {
credential.ValidTo = &validTo.Time
}
if lastLogin.Valid {
credential.LastLoginAt = &lastLogin.Time
}
credential.LockedUntil = locked
credential.UserType = "partner"
return credential, err
}
func (s *PortalService) loginCredential(ctx context.Context, username string) (portalCredential, error) {
credential, err := s.credential(ctx, username)
if err == nil || !errors.Is(err, sql.ErrNoRows) {
return credential, err
}
var admin portalCredential
var lastLogin, locked sql.NullTime
err = s.db.QueryRowContext(ctx, `
SELECT id,username,display_name,password_hash,status,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
FROM platform_user
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(
&admin.ID, &admin.Username, &admin.DisplayName, &admin.PasswordHash,
&admin.Status, &admin.FailedLoginCount, &locked, &lastLogin,
&admin.CreatedBy, &admin.CreatedAt, &admin.UpdatedAt,
)
if lastLogin.Valid {
admin.LastLoginAt = &lastLogin.Time
}
admin.LockedUntil = locked
admin.UserType = "admin"
return admin, err
}
func (s *PortalService) platformAdminUsername(ctx context.Context, username string) (bool, error) {
var count int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM platform_user
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(&count)
return count > 0, err
}
func (s *PortalService) portalApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT a.id,a.name,a.app_key_prefix,a.status,ua.role,a.valid_from,a.valid_to
FROM vehicle_open_user_app ua
JOIN vehicle_open_app a ON a.id=ua.app_id
WHERE ua.user_id=? ORDER BY a.id DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]PortalUserApp, 0)
for rows.Next() {
var app PortalUserApp
var validTo sql.NullTime
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
return nil, err
}
if validTo.Valid {
app.ValidTo = &validTo.Time
}
apps = append(apps, app)
}
return apps, rows.Err()
}
func (s *PortalService) adminApps(ctx context.Context) ([]PortalUserApp, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id,name,app_key_prefix,status,'owner',valid_from,valid_to
FROM vehicle_open_app ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
apps := make([]PortalUserApp, 0)
for rows.Next() {
var app PortalUserApp
var validTo sql.NullTime
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
return nil, err
}
if validTo.Valid {
app.ValidTo = &validTo.Time
}
apps = append(apps, app)
}
return apps, rows.Err()
}
func (s *PortalService) membershipRole(ctx context.Context, userID, appID uint64) (string, error) {
var role string
err := s.db.QueryRowContext(ctx, `SELECT role FROM vehicle_open_user_app WHERE user_id=? AND app_id=?`, userID, appID).Scan(&role)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrForbidden
}
return role, err
}
func (s *PortalService) MembershipRole(ctx context.Context, session PortalSession, appID uint64) (string, error) {
if session.UserType == "admin" {
var exists int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, appID).Scan(&exists); err != nil {
return "", err
}
if exists == 0 {
return "", ErrNotFound
}
return "owner", nil
}
return s.membershipRole(ctx, session.UserID, appID)
}
func (s *PortalService) userAudit(ctx context.Context, userID uint64, actor, action, result string, detail any, remoteAddr string) error {
var nullableUser any
if userID > 0 {
nullableUser = userID
}
var encoded any
if detail != nil {
value, _ := json.Marshal(detail)
encoded = string(value)
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO vehicle_open_user_audit(user_id,actor,action,result,detail_json,remote_addr)
VALUES(?,?,?,?,?,?)`, nullableUser, truncate(actor, 96), action, result, encoded, truncate(remoteAddr, 96))
return err
}
func scanPortalUser(row scanner) (PortalUser, error) {
var user PortalUser
var validTo, lastLogin sql.NullTime
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Status, &user.ValidFrom, &validTo, &lastLogin, &user.CreatedBy, &user.CreatedAt, &user.UpdatedAt)
if validTo.Valid {
user.ValidTo = &validTo.Time
}
if lastLogin.Valid {
user.LastLoginAt = &lastLogin.Time
}
return user, err
}
func validatePortalUserInput(input *PortalUserInput, passwordRequired bool) (time.Time, *time.Time, error) {
input.Username = strings.TrimSpace(input.Username)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
if input.Status == "" {
input.Status = "enabled"
}
if !portalUsernamePattern.MatchString(input.Username) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 96 {
return time.Time{}, nil, fmt.Errorf("%w: invalid portal user", ErrInvalidRequest)
}
if input.Status != "enabled" && input.Status != "disabled" {
return time.Time{}, nil, fmt.Errorf("%w: invalid user status", ErrInvalidRequest)
}
if passwordRequired || input.Password != "" {
if err := validatePortalPassword(input.Password); err != nil {
return time.Time{}, nil, err
}
}
return parseInterval(input.ValidFrom, input.ValidTo)
}
func validatePortalPassword(password string) error {
if len(password) < 12 || len(password) > 128 {
return fmt.Errorf("%w: password must be 12-128 characters", ErrInvalidRequest)
}
var lower, upper, digit bool
for _, value := range password {
switch {
case value >= 'a' && value <= 'z':
lower = true
case value >= 'A' && value <= 'Z':
upper = true
case value >= '0' && value <= '9':
digit = true
}
}
if !lower || !upper || !digit {
return fmt.Errorf("%w: password requires upper, lower and digit", ErrInvalidRequest)
}
return nil
}
func validPortalRole(role string) bool {
return role == "owner" || role == "developer" || role == "viewer"
}
func credentialTable(userType string) string {
if userType == "admin" {
return "platform_user"
}
return "vehicle_open_user"
}
func auditPortalUserID(credential portalCredential) uint64 {
if credential.UserType == "admin" {
return 0
}
return credential.ID
}
func newPortalSessionToken() (string, [sha256.Size]byte, error) {
raw, err := randomHexBytes(32)
if err != nil {
return "", [sha256.Size]byte{}, err
}
return raw, sha256.Sum256([]byte(raw)), nil
}
func randomHexBytes(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", err
}
return hex.EncodeToString(value), nil
}

View File

@@ -0,0 +1,104 @@
package openplatform
import (
"context"
"database/sql"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"golang.org/x/crypto/bcrypt"
)
func TestPortalLoginAcceptsEnabledInternalPlatformAdmin(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 7, 20, 16, 0, 0, 0, time.Local)
hash, err := bcrypt.GenerateFromPassword([]byte("AdminPass2026"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
mock.ExpectQuery(regexp.QuoteMeta("FROM vehicle_open_user WHERE username=?")).
WithArgs("admin").
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("FROM platform_user").
WithArgs("admin").
WillReturnRows(sqlmock.NewRows([]string{
"id", "username", "display_name", "password_hash", "status",
"failed_login_count", "locked_until", "last_login_at",
"created_by", "created_at", "updated_at",
}).AddRow(7, "admin", "平台管理员", string(hash), "enabled", 0, nil, nil, "bootstrap", now, now))
mock.ExpectExec("INSERT INTO vehicle_open_user_session").
WithArgs(sqlmock.AnyArg(), nil, uint64(7), sqlmock.AnyArg(), now, now.Add(time.Hour), now, "127.0.0.1", "test-agent").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec("UPDATE platform_user SET failed_login_count=0").
WithArgs(now, uint64(7)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO vehicle_open_user_audit").
WithArgs(nil, "admin", "login", "success", sqlmock.AnyArg(), "127.0.0.1").
WillReturnResult(sqlmock.NewResult(1, 1))
service := NewPortalService(db, time.Hour)
service.now = func() time.Time { return now }
result, err := service.Login(context.Background(), PortalLoginRequest{
Username: "admin",
Password: "AdminPass2026",
}, "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("admin login failed: %v", err)
}
if result.Session.UserType != "admin" || result.Session.Username != "admin" || len(result.AccessToken) != 64 {
t.Fatalf("unexpected admin session: %+v", result.Session)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPortalAdminMembershipHasOwnerAccessToEveryApp(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM vehicle_open_app WHERE id=?")).
WithArgs(uint64(12)).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
service := NewPortalService(db, time.Hour)
role, err := service.MembershipRole(context.Background(), PortalSession{UserID: 7, UserType: "admin"}, 12)
if err != nil || role != "owner" {
t.Fatalf("role=%q err=%v", role, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPortalVehicleCatalogListsBoundVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery("SELECT b.vin,").
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem"}).
AddRow("LNB00000000000001", "辽A00001", "羚牛").
AddRow("LNB00000000000002", "", "羚牛"))
service := NewPortalService(db, time.Hour)
items, err := service.VehicleCatalog(context.Background())
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0].Plate != "辽A00001" || items[1].Source != "车辆主数据" {
t.Fatalf("unexpected vehicle catalog: %+v", items)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,732 @@
package openplatform
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
)
var appKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
var (
ErrUnauthorized = errors.New("open platform appKey unauthorized")
ErrForbidden = errors.New("open platform vehicle forbidden")
ErrInvalidRequest = errors.New("open platform invalid request")
ErrNotFound = errors.New("open platform resource not found")
)
type Repository interface {
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
CreateMileageSnapshot(context.Context, MileageSnapshot) error
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
Audit(context.Context, uint64, string, string, string, int, string) error
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
ListApps(context.Context) ([]App, error)
UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error)
RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error)
ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error)
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
}
type Service struct {
repository Repository
now func() time.Time
location *time.Location
}
type parsedGrant struct {
VIN string
ValidFrom time.Time
ValidTo *time.Time
}
func NewService(repository Repository) *Service {
return &Service{
repository: repository,
now: time.Now,
location: time.FixedZone("Asia/Shanghai", 8*60*60),
}
}
func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) {
plates, date, start, end, err := s.validateQuery(request)
if err != nil {
return nil, err
}
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "denied", traceID, len(plates), err.Error())
return nil, err
}
if len(plates) == 0 {
plates = vehiclePlates(vehicles)
}
vins := vehicleVINs(vehicles)
values, err := s.repository.DailyHydrogen(ctx, vins, date)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
return nil, err
}
results := make([]HydrogenResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
// Sampling sufficiency is decided by the producer and persisted in
// quality_status. Imported refuelling-ledger rows can be authoritative
// with one transaction, while pressure-derived rows require two samples.
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
return results, nil
}
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
plates, date, start, end, err := s.validateQuery(request)
if err != nil {
return nil, err
}
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
if err != nil {
return nil, err
}
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "denied", traceID, len(plates), err.Error())
return nil, err
}
if len(plates) == 0 {
plates = vehiclePlates(vehicles)
}
vins := vehicleVINs(vehicles)
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
missingVINs := missingMileageVINs(vins, values)
carried := map[string]DailyMileage{}
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
}
results := make([]MileageResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, value.MileageKm)
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageResult(&item, value, 0)
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
return results, nil
}
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
request.Cursor = strings.TrimSpace(request.Cursor)
plates, startDate, endDate, start, end, pageSize, protocols, err := s.validateMileageRange(request)
if err != nil {
return MileageRangeResponse{}, err
}
hash := mileageRangeRequestHash(startDate, endDate, plates, pageSize, protocols)
var (
app AppCredential
snapshot MileageSnapshot
offset int
)
if request.Cursor == "" {
vehicles := map[string]AuthorizedVehicle{}
app, vehicles, err = s.authorize(ctx, appKey, plates, start, end)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
return MileageRangeResponse{}, err
}
snapshotID, idErr := newSnapshotID()
if idErr != nil {
return MileageRangeResponse{}, idErr
}
snapshot = MileageSnapshot{
ID: snapshotID,
AppID: app.ID,
RequestHash: hash[:],
StartDate: startDate,
EndDate: endDate,
ExpiresAt: s.now().Add(2 * time.Hour),
Vehicles: orderedVehicles(vehicles),
}
snapshot.VehicleCount = len(snapshot.Vehicles)
if err = s.repository.CreateMileageSnapshot(ctx, snapshot); err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, len(snapshot.Vehicles), err.Error())
return MileageRangeResponse{}, err
}
} else {
snapshotID, parsedOffset, cursorErr := parseMileageCursor(request.Cursor)
if cursorErr != nil {
return MileageRangeResponse{}, cursorErr
}
offset = parsedOffset
if !appKeyPattern.MatchString(appKey) {
return MileageRangeResponse{}, ErrUnauthorized
}
app, err = s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), start, end)
if err != nil {
_ = s.repository.Audit(ctx, 0, "mileage_range_query", "denied", traceID, len(plates), ErrUnauthorized.Error())
return MileageRangeResponse{}, ErrUnauthorized
}
snapshot, err = s.repository.LoadMileageSnapshot(ctx, snapshotID, app.ID, s.now())
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
return MileageRangeResponse{}, err
}
if !bytes.Equal(snapshot.RequestHash, hash[:]) || snapshot.StartDate != startDate || snapshot.EndDate != endDate {
return MileageRangeResponse{}, fmt.Errorf("%w: cursor does not match request", ErrInvalidRequest)
}
}
dayCount := int(end.Sub(start).Hours() / 24)
total := dayCount * snapshot.VehicleCount
if offset < 0 || offset > total {
return MileageRangeResponse{}, fmt.Errorf("%w: cursor offset out of range", ErrInvalidRequest)
}
pageEnd := offset + pageSize
if pageEnd > total {
pageEnd = total
}
positions := make([]mileageRangePosition, 0, pageEnd-offset)
vinSet := make(map[string]struct{})
var queryStart, queryEnd time.Time
for index := offset; index < pageEnd; index++ {
dayOffset := index / snapshot.VehicleCount
vehicle := snapshot.Vehicles[index%snapshot.VehicleCount]
date := start.AddDate(0, 0, dayOffset)
if len(positions) == 0 {
queryStart = date
}
queryEnd = date
positions = append(positions, mileageRangePosition{vehicle: vehicle, date: date.Format("2006-01-02")})
vinSet[vehicle.VIN] = struct{}{}
}
values := map[string]DailyMileage{}
carried := map[string]DailyMileage{}
if len(positions) > 0 {
vins := make([]string, 0, len(vinSet))
for vin := range vinSet {
vins = append(vins, vin)
}
sort.Strings(vins)
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
missingVINs := missingMileageRangeInitialVINs(positions, values)
if len(missingVINs) > 0 {
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
return MileageRangeResponse{}, err
}
if carried == nil {
carried = map[string]DailyMileage{}
}
}
}
results := make([]MileageRangeResult, 0, len(positions))
for _, position := range positions {
item := MileageRangeResult{
VIN: position.vehicle.VIN,
PlateNumber: position.vehicle.Plate,
Date: position.date,
Status: StatusNoData,
}
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, value.MileageKm)
carried[position.vehicle.VIN] = value
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
fillMileageRangeResult(&item, value, 0)
}
results = append(results, item)
}
response := MileageRangeResponse{
Code: "SUCCESS",
Message: "success",
Data: results,
SnapshotID: snapshot.ID,
TraceID: traceID,
}
if pageEnd < total {
cursor := mileageCursor(snapshot.ID, pageEnd)
response.NextCursor = &cursor
}
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "success", traceID, snapshot.VehicleCount, "")
return response, nil
}
type mileageRangePosition struct {
vehicle AuthorizedVehicle
date string
}
func (s *Service) QueryTotalMileage(ctx context.Context, appKey, traceID string, request TotalMileageQueryRequest) (TotalMileageResult, error) {
vin, queryTime, protocols, protocolInput, err := s.validateTotalMileageQuery(request)
if err != nil {
return TotalMileageResult{}, err
}
result := TotalMileageResult{VIN: vin, QueryTime: queryTime.Format("2006-01-02 15:04:05"), ProtocolInput: protocolInput, SelectionPolicy: strings.Join(vehicleprotocol.MileagePriority(), " > "), Status: StatusNoData}
if !appKeyPattern.MatchString(appKey) {
return TotalMileageResult{}, ErrUnauthorized
}
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), queryTime, queryTime)
if err != nil {
_ = s.repository.Audit(ctx, 0, "total_mileage_query", "denied", traceID, 1, ErrUnauthorized.Error())
return TotalMileageResult{}, ErrUnauthorized
}
authorized, err := s.repository.AuthorizedVIN(ctx, app.ID, vin, queryTime)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
return TotalMileageResult{}, err
}
if !authorized {
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "denied", traceID, 1, ErrForbidden.Error())
return TotalMileageResult{}, ErrForbidden
}
point, err := s.repository.TotalMileage(ctx, vin, queryTime, protocols)
if err != nil {
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
return TotalMileageResult{}, err
}
if point != nil {
value := round3(point.TotalMileageKm)
result.TotalMileageKm = &value
result.Protocol = point.Protocol
result.MileageMeaning = totalMileageMeaning(point.Protocol)
result.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
difference := int64(queryTime.Sub(point.ObservedAt.In(s.location)).Seconds())
result.TimeDifferenceSeconds = &difference
result.Status = StatusNormal
}
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "success", traceID, 1, "")
return result, nil
}
func (s *Service) validateTotalMileageQuery(request TotalMileageQueryRequest) (string, time.Time, []string, string, error) {
vin := strings.ToUpper(strings.TrimSpace(request.VIN))
if !regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`).MatchString(vin) {
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
}
queryTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.Time), s.location)
if err != nil {
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
}
input := strings.TrimSpace(request.Protocol)
if input == "" {
return vin, queryTime, vehicleprotocol.MileagePriority(), "", nil
}
canonical, ok := vehicleprotocol.Canonical(input)
if !ok {
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
}
return vin, queryTime, []string{canonical}, input, nil
}
func totalMileageMeaning(protocol string) string {
switch protocol {
case vehicleprotocol.GB32960:
return "车辆仪表盘累计总里程GB/T 32960整车数据累计里程"
case vehicleprotocol.YutongMQTT:
return "车辆仪表盘或车端控制器累计总里程MQTT平台上报"
case vehicleprotocol.JT808:
return "定位终端累计里程GPS/终端侧计算,非车辆仪表盘里程)"
default:
return "车辆累计总里程"
}
}
func (s *Service) authorize(ctx context.Context, rawKey string, plates []string, start, end time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
if !appKeyPattern.MatchString(rawKey) {
return AppCredential{}, nil, ErrUnauthorized
}
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(rawKey))), s.now(), start, end)
if err != nil {
return AppCredential{}, nil, ErrUnauthorized
}
vehicles, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
if err != nil {
return app, nil, err
}
if len(plates) > 0 && len(vehicles) != len(plates) {
return app, nil, ErrForbidden
}
return app, vehicles, nil
}
func (s *Service) validateQuery(request QueryRequest) ([]string, string, time.Time, time.Time, error) {
plates, err := normalizePlates(request.PlateNumbers, 200)
if err != nil {
return nil, "", time.Time{}, time.Time{}, err
}
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.Date), s.location)
if err != nil {
return nil, "", time.Time{}, time.Time{}, fmt.Errorf("%w: invalid date", ErrInvalidRequest)
}
return plates, start.Format("2006-01-02"), start, start.AddDate(0, 0, 1), nil
}
func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, string, string, time.Time, time.Time, int, []string, error) {
plates, err := normalizePlates(request.PlateNumbers, 5000)
if err != nil {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
}
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
if err != nil {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
}
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.StartDate), s.location)
if err != nil {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid startDate", ErrInvalidRequest)
}
endInclusive, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.EndDate), s.location)
if err != nil {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid endDate", ErrInvalidRequest)
}
if endInclusive.Before(start) {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest)
}
end := endInclusive.AddDate(0, 0, 1)
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest)
}
pageSize := request.PageSize
if pageSize == 0 {
pageSize = 5000
}
if pageSize < 1 || pageSize > 5000 {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: pageSize must be between 1 and 5000", ErrInvalidRequest)
}
return plates, start.Format("2006-01-02"), endInclusive.Format("2006-01-02"), start, end, pageSize, protocols, nil
}
func normalizeProtocolPriority(input ProtocolPriority) ([]string, error) {
if !input.Present && input.Values == nil {
return nil, nil
}
if len(input.Values) == 0 {
return nil, fmt.Errorf("%w: protocolPriority must not be empty", ErrInvalidRequest)
}
protocols := make([]string, 0, len(input.Values))
seen := make(map[string]bool, len(input.Values))
for _, protocol := range input.Values {
if seen[protocol] {
return nil, fmt.Errorf("%w: protocolPriority contains duplicate protocol", ErrInvalidRequest)
}
seen[protocol] = true
switch protocol {
case "GB32960", "JT808":
protocols = append(protocols, protocol)
case "MQTT":
protocols = append(protocols, "YUTONG_MQTT")
default:
return nil, fmt.Errorf("%w: protocolPriority contains unsupported protocol", ErrInvalidRequest)
}
}
return protocols, nil
}
func externalMileageProtocol(protocol string) string {
if protocol == "YUTONG_MQTT" {
return "MQTT"
}
return protocol
}
func normalizePlates(input []string, maximum int) ([]string, error) {
if len(input) > maximum {
return nil, fmt.Errorf("%w: plate numbers exceed %d", ErrInvalidRequest, maximum)
}
plates := make([]string, 0, len(input))
seen := map[string]bool{}
for _, raw := range input {
plate := strings.ToUpper(strings.TrimSpace(raw))
if plate == "" || len([]rune(plate)) > 32 {
return nil, fmt.Errorf("%w: invalid plate", ErrInvalidRequest)
}
if seen[plate] {
return nil, fmt.Errorf("%w: duplicate plate", ErrInvalidRequest)
}
seen[plate] = true
plates = append(plates, plate)
}
return plates, nil
}
func (s *Service) CreateApp(ctx context.Context, input AppInput, actor string) (AppCreated, error) {
from, to, err := s.validateAppInput(&input)
if err != nil {
return AppCreated{}, err
}
key, hash, prefix, err := newAppKey()
if err != nil {
return AppCreated{}, err
}
app, err := s.repository.CreateApp(ctx, input, hash, prefix, from, to, actor)
if err != nil {
return AppCreated{}, err
}
return AppCreated{App: app, AppKey: key}, nil
}
func (s *Service) ListApps(ctx context.Context) ([]App, error) {
return s.repository.ListApps(ctx)
}
func (s *Service) UpdateApp(ctx context.Context, id uint64, input AppInput, actor string) (App, error) {
from, to, err := s.validateAppInput(&input)
if err != nil {
return App{}, err
}
return s.repository.UpdateApp(ctx, id, input, from, to, actor)
}
func (s *Service) RotateKey(ctx context.Context, id uint64, actor string) (AppCreated, error) {
key, hash, prefix, err := newAppKey()
if err != nil {
return AppCreated{}, err
}
app, err := s.repository.RotateKey(ctx, id, hash, prefix, actor)
if err != nil {
return AppCreated{}, err
}
return AppCreated{App: app, AppKey: key}, nil
}
func (s *Service) ReplaceVehicleGrants(ctx context.Context, id uint64, request VehicleGrantRequest, actor string) ([]VehicleGrant, error) {
if len(request.Vehicles) > 5000 {
return nil, fmt.Errorf("%w: grants exceed 5000", ErrInvalidRequest)
}
grants := make([]parsedGrant, 0, len(request.Vehicles))
seen := map[string]bool{}
for _, input := range request.Vehicles {
vin := strings.ToUpper(strings.TrimSpace(input.VIN))
if len(vin) != 17 || seen[vin] {
return nil, fmt.Errorf("%w: invalid or duplicate VIN", ErrInvalidRequest)
}
seen[vin] = true
from, to, err := parseInterval(input.ValidFrom, input.ValidTo)
if err != nil {
return nil, err
}
grants = append(grants, parsedGrant{VIN: vin, ValidFrom: from, ValidTo: to})
}
return s.repository.ReplaceVehicleGrants(ctx, id, grants, actor)
}
func (s *Service) ListVehicleGrants(ctx context.Context, id uint64) ([]VehicleGrant, error) {
return s.repository.ListVehicleGrants(ctx, id)
}
func (s *Service) validateAppInput(input *AppInput) (time.Time, *time.Time, error) {
input.Name = strings.TrimSpace(input.Name)
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
if input.Status == "" {
input.Status = "enabled"
}
if input.Name == "" || len([]rune(input.Name)) > 96 || (input.Status != "enabled" && input.Status != "disabled") {
return time.Time{}, nil, fmt.Errorf("%w: invalid app", ErrInvalidRequest)
}
return parseInterval(input.ValidFrom, input.ValidTo)
}
func parseInterval(rawFrom, rawTo string) (time.Time, *time.Time, error) {
from, err := time.Parse(time.RFC3339, strings.TrimSpace(rawFrom))
if err != nil {
return time.Time{}, nil, fmt.Errorf("%w: validFrom must be RFC3339", ErrInvalidRequest)
}
var to *time.Time
if strings.TrimSpace(rawTo) != "" {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTo))
if err != nil || !parsed.After(from) {
return time.Time{}, nil, fmt.Errorf("%w: invalid validTo", ErrInvalidRequest)
}
to = &parsed
}
return from, to, nil
}
func newAppKey() (string, [sha256.Size]byte, string, error) {
var bytes [16]byte
if _, err := rand.Read(bytes[:]); err != nil {
return "", [sha256.Size]byte{}, "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
key := hex.EncodeToString(bytes[:])
return key, sha256.Sum256([]byte(key)), key[:8], nil
}
func vehicleVINs(vehicles map[string]AuthorizedVehicle) []string {
seen := map[string]bool{}
vins := make([]string, 0, len(vehicles))
for _, vehicle := range vehicles {
if !seen[vehicle.VIN] {
seen[vehicle.VIN] = true
vins = append(vins, vehicle.VIN)
}
}
sort.Strings(vins)
return vins
}
func vehiclePlates(vehicles map[string]AuthorizedVehicle) []string {
plates := make([]string, 0, len(vehicles))
for plate := range vehicles {
plates = append(plates, plate)
}
sort.Strings(plates)
return plates
}
func orderedVehicles(vehicles map[string]AuthorizedVehicle) []AuthorizedVehicle {
plates := vehiclePlates(vehicles)
out := make([]AuthorizedVehicle, 0, len(plates))
for _, plate := range plates {
out = append(out, vehicles[plate])
}
return out
}
func validDailyMileage(value DailyMileage) bool {
return value.MileageKm >= 0 &&
value.TotalMileageKm >= 0 &&
(value.Protocol == "GB32960" || value.Protocol == "YUTONG_MQTT" || value.Protocol == "JT808") &&
value.DataTime != "" &&
value.UpdatedAt != ""
}
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
missing := make([]string, 0)
for _, vin := range vins {
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
missing = append(missing, vin)
}
}
return missing
}
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
seen := make(map[string]struct{}, len(positions))
missing := make([]string, 0)
for _, position := range positions {
vin := position.vehicle.VIN
if _, ok := seen[vin]; ok {
continue
}
seen[vin] = struct{}{}
value, ok := values[dailyMileageKey(vin, position.date)]
if !ok || !validDailyMileage(value) {
missing = append(missing, vin)
}
}
sort.Strings(missing)
return missing
}
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
item.DailyMileageKm = &dailyMileage
totalMileage := value.TotalMileageKm
item.TotalMileageKm = &totalMileage
dataTime := value.DataTime
updatedAt := value.UpdatedAt
item.DataTime = &dataTime
item.UpdatedAt = &updatedAt
sourceProtocol := externalMileageProtocol(value.Protocol)
item.SourceProtocol = &sourceProtocol
item.Status = StatusNormal
}
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
item.DailyMileageKm = &dailyMileage
totalMileage := value.TotalMileageKm
item.TotalMileageKm = &totalMileage
dataTime := value.DataTime
updatedAt := value.UpdatedAt
item.DataTime = &dataTime
item.UpdatedAt = &updatedAt
sourceProtocol := externalMileageProtocol(value.Protocol)
item.SourceProtocol = &sourceProtocol
item.Status = StatusNormal
}
func dailyMileageKey(vin, date string) string {
return vin + "\x00" + date
}
func mileageRangeRequestHash(startDate, endDate string, plates []string, pageSize int, protocols []string) [sha256.Size]byte {
normalized := append([]string(nil), plates...)
sort.Strings(normalized)
return sha256.Sum256([]byte(startDate + "\x00" + endDate + "\x00" + strconv.Itoa(pageSize) + "\x00" + strings.Join(normalized, "\x00") + "\x01" + strings.Join(protocols, "\x00")))
}
func newSnapshotID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func mileageCursor(snapshotID string, offset int) string {
return base64.RawURLEncoding.EncodeToString([]byte(snapshotID + ":" + strconv.Itoa(offset)))
}
func parseMileageCursor(cursor string) (string, int, error) {
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cursor))
if err != nil {
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
}
parts := strings.Split(string(decoded), ":")
if len(parts) != 2 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(parts[0]) {
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
}
offset, err := strconv.Atoi(parts[1])
if err != nil || offset < 0 {
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
}
return parts[0], offset, nil
}
func round3(value float64) float64 {
if value >= 0 {
return float64(int64(value*1000+0.5)) / 1000
}
return float64(int64(value*1000-0.5)) / 1000
}

View File

@@ -0,0 +1,546 @@
package openplatform
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fakeRepository struct {
app AppCredential
authErr error
vehicles map[string]AuthorizedVehicle
hydrogen map[string]DailyHydrogen
mileage map[string]DailyMileage
priorMileage map[string]DailyMileage
authorizedVIN bool
totalMileage *TotalMileagePoint
audits []string
createdHash [sha256.Size]byte
createdPrefix string
requestedPlates []string
dailyVINs []string
dailyProtocols []string
rangeVINs []string
priorVINs []string
priorCalls int
snapshot MileageSnapshot
}
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
return f.app, f.authErr
}
func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, _ time.Time, _ time.Time) (map[string]AuthorizedVehicle, error) {
f.requestedPlates = append([]string(nil), plates...)
return f.vehicles, nil
}
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
f.dailyVINs = append([]string(nil), vins...)
return f.hydrogen, nil
}
func (f *fakeRepository) DailyMileage(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
f.dailyVINs = append([]string(nil), vins...)
f.dailyProtocols = append([]string(nil), protocols...)
return f.mileage, nil
}
func (f *fakeRepository) DailyMileageRange(_ context.Context, vins []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
f.rangeVINs = append([]string(nil), vins...)
f.dailyVINs = append([]string(nil), vins...)
f.dailyProtocols = append([]string(nil), protocols...)
return f.mileage, nil
}
func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
f.priorCalls++
f.priorVINs = append([]string(nil), vins...)
f.dailyVINs = append([]string(nil), vins...)
f.dailyProtocols = append([]string(nil), protocols...)
return f.priorMileage, nil
}
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
f.snapshot = snapshot
return nil
}
func (f *fakeRepository) LoadMileageSnapshot(_ context.Context, id string, appID uint64, _ time.Time) (MileageSnapshot, error) {
if f.snapshot.ID != id || f.snapshot.AppID != appID {
return MileageSnapshot{}, ErrInvalidRequest
}
return f.snapshot, nil
}
func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) {
return f.authorizedVIN, nil
}
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
return f.totalMileage, nil
}
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
f.audits = append(f.audits, endpoint+":"+result)
return nil
}
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
f.createdHash, f.createdPrefix = hash, prefix
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
}
func (f *fakeRepository) ListApps(context.Context) ([]App, error) { return nil, nil }
func (f *fakeRepository) UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) {
return App{}, nil
}
func (f *fakeRepository) RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) {
return App{}, nil
}
func (f *fakeRepository) ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) {
return nil, nil
}
func (f *fakeRepository) ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) {
return nil, nil
}
func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"},
vehicles: map[string]AuthorizedVehicle{
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
},
hydrogen: map[string]DailyHydrogen{
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
},
mileage: map[string]DailyMileage{
"LTEST32960VIN0001": {
VIN: "LTEST32960VIN0001", Protocol: "GB32960", MileageKm: 101.235, TotalMileageKm: 12345.679,
DataTime: "2026-07-01T23:59:00+08:00", UpdatedAt: "2026-07-02T00:01:00+08:00",
},
},
}
service := NewService(repository)
service.now = func() time.Time { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
request := QueryRequest{PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01"}
const key = "0123456789abcdef0123456789abcdef"
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h", request)
if err != nil {
t.Fatal(err)
}
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
t.Fatalf("hydrogen = %#v", hydrogen)
}
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
if err != nil {
t.Fatal(err)
}
if len(mileage) != 2 || mileage[0].VIN != "LTEST32960VIN0001" || mileage[0].DailyMileageKm == nil || *mileage[0].DailyMileageKm != 101.235 || mileage[0].TotalMileageKm == nil || *mileage[0].TotalMileageKm != 12345.679 || mileage[0].DataTime == nil || mileage[0].UpdatedAt == nil || mileage[1].Status != StatusNoData || mileage[1].TotalMileageKm != nil || mileage[1].DataTime != nil {
t.Fatalf("mileage = %#v", mileage)
}
}
func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"},
vehicles: map[string]AuthorizedVehicle{
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
},
hydrogen: map[string]DailyHydrogen{
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 8.5, QualityStatus: "OK"},
},
mileage: map[string]DailyMileage{
"LTEST32960VIN0002": {
VIN: "LTEST32960VIN0002", Protocol: "YUTONG_MQTT", MileageKm: 88.8, TotalMileageKm: 10088.8,
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
},
},
}
service := NewService(repository)
request := QueryRequest{Date: "2026-07-01"}
const key = "0123456789abcdef0123456789abcdef"
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h-all", request)
if err != nil {
t.Fatal(err)
}
if len(repository.requestedPlates) != 0 || len(hydrogen) != 2 || hydrogen[0].PlateNumber != "粤A12345" || hydrogen[1].PlateNumber != "粤B67890" {
t.Fatalf("requested=%v hydrogen=%#v", repository.requestedPlates, hydrogen)
}
if len(repository.dailyVINs) != 2 || repository.dailyVINs[0] != "LTEST32960VIN0001" || repository.dailyVINs[1] != "LTEST32960VIN0002" {
t.Fatalf("daily VINs=%v", repository.dailyVINs)
}
mileage, err := service.QueryMileage(context.Background(), key, "trace-m-all", QueryRequest{PlateNumbers: []string{}, Date: "2026-07-01"})
if err != nil {
t.Fatal(err)
}
if len(mileage) != 2 || mileage[0].PlateNumber != "粤A12345" || mileage[1].PlateNumber != "粤B67890" || mileage[1].DailyMileageKm == nil {
t.Fatalf("mileage=%#v", mileage)
}
}
func TestMileageProtocolPriorityValidationAndExternalSourceName(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
},
mileage: map[string]DailyMileage{
"LTEST32960VIN0001": {
VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", MileageKm: 0, TotalMileageKm: 12345,
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
},
},
}
service := NewService(repository)
priority := ProtocolPriority{Values: []string{"JT808", "GB32960", "MQTT"}}
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-priority", QueryRequest{
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01", ProtocolPriority: priority,
})
if err != nil {
t.Fatal(err)
}
if strings.Join(repository.dailyProtocols, ",") != "JT808,GB32960,YUTONG_MQTT" {
t.Fatalf("internal protocols=%v", repository.dailyProtocols)
}
if len(result) != 1 || result[0].DailyMileageKm == nil || *result[0].DailyMileageKm != 0 || result[0].SourceProtocol == nil || *result[0].SourceProtocol != "MQTT" {
t.Fatalf("result=%#v", result)
}
for _, values := range [][]string{{}, {"YUTONG_MQTT"}, {"GB32960", "GB32960"}, {"mqtt"}} {
invalid := ProtocolPriority{Values: values, Present: true}
_, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-invalid", QueryRequest{
Date: "2026-07-01", ProtocolPriority: invalid,
})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("priority=%v error=%v", values, err)
}
}
}
func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing.T) {
previous := DailyMileage{
VIN: "LTEST32960VIN0001", Date: "2026-07-20", Protocol: "YUTONG_MQTT",
MileageKm: 35.5, TotalMileageKm: 8888.8,
DataTime: "2026-07-20T23:58:00+08:00", UpdatedAt: "2026-07-21T01:15:00+08:00",
}
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
},
mileage: map[string]DailyMileage{},
priorMileage: map[string]DailyMileage{"LTEST32960VIN0001": previous},
}
service := NewService(repository)
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry", QueryRequest{
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-21",
})
if err != nil {
t.Fatal(err)
}
item := result[0]
if item.Status != StatusNormal || item.DailyMileageKm == nil || *item.DailyMileageKm != 0 ||
item.TotalMileageKm == nil || *item.TotalMileageKm != 8888.8 ||
item.SourceProtocol == nil || *item.SourceProtocol != "MQTT" ||
item.DataTime == nil || *item.DataTime != previous.DataTime ||
item.UpdatedAt == nil || *item.UpdatedAt != previous.UpdatedAt {
t.Fatalf("carried result=%#v", item)
}
rangeResult, err := service.QueryMileageRange(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry-range", MileageRangeRequest{
StartDate: "2026-07-21", EndDate: "2026-07-22", PageSize: 10,
})
if err != nil {
t.Fatal(err)
}
if len(rangeResult.Data) != 2 {
t.Fatalf("range=%#v", rangeResult)
}
for _, row := range rangeResult.Data {
if row.DailyMileageKm == nil || *row.DailyMileageKm != 0 ||
row.TotalMileageKm == nil || *row.TotalMileageKm != previous.TotalMileageKm ||
row.UpdatedAt == nil || *row.UpdatedAt != previous.UpdatedAt {
t.Fatalf("carried range row=%#v", row)
}
}
}
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"},
vehicles: map[string]AuthorizedVehicle{
"沪A00002": {VIN: "LTEST32960VIN0002", Plate: "沪A00002"},
"沪A00001": {VIN: "LTEST32960VIN0001", Plate: "沪A00001"},
},
mileage: map[string]DailyMileage{
dailyMileageKey("LTEST32960VIN0001", "2026-07-01"): {
VIN: "LTEST32960VIN0001", Date: "2026-07-01", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 12000,
DataTime: "2026-07-01T23:58:45+08:00", UpdatedAt: "2026-07-02T05:10:00+08:00",
},
dailyMileageKey("LTEST32960VIN0001", "2026-07-02"): {
VIN: "LTEST32960VIN0001", Date: "2026-07-02", Protocol: "GB32960", MileageKm: 10.25, TotalMileageKm: 12010.25,
DataTime: "2026-07-02T20:00:00+08:00", UpdatedAt: "2026-07-02T20:00:01+08:00",
},
dailyMileageKey("LTEST32960VIN0002", "2026-07-02"): {
VIN: "LTEST32960VIN0002", Date: "2026-07-02", Protocol: "JT808", MileageKm: 8.5, TotalMileageKm: 9008.5,
DataTime: "2026-07-02T21:00:00+08:00", UpdatedAt: "2026-07-02T21:00:01+08:00",
},
},
}
service := NewService(repository)
service.now = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
request := MileageRangeRequest{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 3}
const key = "0123456789abcdef0123456789abcdef"
first, err := service.QueryMileageRange(context.Background(), key, "trace-range-1", request)
if err != nil {
t.Fatal(err)
}
if len(first.Data) != 3 || first.SnapshotID == "" || first.NextCursor == nil {
t.Fatalf("first=%#v", first)
}
if first.Data[0].PlateNumber != "沪A00001" || first.Data[0].DailyMileageKm == nil || *first.Data[0].DailyMileageKm != 0 || first.Data[0].Status != StatusNormal {
t.Fatalf("zero mileage row=%#v", first.Data[0])
}
if first.Data[1].PlateNumber != "沪A00002" || first.Data[1].Status != StatusNoData || first.Data[1].DailyMileageKm != nil || first.Data[1].DataTime != nil {
t.Fatalf("no-data row=%#v", first.Data[1])
}
if first.Data[2].Date != "2026-07-02" || first.Data[2].PlateNumber != "沪A00001" {
t.Fatalf("third row=%#v", first.Data[2])
}
repository.vehicles = map[string]AuthorizedVehicle{}
request.Cursor = *first.NextCursor
changedPriority := ProtocolPriority{Values: []string{"JT808"}}
changedRequest := request
changedRequest.ProtocolPriority = changedPriority
if _, err := service.QueryMileageRange(context.Background(), key, "trace-range-changed", changedRequest); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("changed protocol priority must invalidate cursor: %v", err)
}
second, err := service.QueryMileageRange(context.Background(), key, "trace-range-2", request)
if err != nil {
t.Fatal(err)
}
if second.SnapshotID != first.SnapshotID || second.NextCursor != nil || len(second.Data) != 1 || second.Data[0].PlateNumber != "沪A00002" || second.Data[0].Date != "2026-07-02" {
t.Fatalf("second=%#v", second)
}
}
func TestMileageRangeOnlyLoadsPriorMileageForVINsMissingAtTheirFirstPageDate(t *testing.T) {
const (
firstVIN = "LTEST32960VIN0001"
secondVIN = "LTEST32960VIN0002"
)
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{
"沪A00001": {VIN: firstVIN, Plate: "沪A00001"},
"沪A00002": {VIN: secondVIN, Plate: "沪A00002"},
},
mileage: map[string]DailyMileage{
dailyMileageKey(firstVIN, "2026-07-27"): {
VIN: firstVIN, Date: "2026-07-27", Protocol: "GB32960",
MileageKm: 12.5, TotalMileageKm: 12012.5,
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
},
},
priorMileage: map[string]DailyMileage{
secondVIN: {
VIN: secondVIN, Date: "2026-07-26", Protocol: "JT808",
MileageKm: 8, TotalMileageKm: 9008,
DataTime: "2026-07-26T23:00:00+08:00", UpdatedAt: "2026-07-27T01:00:00+08:00",
},
},
}
result, err := NewService(repository).QueryMileageRange(
context.Background(),
"0123456789abcdef0123456789abcdef",
"trace-range-missing-only",
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
)
if err != nil {
t.Fatal(err)
}
if repository.priorCalls != 1 || len(repository.priorVINs) != 1 || repository.priorVINs[0] != secondVIN {
t.Fatalf("prior calls=%d vins=%v, want only %s", repository.priorCalls, repository.priorVINs, secondVIN)
}
if len(result.Data) != 2 || result.Data[0].DailyMileageKm == nil || *result.Data[0].DailyMileageKm != 12.5 ||
result.Data[1].DailyMileageKm == nil || *result.Data[1].DailyMileageKm != 0 {
t.Fatalf("result=%#v", result)
}
}
func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testing.T) {
const vin = "LTEST32960VIN0001"
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{
"沪A00001": {VIN: vin, Plate: "沪A00001"},
},
mileage: map[string]DailyMileage{
dailyMileageKey(vin, "2026-07-27"): {
VIN: vin, Date: "2026-07-27", Protocol: "GB32960",
MileageKm: 12.5, TotalMileageKm: 12012.5,
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
},
},
}
if _, err := NewService(repository).QueryMileageRange(
context.Background(),
"0123456789abcdef0123456789abcdef",
"trace-range-no-prior",
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
); err != nil {
t.Fatal(err)
}
if repository.priorCalls != 0 || len(repository.priorVINs) != 0 {
t.Fatalf("prior calls=%d vins=%v, want no lookup", repository.priorCalls, repository.priorVINs)
}
}
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
service := NewService(&fakeRepository{})
for _, request := range []MileageRangeRequest{
{StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100},
{StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100},
{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001},
} {
if _, _, _, _, _, _, _, err := service.validateMileageRange(request); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("request=%#v error=%v", request, err)
}
}
}
func TestTotalMileageReturnsRecordTimeDifferenceAndProtocolMeaning(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
repository := &fakeRepository{
app: AppCredential{ID: 7}, authorizedVIN: true,
totalMileage: &TotalMileagePoint{VIN: "LA9GG68L2PBAF4790", Protocol: "GB32960", ObservedAt: time.Date(2026, 7, 21, 9, 29, 45, 0, location), TotalMileageKm: 12345.6784},
}
service := NewService(repository)
service.now = func() time.Time { return time.Date(2026, 7, 21, 12, 0, 0, 0, location) }
result, err := service.QueryTotalMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-total", TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00"})
if err != nil {
t.Fatal(err)
}
if result.TotalMileageKm == nil || *result.TotalMileageKm != 12345.678 || result.Protocol != "GB32960" || result.RecordTime != "2026-07-21 09:29:45" || result.TimeDifferenceSeconds == nil || *result.TimeDifferenceSeconds != 15 || result.Status != StatusNormal || !strings.Contains(result.MileageMeaning, "仪表盘") {
t.Fatalf("result = %#v", result)
}
}
func TestTotalMileageCanonicalProtocolsAndStrictTime(t *testing.T) {
service := NewService(&fakeRepository{})
for _, input := range []string{"GB32960", "YUTONG_MQTT", "JT808"} {
_, _, protocols, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: input})
if err != nil || len(protocols) != 1 || protocols[0] != input {
t.Fatalf("%s: protocols=%v err=%v", input, protocols, err)
}
}
for _, alias := range []string{"32960", "mqtt", "808"} {
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: alias}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("alias %s should be rejected: %v", alias, err)
}
}
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026/07/21 09:30"}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("error=%v", err)
}
}
func TestQueryFailsClosedWhenAnyVehicleIsNotGranted(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}},
}
_, err := NewService(repository).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{
PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01",
})
if !errors.Is(err, ErrForbidden) {
t.Fatalf("error = %v", err)
}
}
func TestCreateAppReturns32CharacterKeyOnlyOnce(t *testing.T) {
repository := &fakeRepository{}
service := NewService(repository)
created, err := service.CreateApp(context.Background(), AppInput{
Name: "partner", ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
}, "admin")
if err != nil {
t.Fatal(err)
}
if !appKeyPattern.MatchString(created.AppKey) || len(created.AppKey) != 32 || created.AppKeyPrefix != created.AppKey[:8] {
t.Fatalf("created = %#v", created)
}
if created.AppKey[12] != '4' || !strings.ContainsRune("89ab", rune(created.AppKey[16])) {
t.Fatalf("appKey is not UUID v4: %s", created.AppKey)
}
if repository.createdHash != sha256.Sum256([]byte(created.AppKey)) || repository.createdPrefix != created.AppKey[:8] {
t.Fatal("repository did not receive the appKey hash and prefix")
}
}
func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
repository := &fakeRepository{app: AppCredential{ID: 1}, vehicles: map[string]AuthorizedVehicle{
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
}, hydrogen: map[string]DailyHydrogen{}}
handler := NewHandler(NewService(repository))
request := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026-07-01"}`))
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
}
var body ExternalResponse
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != "SUCCESS" || body.Message != "success" || len(body.TraceID) != 32 {
t.Fatalf("body = %#v", body)
}
allRequest := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01"}`))
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
allResponse := httptest.NewRecorder()
handler.ServeHTTP(allResponse, allRequest)
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
}
rangeRequest := httptest.NewRequest(http.MethodPost, MileageRangeQueryPath, strings.NewReader(`{"startDate":"2026-07-01","endDate":"2026-07-01","pageSize":10}`))
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
rangeResponse := httptest.NewRecorder()
handler.ServeHTTP(rangeResponse, rangeRequest)
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
}
badDate := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026/07/01"}`))
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
badResponse := httptest.NewRecorder()
handler.ServeHTTP(badResponse, badDate)
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
}
badProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":["GB32960","GB32960"]}`))
badProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
badProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(badProtocolResponse, badProtocol)
if badProtocolResponse.Code != http.StatusBadRequest ||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
}
nullProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":null}`))
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
}
}

View File

@@ -0,0 +1,101 @@
package openplatform
import (
"database/sql"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type StandaloneConfig struct {
StaticDir string
SessionTTL time.Duration
RequestTimeout time.Duration
Release string
TDengine *sql.DB
TDengineDatabase string
}
func NewStandaloneServer(db *sql.DB, cfg StandaloneConfig) http.Handler {
repository := NewMySQLRepository(db)
if cfg.TDengine != nil {
repository.WithTDengine(cfg.TDengine, cfg.TDengineDatabase)
}
service := NewService(repository)
portal := NewPortalService(db, cfg.SessionTTL)
external := NewExternalHandler(service, portal)
api := http.NewServeMux()
api.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeStandaloneJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "open-platform-api",
"release": strings.TrimSpace(cfg.Release),
})
})
api.Handle("/", external)
handler := standaloneStatic(cfg.StaticDir, api)
handler = WithDocs(handler)
handler = standaloneSecurityHeaders(handler)
timeout := cfg.RequestTimeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return http.TimeoutHandler(handler, timeout, `{"error":{"code":"REQUEST_TIMEOUT","message":"请求处理超时"}}`)
}
func standaloneStatic(dir string, fallback http.Handler) http.Handler {
dir = strings.TrimSpace(dir)
if dir == "" {
return fallback
}
files := http.FileServer(http.Dir(dir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isStandaloneAPIRoute(r.URL.Path) {
fallback.ServeHTTP(w, r)
return
}
clean := filepath.Clean("/" + r.URL.Path)
path := filepath.Join(dir, clean)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
if clean == "/index.html" {
w.Header().Set("Cache-Control", "no-cache")
} else if strings.HasPrefix(clean, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
})
}
func isStandaloneAPIRoute(path string) bool {
return path == "/healthz" ||
strings.HasPrefix(path, "/api/") ||
strings.HasPrefix(path, "/portal-api/") ||
strings.HasPrefix(path, "/open-api/")
}
func standaloneSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
func writeStandaloneJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

View File

@@ -0,0 +1,121 @@
package openplatform
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestPortalDecodeDetail(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "empty", err: io.EOF, want: "请求内容不能为空"},
{name: "syntax", err: &json.SyntaxError{Offset: 2}, want: "请求内容不是有效的 JSON"},
{name: "type", err: &json.UnmarshalTypeError{Field: "validFrom"}, want: "字段 validFrom 的数据类型不正确"},
{name: "unknown", err: errors.New(`json: unknown field "appId"`), want: `请求包含未支持的字段 "appId"`},
{name: "other", err: errors.New("unexpected"), want: "请求内容无法解析"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := portalDecodeDetail(test.err); got != test.want {
t.Fatalf("portalDecodeDetail() = %q, want %q", got, test.want)
}
})
}
}
func TestStandaloneServerServesPortalDocsAndCatalog(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
staticDir := t.TempDir()
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<div id="root"></div>`), 0o600); err != nil {
t.Fatal(err)
}
handler := NewStandaloneServer(db, StandaloneConfig{
StaticDir: staticDir,
SessionTTL: time.Hour,
RequestTimeout: time.Second,
Release: "test-release",
})
tests := []struct {
path string
content string
contentType string
}{
{"/", `<div id="root"></div>`, "text/html"},
{"/healthz", `"service":"open-platform-api"`, "application/json"},
{"/portal-api/catalog", `"daily_hydrogen"`, "application/json"},
{"/open-api/openapi.yaml", "openapi: 3.0.3", "application/yaml"},
}
for _, item := range tests {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, item.path, nil))
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), item.content) {
t.Fatalf("path=%s status=%d body=%s", item.path, recorder.Code, recorder.Body.String())
}
if value := recorder.Header().Get("Content-Type"); !strings.Contains(value, item.contentType) {
t.Fatalf("path=%s content-type=%s", item.path, value)
}
if recorder.Header().Get("X-Frame-Options") != "DENY" {
t.Fatalf("path=%s missing security headers", item.path)
}
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestStandalonePortalSessionRequiresBearerToken(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
handler := NewStandaloneServer(db, StandaloneConfig{RequestTimeout: time.Second})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/portal-api/session", nil))
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "UNAUTHORIZED") {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPortalCredentialValidation(t *testing.T) {
input := PortalUserInput{
Username: "partner.dev", DisplayName: "合作伙伴开发者",
Password: "StrongPass2026", Status: "enabled",
ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
}
if _, _, err := validatePortalUserInput(&input, true); err != nil {
t.Fatalf("valid portal user rejected: %v", err)
}
if err := validatePortalPassword("weak-password"); err == nil {
t.Fatal("weak password should be rejected")
}
if !validPortalRole("owner") || !validPortalRole("developer") || !validPortalRole("viewer") || validPortalRole("admin") {
t.Fatal("unexpected portal role validation")
}
raw, hash, err := newPortalSessionToken()
if err != nil || len(raw) != 64 || hash == [32]byte{} {
t.Fatalf("invalid session token raw=%d err=%v", len(raw), err)
}
}

View File

@@ -0,0 +1,338 @@
package openplatform
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
)
var hydrogenMassFields = []string{
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg",
"gb32960.gd_fc_vehicle.hydrogen_mass_kg",
"gb32960.gd_fc_vehicle_info.gd_fc_vehicle_hydrogen_mass_kg",
"gd_fc_vehicle_hydrogen_mass_kg",
}
func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat {
if noiseKg <= 0 {
noiseKg = 0.05
}
if maxDropKg <= noiseKg {
maxDropKg = 20
}
grouped := map[string]map[string][]HydrogenObservation{}
for _, observation := range observations {
observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN))
observation.Source = strings.TrimSpace(observation.Source)
if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 {
continue
}
if grouped[observation.VIN] == nil {
grouped[observation.VIN] = map[string][]HydrogenObservation{}
}
grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation)
}
vins := make([]string, 0, len(grouped))
for vin := range grouped {
vins = append(vins, vin)
}
sort.Strings(vins)
stats := make([]HydrogenDailyStat, 0, len(vins))
for _, vin := range vins {
var selected *HydrogenDailyStat
for source, values := range grouped[vin] {
candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg)
if selected == nil || betterHydrogenStat(candidate, *selected) {
copy := candidate
selected = &copy
}
}
if selected != nil {
stats = append(stats, *selected)
}
}
return stats
}
func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
stat := HydrogenDailyStat{
VIN: vin, Source: source, Date: date,
FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg,
SampleCount: len(values), QualityStatus: "OK",
}
abnormalDrops := 0
cycleMinimum := values[0].MassKg
for index := 1; index < len(values); index++ {
value := values[index]
sampleNoise := noiseKg
if value.NoiseKg > sampleNoise {
sampleNoise = value.NoiseKg
}
refuelThreshold := math.Max(1, cycleMinimum*0.05)
if value.RefuelThresholdKg > 0 {
refuelThreshold = value.RefuelThresholdKg
}
delta := cycleMinimum - value.MassKg
switch {
case value.MassKg-cycleMinimum > refuelThreshold:
stat.RefuelCount++
cycleMinimum = value.MassKg
case delta > sampleNoise && delta <= maxDropKg:
stat.ConsumptionKg += delta
cycleMinimum = value.MassKg
case delta > maxDropKg:
abnormalDrops++
}
}
stat.ConsumptionKg = round3(stat.ConsumptionKg)
if stat.SampleCount < 2 {
stat.QualityStatus = "NO_DATA"
stat.QualityReason = "有效车载氢量样本不足2条"
} else if abnormalDrops > 0 {
stat.QualityStatus = "SUSPECT"
stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg)
}
return stat
}
func betterHydrogenStat(candidate, current HydrogenDailyStat) bool {
rank := func(status string) int {
switch status {
case "OK":
return 0
case "SUSPECT":
return 1
default:
return 2
}
}
if rank(candidate.QualityStatus) != rank(current.QualityStatus) {
return rank(candidate.QualityStatus) < rank(current.QualityStatus)
}
if candidate.SampleCount != current.SampleCount {
return candidate.SampleCount > current.SampleCount
}
return candidate.Source < current.Source
}
func ExtractHydrogenMass(parsedJSON string) (float64, bool) {
var fields map[string]any
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
return 0, false
}
for _, key := range hydrogenMassFields {
if value, ok := numericValue(fields[key]); ok && value >= 0 && value <= 200 {
return value, true
}
}
return 0, false
}
func ExtractHydrogenRateAndMileage(parsedJSON string) (float64, float64, bool) {
var fields map[string]any
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
return 0, 0, false
}
rate, rateOK := numericValue(fields["gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"])
mileage, mileageOK := numericValue(fields["gb32960.vehicle.total_mileage_km"])
return rate, mileage, rateOK && mileageOK && rate >= 0 && rate <= 50 && mileage > 0
}
func BuildHydrogenRateDailyStats(observations []HydrogenRateObservation, date string, maxDeltaKm float64) []HydrogenRateDailyStat {
if maxDeltaKm <= 0 {
maxDeltaKm = 10
}
grouped := map[string]map[string][]HydrogenRateObservation{}
for _, value := range observations {
value.VIN = strings.ToUpper(strings.TrimSpace(value.VIN))
if len(value.VIN) != 17 || value.Rate < 0 || value.Rate > 50 || value.MileageKm <= 0 {
continue
}
if grouped[value.VIN] == nil {
grouped[value.VIN] = map[string][]HydrogenRateObservation{}
}
grouped[value.VIN][strings.TrimSpace(value.Source)] = append(grouped[value.VIN][strings.TrimSpace(value.Source)], value)
}
result := make([]HydrogenRateDailyStat, 0, len(grouped))
for vin, sources := range grouped {
var best *HydrogenRateDailyStat
for source, values := range sources {
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
stat := HydrogenRateDailyStat{VIN: vin, Source: source, Date: date, SampleCount: len(values), QualityStatus: "NO_DATA", QualityReason: "尚无有效行驶里程区间"}
movement, abnormal := 0, 0
for i := 1; i < len(values); i++ {
delta := values[i].MileageKm - values[i-1].MileageKm
if delta > 0 && delta <= maxDeltaKm {
stat.ConsumptionKg += delta * (values[i-1].Rate + values[i].Rate) / 200
movement++
} else if delta < 0 || delta > maxDeltaKm {
abnormal++
}
}
stat.ConsumptionKg = round3(stat.ConsumptionKg)
if abnormal > 0 {
stat.QualityStatus, stat.QualityReason = "SUSPECT", "存在异常里程跳变"
} else if movement > 0 {
stat.QualityStatus, stat.QualityReason = "OK", "按里程区间积分百公里氢耗"
}
if best == nil || (stat.QualityStatus == "OK" && best.QualityStatus != "OK") || (stat.QualityStatus == best.QualityStatus && stat.SampleCount > best.SampleCount) {
copy := stat
best = &copy
}
}
if best != nil {
result = append(result, *best)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN })
return result
}
func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64, error) {
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`)
if err != nil {
return nil, err
}
defer rows.Close()
capacities := map[string]float64{}
for rows.Next() {
var vin string
var capacity float64
if err := rows.Scan(&vin, &capacity); err != nil {
return nil, err
}
if len(vin) == 17 && capacity > 0 && capacity <= 10000 {
capacities[vin] = capacity
}
}
return capacities, rows.Err()
}
func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) {
database = strings.TrimSpace(database)
if database == "" {
database = "lingniu_vehicle_ts"
}
query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json
FROM ` + database + `.raw_frames
WHERE protocol='GB32960'
AND ts>='` + quoteTDTime(start) + `'
AND ts<'` + quoteTDTime(end) + `'
AND parse_status='OK'
ORDER BY vin,source_endpoint,ts`
rows, err := tdengine.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
observations := make([]HydrogenObservation, 0)
for rows.Next() {
var vin, parsed string
var source sql.NullString
var unixMS int64
if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil {
return nil, err
}
vin = strings.ToUpper(strings.TrimSpace(vin))
capacity, capacityOK := capacities[vin]
pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed)
if !capacityOK || !ok {
continue
}
mass, ok := PressureHydrogenMassKg(pressure, temperature, capacity)
if !ok {
continue
}
stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity)
noise := math.Min(1, math.Max(0.05, mass-stepMass))
observations = append(observations, HydrogenObservation{
VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass,
TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature,
NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05),
})
}
return observations, rows.Err()
}
func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) {
var fields map[string]any
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
return 0, 0, false
}
pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
return pressure, temperature, pressureOK && temperatureOK
}
func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) {
temperatureK := temperatureC + 273.15
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 {
return 0, false
}
a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
z := 1.0
for index := range a {
z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index])
}
if z <= 0 {
return 0, false
}
density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000
mass := density * capacityLiter / 1000
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
}
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// A pressure-based rebuild is authoritative for the whole day. Delete every
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
// for vehicles without valid pressure observations in this run.
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
return err
}
for _, stat := range stats {
if _, err := tx.ExecContext(ctx, `
INSERT INTO vehicle_open_daily_energy(
vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg,
sample_count,refuel_count,quality_status,quality_reason,calculated_at
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`,
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
); err != nil {
return err
}
}
return tx.Commit()
}
func numericValue(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}
func quoteTDTime(value time.Time) string {
return strings.ReplaceAll(value.Format(time.RFC3339Nano), "'", "''")
}

Some files were not shown because too many files have changed in this diff Show More