Compare commits
220 Commits
1243efc7dd
...
codex/vehi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f3904ad7 | ||
|
|
8539af13e6 | ||
|
|
3c4bece72c | ||
|
|
e3a1f80f86 | ||
|
|
5a3b2e188e | ||
|
|
9d9185790f | ||
|
|
ddbba18431 | ||
|
|
492c48cf47 | ||
|
|
70211e1d7c | ||
|
|
ebf682d863 | ||
|
|
6574b49ff9 | ||
|
|
6245a9b979 | ||
|
|
02472dc4b4 | ||
|
|
1a5bfba20e | ||
|
|
19b187d942 | ||
|
|
3c9eea527c | ||
|
|
19c66dc1f6 | ||
|
|
3d4e1fc01b | ||
|
|
1a73b8bbd4 | ||
|
|
1eba0ad294 | ||
|
|
34d643cc87 | ||
|
|
8e54e08dba | ||
|
|
a8bbbbc5c4 | ||
|
|
4de487e057 | ||
|
|
270bb163eb | ||
|
|
2c09fd7cf0 | ||
|
|
843690cc71 | ||
|
|
c150e45f11 | ||
|
|
9eb77a2d57 | ||
|
|
952f886459 | ||
|
|
04ec00d4be | ||
|
|
adf92ecd17 | ||
|
|
758da369d8 | ||
|
|
5ba636419b | ||
|
|
df03aaac6a | ||
|
|
dfa2782db6 | ||
|
|
0417056eeb | ||
|
|
2eed8d0da4 | ||
|
|
40fd7fe009 | ||
|
|
319728f976 | ||
|
|
537f8d9b45 | ||
|
|
6e9a170fbf | ||
|
|
275d453954 | ||
|
|
2ef027c7c2 | ||
|
|
58ab1ff7bb | ||
|
|
cd13a2952d | ||
|
|
e20f320f6c | ||
|
|
1c18941980 | ||
|
|
4c64c4c738 | ||
|
|
e45daff528 | ||
|
|
2f195e725a | ||
|
|
f8baaf53ba | ||
|
|
d0c2bb3316 | ||
|
|
5539c5e0c2 | ||
|
|
2b18a400b1 | ||
|
|
f59148e260 | ||
|
|
e944b797e5 | ||
|
|
58f2a77033 | ||
|
|
2c93999417 | ||
|
|
0f51e3d375 | ||
|
|
adc01f2fa4 | ||
|
|
53767c707c | ||
|
|
263107d47a | ||
|
|
a13ee8fc30 | ||
|
|
fa2dcf14d6 | ||
|
|
a198e0fc4e | ||
|
|
40c210a192 | ||
|
|
fa60bbb2c8 | ||
|
|
81532d91b7 | ||
|
|
fb105a67c8 | ||
|
|
476069adc6 | ||
|
|
2d987fd185 | ||
|
|
4da26ad3ba | ||
|
|
dffe559160 | ||
|
|
4e4f383988 | ||
|
|
f825c9f417 | ||
|
|
1e57fdfff1 | ||
|
|
7df015b0f0 | ||
|
|
031e22de79 | ||
|
|
2e87378c92 | ||
|
|
343e7c9b89 | ||
|
|
61ca72fab2 | ||
|
|
a82bb28af9 | ||
|
|
1be76c1dfa | ||
|
|
e02d8dba54 | ||
|
|
dcc6b223d9 | ||
|
|
add47b7efc | ||
|
|
a6f6ce260f | ||
|
|
39c828ed32 | ||
|
|
8372ca5349 | ||
|
|
26ccd2434a | ||
|
|
c3881bf25c | ||
|
|
49ae75937e | ||
|
|
af71468e63 | ||
|
|
6795a8ea6d | ||
|
|
3e80da1b73 | ||
|
|
af608b35e0 | ||
|
|
3b5a66b687 | ||
|
|
f2fe5404d9 | ||
|
|
4bd1ad8a85 | ||
|
|
860978fad8 | ||
|
|
63b50732cb | ||
|
|
7eca5ae352 | ||
|
|
2bae87f6a2 | ||
|
|
9a87d14d6d | ||
|
|
d3eb77e2c7 | ||
|
|
712721bc73 | ||
|
|
8168acc107 | ||
|
|
2372d63823 | ||
|
|
bbacf3b913 | ||
|
|
c695d6e431 | ||
|
|
f4f8bf9f4b | ||
|
|
dcfd78f73b | ||
|
|
3770afabec | ||
|
|
e7b49df8ed | ||
|
|
82f81eee33 | ||
|
|
862671c083 | ||
|
|
23b632a2e0 | ||
|
|
c115b28bfe | ||
|
|
045482c161 | ||
|
|
c8814f471b | ||
|
|
c358faf17d | ||
|
|
a7297abdaf | ||
|
|
2824c08eb6 | ||
|
|
b465d1ff7a | ||
|
|
76d35f54c5 | ||
|
|
fd5e16c36c | ||
|
|
e40eabac92 | ||
|
|
911addd891 | ||
|
|
22f1548f8a | ||
|
|
14e87f143d | ||
|
|
9101d26005 | ||
|
|
b5b235e394 | ||
|
|
e95fbe5c19 | ||
|
|
532524b992 | ||
|
|
5d4aa92cc1 | ||
|
|
84684f2dc1 | ||
|
|
db1079ae27 | ||
|
|
23669d8190 | ||
|
|
5bfdc7f637 | ||
|
|
801a9851ea | ||
|
|
82d78926c6 | ||
|
|
29b9ce9594 | ||
|
|
dbd1d9d6ca | ||
|
|
f7b523453c | ||
|
|
8827a05e7e | ||
|
|
cedb4f66b4 | ||
|
|
f28dfebaae | ||
|
|
f0c17eac4d | ||
|
|
8a2edc8bc9 | ||
|
|
b764ba1be8 | ||
|
|
50b5107bc2 | ||
|
|
fe056019eb | ||
|
|
c57caabaa0 | ||
|
|
01d165d771 | ||
|
|
32c8939eb5 | ||
|
|
10a072280b | ||
|
|
3b06dd86a8 | ||
|
|
c7123f6fbd | ||
|
|
ad81d108a7 | ||
|
|
8aa273e579 | ||
|
|
e5bf76ea15 | ||
|
|
a00276816b | ||
|
|
f423240e6f | ||
|
|
09e16e8d26 | ||
|
|
0802b1989a | ||
|
|
622c4398ec | ||
|
|
5805635ccf | ||
|
|
bba6a621aa | ||
|
|
3cd19b276d | ||
|
|
41b5c41998 | ||
|
|
c1c35f8962 | ||
|
|
04d5992252 | ||
|
|
71f334d259 | ||
|
|
1688ba1b50 | ||
|
|
fb7cb4c6d6 | ||
|
|
31dd36ee70 | ||
|
|
9a49d015cf | ||
|
|
aca0edf130 | ||
|
|
1adcff5ecb | ||
|
|
fb171fb0de | ||
|
|
40fcb6fc7b | ||
|
|
ffb1283807 | ||
|
|
0c6a3e4ef1 | ||
|
|
45c0d4d9a3 | ||
|
|
b54d5ba077 | ||
|
|
4b3bb3b3dd | ||
|
|
4bca0482c1 | ||
|
|
f7cd3020f7 | ||
|
|
24d3c1dcac | ||
|
|
6bb2107f5e | ||
|
|
018ec20256 | ||
|
|
32d97a0ddc | ||
|
|
45820daa46 | ||
|
|
9fa68e4ed7 | ||
|
|
80f6880488 | ||
|
|
800d956159 | ||
|
|
866dfdaf5f | ||
|
|
cff2e07a57 | ||
|
|
8291f5ea69 | ||
|
|
3cebfe1982 | ||
|
|
e39140c408 | ||
|
|
91db15ebde | ||
|
|
bb3820bfaa | ||
|
|
1a3a7d49ec | ||
|
|
c29d41fd86 | ||
|
|
5a8eec16f3 | ||
|
|
da9619617d | ||
|
|
f3c1391322 | ||
|
|
1affd30366 | ||
|
|
6d63783630 | ||
|
|
5c0a61f7bd | ||
|
|
1cd92715a5 | ||
|
|
159c80b0ae | ||
|
|
65b4e4f055 | ||
|
|
9a5e6e0c4f | ||
|
|
c270140006 | ||
|
|
d67f42b4f4 | ||
|
|
bbab018d55 | ||
|
|
46f2026c03 |
10
deploy/feichi-ocr/Dockerfile
Normal file
10
deploy/feichi-ocr/Dockerfile
Normal 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
22
deploy/feichi-ocr/ocr.py
Normal 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())
|
||||
15
deploy/systemd/lingniu-go-feichi-bridge.env
Normal file
15
deploy/systemd/lingniu-go-feichi-bridge.env
Normal 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
|
||||
28
deploy/systemd/lingniu-go-feichi-bridge.service
Normal file
28
deploy/systemd/lingniu-go-feichi-bridge.service
Normal 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
552
design-qa.md
Normal 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 P0–P2 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 9–10px 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 10–128 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 681–1100px;
|
||||
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
|
||||
@@ -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` 做可信源选择。
|
||||
|
||||
统计分日优先使用协议事件时间;如果事件时间明显晚于接收时间超过 10 分钟,则认为设备时间异常,使用接收时间归属统计日。RAW 历史仍保留原始事件时间,便于追溯。
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
| MySQL | `vehicle_realtime_snapshot` | 每个协议+VIN 的最新事件态、合并扁平字段,以及独立的首次/前次/最新接收时间、连续间隔、样本数和证据来源 | phone、device、原始嵌套解析树、raw 报文、无限接收历史 |
|
||||
| MySQL | `vehicle_realtime_location` | 每个协议+VIN 的最新位置核心字段 | raw、parsed JSON、消息头内部字段 |
|
||||
| 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 | `jt808_registration` | JT808 phone 主键下的注册、鉴权、VIN 匹配状态、来源端点 | GB32960/MQTT 注册信息、位置历史、created_at |
|
||||
| Redis | `vehicle:realtime-raw:{protocol}:{vin}` | 每协议每 VIN 最新完整 parsed 状态 | 无 VIN 临时身份、历史数据、统计结果 |
|
||||
|
||||
272
docs/ops/2026-07-20-daily-mileage-track-reconciliation.md
Normal file
272
docs/ops/2026-07-20-daily-mileage-track-reconciliation.md
Normal 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` | 主来源 JT808;2,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:48,2026-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 km;2026-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 源数据缺口;容量巡检退出码为 0,stat-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、统计重试队列和待处理批次均为 0;9 个数据链路 `/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 为 0,stat-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 km;JT808 累计总里程从 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
160
docs/ops/feichi-bridge.md
Normal 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 语义报文,不是车辆原始二进制报文的逐字节复制。
|
||||
@@ -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_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_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_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`. |
|
||||
|
||||
@@ -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 的节流尾部投影队列保存在内存中,但来源候选事实已经落在 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 lag;worker 不应超过 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 启动会对既有 `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_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_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:20217/metrics | grep vehicle_identity_writer_kafka_lag
|
||||
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_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="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="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_stat_project_interval_seconds` | 生产异常为 `0` 或被调得过小 | `vehicle_daily_mileage_source` 仍每样本更新,但 `vehicle_daily_mileage` 选举投影会被节流;过小会放大 MySQL 压力。 |
|
||||
| Kafka lag | 连续 5 分钟增长或 `> 10000` | 下游 consumer 或存储存在瓶颈。 |
|
||||
@@ -502,6 +509,16 @@ BACKFILL_DATE_TO=2026-07-12 \
|
||||
/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 天,覆盖上游晚到或断传后恢复的总里程字段:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -6,6 +6,7 @@ RUN go mod download
|
||||
|
||||
COPY . .
|
||||
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/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
|
||||
@@ -18,6 +19,7 @@ RUN apt-get update \
|
||||
|
||||
WORKDIR /app
|
||||
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/stat-writer /app/stat-writer
|
||||
COPY --from=build /out/realtime-api /app/realtime-api
|
||||
|
||||
211
go/vehicle-gateway/cmd/feichi-bridge/main.go
Normal file
211
go/vehicle-gateway/cmd/feichi-bridge/main.go
Normal 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
|
||||
}
|
||||
743
go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go
Normal file
743
go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go
Normal 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
|
||||
}
|
||||
56
go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go
Normal file
56
go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -54,6 +55,7 @@ func main() {
|
||||
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_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))
|
||||
health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "vehicle-stat-writer", []health.Check{
|
||||
{Name: "mysql", Check: db.PingContext},
|
||||
@@ -65,6 +67,7 @@ func main() {
|
||||
writer.SetCacheRetention(cfg.CacheRetention)
|
||||
writer.SetCacheCleanupInterval(cfg.CacheCleanupInterval)
|
||||
writer.SetBaselineMissTTL(cfg.BaselineMissTTL)
|
||||
writer.SetBaselineHitTTL(cfg.BaselineHitTTL)
|
||||
writer.SetMaxCacheEntries(cfg.CacheMaxEntries)
|
||||
if cfg.EnsureSchema {
|
||||
if err := writer.EnsureSchema(ctx); err != nil {
|
||||
@@ -72,6 +75,13 @@ func main() {
|
||||
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 {
|
||||
statDate := time.Now().In(cfg.Location).Format("2006-01-02")
|
||||
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)
|
||||
}
|
||||
}
|
||||
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{
|
||||
delegate: writer,
|
||||
attempts: cfg.RetryAttempts,
|
||||
delay: cfg.RetryDelay,
|
||||
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
|
||||
for workerID := 1; workerID <= cfg.Workers; workerID++ {
|
||||
workers.Add(1)
|
||||
go func(id int) {
|
||||
defer workers.Done()
|
||||
runStatConsumer(ctx, logger, registry, appender, cfg, id)
|
||||
runStatConsumer(ctx, logger, registry, appender, quarantiner, cfg, id)
|
||||
}(workerID)
|
||||
}
|
||||
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 {
|
||||
Error(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{
|
||||
Brokers: cfg.KafkaBrokers,
|
||||
GroupID: cfg.KafkaGroup,
|
||||
@@ -129,7 +282,7 @@ func runStatConsumer(ctx context.Context, logger interface {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
type statMessageQuarantiner interface {
|
||||
Quarantine(context.Context, kafka.Message, error) error
|
||||
}
|
||||
|
||||
type statBatchItem struct {
|
||||
message kafka.Message
|
||||
processed bool
|
||||
@@ -163,6 +320,8 @@ type statBatchItem struct {
|
||||
type statBatchOutcome struct {
|
||||
commitMessages []kafka.Message
|
||||
retryMessages []kafka.Message
|
||||
failedMessage *kafka.Message
|
||||
writeErr error
|
||||
}
|
||||
|
||||
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")
|
||||
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)
|
||||
outcome := statBatchOutcome{retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed)}
|
||||
failedMessage := message
|
||||
outcome := statBatchOutcome{
|
||||
retryMessages: eventbus.MessagesAfterCommittedPrefixes(messages, committed),
|
||||
failedMessage: &failedMessage,
|
||||
writeErr: err,
|
||||
}
|
||||
if commitErr != nil {
|
||||
outcome.commitMessages = committed
|
||||
}
|
||||
@@ -279,13 +443,13 @@ func processStatBatchReliably(ctx context.Context, logger interface {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
}, 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 {
|
||||
Error(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)
|
||||
pending := messages
|
||||
for len(pending) > 0 {
|
||||
@@ -297,6 +461,19 @@ func processStatBatchReliablyForWorker(ctx context.Context, logger interface {
|
||||
}
|
||||
}
|
||||
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)))
|
||||
if len(pending) == 0 {
|
||||
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 {
|
||||
Error(string, ...any)
|
||||
Warn(string, ...any)
|
||||
@@ -447,7 +723,7 @@ func (a retryStatAppender) AppendWithResult(ctx context.Context, env envelope.Fr
|
||||
var err error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
result, err = appendStatEnvelope(ctx, a.delegate, env)
|
||||
if err == nil || !isTransientMySQLStatError(err) {
|
||||
if err == nil || (!isTransientMySQLStatError(err) && !isQuarantinableStatError(err)) {
|
||||
return result, err
|
||||
}
|
||||
if attempt == attempts {
|
||||
@@ -512,6 +788,32 @@ func isTransientMySQLStatError(err error) bool {
|
||||
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) {
|
||||
if registry == nil {
|
||||
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_vin", result.SamplesSkippedMissingVIN)
|
||||
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_positive_mileage", result.SamplesSkippedNonPositiveMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_time", result.SamplesSkippedMissingTime)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_same_mileage", result.SamplesSkippedSameMileage)
|
||||
addStatSampleMetric(registry, message, protocol, "skipped_missing_source", result.SamplesSkippedMissingSource)
|
||||
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) {
|
||||
@@ -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, "source_seen", stats.LastSourceSeenEntries, stats.MaxEntries, stats.LastCleanupSourceSeen, stats.SourceSeenEvictions)
|
||||
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, "gps_accumulation", stats.GPSAccumulationEntries, stats.MaxEntries, stats.LastCleanupGPS, stats.GPSEvictions)
|
||||
if !stats.LastCleanupAt.IsZero() {
|
||||
registry.SetGauge("vehicle_stat_cache_last_cleanup_unix_seconds", nil, float64(stats.LastCleanupAt.Unix()))
|
||||
}
|
||||
@@ -686,6 +1009,7 @@ type config struct {
|
||||
CacheRetention time.Duration
|
||||
CacheCleanupInterval time.Duration
|
||||
BaselineMissTTL time.Duration
|
||||
BaselineHitTTL time.Duration
|
||||
CacheMaxEntries int
|
||||
Workers int
|
||||
BatchSize int
|
||||
@@ -694,6 +1018,13 @@ type config struct {
|
||||
RetryDelay time.Duration
|
||||
NormalizePlatformSourcesOnStart bool
|
||||
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 {
|
||||
@@ -728,6 +1059,7 @@ func loadConfig() config {
|
||||
CacheRetention: time.Duration(envInt("STATS_CACHE_RETENTION_HOURS", 72)) * time.Hour,
|
||||
CacheCleanupInterval: time.Duration(envInt("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", 600)) * 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),
|
||||
Workers: envInt("STATS_WORKERS", 3),
|
||||
BatchSize: envInt("STATS_BATCH_SIZE", 200),
|
||||
@@ -736,6 +1068,13 @@ func loadConfig() config {
|
||||
RetryDelay: time.Duration(envInt("STATS_RETRY_DELAY_MS", 20)) * time.Millisecond,
|
||||
NormalizePlatformSourcesOnStart: env("STATS_NORMALIZE_PLATFORM_SOURCES_ON_START", "true") != "false",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -106,6 +108,7 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
|
||||
LastTotalMileageEntries: 300,
|
||||
LastSourceSeenEntries: 3,
|
||||
LastProjectionEntries: 280,
|
||||
PendingProjectionEntries: 12,
|
||||
BaselineEntries: 295,
|
||||
MaxEntries: 1000000,
|
||||
LastCleanupAt: time.Unix(1782918600, 0),
|
||||
@@ -148,6 +151,7 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
|
||||
`vehicle_stat_cache_entries{cache="last_total_mileage"} 300`,
|
||||
`vehicle_stat_cache_entries{cache="source_seen"} 3`,
|
||||
`vehicle_stat_cache_entries{cache="projection"} 280`,
|
||||
`vehicle_stat_pending_projection_entries 12`,
|
||||
`vehicle_stat_cache_entries{cache="baseline"} 295`,
|
||||
`vehicle_stat_cache_max_entries{cache="last_total_mileage"} 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) {
|
||||
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) {
|
||||
env := envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, Phone: "13307795425", MessageID: "0x0200"}
|
||||
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) {
|
||||
wantErr := errors.New("validation failed")
|
||||
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) {
|
||||
for _, err := range []error{
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("RetryAttempts = %d, want 3", cfg.RetryAttempts)
|
||||
}
|
||||
@@ -915,6 +1086,12 @@ func TestLoadConfigDefaultsToGoFieldsTopics(t *testing.T) {
|
||||
if cfg.NormalizePlatformSourcesTimeout != 30*time.Second {
|
||||
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) {
|
||||
@@ -965,6 +1142,7 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
|
||||
t.Setenv("STATS_CACHE_RETENTION_HOURS", "48")
|
||||
t.Setenv("STATS_CACHE_CLEANUP_INTERVAL_SECONDS", "300")
|
||||
t.Setenv("STATS_BASELINE_MISS_TTL_SECONDS", "45")
|
||||
t.Setenv("STATS_BASELINE_HIT_TTL_SECONDS", "180")
|
||||
t.Setenv("STATS_CACHE_MAX_ENTRIES", "12345")
|
||||
|
||||
cfg := loadConfig()
|
||||
@@ -984,6 +1162,9 @@ func TestLoadConfigSetsStatWriteIntervals(t *testing.T) {
|
||||
if cfg.BaselineMissTTL != 45*time.Second {
|
||||
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 {
|
||||
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_NORMALIZE_PLATFORM_SOURCES_ON_START", "false")
|
||||
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()
|
||||
|
||||
@@ -1021,6 +1205,15 @@ func TestLoadConfigReadsStatBatchSettings(t *testing.T) {
|
||||
if cfg.NormalizePlatformSourcesTimeout != 17*time.Second {
|
||||
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 {
|
||||
@@ -1070,6 +1263,18 @@ type contextCheckingStatCommitter struct {
|
||||
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 {
|
||||
c.ctxErr = ctx.Err()
|
||||
c.count++
|
||||
@@ -1089,6 +1294,19 @@ type discardStatLogger struct{}
|
||||
func (discardStatLogger) Error(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")
|
||||
|
||||
func committedOffsets(messages []kafka.Message) []int64 {
|
||||
|
||||
@@ -35,7 +35,10 @@ type config struct {
|
||||
Reset bool
|
||||
Debug bool
|
||||
EventTimeFullScan bool
|
||||
StationaryCarry bool
|
||||
GPSFallback bool
|
||||
ProgressEvery int64
|
||||
BaselineLookback int
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ type rawFrameRow struct {
|
||||
EventTimeMS int64
|
||||
ReceivedAtMS int64
|
||||
ParsedJSON string
|
||||
RawText string
|
||||
}
|
||||
|
||||
type metricAgg struct {
|
||||
@@ -58,7 +62,9 @@ type metricAgg struct {
|
||||
Protocol envelope.Protocol
|
||||
FirstKM float64
|
||||
LatestKM float64
|
||||
PureHydrogenMileageKM float64
|
||||
Count int64
|
||||
PureHydrogenSampleCount int64
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
@@ -68,6 +74,8 @@ type metricAgg struct {
|
||||
SourceKind string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
LatestPureHydrogenActive bool
|
||||
LatestPureHydrogenModeKnown bool
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
@@ -147,6 +155,14 @@ func main() {
|
||||
if err != nil {
|
||||
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 normalized int
|
||||
if !cfg.DryRun {
|
||||
@@ -159,7 +175,7 @@ func main() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -188,6 +204,9 @@ func main() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
slog.Info("debug raw frame",
|
||||
"protocol", row.Protocol,
|
||||
@@ -257,6 +276,7 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
FirstKM: sample.TotalMileageKM,
|
||||
LatestKM: sample.TotalMileageKM,
|
||||
Count: 1,
|
||||
PureHydrogenSampleCount: activeModeSampleCount(sample),
|
||||
SourceKey: sample.SourceKey,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
@@ -264,6 +284,8 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
PlatformName: sample.PlatformName,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
LatestPureHydrogenActive: sample.PureHydrogenActive,
|
||||
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityReason: "current_day_first_sample",
|
||||
}
|
||||
@@ -274,8 +296,18 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
agg.FirstEventTime = sample.EventTime
|
||||
}
|
||||
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.LatestEventTime = sample.EventTime
|
||||
agg.LatestPureHydrogenActive = sample.PureHydrogenActive
|
||||
agg.LatestPureHydrogenModeKnown = sample.PureHydrogenModeKnown
|
||||
agg.SourceEndpoint = sample.SourceEndpoint
|
||||
if strings.TrimSpace(sample.Phone) != "" {
|
||||
agg.Phone = sample.Phone
|
||||
@@ -288,9 +320,17 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if len(aggregates) == 0 {
|
||||
return 0, nil
|
||||
@@ -316,9 +356,11 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
}
|
||||
clearedTargets[target] = struct{}{}
|
||||
}
|
||||
if agg.QualityReason != stats.QualityReasonGPSCoordinate {
|
||||
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
dailyKM := stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM)
|
||||
candidate := stats.SourceMileageSample{
|
||||
VIN: agg.VIN,
|
||||
@@ -333,9 +375,13 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: dailyKM,
|
||||
PureHydrogenMileageKM: agg.PureHydrogenMileageKM,
|
||||
SampleCount: agg.Count,
|
||||
PureHydrogenSampleCount: agg.PureHydrogenSampleCount,
|
||||
FirstEventTime: agg.FirstEventTime,
|
||||
LatestEventTime: agg.LatestEventTime,
|
||||
LatestPureHydrogenActive: agg.LatestPureHydrogenActive,
|
||||
LatestPureHydrogenModeKnown: agg.LatestPureHydrogenModeKnown,
|
||||
QualityStatus: agg.QualityStatus,
|
||||
QualityReason: agg.QualityReason,
|
||||
}
|
||||
@@ -427,7 +473,7 @@ func buildLastDiffAggregates(ctx context.Context, mysqlDB *sql.DB, tdDB *sql.DB,
|
||||
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 {
|
||||
return 0, fmt.Errorf("aggregates map is nil")
|
||||
}
|
||||
@@ -441,11 +487,6 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
|
||||
continue
|
||||
}
|
||||
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)
|
||||
for _, date := range targetDates {
|
||||
current, err := queryRealtimeLocationLastRows(ctx, mysqlDB, cfg, protocol, date)
|
||||
@@ -453,7 +494,8 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
|
||||
return added, err
|
||||
}
|
||||
for vin, currentSources := range current {
|
||||
for _, currentRow := range currentSources {
|
||||
for i := range currentSources {
|
||||
currentRow := currentSources[i]
|
||||
key := vin + "|" + date + "|" + string(protocol) + "|" + currentRow.SourceKey
|
||||
if _, exists := aggregates[key]; exists {
|
||||
continue
|
||||
@@ -462,8 +504,22 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
|
||||
if err != nil {
|
||||
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)
|
||||
if hasPrevious {
|
||||
if cfg.StationaryCarry {
|
||||
agg.QualityReason = stats.QualityReasonStationaryCarry
|
||||
} else if hasPrevious {
|
||||
agg.QualityReason = "realtime_location_fallback_historical_baseline"
|
||||
} else {
|
||||
agg.QualityReason = "realtime_location_fallback_current_day_first_baseline"
|
||||
@@ -482,6 +538,307 @@ func addRealtimeLocationFallbackAggregates(ctx context.Context, mysqlDB *sql.DB,
|
||||
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 {
|
||||
indexed := map[string]map[string][]dailySourceLast{}
|
||||
for _, agg := range aggregates {
|
||||
@@ -656,7 +1013,9 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto
|
||||
if predicate := mileageBearingFramePredicate(protocol); 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
|
||||
WHERE %s
|
||||
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
|
||||
// calendar days do not make a backfill fall back to the current day's first
|
||||
// sample.
|
||||
where := backfillBeforePredicates(date)
|
||||
where := backfillBeforePredicates(cfg, date)
|
||||
where = append(where,
|
||||
"parse_status = 'OK'",
|
||||
"vin IS NOT NULL",
|
||||
@@ -679,17 +1038,31 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr
|
||||
if predicate := mileageBearingFramePredicate(protocol); 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
|
||||
WHERE %s
|
||||
GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND "))
|
||||
return querySourceRows(ctx, db, cfg, protocol, sqlText, false)
|
||||
}
|
||||
|
||||
func backfillBeforePredicates(eventDateExclusive string) []string {
|
||||
return []string{
|
||||
func backfillBeforePredicates(cfg config, eventDateExclusive string) []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)),
|
||||
}
|
||||
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) {
|
||||
@@ -700,15 +1073,28 @@ func queryRealtimeLocationLastRows(ctx context.Context, db *sql.DB, cfg config,
|
||||
if loc == nil {
|
||||
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
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.protocol = l.protocol AND s.vin = l.vin
|
||||
WHERE l.protocol = ?
|
||||
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_event_time >= ?
|
||||
AND l.total_mileage_event_time < DATE_ADD(?, INTERVAL 1 DAY)`
|
||||
rows, err := db.QueryContext(ctx, sqlText, string(protocol), date, date)
|
||||
AND ` + timePredicate
|
||||
args := []any{string(protocol), date, date}
|
||||
if cfg.StationaryCarry {
|
||||
args = append(args, date)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, sqlText, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -775,27 +1161,30 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel
|
||||
var sourceEndpoint string
|
||||
var firstTS time.Time
|
||||
var firstParsedJSON string
|
||||
var firstRawText string
|
||||
var ts time.Time
|
||||
var parsedJSON string
|
||||
var rawText string
|
||||
var rawSampleCount int64
|
||||
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
|
||||
}
|
||||
} 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
|
||||
}
|
||||
firstTS = ts
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
firstTotalKM := latestTotalKM
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -852,6 +1241,16 @@ func mileageFromParsed(protocol envelope.Protocol, vin string, parsedJSON string
|
||||
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 {
|
||||
return normalizedSourceKeyForSource(protocol, phone, deviceID, endpoint, "", "")
|
||||
}
|
||||
@@ -896,9 +1295,49 @@ func fieldsForStats(protocol envelope.Protocol, vin string, text string) map[str
|
||||
}
|
||||
fields[key] = value
|
||||
}
|
||||
promoteFuelCellWorkModeForStats(protocol, fields, text)
|
||||
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 {
|
||||
switch protocol {
|
||||
case envelope.ProtocolGB32960:
|
||||
@@ -950,7 +1389,10 @@ func loadConfig() (config, error) {
|
||||
Reset: envBool("BACKFILL_RESET", false),
|
||||
Debug: envBool("BACKFILL_DEBUG", 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)),
|
||||
BaselineLookback: envInt("BACKFILL_BASELINE_LOOKBACK_DAYS", 7),
|
||||
Location: loc,
|
||||
}, 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, 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
|
||||
WHERE %s
|
||||
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))
|
||||
for _, token := range tokens {
|
||||
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 ") + ")"
|
||||
}
|
||||
@@ -1071,10 +1516,10 @@ func mileageSearchTokens(protocol envelope.Protocol) []string {
|
||||
}
|
||||
|
||||
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 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{
|
||||
@@ -1088,6 +1533,7 @@ func scanRawFrame(rows *sql.Rows) (rawFrameRow, error) {
|
||||
EventTimeMS: eventTime.UnixMilli(),
|
||||
ReceivedAtMS: receivedAt.UnixMilli(),
|
||||
ParsedJSON: parsed,
|
||||
RawText: rawText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1267,6 +1713,14 @@ func previousDate(date string) string {
|
||||
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) {
|
||||
dates, err := dateRange(from, to)
|
||||
if err != nil {
|
||||
|
||||
@@ -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) {
|
||||
previous := []dailySourceLast{{
|
||||
VIN: "LB9A32A28R0LS1574",
|
||||
@@ -233,23 +300,23 @@ func TestBuildLastDiffAggregatesCarriesNearestHistoryAcrossEmptyDays(t *testing.
|
||||
dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc)
|
||||
currentRows := func() *sqlmock.Rows {
|
||||
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 {
|
||||
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'").
|
||||
WillReturnRows(previousRows())
|
||||
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'").
|
||||
WillReturnRows(currentRows())
|
||||
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{
|
||||
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) {
|
||||
mysqlDB, mysqlMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -342,18 +498,10 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
|
||||
WithArgs("YUTONG_MQTT", "2026-07-12", "2026-07-12").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "total_mileage_event_time", "total_mileage_km"}).
|
||||
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").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "COUNT(*)",
|
||||
}).AddRow(
|
||||
"LMRKH9AC0R1004086",
|
||||
"",
|
||||
"LMRKH9AC0R1004086",
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
previousTS,
|
||||
`{"data":{"TOTAL_MILEAGE":11500000}}`,
|
||||
int64(12),
|
||||
))
|
||||
sourceKey := stats.SourceKeyForSource(envelope.ProtocolYutongMQTT, "", "LMRKH9AC0R1004086", "mqtt", "PLATFORM", "yutong")
|
||||
mysqlMock.ExpectQuery("(?s)SELECT latest_total_mileage_km, latest_event_time.*FROM vehicle_daily_mileage_source").
|
||||
WithArgs("LMRKH9AC0R1004086", "2026-07-12", "YUTONG_MQTT", sourceKey).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"latest_total_mileage_km", "latest_event_time"}).AddRow(11500.0, previousTS))
|
||||
|
||||
aggregates := map[string]*metricAgg{}
|
||||
added, err := addRealtimeLocationFallbackAggregates(context.Background(), mysqlDB, tdDB, config{
|
||||
@@ -373,7 +521,7 @@ func TestAddRealtimeLocationFallbackAggregatesUsesHistoricalBaseline(t *testing.
|
||||
if agg.FirstKM != 11500 || agg.LatestKM != 11578 {
|
||||
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)
|
||||
}
|
||||
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)
|
||||
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"} {
|
||||
mysqlMock.ExpectQuery("(?s)FROM vehicle_realtime_location l.*l.protocol = \\?").
|
||||
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)
|
||||
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{
|
||||
"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(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
"LMRKH9AC6R1004108",
|
||||
"mqtt://yutong/ytforward/shln/3",
|
||||
firstTS,
|
||||
`{"yutong_mqtt.data.total_mileage":"65422000"}`,
|
||||
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65422000}}`,
|
||||
lastTS,
|
||||
`{"yutong_mqtt.data.total_mileage":"65423000"}`,
|
||||
`{"yutong_mqtt.data.latitude":"30.1"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65423000}}`,
|
||||
int64(42),
|
||||
))
|
||||
|
||||
@@ -526,14 +672,15 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
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%'").
|
||||
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(
|
||||
"LMRKH9AC6R1004108",
|
||||
"",
|
||||
"LMRKH9AC6R1004108",
|
||||
"mqtt://yutong/ytforward/shln/3",
|
||||
lastTS,
|
||||
`{"yutong_mqtt.data.total_mileage":"65377000"}`,
|
||||
`{"yutong_mqtt.data.latitude":"30.0"}`,
|
||||
`{"data":{"TOTAL_MILEAGE":65377000}}`,
|
||||
int64(31),
|
||||
))
|
||||
|
||||
@@ -556,13 +703,19 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillBeforePredicatesSearchesAllEarlierHistory(t *testing.T) {
|
||||
where := strings.Join(backfillBeforePredicates("2026-07-08"), " AND ")
|
||||
func TestBackfillBeforePredicatesBoundsHistoryScan(t *testing.T) {
|
||||
where := strings.Join(backfillBeforePredicates(config{BaselineLookback: 7}, "2026-07-08"), " AND ")
|
||||
if !strings.Contains(where, "event_time < '2026-07-08 00:00:00'") {
|
||||
t.Fatalf("pre-window predicate missing exclusive upper bound: %s", where)
|
||||
}
|
||||
if strings.Contains(where, "event_time >=") || strings.Contains(where, "ts >=") {
|
||||
t.Fatalf("pre-window predicate must not stop at the previous day: %s", where)
|
||||
for _, predicate := range []string{
|
||||
"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)
|
||||
mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'").
|
||||
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(
|
||||
"LMRKH9AC7R1004098",
|
||||
"",
|
||||
@@ -585,6 +738,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone
|
||||
"mqtt://yutong/ytforward/shln/4",
|
||||
utcInstant,
|
||||
`{"yutong_mqtt.data.total_mileage":"41249000"}`,
|
||||
"",
|
||||
int64(1),
|
||||
))
|
||||
|
||||
@@ -609,7 +763,8 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
||||
fields := fieldsForStats(envelope.ProtocolYutongMQTT, "LMRKH9AC6R1004108", `{
|
||||
"data": {
|
||||
"TOTAL_MILEAGE": 65423000,
|
||||
"METER_SPEED": 12.3
|
||||
"METER_SPEED": 12.3,
|
||||
"TRIANGLE_STATE": 11
|
||||
},
|
||||
"root": {
|
||||
"device": "LMRKH9AC6R1004108"
|
||||
@@ -619,6 +774,9 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
||||
if got := fields["yutong_mqtt.data.total_mileage"]; got == nil {
|
||||
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{
|
||||
Protocol: envelope.ProtocolYutongMQTT,
|
||||
@@ -637,6 +795,32 @@ func TestFieldsForStatsExtractsRawYutongTotalMileage(t *testing.T) {
|
||||
if samples[0].TotalMileageKM != 65423 {
|
||||
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) {
|
||||
@@ -709,9 +893,13 @@ func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T)
|
||||
float64(4100),
|
||||
float64(4101),
|
||||
float64(1),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
projNow,
|
||||
projNow,
|
||||
false,
|
||||
false,
|
||||
stats.QualityInvalidDelta,
|
||||
"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) {
|
||||
t.Setenv("BACKFILL_METHOD", "")
|
||||
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) {
|
||||
t.Setenv("BACKFILL_DATE_FROM", "")
|
||||
t.Setenv("BACKFILL_DATE_TO", "")
|
||||
|
||||
@@ -38,6 +38,7 @@ const (
|
||||
FieldLongitude = "longitude"
|
||||
FieldLatitude = "latitude"
|
||||
FieldSOCPercent = "soc_percent"
|
||||
FieldFuelCellWorkMode = "fuel_cell_work_mode"
|
||||
)
|
||||
|
||||
type FrameEnvelope struct {
|
||||
|
||||
57
go/vehicle-gateway/internal/feichibridge/captcha.go
Normal file
57
go/vehicle-gateway/internal/feichibridge/captcha.go
Normal 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
|
||||
}
|
||||
211
go/vehicle-gateway/internal/feichibridge/client.go
Normal file
211
go/vehicle-gateway/internal/feichibridge/client.go
Normal 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)
|
||||
}
|
||||
154
go/vehicle-gateway/internal/feichibridge/client_test.go
Normal file
154
go/vehicle-gateway/internal/feichibridge/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
481
go/vehicle-gateway/internal/feichibridge/encoder.go
Normal file
481
go/vehicle-gateway/internal/feichibridge/encoder.go
Normal 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
|
||||
}
|
||||
96
go/vehicle-gateway/internal/feichibridge/encoder_test.go
Normal file
96
go/vehicle-gateway/internal/feichibridge/encoder_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
209
go/vehicle-gateway/internal/feichibridge/login.go
Normal file
209
go/vehicle-gateway/internal/feichibridge/login.go
Normal 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
|
||||
}
|
||||
94
go/vehicle-gateway/internal/feichibridge/model.go
Normal file
94
go/vehicle-gateway/internal/feichibridge/model.go
Normal 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)
|
||||
}
|
||||
469
go/vehicle-gateway/internal/feichibridge/service.go
Normal file
469
go/vehicle-gateway/internal/feichibridge/service.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
123
go/vehicle-gateway/internal/feichibridge/service_test.go
Normal file
123
go/vehicle-gateway/internal/feichibridge/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
160
go/vehicle-gateway/internal/feichibridge/state.go
Normal file
160
go/vehicle-gateway/internal/feichibridge/state.go
Normal 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[:])
|
||||
}
|
||||
38
go/vehicle-gateway/internal/feichibridge/state_test.go
Normal file
38
go/vehicle-gateway/internal/feichibridge/state_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
193
go/vehicle-gateway/internal/feichibridge/target.go
Normal file
193
go/vehicle-gateway/internal/feichibridge/target.go
Normal 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
|
||||
}
|
||||
83
go/vehicle-gateway/internal/feichibridge/target_test.go
Normal file
83
go/vehicle-gateway/internal/feichibridge/target_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -151,16 +151,23 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim
|
||||
fields[envelope.FieldSOCPercent] = unit["soc_percent"]
|
||||
cursor += size
|
||||
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"})
|
||||
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})
|
||||
fields["position_status"] = unit["position_status"]
|
||||
if coordinateSystem, ok := unit["coordinate_system"]; ok {
|
||||
fields["coordinate_system"] = coordinateSystem
|
||||
}
|
||||
fields[envelope.FieldLongitude] = unit["longitude"]
|
||||
fields[envelope.FieldLatitude] = unit["latitude"]
|
||||
cursor += 9
|
||||
cursor += size
|
||||
case 0x02:
|
||||
if len(body[cursor:]) < 1 {
|
||||
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])
|
||||
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 {
|
||||
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_water_outlet_temp_c"] = summaries[0]["stack_water_outlet_temp_c"]
|
||||
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 {
|
||||
return map[string]any{
|
||||
out := map[string]any{
|
||||
"vehicle_status": int(data[0]),
|
||||
"charge_status": int(data[1]),
|
||||
"running_mode": int(data[2]),
|
||||
"speed_kmh": scaledU16(data[3:5], 10),
|
||||
"total_mileage_km": scaledU32(data[5:9], 10),
|
||||
"total_voltage_v": scaledU16(data[9:11], 10),
|
||||
"total_current_a": scaledU16WithOffset(data[11:13], 10, -1000),
|
||||
"soc_percent": int(data[13]),
|
||||
"speed_kmh": nullableScaledU16(data[3:5], 10),
|
||||
"total_mileage_km": nullableScaledU32(data[5:9], 10),
|
||||
"total_voltage_v": nullableScaledU16(data[9:11], 10),
|
||||
"total_current_a": nullableScaledU16WithOffset(data[11:13], 10, -1000),
|
||||
"soc_percent": nullableByte(data[13]),
|
||||
"dc_dc_status": int(data[14]),
|
||||
"gear": int(data[15]),
|
||||
"insulation_kohm": binary.BigEndian.Uint16(data[16:18]),
|
||||
"accelerator_pct": int(data[18]),
|
||||
"brake_pct": int(data[19]),
|
||||
"insulation_kohm": nullableU16(data[16:18]),
|
||||
}
|
||||
// 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 {
|
||||
@@ -389,12 +403,12 @@ func parseDriveMotorData(data []byte) map[string]any {
|
||||
motors = append(motors, map[string]any{
|
||||
"serial_no": int(data[cursor]),
|
||||
"state": int(data[cursor+1]),
|
||||
"controller_temperature_c": int(data[cursor+2]) - 40,
|
||||
"speed_rpm": int(binary.BigEndian.Uint16(data[cursor+3:cursor+5])) - 20000,
|
||||
"torque_nm": scaledIntWithOffset(int64(binary.BigEndian.Uint16(data[cursor+5:cursor+7])), 10, -20000),
|
||||
"motor_temperature_c": int(data[cursor+7]) - 40,
|
||||
"controller_voltage_v": scaledU16(data[cursor+8:cursor+10], 10),
|
||||
"controller_current_a": scaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
|
||||
"controller_temperature_c": nullableTempOffset40(data[cursor+2]),
|
||||
"speed_rpm": nullableU16WithOffset(data[cursor+3:cursor+5], -20000),
|
||||
"torque_nm": nullableScaledU16WithOffset(data[cursor+5:cursor+7], 10, -20000),
|
||||
"motor_temperature_c": nullableTempOffset40(data[cursor+7]),
|
||||
"controller_voltage_v": nullableScaledU16(data[cursor+8:cursor+10], 10),
|
||||
"controller_current_a": nullableScaledU16WithOffset(data[cursor+10:cursor+12], 10, -1000),
|
||||
})
|
||||
cursor += 12
|
||||
}
|
||||
@@ -406,34 +420,45 @@ func parseDriveMotorData(data []byte) map[string]any {
|
||||
|
||||
func parseFuelCellData(data []byte) map[string]any {
|
||||
probeCount := int(binary.BigEndian.Uint16(data[6:8]))
|
||||
probes := make([]int, 0, probeCount)
|
||||
probes := make([]any, 0, probeCount)
|
||||
cursor := 8
|
||||
for i := 0; i < probeCount; i++ {
|
||||
probes = append(probes, int(data[cursor])-40)
|
||||
probes = append(probes, nullableTempOffset40(data[cursor]))
|
||||
cursor++
|
||||
}
|
||||
out := map[string]any{
|
||||
"fuel_cell_voltage_v": scaledU16(data[0:2], 10),
|
||||
"fuel_cell_current_a": scaledU16(data[2:4], 10),
|
||||
"hydrogen_consumption_kg_per_100km": scaledU16(data[4:6], 100),
|
||||
"fuel_cell_voltage_v": nullableScaledU16(data[0:2], 10),
|
||||
"fuel_cell_current_a": nullableScaledU16(data[2:4], 10),
|
||||
"hydrogen_consumption_kg_per_100km": nullableScaledU16(data[4:6], 100),
|
||||
"temperature_probe_count": probeCount,
|
||||
"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_concentration_fraction": scaledU16(data[cursor+3:cursor+5], 1_000_000),
|
||||
"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]),
|
||||
"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
|
||||
}
|
||||
|
||||
func parseEngineData(data []byte) map[string]any {
|
||||
return map[string]any{
|
||||
"engine_status": int(data[0]),
|
||||
"crank_speed_rpm": binary.BigEndian.Uint16(data[1:3]),
|
||||
"fuel_rate": binary.BigEndian.Uint16(data[3:5]),
|
||||
"crank_speed_rpm": nullableU16(data[1:3]),
|
||||
// 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,
|
||||
}
|
||||
cursor += 22
|
||||
frameVoltages := make([]float64, 0, frameCellCount)
|
||||
maxCell := 0.0
|
||||
minCell := 0.0
|
||||
seen := false
|
||||
for c := 0; c < frameCellCount; c++ {
|
||||
voltage := scaledU16(data[cursor:cursor+2], 1000)
|
||||
frameVoltages = append(frameVoltages, voltage)
|
||||
if !seen || voltage > maxCell {
|
||||
maxCell = voltage
|
||||
}
|
||||
@@ -650,6 +677,7 @@ func parseGdFCStackData(data []byte) map[string]any {
|
||||
summary["frame_max_cell_voltage_v"] = maxCell
|
||||
summary["frame_min_cell_voltage_v"] = minCell
|
||||
}
|
||||
summary["frame_cell_voltages_v"] = frameVoltages
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
return map[string]any{
|
||||
@@ -760,6 +788,13 @@ func nullableU16WithOffset(data []byte, offset int) any {
|
||||
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 {
|
||||
return scaledInt(int64(binary.BigEndian.Uint16(data)), divisor)
|
||||
}
|
||||
@@ -791,6 +826,14 @@ func nullableScaledU16(data []byte, divisor int64) any {
|
||||
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 {
|
||||
value := binary.BigEndian.Uint16(data)
|
||||
if value == 0xfffe || value == 0xffff {
|
||||
@@ -806,12 +849,16 @@ func nullableTempOffset40(value byte) any {
|
||||
return int(value) - 40
|
||||
}
|
||||
|
||||
func parsePositionData(data []byte) map[string]any {
|
||||
return map[string]any{
|
||||
"position_status": int(data[0]),
|
||||
"longitude": float64(binary.BigEndian.Uint32(data[1:5])) / 1_000_000,
|
||||
"latitude": float64(binary.BigEndian.Uint32(data[5:9])) / 1_000_000,
|
||||
func parsePositionData(version string, data []byte) map[string]any {
|
||||
offset := 1
|
||||
out := map[string]any{"position_status": int(data[0])}
|
||||
if version == "V2025" {
|
||||
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 {
|
||||
|
||||
@@ -197,6 +197,7 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
|
||||
t.Fatalf("vin = %q", env.VIN)
|
||||
}
|
||||
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, envelope.FieldLongitude, 120.800326)
|
||||
assertFloatField(t, env, envelope.FieldLatitude, 31.634907)
|
||||
@@ -222,6 +223,13 @@ func TestParseFrameKeepsParsingRealFuelCellReportUntilUnknownExtension(t *testin
|
||||
if units[2]["name"] != "fuel_cell" {
|
||||
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" {
|
||||
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) {
|
||||
unit := parseDriveMotorData([]byte{
|
||||
0x01,
|
||||
|
||||
@@ -115,6 +115,9 @@ func fieldsFromData(data map[string]any) map[string]any {
|
||||
if gear, ok := firstFloat(data, "ON_GEAR", "gear"); ok {
|
||||
fields["gear"] = gear
|
||||
}
|
||||
if workMode, ok := firstFloat(data, "TRIANGLE_STATE", "triangleState", "triangle_state"); ok {
|
||||
fields[envelope.FieldFuelCellWorkMode] = workMode
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ func TestParseMessageMapsYutongPayloadToEnvelope(t *testing.T) {
|
||||
"BATTERY_CAPACITY_SOC": 70,
|
||||
"LONGITUDE": 116397128,
|
||||
"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.FieldLatitude, 39.916527)
|
||||
assertFloatField(t, env, "direction_deg", 88)
|
||||
assertFloatField(t, env, envelope.FieldFuelCellWorkMode, 11)
|
||||
if env.EventTimeMS == 1782745114999 {
|
||||
t.Fatal("event time should come from device time")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
@@ -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) {
|
||||
fieldsEnv, ok := BuildFieldsEnvelope(envelope.FrameEnvelope{
|
||||
Protocol: envelope.ProtocolJT808,
|
||||
|
||||
@@ -478,6 +478,7 @@ func realtimeOpenAPISpec() map[string]any {
|
||||
"source_code": stringSchema("G7S"),
|
||||
"source_kind": stringSchema("PLATFORM"),
|
||||
"daily_mileage_km": numberSchema(23.1),
|
||||
"pure_hydrogen_mileage_km": numberSchema(18.6),
|
||||
"latest_total_mileage_km": numberSchema(4123.9),
|
||||
"updated_at": stringSchema("2026-07-08 13:30:57"),
|
||||
},
|
||||
@@ -501,6 +502,8 @@ func realtimeOpenAPISpec() map[string]any {
|
||||
"first_total_mileage_km": numberSchema(4100.8),
|
||||
"latest_total_mileage_km": numberSchema(4123.9),
|
||||
"daily_mileage_km": numberSchema(23.1),
|
||||
"pure_hydrogen_mileage_km": numberSchema(18.6),
|
||||
"pure_hydrogen_sample_count": integerSchema(42),
|
||||
"sample_count": integerSchema(128),
|
||||
"first_event_time": stringSchema("2026-07-08 00:01:00"),
|
||||
"latest_event_time": stringSchema("2026-07-08 23:59:00"),
|
||||
|
||||
@@ -825,12 +825,27 @@ func positiveNumber(value any) bool {
|
||||
return typed > 0
|
||||
case int:
|
||||
return typed > 0
|
||||
case int8:
|
||||
return typed > 0
|
||||
case int16:
|
||||
return typed > 0
|
||||
case int32:
|
||||
return typed > 0
|
||||
case int64:
|
||||
return typed > 0
|
||||
case uint:
|
||||
return typed > 0
|
||||
case uint8:
|
||||
return typed > 0
|
||||
case uint16:
|
||||
return typed > 0
|
||||
case uint32:
|
||||
return typed > 0
|
||||
case uint64:
|
||||
return typed > 0
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
return err == nil && parsed > 0
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return err == nil && parsed > 0
|
||||
|
||||
@@ -3,7 +3,9 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -18,7 +20,9 @@ const (
|
||||
defaultCacheRetention = 72 * time.Hour
|
||||
defaultCacheCleanupInterval = 10 * time.Minute
|
||||
defaultBaselineMissTTL = time.Minute
|
||||
defaultBaselineHitTTL = 5 * time.Minute
|
||||
defaultMaxCacheEntries = 1000000
|
||||
defaultGPSAccumulationInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
type Execer interface {
|
||||
@@ -34,6 +38,7 @@ type Writer struct {
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
@@ -42,10 +47,14 @@ type Writer struct {
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
pendingProjection map[string]pendingProjectionEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
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 {
|
||||
@@ -59,6 +68,8 @@ type MetricSample struct {
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
PlatformName string
|
||||
PureHydrogenActive bool
|
||||
PureHydrogenModeKnown bool
|
||||
}
|
||||
|
||||
type AppendResult struct {
|
||||
@@ -67,6 +78,8 @@ type AppendResult struct {
|
||||
SamplesSkippedMissingFields int
|
||||
SamplesSkippedMissingVIN int
|
||||
SamplesSkippedMissingMileage int
|
||||
SamplesRecoveredStationary int
|
||||
SamplesRecoveredGPSCoordinate int
|
||||
SamplesSkippedNonMileageFrame int
|
||||
SamplesSkippedNonPositiveMileage int
|
||||
SamplesSkippedMissingTime int
|
||||
@@ -81,23 +94,45 @@ type AppendResult struct {
|
||||
ProjectionsAttempted int
|
||||
ProjectionsWritten 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 {
|
||||
LastTotalMileageEntries int
|
||||
LastSourceSeenEntries int
|
||||
LastProjectionEntries int
|
||||
PendingProjectionEntries int
|
||||
BaselineEntries int
|
||||
GPSAccumulationEntries int
|
||||
MaxEntries int
|
||||
LastCleanupAt time.Time
|
||||
LastCleanupTotalMileage int
|
||||
LastCleanupSourceSeen int
|
||||
LastCleanupProjection int
|
||||
LastCleanupBaseline int
|
||||
LastCleanupGPS int
|
||||
TotalMileageEvictions int
|
||||
SourceSeenEvictions int
|
||||
ProjectionEvictions int
|
||||
BaselineEvictions int
|
||||
GPSEvictions int
|
||||
}
|
||||
|
||||
type cacheCleanupStats struct {
|
||||
@@ -105,6 +140,7 @@ type cacheCleanupStats struct {
|
||||
sourceSeen int
|
||||
projection int
|
||||
baseline int
|
||||
gps int
|
||||
}
|
||||
|
||||
func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
@@ -122,14 +158,19 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
pendingProjection: map[string]pendingProjectionEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
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 {
|
||||
writer.query = query
|
||||
@@ -137,6 +178,25 @@ func NewWriter(exec Execer, loc *time.Location) *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) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -182,6 +242,15 @@ func (w *Writer) SetBaselineMissTTL(ttl time.Duration) {
|
||||
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) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -192,6 +261,15 @@ func (w *Writer) SetMaxCacheEntries(maxEntries int) {
|
||||
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 {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -199,25 +277,32 @@ func (w *Writer) CacheStats() CacheStats {
|
||||
LastTotalMileageEntries: len(w.lastTotalMileage),
|
||||
LastSourceSeenEntries: len(w.lastSourceSeen),
|
||||
LastProjectionEntries: len(w.lastProjection),
|
||||
PendingProjectionEntries: len(w.pendingProjection),
|
||||
BaselineEntries: len(w.baselineCache),
|
||||
GPSAccumulationEntries: len(w.lastGPSAccumulation),
|
||||
MaxEntries: w.maxCacheEntries,
|
||||
LastCleanupAt: w.lastCacheCleanup,
|
||||
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
|
||||
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
|
||||
LastCleanupProjection: w.lastCacheCleanupStats.projection,
|
||||
LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
|
||||
LastCleanupGPS: w.lastCacheCleanupStats.gps,
|
||||
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 {
|
||||
for _, statement := range []string{
|
||||
DataSourceTableSQL,
|
||||
GPSMileageStateTableSQL,
|
||||
DailyMileageSourceTableSQL,
|
||||
DailyMileageTableSQL,
|
||||
HydrogenTankCapacityTableSQL,
|
||||
HydrogenStreamStateTableSQL,
|
||||
} {
|
||||
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
@@ -245,6 +330,16 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
seenAt := w.sourceSeenAt(env)
|
||||
w.maybeCleanupCaches(seenAt)
|
||||
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
|
||||
if hasSource && ShouldManageDataSource(identity) {
|
||||
if w.shouldTouchSource(identity, seenAt) {
|
||||
@@ -261,6 +356,11 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
sourceResult.SourceTouchesSkippedUnmanaged = 1
|
||||
}
|
||||
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.SourceTouchesAttempted += sourceResult.SourceTouchesAttempted
|
||||
result.SourceTouchesWritten += sourceResult.SourceTouchesWritten
|
||||
@@ -270,6 +370,34 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
if err != nil {
|
||||
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 {
|
||||
if !hasSource {
|
||||
result.SamplesSkippedMissingSource++
|
||||
@@ -278,12 +406,34 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
}
|
||||
if w.seenSameMileage(sample) {
|
||||
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
|
||||
}
|
||||
candidate := SourceMileageSampleFromMetric(sample, identity)
|
||||
if stationaryCandidate != nil {
|
||||
candidate = *stationaryCandidate
|
||||
} else {
|
||||
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
projectDaily := w.shouldProjectDailyMileage(sample)
|
||||
if projectDaily {
|
||||
result.ProjectionsAttempted++
|
||||
@@ -297,6 +447,8 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
if projectDaily {
|
||||
w.markProjected(sample)
|
||||
result.ProjectionsWritten++
|
||||
} else {
|
||||
w.markProjectionPending(sample, time.Now())
|
||||
}
|
||||
w.markMileageWritten(sample)
|
||||
result.SamplesWritten++
|
||||
@@ -304,6 +456,72 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop
|
||||
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 {
|
||||
if projectDaily {
|
||||
if beginner, ok := w.exec.(txBeginner); ok {
|
||||
@@ -403,12 +621,168 @@ func (w *Writer) markProjected(sample MetricSample) {
|
||||
entry.sourceKeys = map[string]struct{}{}
|
||||
}
|
||||
entry.sourceKeys[sample.SourceKey] = struct{}{}
|
||||
if entry.projectedAt.IsZero() || sample.EventTime.After(entry.projectedAt) {
|
||||
entry.projectedAt = sample.EventTime
|
||||
}
|
||||
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.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 {
|
||||
key := mileageCacheKey(sample)
|
||||
w.mu.Lock()
|
||||
@@ -475,6 +849,12 @@ func (w *Writer) maybeCleanupCaches(now time.Time) {
|
||||
cleanupStats.baseline++
|
||||
}
|
||||
}
|
||||
for key, eventTime := range w.lastGPSAccumulation {
|
||||
if eventTime.IsZero() || eventTime.Before(cutoff) {
|
||||
delete(w.lastGPSAccumulation, key)
|
||||
cleanupStats.gps++
|
||||
}
|
||||
}
|
||||
w.lastCacheCleanupStats = cleanupStats
|
||||
w.enforceCacheLimitsLocked()
|
||||
}
|
||||
@@ -517,8 +897,12 @@ func (w *Writer) previousBaseline(ctx context.Context, candidate SourceMileageSa
|
||||
now := time.Now()
|
||||
w.mu.Lock()
|
||||
if cached, ok := w.baselineCache[cacheKey]; ok {
|
||||
missExpired := !cached.found && (w.baselineMissTTL == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= w.baselineMissTTL)
|
||||
if !missExpired {
|
||||
ttl := w.baselineMissTTL
|
||||
if cached.found {
|
||||
ttl = w.baselineHitTTL
|
||||
}
|
||||
expired := ttl == 0 || cached.cachedAt.IsZero() || now.Sub(cached.cachedAt) >= ttl
|
||||
if !expired {
|
||||
w.mu.Unlock()
|
||||
return cached.baseline, cached.found, nil
|
||||
}
|
||||
@@ -570,6 +954,16 @@ func (w *Writer) cacheBaseline(candidate SourceMileageSample, cacheKey string, e
|
||||
return
|
||||
}
|
||||
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() {
|
||||
entry.cachedAt = time.Now()
|
||||
}
|
||||
@@ -634,6 +1028,7 @@ func (w *Writer) deleteBaselineKeyLocked(key string) {
|
||||
|
||||
func (w *Writer) deleteProjectionKeyLocked(key string) {
|
||||
delete(w.lastProjection, key)
|
||||
delete(w.pendingProjection, key)
|
||||
w.deleteIndexedCacheKeyLocked(w.projectionKeysByPrefix, cacheKeyPrefix(key), key)
|
||||
}
|
||||
|
||||
@@ -687,6 +1082,14 @@ func (w *Writer) enforceCacheLimitsLocked() {
|
||||
delete(w.lastSourceSeen, victim)
|
||||
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 {
|
||||
@@ -781,6 +1184,11 @@ type projectionCacheEntry struct {
|
||||
sourceKeys map[string]struct{}
|
||||
}
|
||||
|
||||
type pendingProjectionEntry struct {
|
||||
sample MetricSample
|
||||
queuedAt time.Time
|
||||
}
|
||||
|
||||
type sourceBaselineCacheEntry struct {
|
||||
baseline sourceBaseline
|
||||
found bool
|
||||
@@ -828,6 +1236,7 @@ func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Locatio
|
||||
result.SamplesAdjustedFutureEventTime = 1
|
||||
}
|
||||
statDate := time.UnixMilli(eventMS).In(loc).Format("2006-01-02")
|
||||
pureHydrogenActive, pureHydrogenModeKnown := PureHydrogenModeFromEnvelope(env)
|
||||
result.SamplesFound = 1
|
||||
return []MetricSample{{
|
||||
VIN: vin,
|
||||
@@ -840,6 +1249,8 @@ func samplesFromEnvelopeWithResult(env envelope.FrameEnvelope, loc *time.Locatio
|
||||
DeviceID: strings.TrimSpace(env.DeviceID),
|
||||
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
|
||||
PlatformName: strings.TrimSpace(env.PlatformName),
|
||||
PureHydrogenActive: pureHydrogenActive,
|
||||
PureHydrogenModeKnown: pureHydrogenModeKnown,
|
||||
}}, result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
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) {
|
||||
exec := &recordingExec{}
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
@@ -899,9 +1069,13 @@ func TestWriterAppendUsesCurrentSampleWhenNoHistoricalBaseline(t *testing.T) {
|
||||
float64(4123.9),
|
||||
float64(4123.9),
|
||||
float64(0),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
eventTime,
|
||||
eventTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonCurrentDayFirst,
|
||||
).
|
||||
@@ -979,9 +1153,13 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
|
||||
float64(4123.9),
|
||||
float64(4123.9),
|
||||
float64(0),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
firstTime,
|
||||
firstTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonCurrentDayFirst,
|
||||
).
|
||||
@@ -1025,9 +1203,13 @@ func TestWriterCachesMissingBaselineAfterSuccessfulFirstSample(t *testing.T) {
|
||||
float64(4123.9),
|
||||
float64(4124.4),
|
||||
approxFloat64{want: 0.5, tolerance: 0.000001},
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
firstTime,
|
||||
secondTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
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) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -1135,9 +1402,13 @@ func TestWriterAppendUsesPreviousSourceBaselineForRealtimeCandidate(t *testing.T
|
||||
float64(4100.8),
|
||||
float64(4123.9),
|
||||
approxFloat64{want: 23.1, tolerance: 0.000001},
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
previousTime,
|
||||
currentTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonHistorical,
|
||||
).
|
||||
@@ -1220,9 +1491,13 @@ func TestWriterAppendUsesOlderHistoricalSourceBaseline(t *testing.T) {
|
||||
float64(120672.0),
|
||||
float64(120788.0),
|
||||
approxFloat64{want: 116.0, tolerance: 0.000001},
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
historicalTime,
|
||||
currentTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonHistorical,
|
||||
).
|
||||
@@ -1555,9 +1830,13 @@ func TestWriterAppendMarksCandidateNoPreviousBaselineWhenPreviousBaselineMissing
|
||||
float64(4136.8),
|
||||
float64(4136.8),
|
||||
float64(0),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
currentTime,
|
||||
currentTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonCurrentDayFirst,
|
||||
).
|
||||
@@ -1615,34 +1894,37 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
|
||||
t.Fatalf("Append() error = %v", err)
|
||||
}
|
||||
|
||||
schemaCalls := 3 + len(DailyMileageAlterSQL)
|
||||
schemaCalls := 6 + len(DailyMileageAlterSQL)
|
||||
if len(exec.calls) != schemaCalls+5 {
|
||||
t.Fatalf("exec calls = %d", len(exec.calls))
|
||||
}
|
||||
for i, want := range []string{
|
||||
"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",
|
||||
"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) {
|
||||
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"} {
|
||||
if strings.Contains(exec.calls[2].query, column) {
|
||||
t.Fatalf("schema should not include %s: %s", column, exec.calls[2].query)
|
||||
if strings.Contains(exec.calls[3].query, column) {
|
||||
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)"} {
|
||||
if !strings.Contains(exec.calls[2].query, column) {
|
||||
t.Fatalf("schema should include %s: %s", column, exec.calls[2].query)
|
||||
if !strings.Contains(exec.calls[3].query, column) {
|
||||
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)") {
|
||||
t.Fatalf("daily mileage table should key by vin/stat_date/protocol: %s", exec.calls[2].query)
|
||||
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[3].query)
|
||||
}
|
||||
if strings.Contains(exec.calls[2].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)
|
||||
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[3].query)
|
||||
}
|
||||
projectCall := exec.calls[schemaCalls+2]
|
||||
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") {
|
||||
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) {
|
||||
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) {
|
||||
exec := &recordingExec{}
|
||||
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) {
|
||||
exec := &recordingExec{errs: []error{errors.New("mysql unavailable"), nil}}
|
||||
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(0),
|
||||
float64(0),
|
||||
int64(1),
|
||||
int64(0),
|
||||
eventTime,
|
||||
eventTime,
|
||||
false,
|
||||
false,
|
||||
QualityOK,
|
||||
QualityReasonCurrentDayFirst,
|
||||
).
|
||||
@@ -2135,6 +2594,17 @@ type recordingExec struct {
|
||||
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 {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
|
||||
442
go/vehicle-gateway/internal/stats/gps_mileage.go
Normal file
442
go/vehicle-gateway/internal/stats/gps_mileage.go
Normal 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`
|
||||
308
go/vehicle-gateway/internal/stats/gps_mileage_test.go
Normal file
308
go/vehicle-gateway/internal/stats/gps_mileage_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
149
go/vehicle-gateway/internal/stats/hydrogen_capacity.go
Normal file
149
go/vehicle-gateway/internal/stats/hydrogen_capacity.go
Normal 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()
|
||||
}
|
||||
72
go/vehicle-gateway/internal/stats/hydrogen_capacity_test.go
Normal file
72
go/vehicle-gateway/internal/stats/hydrogen_capacity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
270
go/vehicle-gateway/internal/stats/hydrogen_stream.go
Normal file
270
go/vehicle-gateway/internal/stats/hydrogen_stream.go
Normal 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)`
|
||||
138
go/vehicle-gateway/internal/stats/hydrogen_stream_test.go
Normal file
138
go/vehicle-gateway/internal/stats/hydrogen_stream_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
120
go/vehicle-gateway/internal/stats/pure_hydrogen.go
Normal file
120
go/vehicle-gateway/internal/stats/pure_hydrogen.go
Normal 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
|
||||
}
|
||||
}
|
||||
134
go/vehicle-gateway/internal/stats/pure_hydrogen_test.go
Normal file
134
go/vehicle-gateway/internal/stats/pure_hydrogen_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ type MetricRow struct {
|
||||
SourceCode string `json:"source_code,omitempty"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||||
PureHydrogenMileageKM float64 `json:"pure_hydrogen_mileage_km"`
|
||||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
@@ -90,6 +91,8 @@ type MetricSourceRow struct {
|
||||
FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"`
|
||||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||||
PureHydrogenMileageKM float64 `json:"pure_hydrogen_mileage_km"`
|
||||
PureHydrogenSampleCount int64 `json:"pure_hydrogen_sample_count"`
|
||||
SampleCount int64 `json:"sample_count"`
|
||||
FirstEventTime string `json:"first_event_time,omitempty"`
|
||||
LatestEventTime string `json:"latest_event_time,omitempty"`
|
||||
@@ -241,6 +244,7 @@ func (r *MetricRepository) Query(ctx context.Context, query MetricQuery) ([]Metr
|
||||
&sourceCode,
|
||||
&sourceKind,
|
||||
&row.DailyMileageKM,
|
||||
&row.PureHydrogenMileageKM,
|
||||
&latest,
|
||||
&updatedAt,
|
||||
); err != nil {
|
||||
@@ -303,7 +307,9 @@ func (r *MetricRepository) QuerySources(ctx context.Context, query MetricSourceQ
|
||||
&firstTotal,
|
||||
&latestTotal,
|
||||
&row.DailyMileageKM,
|
||||
&row.PureHydrogenMileageKM,
|
||||
&row.SampleCount,
|
||||
&row.PureHydrogenSampleCount,
|
||||
&firstEvent,
|
||||
&latestEvent,
|
||||
&row.QualityStatus,
|
||||
@@ -1108,7 +1114,7 @@ func buildMetricSQL(query MetricQuery) (string, []any) {
|
||||
where, args := buildMetricWhere(query)
|
||||
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,
|
||||
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
|
||||
LEFT JOIN vehicle_data_source ds ON ds.id = m.source_id`
|
||||
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,
|
||||
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,
|
||||
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
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds ON ds.protocol = s.protocol AND ds.source_ip = s.source_ip`
|
||||
|
||||
@@ -32,10 +32,10 @@ func TestMetricRepositoryQueriesDailyMetricsWithFilters(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_id",
|
||||
"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(
|
||||
"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)),
|
||||
))
|
||||
|
||||
@@ -80,10 +80,10 @@ func TestMetricQueryReturnsSelectedSourceID(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_id",
|
||||
"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(
|
||||
"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",
|
||||
))
|
||||
|
||||
@@ -115,13 +115,13 @@ func TestMetricRepositoryQueriesDailyMetricSourcesWithEvidence(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
|
||||
"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",
|
||||
}).AddRow(
|
||||
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
|
||||
"115.231.168.135:41561", "13307795425", nil,
|
||||
"信达", 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",
|
||||
))
|
||||
|
||||
@@ -856,10 +856,10 @@ func TestMetricHandlerReturnsDailyMetrics(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_id",
|
||||
"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(
|
||||
"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",
|
||||
))
|
||||
|
||||
@@ -900,13 +900,13 @@ func TestMetricHandlerReturnsDailyMetricSources(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_key", "source_ip", "source_endpoint", "phone", "device_id",
|
||||
"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",
|
||||
}).AddRow(
|
||||
"LA9GG64L7PBAF4001", "2026-07-08", "JT808", "JT808:13307795425@115.231.168.135", "115.231.168.135",
|
||||
"115.231.168.135:41561", "13307795425", nil,
|
||||
"信达", 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",
|
||||
))
|
||||
|
||||
@@ -1340,7 +1340,7 @@ func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_id",
|
||||
"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))
|
||||
@@ -1372,10 +1372,10 @@ func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) {
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "stat_date", "protocol", "source_id",
|
||||
"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(
|
||||
"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",
|
||||
))
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const DailyMileageTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mileage (
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
source_id BIGINT NULL,
|
||||
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,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
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_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 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 DROP COLUMN first_total_mileage_km",
|
||||
"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_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 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 (
|
||||
@@ -67,9 +73,13 @@ const DailyMileageSourceTableSQL = `CREATE TABLE IF NOT EXISTS vehicle_daily_mil
|
||||
first_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,
|
||||
pure_hydrogen_mileage_km DECIMAL(18,3) 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,
|
||||
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_reason VARCHAR(255) NULL,
|
||||
is_selected TINYINT(1) NOT NULL DEFAULT 0,
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
QualityInvalidDelta = "INVALID_DELTA"
|
||||
QualityReasonHistorical = "historical_source_baseline"
|
||||
QualityReasonCurrentDayFirst = "current_day_first_baseline"
|
||||
QualityReasonStationaryCarry = "stationary_carry_forward"
|
||||
maxSelectedDailyMileageKM = 2500
|
||||
maxSelectedDailyMileageKMSQL = "2500"
|
||||
maxNegativeMileageJitterKMSQL = "1"
|
||||
@@ -35,9 +36,13 @@ type SourceMileageSample struct {
|
||||
FirstTotalKM float64
|
||||
LatestTotalKM float64
|
||||
DailyKM float64
|
||||
PureHydrogenMileageKM float64
|
||||
SampleCount int64
|
||||
PureHydrogenSampleCount int64
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
LatestPureHydrogenActive bool
|
||||
LatestPureHydrogenModeKnown bool
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
@@ -91,14 +96,25 @@ func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity)
|
||||
FirstTotalKM: sample.TotalMileageKM,
|
||||
LatestTotalKM: sample.TotalMileageKM,
|
||||
DailyKM: 0,
|
||||
PureHydrogenMileageKM: 0,
|
||||
SampleCount: 1,
|
||||
PureHydrogenSampleCount: boolCount(sample.PureHydrogenActive && sample.PureHydrogenModeKnown),
|
||||
FirstEventTime: eventTime,
|
||||
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 {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
@@ -202,9 +218,13 @@ func UpsertSourceMileage(ctx context.Context, exec Execer, sample SourceMileageS
|
||||
sample.FirstTotalKM,
|
||||
sample.LatestTotalKM,
|
||||
sample.DailyKM,
|
||||
sample.PureHydrogenMileageKM,
|
||||
sample.SampleCount,
|
||||
sample.PureHydrogenSampleCount,
|
||||
sample.FirstEventTime,
|
||||
sample.LatestEventTime,
|
||||
sample.LatestPureHydrogenActive,
|
||||
sample.LatestPureHydrogenModeKnown,
|
||||
sample.QualityStatus,
|
||||
sample.QualityReason,
|
||||
)
|
||||
@@ -337,10 +357,30 @@ func projectDailyMileageWithExec(ctx context.Context, exec Execer, vin string, s
|
||||
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
|
||||
WHEN first_total_mileage_km IS NULL OR first_total_mileage_km <= 0
|
||||
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)
|
||||
ELSE first_total_mileage_km
|
||||
END`
|
||||
@@ -354,7 +394,7 @@ const upsertSourceMergedLatestTotalSQL = `CASE
|
||||
END`
|
||||
|
||||
const upsertSourceMergedFirstEventSQL = `CASE
|
||||
WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time
|
||||
WHEN ` + upsertSourcePreferIncomingFirstSQL + `
|
||||
THEN VALUES(first_event_time)
|
||||
ELSE first_event_time
|
||||
END`
|
||||
@@ -401,18 +441,44 @@ const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysS
|
||||
|
||||
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 = `
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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,
|
||||
latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, 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 = 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 + `,
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
@@ -435,8 +501,9 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
const normalizePlatformSourceMileageInsertSQL = `
|
||||
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)
|
||||
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,
|
||||
latest_pure_hydrogen_active, latest_pure_hydrogen_mode_known, quality_status, quality_reason)
|
||||
SELECT
|
||||
s.vin,
|
||||
s.stat_date,
|
||||
@@ -450,9 +517,13 @@ SELECT
|
||||
` + normalizePlatformFirstTotalSQL + ` AS first_total_mileage_km,
|
||||
` + normalizePlatformLatestTotalSQL + ` AS latest_total_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.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count,
|
||||
MIN(s.first_event_time) AS first_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
|
||||
WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN '` + QualityInvalidDelta + `'
|
||||
ELSE '` + QualityOK + `'
|
||||
@@ -490,6 +561,9 @@ WHERE s.vin = ?
|
||||
AND s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
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(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))
|
||||
@@ -506,6 +580,16 @@ ON DUPLICATE KEY UPDATE
|
||||
phone = COALESCE(NULLIF(TRIM(VALUES(phone)), ''), phone),
|
||||
device_id = COALESCE(NULLIF(TRIM(VALUES(device_id)), ''), device_id),
|
||||
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 + `,
|
||||
latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `,
|
||||
daily_mileage_km = ` + upsertSourceMergedDailySQL + `,
|
||||
@@ -558,6 +642,9 @@ WHERE s.vin = ?
|
||||
AND s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
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(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))
|
||||
@@ -589,21 +676,71 @@ LEFT JOIN (
|
||||
WHERE s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
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(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))
|
||||
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 = `
|
||||
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
|
||||
s.vin,
|
||||
s.stat_date,
|
||||
s.protocol,
|
||||
ds.id,
|
||||
s.daily_mileage_km,
|
||||
LEAST(s.pure_hydrogen_mileage_km, s.daily_mileage_km),
|
||||
s.latest_total_mileage_km
|
||||
FROM vehicle_daily_mileage_source s
|
||||
LEFT JOIN vehicle_data_source ds
|
||||
@@ -612,26 +749,30 @@ WHERE s.vin = ?
|
||||
AND s.stat_date = ?
|
||||
AND s.protocol = ?
|
||||
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 (ds.id IS NULL OR ds.enabled = 1 OR (
|
||||
COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
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 'DIRECT' THEN 1
|
||||
WHEN 'UNKNOWN' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
COALESCE(ds.trust_priority, 100000),
|
||||
s.sample_count DESC,
|
||||
` + projectSourceIdentitySampleCountSQL + ` DESC,
|
||||
s.latest_event_time DESC,
|
||||
s.sample_count DESC,
|
||||
s.source_key ASC
|
||||
LIMIT 1
|
||||
ON DUPLICATE KEY UPDATE
|
||||
source_id = VALUES(source_id),
|
||||
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),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
@@ -657,15 +798,17 @@ LEFT JOIN (
|
||||
AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')
|
||||
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 'DIRECT' THEN 1
|
||||
WHEN 'UNKNOWN' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
COALESCE(ds.trust_priority, 100000),
|
||||
s2.sample_count DESC,
|
||||
` + selectedSourceIdentitySampleCountSQL + ` DESC,
|
||||
s2.latest_event_time DESC,
|
||||
s2.sample_count DESC,
|
||||
s2.source_key ASC
|
||||
LIMIT 1
|
||||
) selected_source
|
||||
|
||||
@@ -176,10 +176,10 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing
|
||||
if len(exec.calls) != 1 {
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,9 @@ func TestUpsertSourceMileageTruncatesSubsecondEventTimesAtDayBoundary(t *testing
|
||||
func TestUpsertSourceMileageUsesEventTimeBoundaries(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"first_total_mileage_km = CASE",
|
||||
"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",
|
||||
"VALUES(latest_event_time) >= latest_event_time",
|
||||
"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) {
|
||||
if maxSelectedDailyMileageKMSQL != "2500" || maxNegativeMileageJitterKMSQL != "1" {
|
||||
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) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
@@ -487,17 +526,25 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
"FROM vehicle_daily_mileage_source s",
|
||||
"LEFT JOIN vehicle_data_source ds",
|
||||
"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)",
|
||||
"WHEN 'PLATFORM' THEN 0",
|
||||
"WHEN 'DIRECT' THEN 1",
|
||||
"WHEN 'UNKNOWN' THEN 2",
|
||||
"ds.trust_priority",
|
||||
"s.quality_status = '" + QualityOK + "'",
|
||||
"s.latest_total_mileage_km IS NOT NULL",
|
||||
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
||||
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
||||
"AND (ds.platform_name IS NULL OR TRIM(ds.platform_name) = '')",
|
||||
"s.daily_mileage_km BETWEEN 0 AND",
|
||||
"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) {
|
||||
t.Fatalf("project query missing %q: %s", want, projectFinal)
|
||||
@@ -508,7 +555,6 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
"trusted_phone",
|
||||
"trusted_source_endpoint",
|
||||
"first_total_mileage_km",
|
||||
"sample_count)",
|
||||
"ds.latest_source_endpoint",
|
||||
} {
|
||||
if strings.Contains(projectFinal, forbidden) {
|
||||
@@ -533,11 +579,18 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
|
||||
"COALESCE(NULLIF(TRIM(ds.source_kind), ''), 'UNKNOWN') = 'UNKNOWN'",
|
||||
"AND (ds.source_code IS NULL OR TRIM(ds.source_code) = '')",
|
||||
"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)",
|
||||
"WHEN 'PLATFORM' THEN 0",
|
||||
"WHEN 'DIRECT' THEN 1",
|
||||
"WHEN 'UNKNOWN' THEN 2",
|
||||
"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",
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("mark query max mileage arg = %#v, want %d", got, maxSelectedDailyMileageKM)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
for _, statement := range statements {
|
||||
if strings.Contains(statement, fragment) {
|
||||
|
||||
@@ -5,6 +5,7 @@ HOST=""
|
||||
REMOTE_ROOT="/opt/lingniu-go-native"
|
||||
DAYS_BACK=1
|
||||
WINDOW_DAYS=3
|
||||
BASELINE_LOOKBACK_DAYS=7
|
||||
PROTOCOLS="GB32960,JT808,YUTONG_MQTT"
|
||||
ON_CALENDAR="*-*-* 01:30:00"
|
||||
DRY_RUN="false"
|
||||
@@ -19,6 +20,9 @@ Options:
|
||||
--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.
|
||||
--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.
|
||||
--on-calendar SPEC systemd OnCalendar value. Defaults to "*-*-* 01:30:00".
|
||||
--dry-run Install timer in dry-run mode.
|
||||
@@ -44,6 +48,10 @@ while [[ $# -gt 0 ]]; do
|
||||
WINDOW_DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--baseline-lookback-days)
|
||||
BASELINE_LOOKBACK_DAYS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--protocols)
|
||||
PROTOCOLS="${2:-}"
|
||||
shift 2
|
||||
@@ -86,11 +94,17 @@ case "$WINDOW_DAYS" in
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
case "$BASELINE_LOOKBACK_DAYS" in
|
||||
''|*[!0-9]*|0)
|
||||
echo "--baseline-lookback-days must be a positive integer" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
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" "$DAYS_BACK" "$WINDOW_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN"
|
||||
"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" "$BASELINE_LOOKBACK_DAYS" "$PROTOCOLS" "$ON_CALENDAR" "$DRY_RUN"
|
||||
ssh "$HOST" "$remote_cmd" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
|
||||
@@ -110,6 +124,7 @@ Environment=BACKFILL_METHOD=last_diff
|
||||
Environment=BACKFILL_DRY_RUN=${DRY_RUN}
|
||||
Environment=BACKFILL_DAYS_BACK=${DAYS_BACK}
|
||||
Environment=BACKFILL_WINDOW_DAYS=${WINDOW_DAYS}
|
||||
Environment=BACKFILL_BASELINE_LOOKBACK_DAYS=${BASELINE_LOOKBACK_DAYS}
|
||||
Environment=BACKFILL_PROTOCOLS=${PROTOCOLS}
|
||||
ExecStart=${REMOTE_ROOT}/current/stats-backfill
|
||||
TimeoutStartSec=45min
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,6 @@ func main() {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -34,21 +30,12 @@ func run() error {
|
||||
timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open vehicle platform database: %w", err)
|
||||
}
|
||||
defer target.Close()
|
||||
candidates, err := businessscope.ReadCandidates(ctx, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
|
||||
snapshot, sourceKind, err := readSnapshot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -64,11 +51,50 @@ func run() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("OneOS scope sync complete changed=%t candidates=%d accepted=%d rejected=%d version=%s",
|
||||
result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
|
||||
log.Printf("OneOS scope sync complete source=%s changed=%t candidates=%d accepted=%d rejected=%d version=%s",
|
||||
sourceKind, result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
|
||||
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) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
82
vehicle-data-platform/apps/api/cmd/open-platform-api/main.go
Normal file
82
vehicle-data-platform/apps/api/cmd/open-platform-api/main.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,15 @@ module lingniu/vehicle-data-platform/apps/api
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
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
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
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/gorilla/websocket v1.5.0 // 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/reflect2 v1.0.2 // 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
|
||||
)
|
||||
|
||||
@@ -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/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/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/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -30,6 +30,8 @@ type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
@@ -60,7 +62,17 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
if err != nil {
|
||||
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{}
|
||||
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
|
||||
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 {
|
||||
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 a.local == nil {
|
||||
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))
|
||||
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))
|
||||
}
|
||||
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) {
|
||||
return
|
||||
}
|
||||
if a.demo != nil && a.demo.handleAdmin(w, r, principal) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -220,9 +247,9 @@ func requiredMenu(r *http.Request) string {
|
||||
return "shared"
|
||||
case path == "/api/v2/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"
|
||||
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"
|
||||
case strings.HasPrefix(path, "/api/v2/vehicles/"):
|
||||
return "vehicles"
|
||||
@@ -232,6 +259,19 @@ func requiredMenu(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"
|
||||
@@ -247,7 +287,9 @@ func requiredRole(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
if r.Method == http.MethodPost {
|
||||
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"
|
||||
case "/api/v2/alerts/notifications/read":
|
||||
return "operator"
|
||||
@@ -258,6 +300,9 @@ func requiredRole(r *http.Request) string {
|
||||
if path == "/api/v2/alerts/rules" {
|
||||
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/")) {
|
||||
return "admin"
|
||||
|
||||
260
vehicle-data-platform/apps/api/internal/app/auth_demo.go
Normal file
260
vehicle-data-platform/apps/api/internal/app/auth_demo.go
Normal 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)
|
||||
}
|
||||
@@ -123,6 +123,41 @@ type userMutation struct {
|
||||
VehicleGrants []userVehicleGrantInput `json:"vehicleGrants"`
|
||||
}
|
||||
|
||||
const maxUserBatchItems = 50
|
||||
|
||||
type userBatchRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Items []userBatchItem `json:"items"`
|
||||
}
|
||||
|
||||
type userBatchItem struct {
|
||||
Row int `json:"row"`
|
||||
Input userMutation `json:"input"`
|
||||
}
|
||||
|
||||
type userBatchResultItem struct {
|
||||
Row int `json:"row"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type userBatchSummary struct {
|
||||
Received int `json:"received"`
|
||||
Ready int `json:"ready"`
|
||||
Created int `json:"created"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type userBatchResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Summary userBatchSummary `json:"summary"`
|
||||
Items []userBatchResultItem `json:"items"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -139,6 +174,17 @@ type loginResponse struct {
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
type oneOSTicketExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
|
||||
type oneOSTicketExchangeResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
ReturnTo string `json:"returnTo"`
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
principal platform.Principal
|
||||
expiresAt time.Time
|
||||
@@ -148,6 +194,7 @@ type cachedSession struct {
|
||||
type authStore struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
oneOSSessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
}
|
||||
@@ -160,7 +207,11 @@ func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 12 * time.Hour
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}}
|
||||
oneOSTTL := cfg.OneOSSessionTTL
|
||||
if oneOSTTL <= 0 {
|
||||
oneOSTTL = 30 * time.Minute
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, oneOSSessionTTL: oneOSTTL, cache: map[string]cachedSession{}}
|
||||
if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,28 +294,168 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
_, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255))
|
||||
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal})
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
}
|
||||
|
||||
func (s *authStore) exchangeOneOSTicket(w http.ResponseWriter, r *http.Request, adapter *oneOSIdentityAdapter) {
|
||||
var input oneOSTicketExchangeRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
identity, err := adapter.ExchangeTicket(r.Context(), input.Ticket)
|
||||
if err != nil {
|
||||
s.audit(r.Context(), "oneos", "sso.exchange", "ticket", "", "denied", map[string]any{"reason": err.Error()}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "ONEOS_TICKET_REJECTED", "OneOS 登录票据无效、已过期或已使用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user, err := s.syncOneOSIdentity(r.Context(), identity, adapter.defaultMenus)
|
||||
if err != nil {
|
||||
s.audit(r.Context(), identity.Username, "sso.exchange", "user", identity.Subject, "failed", map[string]any{"reason": err.Error()}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法同步 OneOS 用户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), user)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法加载 OneOS 用户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
response, err := s.issueSession(r, user.ID, principal, s.oneOSSessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET last_login_at=NOW(3),failed_login_count=0,locked_until=NULL WHERE id=?`, user.ID)
|
||||
s.audit(r.Context(), principal.Name, "sso.exchange", "user", identity.Subject, "success", map[string]any{
|
||||
"scopeLevel": identity.ScopeLevel, "departmentIds": identity.DepartmentIDs,
|
||||
}, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), oneOSTicketExchangeResponse{
|
||||
AccessToken: response.AccessToken,
|
||||
ExpiresAt: response.ExpiresAt,
|
||||
Session: response.Session,
|
||||
ReturnTo: identity.ReturnTo,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *authStore) syncOneOSIdentity(ctx context.Context, identity oneOSIdentity, menus []string) (authUser, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID uint64
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`, identity.Subject).Scan(&userID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
result, insertErr := tx.ExecContext(ctx, `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')`,
|
||||
oneOSPlatformUsername(identity.Subject), oneOSDisplayName(identity), "", identity.TenantID, identity.Subject,
|
||||
)
|
||||
if insertErr != nil {
|
||||
return authUser{}, insertErr
|
||||
}
|
||||
insertedID, insertErr := result.LastInsertId()
|
||||
if insertErr != nil {
|
||||
return authUser{}, insertErr
|
||||
}
|
||||
userID = uint64(insertedID)
|
||||
case err != nil:
|
||||
return authUser{}, err
|
||||
default:
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE platform_user SET display_name=?,tenant_ref=?,status='enabled',updated_by='oneos-sso' WHERE id=?`,
|
||||
oneOSDisplayName(identity), identity.TenantID, userID); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if !customerMenuSet[menu] {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`, userID, menu); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
}
|
||||
departmentIDs := strings.Join(normalizeStringList(identity.DepartmentIDs, 100), ",")
|
||||
if _, err = tx.ExecContext(ctx, `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)`,
|
||||
userID, identity.ScopeLevel, departmentIDs, strings.TrimSpace(identity.ResponsibleUserID), identity.IssuedAt); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
s.invalidateUser(userID)
|
||||
return authUser{
|
||||
ID: userID, Username: oneOSPlatformUsername(identity.Subject), DisplayName: oneOSDisplayName(identity),
|
||||
UserType: "customer", Status: "enabled", TenantRef: identity.TenantID,
|
||||
AuthProvider: "oneos", ExternalSubject: identity.Subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authStore) issueSession(r *http.Request, userID uint64, principal platform.Principal, ttl time.Duration) (loginResponse, error) {
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
expiresAt := now.Add(ttl)
|
||||
if _, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
sessionID, userID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255)); err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
|
||||
}
|
||||
|
||||
func oneOSPlatformUsername(subject string) string {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, strings.TrimSpace(subject))
|
||||
normalized = strings.Trim(normalized, "-")
|
||||
if normalized == "" {
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
normalized = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
value := "oneos-" + normalized
|
||||
if len(value) > 64 {
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
value = "oneos-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func oneOSDisplayName(identity oneOSIdentity) string {
|
||||
if value := strings.TrimSpace(identity.DisplayName); value != "" {
|
||||
return truncateUTF8(value, 96)
|
||||
}
|
||||
return truncateUTF8(identity.Username, 96)
|
||||
}
|
||||
|
||||
func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) {
|
||||
@@ -363,6 +554,9 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
businessScopeLevel := ""
|
||||
departmentIDs := []string{}
|
||||
responsibleUserID := ""
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
@@ -404,14 +598,96 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(user.AuthProvider), "oneos") {
|
||||
var rawDepartmentIDs string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT scope_level,department_ids,responsible_user_id
|
||||
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`, user.ID).Scan(
|
||||
&businessScopeLevel, &rawDepartmentIDs, &responsibleUserID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if err == nil {
|
||||
departmentIDs = normalizeScopeValues(rawDepartmentIDs)
|
||||
businessVINs, err := s.loadOneOSBusinessVINs(ctx, businessScopeLevel, departmentIDs, responsibleUserID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
vehicles = businessVINs
|
||||
vehicleGrants = make([]platform.VehicleGrant, 0, len(businessVINs))
|
||||
for _, vin := range businessVINs {
|
||||
vehicleGrants = append(vehicleGrants, platform.VehicleGrant{VIN: vin})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return platform.Principal{
|
||||
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
|
||||
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, VehicleGrants: vehicleGrants,
|
||||
BusinessScopeLevel: businessScopeLevel, DepartmentIDs: departmentIDs, ResponsibleUserID: strings.TrimSpace(responsibleUserID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeScopeValues(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range strings.Split(raw, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] || len(result) >= 100 {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *authStore) loadOneOSBusinessVINs(ctx context.Context, level string, departmentIDs []string, responsibleUserID string) ([]string, error) {
|
||||
where := []string{"st.id=1"}
|
||||
args := []any{}
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "department":
|
||||
if len(departmentIDs) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(departmentIDs)), ",")
|
||||
where = append(where, "scope.department_id IN ("+placeholders+")")
|
||||
for _, id := range departmentIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
case "responsible":
|
||||
responsibleUserID = strings.TrimSpace(responsibleUserID)
|
||||
if responsibleUserID == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
where = append(where, "scope.responsible_user_id=?")
|
||||
args = append(args, responsibleUserID)
|
||||
default:
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT UPPER(TRIM(scope.vin))
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope scope ON BINARY scope.source_version=BINARY st.active_version
|
||||
WHERE `+strings.Join(where, " AND ")+` AND scope.vin<>'' ORDER BY UPPER(TRIM(scope.vin))`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vin = strings.TrimSpace(vin); vin != "" {
|
||||
result = append(result, vin)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *authStore) 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
|
||||
@@ -423,6 +699,8 @@ func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principa
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
|
||||
s.listUsers(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
|
||||
s.batchCustomers(w, r, principal)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
|
||||
s.createCustomer(w, r, principal)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
|
||||
@@ -598,32 +876,127 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name, actor.Name)
|
||||
id, grantChanges, err := s.createCustomerRecord(r.Context(), input, menus, grants, actor.Name)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
grantChanges := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, grants, actor.Name, &grantChanges); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
} else {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) batchCustomers(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
var input userBatchRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Mode = strings.ToLower(strings.TrimSpace(input.Mode))
|
||||
if input.Mode != "preview" && input.Mode != "create" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量操作模式必须为 preview 或 create", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", fmt.Sprintf("每次需导入 1-%d 个客户账号", maxUserBatchItems), "", 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 index, item := range input.Items {
|
||||
row := item.Row
|
||||
if row <= 1 {
|
||||
row = index + 2
|
||||
}
|
||||
item.Input.Username = strings.TrimSpace(item.Input.Username)
|
||||
item.Input.DisplayName = strings.TrimSpace(item.Input.DisplayName)
|
||||
item.Input.Status = firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled")
|
||||
entry := userBatchResultItem{Row: row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
|
||||
usernameKey := strings.ToLower(item.Input.Username)
|
||||
if seen[usernameKey] {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内用户名重复"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
seen[usernameKey] = true
|
||||
menus, grants, err := s.validateMutation(r.Context(), item.Input, true)
|
||||
if err != nil {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
var existingID uint64
|
||||
err = s.db.QueryRowContext(r.Context(), `SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`, item.Input.Username).Scan(&existingID)
|
||||
if err == nil {
|
||||
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
entry.Status, entry.Code, entry.Message = "failed", "USER_CHECK_FAILED", "暂时无法校验用户名"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
if input.Mode == "preview" {
|
||||
entry.Status, entry.Message = "ready", "校验通过,可以创建"
|
||||
result.Summary.Ready++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
id, grantChanges, err := s.createCustomerRecord(r.Context(), item.Input, menus, grants, actor.Name)
|
||||
if err != nil {
|
||||
entry.Status, entry.Code, entry.Message = "failed", "USER_CREATE_FAILED", "创建失败,请重试"
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
|
||||
}
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
entry.Status, entry.Message, entry.ID = "created", "账号与权限已创建", id
|
||||
result.Summary.Created++
|
||||
result.Items = append(result.Items, entry)
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"batch": true, "row": row, "menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.batch."+input.Mode, "user_batch", strconv.Itoa(len(input.Items)), "success", map[string]any{"received": result.Summary.Received, "ready": result.Summary.Ready, "created": result.Summary.Created, "failed": result.Summary.Failed}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), result)
|
||||
}
|
||||
|
||||
func (s *authStore) createCustomerRecord(ctx context.Context, input userMutation, menus []string, grants []vehicleGrantMutation, actor string) (int64, []vehicleGrantAuditChange, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
created, err := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor, actor)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
id, err := created.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
grantChanges := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(ctx, tx, uint64(id), menus, grants, actor, &grantChanges); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return id, grantChanges, nil
|
||||
}
|
||||
|
||||
func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
idText := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/")
|
||||
id, err := strconv.ParseUint(idText, 10, 64)
|
||||
@@ -647,11 +1020,15 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userType string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType); err != nil || userType != "customer" {
|
||||
var userType, authProvider string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType, &authProvider); err != nil || userType != "customer" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if authProvider = strings.TrimSpace(authProvider); authProvider != "" && !strings.EqualFold(authProvider, "local") && input.Password != "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", authProvider, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
args := []any{strings.TrimSpace(input.DisplayName), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name}
|
||||
query := `UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
|
||||
@@ -1,14 +1,177 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -210,3 +373,36 @@ func TestNormalizeVehicleGrantMutationsRequiresOrderedInterval(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -86,6 +87,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorRule.Code != http.StatusForbidden {
|
||||
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)
|
||||
if operatorProfile.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code)
|
||||
@@ -94,6 +99,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorProfileSync.Code != http.StatusForbidden {
|
||||
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)
|
||||
@@ -106,6 +115,26 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
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)
|
||||
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"))
|
||||
@@ -118,10 +147,45 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if adminThreshold.Code != http.StatusNoContent {
|
||||
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) {
|
||||
@@ -139,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) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"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/static"
|
||||
)
|
||||
@@ -72,7 +74,12 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
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,
|
||||
ExportDir: strings.TrimSpace(cfg.ExportDir),
|
||||
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
|
||||
@@ -84,13 +91,30 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
|
||||
AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode),
|
||||
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 {
|
||||
log.Printf("platform data store unavailable: %v", storeErr)
|
||||
api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// stay behind the same authentication boundary as the platform API.
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
type appConfig struct {
|
||||
AMapWebJSKey string `json:"amapWebJsKey,omitempty"`
|
||||
|
||||
@@ -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) {
|
||||
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-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())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type Candidate struct {
|
||||
VIN string
|
||||
PlateNumber string
|
||||
CustomerID int64
|
||||
CustomerName string
|
||||
CustomerPresent bool
|
||||
CustomerProfileExists bool
|
||||
ContractID int64
|
||||
@@ -42,6 +43,10 @@ type Candidate struct {
|
||||
EffectiveCustomerSet bool
|
||||
ContractCode string
|
||||
ProjectName string
|
||||
DepartmentID string
|
||||
DepartmentName string
|
||||
ResponsibleUserID string
|
||||
ResponsibleUserName string
|
||||
OperationStatus string
|
||||
ScopeStartAt time.Time
|
||||
SourceUpdatedAt *time.Time
|
||||
@@ -52,9 +57,14 @@ type ScopeItem struct {
|
||||
VIN string
|
||||
PlateNumber string
|
||||
CustomerID int64
|
||||
CustomerName string
|
||||
ContractID int64
|
||||
ContractCode string
|
||||
ProjectName string
|
||||
DepartmentID string
|
||||
DepartmentName string
|
||||
ResponsibleUserID string
|
||||
ResponsibleUserName string
|
||||
OperationStatus string
|
||||
ScopeStartAt time.Time
|
||||
SourceUpdatedAt *time.Time
|
||||
@@ -119,8 +129,10 @@ func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, err
|
||||
}
|
||||
items = append(items, ScopeItem{
|
||||
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),
|
||||
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,
|
||||
SourceUpdatedAt: candidate.SourceUpdatedAt,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
|
||||
department_id,department_name,responsible_user_id,responsible_user_name,operation_status,scope_start_at,source_updated_at,published_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
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 {
|
||||
contractID = item.ContractID
|
||||
}
|
||||
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID,
|
||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
|
||||
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, contractID,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snapshot.Rejections) > 0 {
|
||||
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(?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
)
|
||||
|
||||
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
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
|
||||
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 {
|
||||
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
|
||||
@@ -21,8 +22,9 @@ func publishSnapshot(version string) Snapshot {
|
||||
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
GeneratedAt: generatedAt, Candidates: 1,
|
||||
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",
|
||||
DepartmentID: "20", DepartmentName: "运营一部", ResponsibleUserID: "30", ResponsibleUserName: "负责人",
|
||||
ScopeStartAt: generatedAt.Add(-time.Hour),
|
||||
}},
|
||||
}
|
||||
@@ -47,8 +49,9 @@ func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) {
|
||||
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
|
||||
item := snapshot.Items[0]
|
||||
prepared.ExpectExec().WithArgs(
|
||||
snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID,
|
||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
|
||||
snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, item.ContractID,
|
||||
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))
|
||||
mock.ExpectExec(`UPDATE business_scope_state SET`).
|
||||
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
|
||||
@@ -24,6 +24,35 @@ const candidateQuery = `SELECT
|
||||
COALESCE(co.other_customer_id, co.customer_id),
|
||||
COALESCE(co.contract_code, r.contract_code, ''),
|
||||
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, ''),
|
||||
dv.delivery_time,
|
||||
dv.update_time
|
||||
@@ -89,12 +118,14 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
|
||||
for rows.Next() {
|
||||
var vehicleID, vehicleProfileID, customerID, customerProfileID 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 updatedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID,
|
||||
&contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName,
|
||||
&customerName, &departmentID, &departmentName, &responsibleUserID, &responsibleUserName,
|
||||
&operationStatus, &scopeStart, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan OneOS scope candidate: %w", err)
|
||||
@@ -108,7 +139,10 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
|
||||
ContractID: contractID.Int64, ContractPresent: contractID.Valid,
|
||||
ContractProfileExists: contractProfileID.Valid,
|
||||
EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid,
|
||||
ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus,
|
||||
CustomerName: customerName, ContractCode: contractCode, ProjectName: projectName,
|
||||
DepartmentID: departmentID, DepartmentName: departmentName,
|
||||
ResponsibleUserID: responsibleUserID, ResponsibleUserName: responsibleUserName,
|
||||
OperationStatus: operationStatus,
|
||||
ScopeStartAt: scopeStart,
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
|
||||
@@ -13,6 +13,11 @@ func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t
|
||||
"rt.status IN (2, 3, 5)",
|
||||
"FROM delivery_vehicle newer",
|
||||
"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 {
|
||||
if !strings.Contains(candidateQuery, expected) {
|
||||
|
||||
@@ -25,6 +25,14 @@ type Config struct {
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
SessionTTL time.Duration
|
||||
OneOSSSOEnabled bool
|
||||
OneOSIntrospectionURL string
|
||||
OneOSServiceToken string
|
||||
OneOSSigningSecret string
|
||||
OneOSAudience string
|
||||
OneOSDefaultMenus []string
|
||||
OneOSRequestTimeout time.Duration
|
||||
OneOSSessionTTL time.Duration
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
@@ -41,6 +49,20 @@ type Config struct {
|
||||
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 {
|
||||
@@ -62,6 +84,14 @@ func Load() Config {
|
||||
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
OneOSSSOEnabled: envBool("ONEOS_SSO_ENABLED", false),
|
||||
OneOSIntrospectionURL: os.Getenv("ONEOS_SSO_INTROSPECTION_URL"),
|
||||
OneOSServiceToken: os.Getenv("ONEOS_SSO_SERVICE_TOKEN"),
|
||||
OneOSSigningSecret: os.Getenv("ONEOS_SSO_SIGNING_SECRET"),
|
||||
OneOSAudience: env("ONEOS_SSO_AUDIENCE", "vehicle-platform"),
|
||||
OneOSDefaultMenus: splitCSV(env("ONEOS_SSO_DEFAULT_MENUS", "vehicles")),
|
||||
OneOSRequestTimeout: time.Duration(envInt("ONEOS_SSO_REQUEST_TIMEOUT_MS", 3000)) * time.Millisecond,
|
||||
OneOSSessionTTL: time.Duration(envInt("ONEOS_SSO_SESSION_TTL_MINUTES", 30)) * time.Minute,
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
@@ -78,6 +108,20 @@ func Load() Config {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,3 +50,32 @@ func TestLoadKeepsExplicitMockDataMode(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<!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 <32位appKey>
|
||||
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>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/realtime/query</h3>
|
||||
<p>查询当前有效授权车辆的最新位置、在线及运动状态、速度、累计里程、实际协议和记录时间。请求体传 <code>{}</code> 返回全部授权车辆。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/hydrogen-stations/query</h3>
|
||||
<p>只读查询资产管理库中有有效坐标的加氢站;可按 <code>province</code>、<code>city</code>、<code>cooperateOnly</code> 筛选。</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 > YUTONG_MQTT > 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>
|
||||
|
||||
<h3>实时车辆与全部加氢站</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/realtime/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' -d '{}'
|
||||
|
||||
curl -X POST 'https://your-host/api/v1/hydrogen-stations/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' -d '{}'</pre>
|
||||
|
||||
<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 > YUTONG_MQTT > 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>
|
||||
@@ -0,0 +1,880 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.6.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/v1/vehicles/realtime/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询授权车辆实时位置与状态
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
||||
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。
|
||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||
operationId: queryRealtimeVehicles
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;没有实时记录的授权车辆以 NO_DATA 返回
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RealtimeVehicleQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/hydrogen-stations/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询加氢站地图点位
|
||||
description: 只读返回资产管理数据库内具有有效经纬度的加氢站,可按省、市和合作状态筛选;无需车辆授权但要求有效 appKey。
|
||||
operationId: queryHydrogenStations
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQuery'
|
||||
example: {}
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenStationQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'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
|
||||
RealtimeVehicleQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 2000
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||
HydrogenStationQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
province:
|
||||
type: string
|
||||
maxLength: 32
|
||||
city:
|
||||
type: string
|
||||
maxLength: 32
|
||||
cooperateOnly:
|
||||
type: boolean
|
||||
description: true仅合作站,false仅外部站,省略则返回全部
|
||||
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: 当日所选协议最后有效累计总里程,km;status=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'
|
||||
RealtimeVehicleResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
||||
properties:
|
||||
vin: { type: string }
|
||||
plateNumber: { type: string }
|
||||
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] }
|
||||
longitude: { type: number, format: double, nullable: true }
|
||||
latitude: { type: number, format: double, nullable: true }
|
||||
speedKmh: { type: number, format: double, nullable: true }
|
||||
totalMileageKm: { type: number, format: double, nullable: true }
|
||||
recordTime: { type: string }
|
||||
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 }
|
||||
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
|
||||
motionStatus: { type: string, enum: [driving, idle, offline] }
|
||||
locationAvailable: { type: boolean }
|
||||
status: { $ref: '#/components/schemas/DataStatus' }
|
||||
RealtimeVehicleQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/RealtimeVehicleResult' }
|
||||
HydrogenStation:
|
||||
type: object
|
||||
required: [id, name, longitude, latitude, cooperative]
|
||||
properties:
|
||||
id: { type: string, description: 站点ID使用字符串避免JavaScript整数精度损失 }
|
||||
name: { type: string }
|
||||
shortName: { type: string }
|
||||
address: { type: string }
|
||||
longitude: { type: number, format: double }
|
||||
latitude: { type: number, format: double }
|
||||
province: { type: string }
|
||||
city: { type: string }
|
||||
district: { type: string }
|
||||
cooperative: { type: boolean }
|
||||
HydrogenStationQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/HydrogenStation' }
|
||||
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
|
||||
@@ -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"
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal file
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
775
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
775
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
@@ -0,0 +1,775 @@
|
||||
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"
|
||||
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
|
||||
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/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)
|
||||
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
|
||||
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
|
||||
}
|
||||
|
||||
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)
|
||||
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
|
||||
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
|
||||
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 || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request RealtimeVehicleRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryRealtimeVehicles(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) hydrogenStations(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request HydrogenStationRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHydrogenStations(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) 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",
|
||||
},
|
||||
{
|
||||
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
|
||||
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: RealtimeVehicleQueryPath, Unit: "",
|
||||
},
|
||||
{
|
||||
Code: "hydrogen_station", Name: "加氢站地图点位",
|
||||
Description: "只读查询资产管理库中的加氢站名称、经纬度、行政区划和合作状态。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: HydrogenStationQueryPath, Unit: "座",
|
||||
},
|
||||
}
|
||||
}
|
||||
380
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
380
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
@@ -0,0 +1,380 @@
|
||||
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 RealtimeVehicleRequest struct {
|
||||
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
}
|
||||
|
||||
type RealtimeVehicleResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Longitude *float64 `json:"longitude"`
|
||||
Latitude *float64 `json:"latitude"`
|
||||
SpeedKmh *float64 `json:"speedKmh"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
RecordTime string `json:"recordTime,omitempty"`
|
||||
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||
Online bool `json:"online"`
|
||||
MotionStatus string `json:"motionStatus"`
|
||||
LocationAvailable bool `json:"locationAvailable"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type HydrogenStationRequest struct {
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
CooperateOnly *bool `json:"cooperateOnly,omitempty"`
|
||||
}
|
||||
|
||||
type HydrogenStation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"shortName,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Province string `json:"province,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
District string `json:"district,omitempty"`
|
||||
Cooperative bool `json:"cooperative"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
702
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
702
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
@@ -0,0 +1,702 @@
|
||||
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(×tampMS, &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) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
||||
if len(vins) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
args := make([]any, 0, len(vins)+1)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
args = append(args, now.Add(-10*time.Minute))
|
||||
query := `
|
||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||
COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at
|
||||
FROM vehicle_realtime_location l
|
||||
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||
ORDER BY l.vin,
|
||||
CASE WHEN l.updated_at>=? THEN 0 ELSE 1 END,
|
||||
CASE l.protocol WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END,
|
||||
l.updated_at DESC,l.protocol ASC`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
onlineThreshold := now.Add(-time.Minute)
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.Online = !point.ObservedAt.Before(onlineThreshold)
|
||||
if selected, exists := out[point.VIN]; !exists {
|
||||
out[point.VIN] = point
|
||||
} else if point.Online && !selected.Online {
|
||||
// The selected source still follows the documented protocol priority, but
|
||||
// online means that any source for this VIN reported in the last minute.
|
||||
selected.Online = true
|
||||
out[point.VIN] = selected
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
where := []string{
|
||||
"s.longitude BETWEEN -180 AND 180",
|
||||
"s.latitude BETWEEN -90 AND 90",
|
||||
"NOT (s.longitude=0 AND s.latitude=0)",
|
||||
}
|
||||
args := make([]any, 0, 3)
|
||||
if request.Province != "" {
|
||||
where = append(where, "s.province=?")
|
||||
args = append(args, request.Province)
|
||||
}
|
||||
if request.City != "" {
|
||||
where = append(where, "s.city=?")
|
||||
args = append(args, request.City)
|
||||
}
|
||||
if request.CooperateOnly != nil {
|
||||
if *request.CooperateOnly {
|
||||
where = append(where, "s.inner_site_id IS NOT NULL")
|
||||
} else {
|
||||
where = append(where, "s.inner_site_id IS NULL")
|
||||
}
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
|
||||
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
|
||||
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
|
||||
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
|
||||
FROM ln_asset_management.tab_outside_hydrogen_site s
|
||||
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY s.province,s.city,s.fixed_station_name,s.id
|
||||
LIMIT 2000`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
stations := make([]HydrogenStation, 0, 512)
|
||||
for rows.Next() {
|
||||
var station HydrogenStation
|
||||
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stations = append(stations, station)
|
||||
}
|
||||
return stations, rows.Err()
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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 TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
||||
vin := "LTEST32960VIN0001"
|
||||
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||
WithArgs(vin, now.Add(-10*time.Minute)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}).
|
||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)).
|
||||
AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second)))
|
||||
|
||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
point := points[vin]
|
||||
if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
||||
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
||||
}
|
||||
if err := mock.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)
|
||||
}
|
||||
}
|
||||
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal file
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal 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(¤tHash); 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
815
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
815
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
@@ -0,0 +1,815 @@
|
||||
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)
|
||||
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
|
||||
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, 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)
|
||||
}
|
||||
|
||||
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
|
||||
now := s.now().In(s.location)
|
||||
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = point.Protocol
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
item.MotionStatus = "offline"
|
||||
if item.Online && point.SpeedKmh > 3 {
|
||||
item.MotionStatus = "driving"
|
||||
} else if item.Online {
|
||||
item.MotionStatus = "idle"
|
||||
}
|
||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||
if validCoordinate(point.Longitude, point.Latitude) {
|
||||
longitude, latitude := point.Longitude, point.Latitude
|
||||
item.Longitude, item.Latitude = &longitude, &latitude
|
||||
item.LocationAvailable = true
|
||||
}
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
now := s.now().In(s.location)
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
request.Province = strings.TrimSpace(request.Province)
|
||||
request.City = strings.TrimSpace(request.City)
|
||||
if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 {
|
||||
return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest)
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error())
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
stations, err := s.repository.HydrogenStations(ctx, request)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "")
|
||||
return stations, nil
|
||||
}
|
||||
|
||||
func validCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
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
|
||||
realtime map[string]RealtimeVehiclePoint
|
||||
stations []HydrogenStation
|
||||
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) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
return f.realtime, nil
|
||||
}
|
||||
func (f *fakeRepository) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
return f.stations, nil
|
||||
}
|
||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||
f.audits = append(f.audits, endpoint+":"+result)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
|
||||
},
|
||||
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), key, "trace-v", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
|
||||
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
|
||||
}
|
||||
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
|
||||
t.Fatalf("missing realtime row should remain explicit: %#v", vehicles[1])
|
||||
}
|
||||
stations, err := service.QueryHydrogenStations(context.Background(), key, "trace-s", HydrogenStationRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stations) != 1 || stations[0].Name != "测试加氢站" {
|
||||
t.Fatalf("unexpected stations: %#v", stations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||
},
|
||||
realtime: map[string]RealtimeVehiclePoint{
|
||||
// The selected GB32960 point can be older while JT808/MQTT keeps the VIN online.
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, ObservedAt: now.Add(-2 * time.Minute), Online: true},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
|
||||
vehicles, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-any-source", RealtimeVehicleRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 1 || !vehicles[0].Online || vehicles[0].MotionStatus != "idle" {
|
||||
t.Fatalf("fresh alternate protocol should keep vehicle online: %#v", vehicles)
|
||||
}
|
||||
if vehicles[0].TimeDifferenceSeconds == nil || *vehicles[0].TimeDifferenceSeconds != 120 {
|
||||
t.Fatalf("selected source record time must remain auditable: %#v", vehicles[0])
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ©
|
||||
}
|
||||
}
|
||||
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 = ©
|
||||
}
|
||||
}
|
||||
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
Reference in New Issue
Block a user