Compare commits
74 Commits
4e4f383988
...
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 |
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、重试队列和回补任务状态;源头完全不提供里程且没有可信移动线段时,系统必须保留缺口而不是构造数值。
|
||||
@@ -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,6 +47,8 @@ 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`,处理前不得删除。
|
||||
@@ -339,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 或存储存在瓶颈。 |
|
||||
@@ -506,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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -75,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)
|
||||
@@ -86,6 +93,18 @@ 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,
|
||||
@@ -95,6 +114,21 @@ func main() {
|
||||
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(), "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)
|
||||
@@ -104,6 +138,121 @@ func main() {
|
||||
}(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 {
|
||||
@@ -687,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) {
|
||||
@@ -762,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()))
|
||||
}
|
||||
@@ -848,7 +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 {
|
||||
@@ -892,7 +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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,20 +105,21 @@ func TestProcessStatMessageRecordsMetrics(t *testing.T) {
|
||||
ProjectionsWritten: 1,
|
||||
ProjectionsSkippedThrottled: 2,
|
||||
}, cacheStats: stats.CacheStats{
|
||||
LastTotalMileageEntries: 300,
|
||||
LastSourceSeenEntries: 3,
|
||||
LastProjectionEntries: 280,
|
||||
BaselineEntries: 295,
|
||||
MaxEntries: 1000000,
|
||||
LastCleanupAt: time.Unix(1782918600, 0),
|
||||
LastCleanupTotalMileage: 11,
|
||||
LastCleanupSourceSeen: 1,
|
||||
LastCleanupProjection: 9,
|
||||
LastCleanupBaseline: 10,
|
||||
TotalMileageEvictions: 4,
|
||||
SourceSeenEvictions: 1,
|
||||
ProjectionEvictions: 2,
|
||||
BaselineEvictions: 3,
|
||||
LastTotalMileageEntries: 300,
|
||||
LastSourceSeenEntries: 3,
|
||||
LastProjectionEntries: 280,
|
||||
PendingProjectionEntries: 12,
|
||||
BaselineEntries: 295,
|
||||
MaxEntries: 1000000,
|
||||
LastCleanupAt: time.Unix(1782918600, 0),
|
||||
LastCleanupTotalMileage: 11,
|
||||
LastCleanupSourceSeen: 1,
|
||||
LastCleanupProjection: 9,
|
||||
LastCleanupBaseline: 10,
|
||||
TotalMileageEvictions: 4,
|
||||
SourceSeenEvictions: 1,
|
||||
ProjectionEvictions: 2,
|
||||
BaselineEvictions: 3,
|
||||
}},
|
||||
&contextCheckingStatCommitter{},
|
||||
kafka.Message{Topic: "vehicle.fields.go.jt808.v1", Partition: 4, Offset: 30, HighWaterMark: 41, Value: payload},
|
||||
@@ -150,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`,
|
||||
@@ -177,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()
|
||||
|
||||
@@ -1038,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) {
|
||||
@@ -1124,6 +1178,8 @@ 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()
|
||||
@@ -1149,6 +1205,12 @@ 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)
|
||||
}
|
||||
@@ -1232,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,6 +35,8 @@ type config struct {
|
||||
Reset bool
|
||||
Debug bool
|
||||
EventTimeFullScan bool
|
||||
StationaryCarry bool
|
||||
GPSFallback bool
|
||||
ProgressEvery int64
|
||||
BaselineLookback int
|
||||
Location *time.Location
|
||||
@@ -55,23 +57,27 @@ type rawFrameRow struct {
|
||||
}
|
||||
|
||||
type metricAgg struct {
|
||||
VIN string
|
||||
Date string
|
||||
Protocol envelope.Protocol
|
||||
FirstKM float64
|
||||
LatestKM float64
|
||||
Count int64
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
VIN string
|
||||
Date string
|
||||
Protocol envelope.Protocol
|
||||
FirstKM float64
|
||||
LatestKM float64
|
||||
PureHydrogenMileageKM float64
|
||||
Count int64
|
||||
PureHydrogenSampleCount int64
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
SourceCode string
|
||||
PlatformName string
|
||||
SourceKind string
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
LatestPureHydrogenActive bool
|
||||
LatestPureHydrogenModeKnown bool
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type dailySourceLast struct {
|
||||
@@ -149,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 {
|
||||
@@ -161,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
|
||||
}
|
||||
|
||||
@@ -256,21 +270,24 @@ func addSamples(aggregates map[string]*metricAgg, samples []stats.MetricSample)
|
||||
agg, ok := aggregates[key]
|
||||
if !ok {
|
||||
aggregates[key] = &metricAgg{
|
||||
VIN: sample.VIN,
|
||||
Date: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
FirstKM: sample.TotalMileageKM,
|
||||
LatestKM: sample.TotalMileageKM,
|
||||
Count: 1,
|
||||
SourceKey: sample.SourceKey,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
SourceEndpoint: sample.SourceEndpoint,
|
||||
PlatformName: sample.PlatformName,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityReason: "current_day_first_sample",
|
||||
VIN: sample.VIN,
|
||||
Date: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
FirstKM: sample.TotalMileageKM,
|
||||
LatestKM: sample.TotalMileageKM,
|
||||
Count: 1,
|
||||
PureHydrogenSampleCount: activeModeSampleCount(sample),
|
||||
SourceKey: sample.SourceKey,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
SourceEndpoint: sample.SourceEndpoint,
|
||||
PlatformName: sample.PlatformName,
|
||||
FirstEventTime: sample.EventTime,
|
||||
LatestEventTime: sample.EventTime,
|
||||
LatestPureHydrogenActive: sample.PureHydrogenActive,
|
||||
LatestPureHydrogenModeKnown: sample.PureHydrogenModeKnown,
|
||||
QualityStatus: stats.QualityOK,
|
||||
QualityReason: "current_day_first_sample",
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -279,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
|
||||
@@ -293,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
|
||||
@@ -321,28 +356,34 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
|
||||
}
|
||||
clearedTargets[target] = struct{}{}
|
||||
}
|
||||
if err := stats.UpsertDataSource(ctx, db, identity, agg.LatestEventTime); err != nil {
|
||||
return written, err
|
||||
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,
|
||||
StatDate: agg.Date,
|
||||
Protocol: agg.Protocol,
|
||||
SourceKey: agg.SourceKey,
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
PlatformName: agg.PlatformName,
|
||||
FirstTotalKM: agg.FirstKM,
|
||||
LatestTotalKM: agg.LatestKM,
|
||||
DailyKM: dailyKM,
|
||||
SampleCount: agg.Count,
|
||||
FirstEventTime: agg.FirstEventTime,
|
||||
LatestEventTime: agg.LatestEventTime,
|
||||
QualityStatus: agg.QualityStatus,
|
||||
QualityReason: agg.QualityReason,
|
||||
VIN: agg.VIN,
|
||||
StatDate: agg.Date,
|
||||
Protocol: agg.Protocol,
|
||||
SourceKey: agg.SourceKey,
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: agg.SourceEndpoint,
|
||||
Phone: agg.Phone,
|
||||
DeviceID: agg.DeviceID,
|
||||
PlatformName: agg.PlatformName,
|
||||
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,
|
||||
}
|
||||
if candidate.QualityStatus == "" {
|
||||
candidate.QualityStatus = stats.QualityOK
|
||||
@@ -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 {
|
||||
@@ -716,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
|
||||
}
|
||||
@@ -925,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:
|
||||
@@ -979,6 +1389,8 @@ 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,
|
||||
|
||||
@@ -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",
|
||||
@@ -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 {
|
||||
@@ -607,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"
|
||||
@@ -617,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,
|
||||
@@ -635,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) {
|
||||
@@ -707,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",
|
||||
).
|
||||
@@ -838,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", "")
|
||||
@@ -901,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", "")
|
||||
|
||||
@@ -33,11 +33,12 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
FieldSpeedKMH = "speed_kmh"
|
||||
FieldTotalMileageKM = "total_mileage_km"
|
||||
FieldLongitude = "longitude"
|
||||
FieldLatitude = "latitude"
|
||||
FieldSOCPercent = "soc_percent"
|
||||
FieldSpeedKMH = "speed_kmh"
|
||||
FieldTotalMileageKM = "total_mileage_km"
|
||||
FieldLongitude = "longitude"
|
||||
FieldLatitude = "latitude"
|
||||
FieldSOCPercent = "soc_percent"
|
||||
FieldFuelCellWorkMode = "fuel_cell_work_mode"
|
||||
)
|
||||
|
||||
type FrameEnvelope struct {
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
|
||||
type Encoder struct{}
|
||||
|
||||
func (Encoder) DataFrame(command byte, vin string, at time.Time, record Record) ([]byte, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestDataFrameMapsFeichiFields(t *testing.T) {
|
||||
"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",
|
||||
"2103": "1:35_36_37", "2115": "44", "2119": "6.0", "100016": "20.8",
|
||||
}
|
||||
frame, err := (Encoder{}).DataFrame(CommandRealtime, "LTEST32960VIN0001", at, record)
|
||||
if err != nil {
|
||||
@@ -49,6 +49,9 @@ func TestDataFrameMapsFeichiFields(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -250,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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -468,49 +468,52 @@ func realtimeOpenAPISpec() map[string]any {
|
||||
"DailyMetricRow": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"vin": stringSchema("LA9GG64L7PBAF4001"),
|
||||
"stat_date": stringSchema("2026-07-08"),
|
||||
"protocol": stringSchema("JT808"),
|
||||
"source_id": integerSchema(3),
|
||||
"source_ip": stringSchema("115.231.168.135"),
|
||||
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
|
||||
"platform_name": stringSchema("G7 平台"),
|
||||
"source_code": stringSchema("G7S"),
|
||||
"source_kind": stringSchema("PLATFORM"),
|
||||
"daily_mileage_km": numberSchema(23.1),
|
||||
"latest_total_mileage_km": numberSchema(4123.9),
|
||||
"updated_at": stringSchema("2026-07-08 13:30:57"),
|
||||
"vin": stringSchema("LA9GG64L7PBAF4001"),
|
||||
"stat_date": stringSchema("2026-07-08"),
|
||||
"protocol": stringSchema("JT808"),
|
||||
"source_id": integerSchema(3),
|
||||
"source_ip": stringSchema("115.231.168.135"),
|
||||
"latest_source_endpoint": stringSchema("115.231.168.135:41561"),
|
||||
"platform_name": stringSchema("G7 平台"),
|
||||
"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"),
|
||||
},
|
||||
},
|
||||
"DailyMetricSourceRow": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"vin": stringSchema("LA9GG64L7PBAF4001"),
|
||||
"stat_date": stringSchema("2026-07-08"),
|
||||
"protocol": stringSchema("JT808"),
|
||||
"source_key": stringSchema("JT808:13307795425@115.231.168.135"),
|
||||
"source_ip": stringSchema("115.231.168.135"),
|
||||
"source_endpoint": stringSchema("115.231.168.135:41561"),
|
||||
"phone": stringSchema("13307795425"),
|
||||
"platform_name": stringSchema("信达"),
|
||||
"source_id": integerSchema(5),
|
||||
"source_code": stringSchema("xinda"),
|
||||
"source_kind": stringSchema("PLATFORM"),
|
||||
"source_enabled": map[string]any{"type": "boolean", "example": true},
|
||||
"trust_priority": integerSchema(10),
|
||||
"first_total_mileage_km": numberSchema(4100.8),
|
||||
"latest_total_mileage_km": numberSchema(4123.9),
|
||||
"daily_mileage_km": numberSchema(23.1),
|
||||
"sample_count": integerSchema(128),
|
||||
"first_event_time": stringSchema("2026-07-08 00:01:00"),
|
||||
"latest_event_time": stringSchema("2026-07-08 23:59:00"),
|
||||
"quality_status": stringSchema("OK"),
|
||||
"quality_reason": stringSchema("historical_source_baseline"),
|
||||
"is_selected": map[string]any{"type": "boolean", "example": true},
|
||||
"selection_status": stringSchema("selected"),
|
||||
"selection_reason": stringSchema("selected_current_projection"),
|
||||
"selection_action": stringSchema("当前来源已被投影到最终日里程表"),
|
||||
"updated_at": stringSchema("2026-07-08 23:59:10"),
|
||||
"vin": stringSchema("LA9GG64L7PBAF4001"),
|
||||
"stat_date": stringSchema("2026-07-08"),
|
||||
"protocol": stringSchema("JT808"),
|
||||
"source_key": stringSchema("JT808:13307795425@115.231.168.135"),
|
||||
"source_ip": stringSchema("115.231.168.135"),
|
||||
"source_endpoint": stringSchema("115.231.168.135:41561"),
|
||||
"phone": stringSchema("13307795425"),
|
||||
"platform_name": stringSchema("信达"),
|
||||
"source_id": integerSchema(5),
|
||||
"source_code": stringSchema("xinda"),
|
||||
"source_kind": stringSchema("PLATFORM"),
|
||||
"source_enabled": map[string]any{"type": "boolean", "example": true},
|
||||
"trust_priority": integerSchema(10),
|
||||
"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"),
|
||||
"quality_status": stringSchema("OK"),
|
||||
"quality_reason": stringSchema("historical_source_baseline"),
|
||||
"is_selected": map[string]any{"type": "boolean", "example": true},
|
||||
"selection_status": stringSchema("selected"),
|
||||
"selection_reason": stringSchema("selected_current_projection"),
|
||||
"selection_action": stringSchema("当前来源已被投影到最终日里程表"),
|
||||
"updated_at": stringSchema("2026-07-08 23:59:10"),
|
||||
},
|
||||
},
|
||||
"DailyMetricSourceQualityRow": map[string]any{
|
||||
|
||||
@@ -3,7 +3,9 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -14,12 +16,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxNegativeMileageJitterKM = 1.0
|
||||
defaultCacheRetention = 72 * time.Hour
|
||||
defaultCacheCleanupInterval = 10 * time.Minute
|
||||
defaultBaselineMissTTL = time.Minute
|
||||
defaultBaselineHitTTL = 5 * time.Minute
|
||||
defaultMaxCacheEntries = 1000000
|
||||
maxNegativeMileageJitterKM = 1.0
|
||||
defaultCacheRetention = 72 * time.Hour
|
||||
defaultCacheCleanupInterval = 10 * time.Minute
|
||||
defaultBaselineMissTTL = time.Minute
|
||||
defaultBaselineHitTTL = 5 * time.Minute
|
||||
defaultMaxCacheEntries = 1000000
|
||||
defaultGPSAccumulationInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
type Execer interface {
|
||||
@@ -27,40 +30,46 @@ type Execer interface {
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
lastTotalMileage map[string]float64
|
||||
lastSourceSeen map[string]time.Time
|
||||
lastProjection map[string]projectionCacheEntry
|
||||
baselineCache map[string]sourceBaselineCacheEntry
|
||||
mileageKeysByPrefix map[string]map[string]struct{}
|
||||
projectionKeysByPrefix map[string]map[string]struct{}
|
||||
baselineKeysByPrefix map[string]map[string]struct{}
|
||||
exec Execer
|
||||
query Queryer
|
||||
loc *time.Location
|
||||
sourceTouchInterval time.Duration
|
||||
projectionInterval time.Duration
|
||||
cacheRetention time.Duration
|
||||
cacheCleanupInterval time.Duration
|
||||
baselineMissTTL time.Duration
|
||||
baselineHitTTL time.Duration
|
||||
maxCacheEntries int
|
||||
lastCacheCleanup time.Time
|
||||
lastCacheCleanupStats cacheCleanupStats
|
||||
cacheEvictions cacheCleanupStats
|
||||
mu sync.Mutex
|
||||
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 {
|
||||
VIN string
|
||||
Protocol envelope.Protocol
|
||||
StatDate string
|
||||
TotalMileageKM float64
|
||||
EventTime time.Time
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
PlatformName string
|
||||
VIN string
|
||||
Protocol envelope.Protocol
|
||||
StatDate string
|
||||
TotalMileageKM float64
|
||||
EventTime time.Time
|
||||
SourceKey string
|
||||
Phone string
|
||||
DeviceID string
|
||||
SourceEndpoint string
|
||||
PlatformName string
|
||||
PureHydrogenActive bool
|
||||
PureHydrogenModeKnown bool
|
||||
}
|
||||
|
||||
type AppendResult struct {
|
||||
@@ -69,6 +78,8 @@ type AppendResult struct {
|
||||
SamplesSkippedMissingFields int
|
||||
SamplesSkippedMissingVIN int
|
||||
SamplesSkippedMissingMileage int
|
||||
SamplesRecoveredStationary int
|
||||
SamplesRecoveredGPSCoordinate int
|
||||
SamplesSkippedNonMileageFrame int
|
||||
SamplesSkippedNonPositiveMileage int
|
||||
SamplesSkippedMissingTime int
|
||||
@@ -83,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
|
||||
BaselineEntries int
|
||||
MaxEntries int
|
||||
LastCleanupAt time.Time
|
||||
LastCleanupTotalMileage int
|
||||
LastCleanupSourceSeen int
|
||||
LastCleanupProjection int
|
||||
LastCleanupBaseline int
|
||||
TotalMileageEvictions int
|
||||
SourceSeenEvictions int
|
||||
ProjectionEvictions int
|
||||
BaselineEvictions int
|
||||
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 {
|
||||
@@ -107,6 +140,7 @@ type cacheCleanupStats struct {
|
||||
sourceSeen int
|
||||
projection int
|
||||
baseline int
|
||||
gps int
|
||||
}
|
||||
|
||||
func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
@@ -117,22 +151,26 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
|
||||
loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
}
|
||||
writer := &Writer{
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
cacheRetention: defaultCacheRetention,
|
||||
cacheCleanupInterval: defaultCacheCleanupInterval,
|
||||
baselineMissTTL: defaultBaselineMissTTL,
|
||||
baselineHitTTL: defaultBaselineHitTTL,
|
||||
maxCacheEntries: defaultMaxCacheEntries,
|
||||
lastTotalMileage: map[string]float64{},
|
||||
lastSourceSeen: map[string]time.Time{},
|
||||
lastProjection: map[string]projectionCacheEntry{},
|
||||
baselineCache: map[string]sourceBaselineCacheEntry{},
|
||||
mileageKeysByPrefix: map[string]map[string]struct{}{},
|
||||
projectionKeysByPrefix: map[string]map[string]struct{}{},
|
||||
baselineKeysByPrefix: map[string]map[string]struct{}{},
|
||||
exec: exec,
|
||||
loc: loc,
|
||||
sourceTouchInterval: time.Minute,
|
||||
projectionInterval: 15 * time.Second,
|
||||
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
|
||||
@@ -140,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()
|
||||
@@ -204,32 +261,48 @@ 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()
|
||||
return CacheStats{
|
||||
LastTotalMileageEntries: len(w.lastTotalMileage),
|
||||
LastSourceSeenEntries: len(w.lastSourceSeen),
|
||||
LastProjectionEntries: len(w.lastProjection),
|
||||
BaselineEntries: len(w.baselineCache),
|
||||
MaxEntries: w.maxCacheEntries,
|
||||
LastCleanupAt: w.lastCacheCleanup,
|
||||
LastCleanupTotalMileage: w.lastCacheCleanupStats.totalMileage,
|
||||
LastCleanupSourceSeen: w.lastCacheCleanupStats.sourceSeen,
|
||||
LastCleanupProjection: w.lastCacheCleanupStats.projection,
|
||||
LastCleanupBaseline: w.lastCacheCleanupStats.baseline,
|
||||
TotalMileageEvictions: w.cacheEvictions.totalMileage,
|
||||
SourceSeenEvictions: w.cacheEvictions.sourceSeen,
|
||||
ProjectionEvictions: w.cacheEvictions.projection,
|
||||
BaselineEvictions: w.cacheEvictions.baseline,
|
||||
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
|
||||
@@ -257,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) {
|
||||
@@ -273,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
|
||||
@@ -282,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++
|
||||
@@ -290,11 +406,33 @@ 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 err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
|
||||
return result, err
|
||||
if stationaryCandidate != nil {
|
||||
candidate = *stationaryCandidate
|
||||
} else {
|
||||
if err := w.applyRealtimeBaseline(ctx, &candidate); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
projectDaily := w.shouldProjectDailyMileage(sample)
|
||||
if projectDaily {
|
||||
@@ -309,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++
|
||||
@@ -316,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 {
|
||||
@@ -415,12 +621,168 @@ func (w *Writer) markProjected(sample MetricSample) {
|
||||
entry.sourceKeys = map[string]struct{}{}
|
||||
}
|
||||
entry.sourceKeys[sample.SourceKey] = struct{}{}
|
||||
entry.projectedAt = sample.EventTime
|
||||
if entry.projectedAt.IsZero() || sample.EventTime.After(entry.projectedAt) {
|
||||
entry.projectedAt = sample.EventTime
|
||||
}
|
||||
w.lastProjection[key] = entry
|
||||
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()
|
||||
@@ -487,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()
|
||||
}
|
||||
@@ -660,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)
|
||||
}
|
||||
|
||||
@@ -713,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 {
|
||||
@@ -807,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
|
||||
@@ -854,18 +1236,21 @@ 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,
|
||||
Protocol: env.Protocol,
|
||||
StatDate: statDate,
|
||||
TotalMileageKM: totalMileage,
|
||||
EventTime: time.UnixMilli(eventMS).In(loc),
|
||||
SourceKey: sourceKey(env),
|
||||
Phone: strings.TrimSpace(env.Phone),
|
||||
DeviceID: strings.TrimSpace(env.DeviceID),
|
||||
SourceEndpoint: strings.TrimSpace(env.SourceEndpoint),
|
||||
PlatformName: strings.TrimSpace(env.PlatformName),
|
||||
VIN: vin,
|
||||
Protocol: env.Protocol,
|
||||
StatDate: statDate,
|
||||
TotalMileageKM: totalMileage,
|
||||
EventTime: time.UnixMilli(eventMS).In(loc),
|
||||
SourceKey: sourceKey(env),
|
||||
Phone: strings.TrimSpace(env.Phone),
|
||||
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,
|
||||
).
|
||||
@@ -1220,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,
|
||||
).
|
||||
@@ -1305,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,
|
||||
).
|
||||
@@ -1640,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,
|
||||
).
|
||||
@@ -1700,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") {
|
||||
@@ -1745,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)
|
||||
}
|
||||
@@ -1838,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))
|
||||
@@ -1871,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))
|
||||
@@ -1939,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,
|
||||
).
|
||||
@@ -2220,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -58,48 +58,51 @@ type MetricDiagnosisQuery struct {
|
||||
}
|
||||
|
||||
type MetricRow struct {
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceID *int64 `json:"source_id,omitempty"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
|
||||
PlatformName string `json:"platform_name,omitempty"`
|
||||
SourceCode string `json:"source_code,omitempty"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceID *int64 `json:"source_id,omitempty"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
|
||||
PlatformName string `json:"platform_name,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type MetricSourceRow struct {
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceKey string `json:"source_key"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
PlatformName string `json:"platform_name,omitempty"`
|
||||
SourceID *int64 `json:"source_id,omitempty"`
|
||||
SourceCode string `json:"source_code,omitempty"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
SourceEnabled *bool `json:"source_enabled,omitempty"`
|
||||
TrustPriority *int `json:"trust_priority,omitempty"`
|
||||
FirstTotalMileageKM *float64 `json:"first_total_mileage_km,omitempty"`
|
||||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||||
SampleCount int64 `json:"sample_count"`
|
||||
FirstEventTime string `json:"first_event_time,omitempty"`
|
||||
LatestEventTime string `json:"latest_event_time,omitempty"`
|
||||
QualityStatus string `json:"quality_status"`
|
||||
QualityReason string `json:"quality_reason,omitempty"`
|
||||
IsSelected bool `json:"is_selected"`
|
||||
SelectionStatus string `json:"selection_status,omitempty"`
|
||||
SelectionReason string `json:"selection_reason,omitempty"`
|
||||
SelectionAction string `json:"selection_action,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
VIN string `json:"vin"`
|
||||
StatDate string `json:"stat_date"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceKey string `json:"source_key"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
PlatformName string `json:"platform_name,omitempty"`
|
||||
SourceID *int64 `json:"source_id,omitempty"`
|
||||
SourceCode string `json:"source_code,omitempty"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
SourceEnabled *bool `json:"source_enabled,omitempty"`
|
||||
TrustPriority *int `json:"trust_priority,omitempty"`
|
||||
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"`
|
||||
QualityStatus string `json:"quality_status"`
|
||||
QualityReason string `json:"quality_reason,omitempty"`
|
||||
IsSelected bool `json:"is_selected"`
|
||||
SelectionStatus string `json:"selection_status,omitempty"`
|
||||
SelectionReason string `json:"selection_reason,omitempty"`
|
||||
SelectionAction string `json:"selection_action,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MetricDiagnosisRow struct {
|
||||
@@ -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"
|
||||
@@ -23,23 +24,27 @@ const (
|
||||
)
|
||||
|
||||
type SourceMileageSample struct {
|
||||
VIN string
|
||||
StatDate string
|
||||
Protocol envelope.Protocol
|
||||
SourceKey string
|
||||
SourceIP string
|
||||
SourceEndpoint string
|
||||
Phone string
|
||||
DeviceID string
|
||||
PlatformName string
|
||||
FirstTotalKM float64
|
||||
LatestTotalKM float64
|
||||
DailyKM float64
|
||||
SampleCount int64
|
||||
FirstEventTime time.Time
|
||||
LatestEventTime time.Time
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
VIN string
|
||||
StatDate string
|
||||
Protocol envelope.Protocol
|
||||
SourceKey string
|
||||
SourceIP string
|
||||
SourceEndpoint string
|
||||
Phone string
|
||||
DeviceID string
|
||||
PlatformName string
|
||||
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
|
||||
}
|
||||
|
||||
func SourceKey(protocol envelope.Protocol, phone string, deviceID string, sourceIP string) string {
|
||||
@@ -79,26 +84,37 @@ func SourceKeyForSource(protocol envelope.Protocol, phone string, deviceID strin
|
||||
func SourceMileageSampleFromMetric(sample MetricSample, identity SourceIdentity) SourceMileageSample {
|
||||
eventTime := sample.EventTime
|
||||
return SourceMileageSample{
|
||||
VIN: sample.VIN,
|
||||
StatDate: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: identity.SourceEndpoint,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
|
||||
FirstTotalKM: sample.TotalMileageKM,
|
||||
LatestTotalKM: sample.TotalMileageKM,
|
||||
DailyKM: 0,
|
||||
SampleCount: 1,
|
||||
FirstEventTime: eventTime,
|
||||
LatestEventTime: eventTime,
|
||||
QualityStatus: QualityOK,
|
||||
QualityReason: "realtime_sample",
|
||||
VIN: sample.VIN,
|
||||
StatDate: sample.StatDate,
|
||||
Protocol: sample.Protocol,
|
||||
SourceKey: SourceKeyForSource(sample.Protocol, sample.Phone, sample.DeviceID, identity.SourceIP, identity.SourceKind, identity.SourceCode),
|
||||
SourceIP: identity.SourceIP,
|
||||
SourceEndpoint: identity.SourceEndpoint,
|
||||
Phone: sample.Phone,
|
||||
DeviceID: sample.DeviceID,
|
||||
PlatformName: firstNonEmpty(sample.PlatformName, identity.PlatformName),
|
||||
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,
|
||||
)
|
||||
@@ -421,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 + `,
|
||||
@@ -455,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,
|
||||
@@ -470,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 + `'
|
||||
@@ -510,6 +561,7 @@ 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
|
||||
@@ -528,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 + `,
|
||||
@@ -580,6 +642,7 @@ 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
|
||||
@@ -613,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
|
||||
@@ -636,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
|
||||
`
|
||||
@@ -681,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))
|
||||
}
|
||||
}
|
||||
@@ -211,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)
|
||||
@@ -505,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)
|
||||
@@ -526,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) {
|
||||
@@ -551,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) {
|
||||
@@ -573,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) {
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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,7 +259,14 @@ 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"
|
||||
}
|
||||
@@ -255,7 +289,7 @@ func requiredRole(r *http.Request) string {
|
||||
switch path {
|
||||
case "/api/v2/auth/logout":
|
||||
return "viewer"
|
||||
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/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"
|
||||
@@ -266,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
|
||||
@@ -146,10 +192,11 @@ type cachedSession struct {
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
oneOSSessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
}
|
||||
|
||||
func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
@@ -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)
|
||||
id, grantChanges, err := s.createCustomerRecord(r.Context(), input, menus, grants, actor.Name)
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
} 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)
|
||||
@@ -114,6 +123,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
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"))
|
||||
@@ -126,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) {
|
||||
@@ -156,6 +212,18 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +139,11 @@ 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,
|
||||
ScopeStartAt: scopeStart,
|
||||
CustomerName: customerName, ContractCode: contractCode, ProjectName: projectName,
|
||||
DepartmentID: departmentID, DepartmentName: departmentName,
|
||||
ResponsibleUserID: responsibleUserID, ResponsibleUserName: responsibleUserName,
|
||||
OperationStatus: operationStatus,
|
||||
ScopeStartAt: scopeStart,
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
value := updatedAt.Time
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,76 +8,120 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
StaticDir string
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
CapacityCheckBin string
|
||||
AuthToken string
|
||||
AuthMode string
|
||||
AuthTokensJSON string
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
SessionTTL time.Duration
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
AMapWebJSKey string
|
||||
AMapAPIKey string
|
||||
AMapSecurityCode string
|
||||
AMapServiceHost string
|
||||
PlatformRelease string
|
||||
AlertEvaluationInterval time.Duration
|
||||
AlertStreamMode string
|
||||
AlertStreamKafkaBrokers []string
|
||||
AlertStreamKafkaTopics []string
|
||||
AlertStreamKafkaGroup string
|
||||
AlertStreamBatchSize int
|
||||
AlertStreamBatchWait time.Duration
|
||||
AlertStreamLateness time.Duration
|
||||
HTTPAddr string
|
||||
StaticDir string
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
CapacityCheckBin string
|
||||
AuthToken string
|
||||
AuthMode string
|
||||
AuthTokensJSON string
|
||||
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
|
||||
AMapWebJSKey string
|
||||
AMapAPIKey string
|
||||
AMapSecurityCode string
|
||||
AMapServiceHost string
|
||||
PlatformRelease string
|
||||
AlertEvaluationInterval time.Duration
|
||||
AlertStreamMode string
|
||||
AlertStreamKafkaBrokers []string
|
||||
AlertStreamKafkaTopics []string
|
||||
AlertStreamKafkaGroup string
|
||||
AlertStreamBatchSize int
|
||||
AlertStreamBatchWait time.Duration
|
||||
AlertStreamLateness time.Duration
|
||||
AlertNotificationTargetsJSON string
|
||||
AlertNotificationSMSURL string
|
||||
AlertNotificationSMSSecret string
|
||||
AlertNotificationEmailURL string
|
||||
AlertNotificationEmailSecret string
|
||||
AlertNotificationWeComURL string
|
||||
AlertNotificationWeComSecret string
|
||||
AlertNotificationBatchSize int
|
||||
AlertNotificationPollInterval time.Duration
|
||||
AlertNotificationLease time.Duration
|
||||
AlertNotificationTimeout time.Duration
|
||||
HistoryCleanupAutomation bool
|
||||
HistoryCleanupPollInterval time.Duration
|
||||
HistoryCleanupLease time.Duration
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
HTTPAddr: env("HTTP_ADDR", ":20300"),
|
||||
StaticDir: env("STATIC_DIR", ""),
|
||||
MySQLDSN: os.Getenv("MYSQL_DSN"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
RedisDB: envInt("REDIS_DB", 50),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: os.Getenv("TDENGINE_DSN"),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
|
||||
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||
AuthMode: authMode(),
|
||||
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
|
||||
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
|
||||
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
|
||||
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
|
||||
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
|
||||
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
|
||||
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
|
||||
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
|
||||
HTTPAddr: env("HTTP_ADDR", ":20300"),
|
||||
StaticDir: env("STATIC_DIR", ""),
|
||||
MySQLDSN: os.Getenv("MYSQL_DSN"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
RedisDB: envInt("REDIS_DB", 50),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: os.Getenv("TDENGINE_DSN"),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
|
||||
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||
AuthMode: authMode(),
|
||||
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
|
||||
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,
|
||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
|
||||
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
|
||||
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
|
||||
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
|
||||
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
|
||||
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
|
||||
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
|
||||
AlertNotificationTargetsJSON: os.Getenv("ALERT_NOTIFICATION_TARGETS_JSON"),
|
||||
AlertNotificationSMSURL: os.Getenv("ALERT_NOTIFICATION_SMS_URL"),
|
||||
AlertNotificationSMSSecret: os.Getenv("ALERT_NOTIFICATION_SMS_SECRET"),
|
||||
AlertNotificationEmailURL: os.Getenv("ALERT_NOTIFICATION_EMAIL_URL"),
|
||||
AlertNotificationEmailSecret: os.Getenv("ALERT_NOTIFICATION_EMAIL_SECRET"),
|
||||
AlertNotificationWeComURL: os.Getenv("ALERT_NOTIFICATION_WECOM_URL"),
|
||||
AlertNotificationWeComSecret: os.Getenv("ALERT_NOTIFICATION_WECOM_SECRET"),
|
||||
AlertNotificationBatchSize: envInt("ALERT_NOTIFICATION_BATCH_SIZE", 20),
|
||||
AlertNotificationPollInterval: time.Duration(envInt("ALERT_NOTIFICATION_POLL_INTERVAL_MS", 1000)) * time.Millisecond,
|
||||
AlertNotificationLease: time.Duration(envInt("ALERT_NOTIFICATION_LEASE_SEC", 30)) * time.Second,
|
||||
AlertNotificationTimeout: time.Duration(envInt("ALERT_NOTIFICATION_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
HistoryCleanupAutomation: envBool("HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED", false),
|
||||
HistoryCleanupPollInterval: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_POLL_SEC", 60)) * time.Second,
|
||||
HistoryCleanupLease: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_LEASE_SEC", 300)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +186,15 @@ func envInt(key string, fallback int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
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), "'", "''")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) {
|
||||
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
stat := stats[0]
|
||||
if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" {
|
||||
t.Fatalf("stat = %#v", stat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 {
|
||||
t.Fatalf("stat = %#v", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
|
||||
for _, encoded := range []string{
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"12.3"}`,
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":12.3}`,
|
||||
} {
|
||||
value, ok := ExtractHydrogenMass(encoded)
|
||||
if !ok || value != 12.3 {
|
||||
t.Fatalf("value=%v ok=%v for %s", value, ok, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) {
|
||||
mass, ok := PressureHydrogenMassKg(16.4, 34, 380)
|
||||
if !ok || mass < 4.47 || mass > 4.49 {
|
||||
t.Fatalf("mass=%.3f ok=%v", mass, ok)
|
||||
}
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(`{
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"16.4",
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_c":34,
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99.9
|
||||
}`)
|
||||
if !ok || pressure != 16.4 || temperature != 34 {
|
||||
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type accessUnresolvedIdentityStore interface {
|
||||
AccessUnresolvedIdentities(context.Context, AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error)
|
||||
}
|
||||
|
||||
type accessIdentityClaimStore interface {
|
||||
ClaimAccessIdentity(context.Context, string, AccessIdentityClaimInput) (AccessIdentityClaimResult, error)
|
||||
}
|
||||
|
||||
func defaultAccessThresholds(now time.Time) AccessThresholdConfig {
|
||||
return AccessThresholdConfig{
|
||||
Version: 1,
|
||||
@@ -114,6 +118,42 @@ func (s *Service) AccessUnresolvedIdentities(ctx context.Context, query AccessUn
|
||||
return store.AccessUnresolvedIdentities(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
identityID = strings.TrimSpace(identityID)
|
||||
if identityID == "" || len(identityID) > 128 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"}
|
||||
}
|
||||
input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if !validAccessClaimVIN(input.VIN) {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"}
|
||||
}
|
||||
input.Note = strings.TrimSpace(input.Note)
|
||||
if len([]rune(input.Note)) > 500 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_NOTE_TOO_LONG", Message: "认领说明不能超过 500 个字"}
|
||||
}
|
||||
input.Actor = strings.TrimSpace(input.Actor)
|
||||
if input.Actor == "" {
|
||||
input.Actor = "platform-admin"
|
||||
}
|
||||
store, ok := s.store.(accessIdentityClaimStore)
|
||||
if !ok {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_READ_ONLY", Message: "当前存储不支持来源身份认领"}
|
||||
}
|
||||
return store.ClaimAccessIdentity(ctx, identityID, input)
|
||||
}
|
||||
|
||||
func validAccessClaimVIN(vin string) bool {
|
||||
if len(vin) < 6 || len(vin) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range vin {
|
||||
if (char < 'A' || char > 'Z') && (char < '0' || char > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessSummary, error) {
|
||||
rows, config, err := s.accessRows(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -61,6 +61,102 @@ GREATEST(0,TIMESTAMPDIFF(SECOND,r.latest_seen_at,NOW())),
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
if err := s.ensureAccessSchema(ctx); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var phone, sourcePlate, manufacturer string
|
||||
err = tx.QueryRowContext(ctx, `SELECT r.phone,COALESCE(r.plate,''),COALESCE(r.manufacturer,'')
|
||||
FROM jt808_registration r
|
||||
LEFT JOIN vehicle_identity_binding b ON b.phone=r.phone
|
||||
WHERE SHA2(CONCAT('JT808:',r.phone),256)=?
|
||||
AND COALESCE(NULLIF(TRIM(b.vin),''),'')=''
|
||||
AND (r.vin IS NULL OR TRIM(r.vin)='' OR LOWER(TRIM(r.vin))='unknown')
|
||||
LIMIT 1 FOR UPDATE`, identityID).Scan(&phone, &sourcePlate, &manufacturer)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
var targetPlate, currentPhone string
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(plate,''),COALESCE(phone,'') FROM vehicle_identity_binding WHERE BINARY vin=BINARY ? FOR UPDATE`, input.VIN).Scan(&targetPlate, ¤tPhone)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if currentPhone != "" && currentPhone != phone {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_TARGET_CONFLICT", Message: "所选车辆已绑定其他 JT808 终端,请先核对换绑关系"}
|
||||
}
|
||||
|
||||
var conflictingVIN string
|
||||
err = tx.QueryRowContext(ctx, `SELECT vin FROM vehicle_identity_binding WHERE phone=? AND BINARY vin<>BINARY ? LIMIT 1 FOR UPDATE`, phone, input.VIN).Scan(&conflictingVIN)
|
||||
if err == nil {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_SOURCE_CONFLICT", Message: "该来源已绑定其他车辆,请刷新待办后核对"}
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_identity_binding SET phone=?,updated_at=CURRENT_TIMESTAMP WHERE BINARY vin=BINARY ?`, phone, input.VIN); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE jt808_registration SET vin=? WHERE phone=?`, input.VIN, phone); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
profileValues := make([]string, 7)
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(brand_name,''),COALESCE(model_name,''),COALESCE(vehicle_type,''),COALESCE(company_name,''),COALESCE(operation_status,''),COALESCE(access_provider,''),COALESCE(DATE_FORMAT(first_access_at,'%Y-%m-%d %H:%i:%s'),'') FROM vehicle_profile WHERE BINARY vin=BINARY ?`, input.VIN).Scan(
|
||||
&profileValues[0], &profileValues[1], &profileValues[2], &profileValues[3], &profileValues[4], &profileValues[5], &profileValues[6],
|
||||
)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
missingLabels := []string{"车辆品牌", "车型", "车辆类型", "所属企业", "运营状态", "接入服务商", "首次接入"}
|
||||
missing := make([]string, 0, len(missingLabels))
|
||||
for index, label := range missingLabels {
|
||||
if strings.TrimSpace(profileValues[index]) == "" || (index == 4 && strings.EqualFold(profileValues[index], "unknown")) {
|
||||
missing = append(missing, label)
|
||||
}
|
||||
}
|
||||
note := input.Note
|
||||
if note == "" {
|
||||
note = "核对来源标识、车牌与厂家后绑定权威 VIN"
|
||||
}
|
||||
claimedAt := time.Now().Format(time.RFC3339)
|
||||
audit, err := tx.ExecContext(ctx, `INSERT INTO vehicle_access_identity_audit
|
||||
(identity_id,protocol,identifier_hash,identifier_masked,source_plate,manufacturer,vin,actor,note,claimed_at)
|
||||
VALUES (?,'JT808',?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`, identityID, identityID, maskAccessIdentifier(phone), sourcePlate, manufacturer, input.VIN, input.Actor, note)
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
auditID, _ := audit.LastInsertId()
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identityID, Protocol: "JT808", IdentifierMasked: maskAccessIdentifier(phone), VIN: input.VIN,
|
||||
Plate: targetPlate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: claimedAt, AuditID: auditID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func maskAccessIdentifier(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 7 {
|
||||
return "***"
|
||||
}
|
||||
return value[:3] + "****" + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
v.vin,
|
||||
@@ -166,6 +262,22 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_threshold_audit_version (version),
|
||||
KEY idx_access_threshold_audit_changed (changed_at)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
identity_id VARCHAR(128) NOT NULL,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
identifier_hash VARCHAR(128) NOT NULL,
|
||||
identifier_masked VARCHAR(64) NOT NULL,
|
||||
source_plate VARCHAR(64) NOT NULL,
|
||||
manufacturer VARCHAR(128) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
actor VARCHAR(128) NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
claimed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_identity_vin (vin),
|
||||
KEY idx_access_identity_claimed (claimed_at),
|
||||
UNIQUE KEY uk_access_identity_claim (identity_id)
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
|
||||
@@ -212,6 +212,60 @@ func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAccessIdentityBindsExistingVehicleAndReturnsProfileFollowUp(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
result, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426", Actor: "access-admin", Note: "设备交付单核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("claim access identity: %v", err)
|
||||
}
|
||||
if result.VIN != "LB9A32A24R0LS1426" || result.Plate != "粤AG18312" || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("claim result lost identity evidence: %+v", result)
|
||||
}
|
||||
if !result.ProfileComplete || len(result.ProfileMissingFields) != 0 || result.AuditID == 0 || result.ClaimedBy != "access-admin" {
|
||||
t.Fatalf("claim result must expose profile and audit state: %+v", result)
|
||||
}
|
||||
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "JT808", Limit: 20})
|
||||
if err != nil || page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("claimed identity must leave the pending queue: page=%+v err=%v", page, err)
|
||||
}
|
||||
if _, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426"}); err == nil {
|
||||
t.Fatal("repeated claim must fail as stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionClaimAccessIdentityUsesLockedBindingAndImmutableAudit(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)INSERT IGNORE INTO vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)SELECT r\.phone.*SHA2\(CONCAT\('JT808:',r\.phone\),256\)=\?.*FOR UPDATE`).WithArgs("identity-hash").WillReturnRows(sqlmock.NewRows([]string{"phone", "plate", "manufacturer"}).AddRow("13800000001", "粤A00001", "测试终端"))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(plate,''\),COALESCE\(phone,''\).*vehicle_identity_binding.*FOR UPDATE`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"plate", "phone"}).AddRow("粤A00001", ""))
|
||||
mock.ExpectQuery(`(?s)SELECT vin FROM vehicle_identity_binding WHERE phone=\?.*FOR UPDATE`).WithArgs("13800000001", "VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}))
|
||||
mock.ExpectExec(`UPDATE vehicle_identity_binding SET phone=\?,updated_at=CURRENT_TIMESTAMP`).WithArgs("13800000001", "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE jt808_registration SET vin=\? WHERE phone=\?`).WithArgs("VIN001", "13800000001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(brand_name,''\).*FROM vehicle_profile`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"brand", "model", "type", "company", "status", "provider", "first_access"}).AddRow("品牌", "车型", "乘用车", "测试企业", "active", "测试服务商", "2026-07-01 08:00:00"))
|
||||
mock.ExpectExec(`(?s)INSERT INTO vehicle_access_identity_audit`).WithArgs("identity-hash", "identity-hash", "138****0001", "粤A00001", "测试终端", "VIN001", "access-admin", "核对通过").WillReturnResult(sqlmock.NewResult(42, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := store.ClaimAccessIdentity(t.Context(), "identity-hash", AccessIdentityClaimInput{VIN: "VIN001", Actor: "access-admin", Note: "核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("production claim: %v", err)
|
||||
}
|
||||
if result.AuditID != 42 || !result.ProfileComplete || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("production claim result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertStore interface {
|
||||
@@ -13,17 +15,26 @@ type alertStore interface {
|
||||
AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error)
|
||||
AlertEvent(context.Context, string) (AlertEvent, error)
|
||||
AlertRules(context.Context) ([]AlertRule, error)
|
||||
AlertRulePage(context.Context, AlertRuleQuery) (AlertRulePage, error)
|
||||
AlertRuleRevisions(context.Context, string) ([]AlertRuleRevision, error)
|
||||
SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error)
|
||||
SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error)
|
||||
SetAlertRuleArchived(context.Context, string, bool, AlertRuleLifecycleRequest) (AlertRule, error)
|
||||
ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error)
|
||||
AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error)
|
||||
MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error)
|
||||
RetryAlertNotification(context.Context, int64, AlertNotificationRetryRequest) (AlertNotificationRetryResult, error)
|
||||
AlertNotificationRetryAudits(context.Context, int64) ([]AlertNotificationRetryAudit, error)
|
||||
}
|
||||
|
||||
type alertEvaluatorStore interface {
|
||||
EvaluateAlerts(context.Context) (AlertEvaluationResult, error)
|
||||
}
|
||||
|
||||
type alertNotificationHealthStore interface {
|
||||
AlertNotificationDeliveryHealth(context.Context) (AlertNotificationDeliveryHealth, error)
|
||||
}
|
||||
|
||||
func (s *Service) alertStore() (alertStore, error) {
|
||||
store, ok := s.store.(alertStore)
|
||||
if !ok {
|
||||
@@ -58,7 +69,14 @@ func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[Alert
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
return store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
page, err := store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
for index := range page.Items {
|
||||
page.Items[index] = normalizeVehicleEvent(page.Items[index])
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
|
||||
@@ -69,7 +87,11 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
event, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
@@ -80,7 +102,176 @@ func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return store.AlertRules(ctx)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleQuery(query AlertRuleQuery) AlertRuleQuery {
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
if len([]rune(query.Keyword)) > 160 {
|
||||
query.Keyword = string([]rune(query.Keyword)[:160])
|
||||
}
|
||||
query.Protocol = strings.TrimSpace(query.Protocol)
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
if query.Status != "enabled" && query.Status != "disabled" {
|
||||
query.Status = "all"
|
||||
}
|
||||
query.Lifecycle = strings.ToLower(strings.TrimSpace(query.Lifecycle))
|
||||
if query.Lifecycle != "archived" {
|
||||
query.Lifecycle = "current"
|
||||
}
|
||||
if query.Limit != 20 && query.Limit != 50 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Offset = query.Offset / query.Limit * query.Limit
|
||||
return query
|
||||
}
|
||||
|
||||
func (s *Service) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
return store.AlertRulePage(ctx, normalizeAlertRuleQuery(query))
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationConfig(ctx context.Context) (AlertNotificationConfig, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationConfig{}, err
|
||||
}
|
||||
return NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
store, ok := s.store.(alertNotificationHealthStore)
|
||||
if !ok {
|
||||
return AlertNotificationDeliveryHealth{}, fmt.Errorf("store does not provide notification delivery health")
|
||||
}
|
||||
health, err := store.AlertNotificationDeliveryHealth(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
configured := map[string]AlertNotificationChannelCapability{}
|
||||
for _, channel := range config.Channels {
|
||||
configured[channel.Channel] = channel
|
||||
}
|
||||
byChannel := map[string]AlertNotificationChannelHealth{}
|
||||
for _, channel := range health.Channels {
|
||||
byChannel[channel.Channel] = channel
|
||||
}
|
||||
health.Channels = make([]AlertNotificationChannelHealth, 0, len(config.Channels))
|
||||
for _, capability := range config.Channels {
|
||||
channel := byChannel[capability.Channel]
|
||||
channel.Channel = capability.Channel
|
||||
channel.Label = capability.Label
|
||||
channel.Configured = capability.Configured
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
if health.AsOf == "" {
|
||||
health.AsOf = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertRuleRevisions(ctx, id)
|
||||
}
|
||||
|
||||
func alertRuleInputFromRule(rule AlertRule) AlertRuleInput {
|
||||
return AlertRuleInput{
|
||||
ID: rule.ID, Name: rule.Name, Description: rule.Description, TriggerType: rule.TriggerType,
|
||||
FenceName: rule.FenceName, FenceLongitude: rule.FenceLongitude, FenceLatitude: rule.FenceLatitude, FenceRadiusM: rule.FenceRadiusM,
|
||||
Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator,
|
||||
Threshold: rule.Threshold, ThresholdHigh: rule.ThresholdHigh, BooleanThreshold: rule.BooleanThreshold,
|
||||
DurationSec: rule.DurationSec, RecoveryOperator: rule.RecoveryOperator, RecoveryThreshold: rule.RecoveryThreshold,
|
||||
RepeatIntervalSec: rule.RepeatIntervalSec, ScopeProtocols: append([]string(nil), rule.ScopeProtocols...), ScopeVINs: append([]string(nil), rule.ScopeVINs...),
|
||||
ScopeOEMs: append([]string(nil), rule.ScopeOEMs...), ScopeModels: append([]string(nil), rule.ScopeModels...), ScopeCompanies: append([]string(nil), rule.ScopeCompanies...),
|
||||
NotificationChannels: append([]string(nil), rule.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), rule.NotificationTargets...), Enabled: rule.Enabled, Version: rule.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func alertRuleConfigurationSignature(input AlertRuleInput) string {
|
||||
input.Version = 0
|
||||
input.Actor = ""
|
||||
input.AuditAction = ""
|
||||
value, _ := json.Marshal(input)
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func (s *Service) RollbackAlertRule(ctx context.Context, id string, request AlertRuleRollbackRequest) (AlertRule, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || request.TargetVersion <= 0 || request.CurrentVersion <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_VERSION_REQUIRED", Message: "自动化 ID、当前版本和目标版本不能为空"}
|
||||
}
|
||||
if request.TargetVersion == request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_TARGET_INVALID", Message: "目标版本必须与当前版本不同"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rules, err := store.AlertRules(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var current *AlertRule
|
||||
for index := range rules {
|
||||
if rules[index].ID == id {
|
||||
current = &rules[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
if current.Version != request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
revisions, err := store.AlertRuleRevisions(ctx, id)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var target *AlertRule
|
||||
for index := range revisions {
|
||||
if revisions[index].Version == request.TargetVersion {
|
||||
target = &revisions[index].Snapshot
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_REVISION_NOT_FOUND", Message: "目标版本不存在或已被清理"}
|
||||
}
|
||||
next := alertRuleInputFromRule(*target)
|
||||
next.ID = id
|
||||
next.Version = current.Version
|
||||
next.Enabled = current.Enabled
|
||||
next.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
next.AuditAction = "rollback"
|
||||
if alertRuleConfigurationSignature(next) == alertRuleConfigurationSignature(alertRuleInputFromRule(*current)) {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_NO_CHANGES", Message: "目标版本与当前配置相同,无需恢复"}
|
||||
}
|
||||
return s.SaveAlertRule(ctx, next)
|
||||
}
|
||||
|
||||
func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
normalizeAlertRuleTrigger(&input)
|
||||
definitions, err := s.metricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
@@ -105,7 +296,12 @@ func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (Aler
|
||||
if err := validateAlertRuleScopes(input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err := s.validateAlertNotificationTargets(input.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.EqualFold(input.Operator, "changed") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
@@ -144,6 +340,9 @@ func validateAlertRuleScopes(input AlertRuleInput) error {
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.TrimSpace(id) == "" || update.Version <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"}
|
||||
}
|
||||
@@ -155,6 +354,40 @@ func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update Ale
|
||||
return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleLifecycleRequest(request AlertRuleLifecycleRequest) (AlertRuleLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 200 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 200 字"}
|
||||
}
|
||||
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
normalized, err := normalizeAlertRuleLifecycleRequest(request)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return store.SetAlertRuleArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
if strings.TrimSpace(id) == "" || request.Version <= 0 {
|
||||
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"}
|
||||
@@ -171,7 +404,73 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
||||
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
||||
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
||||
case "processing":
|
||||
event.ExecutionState = "processing"
|
||||
case "recovered":
|
||||
event.ExecutionState = "recovered"
|
||||
case "closed":
|
||||
event.ExecutionState = "completed"
|
||||
case "ignored":
|
||||
event.ExecutionState = "ignored"
|
||||
default:
|
||||
event.ExecutionState = "pending"
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func canonicalVehicleEvent(triggerType, metric, operator string) (string, string) {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
metric = strings.ToLower(strings.TrimSpace(metric))
|
||||
operator = strings.ToLower(strings.TrimSpace(operator))
|
||||
if triggerType == "geofence" {
|
||||
action := map[string]string{"enter": "entered", "exit": "exited", "inside": "inside", "outside": "outside"}[operator]
|
||||
if action == "" {
|
||||
action = "changed"
|
||||
}
|
||||
return "geofence", "vehicle.geofence." + action
|
||||
}
|
||||
if triggerType == "offline" || metric == "freshness_sec" {
|
||||
return "connectivity", "vehicle.connectivity.offline"
|
||||
}
|
||||
if triggerType == "stationary" {
|
||||
return "telemetry", "vehicle.motion.stationary"
|
||||
}
|
||||
switch metric {
|
||||
case "soc_percent":
|
||||
if operator == "lt" || operator == "lte" {
|
||||
return "telemetry", "vehicle.telemetry.soc_low"
|
||||
}
|
||||
case "speed_kmh":
|
||||
if operator == "gt" || operator == "gte" {
|
||||
return "telemetry", "vehicle.motion.speed_high"
|
||||
}
|
||||
case "alarm_active":
|
||||
return "safety", "vehicle.safety.alarm_activated"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "safety", "vehicle.safety.hydrogen_concentration_high"
|
||||
case "daily_mileage_km":
|
||||
return "business", "vehicle.mileage.daily_completed"
|
||||
}
|
||||
if strings.Contains(metric, "hydrogen") {
|
||||
return "safety", "vehicle.safety." + metric
|
||||
}
|
||||
if strings.Contains(metric, "mileage") || strings.HasPrefix(metric, "daily_") {
|
||||
return "business", "vehicle.business." + metric
|
||||
}
|
||||
if metric == "" {
|
||||
metric = "changed"
|
||||
}
|
||||
return "telemetry", "vehicle.telemetry." + metric
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
|
||||
@@ -184,6 +483,23 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Search = strings.TrimSpace(query.Search)
|
||||
searchRunes := []rune(query.Search)
|
||||
if len(searchRunes) > 160 {
|
||||
query.Search = string(searchRunes[:160])
|
||||
}
|
||||
query.DeliveryStatus = strings.ToLower(strings.TrimSpace(query.DeliveryStatus))
|
||||
switch query.DeliveryStatus {
|
||||
case "", "failed", "queued", "delivered":
|
||||
default:
|
||||
return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
query.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
@@ -196,6 +512,12 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
|
||||
return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"}
|
||||
}
|
||||
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
request.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(request.AllowedVINs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -203,6 +525,106 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
|
||||
return store.MarkAlertNotificationsRead(ctx, request)
|
||||
}
|
||||
|
||||
func alertNotificationAllowedVINs(principal Principal) []string {
|
||||
seen := map[string]struct{}{}
|
||||
allowed := make([]string, 0, len(principal.VehicleVINs)+len(principal.VehicleGrants))
|
||||
for _, value := range principal.VehicleVINs {
|
||||
vin := strings.ToUpper(strings.TrimSpace(value))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
for _, grant := range principal.VehicleGrants {
|
||||
vin := strings.ToUpper(strings.TrimSpace(grant.VIN))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func alertNotificationRetryActor(ctx context.Context) (string, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_AUTH_REQUIRED", Message: "通知重试需要已认证的操作账号"}
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(principal.Role))
|
||||
if role != "admin" && role != "operator" {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_FORBIDDEN", Message: "当前账号无权重新投递通知"}
|
||||
}
|
||||
return firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "platform-operator"), nil
|
||||
}
|
||||
|
||||
func validAlertNotificationRetryKey(value string) bool {
|
||||
if len(value) < 16 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
actor, err := alertNotificationRetryActor(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
reasonRunes := []rune(request.Reason)
|
||||
if len(reasonRunes) < 4 || len(reasonRunes) > 200 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_REASON_INVALID", Message: "请填写 4 到 200 个字符的重试原因"}
|
||||
}
|
||||
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
|
||||
if !validAlertNotificationRetryKey(request.IdempotencyKey) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_INVALID", Message: "重试请求键无效,请重新打开确认窗口"}
|
||||
}
|
||||
if request.ExpectedAttemptCount <= 0 || request.ExpectedAttemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_INVALID", Message: "通知尝试次数无效或已经达到上限"}
|
||||
}
|
||||
request.Actor = actor
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
return store.RetryAlertNotification(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertNotificationRetryAudits(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
|
||||
store, ok := s.store.(alertEvaluatorStore)
|
||||
if !ok {
|
||||
@@ -237,6 +659,10 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if severity != "critical" && severity != "major" && severity != "minor" {
|
||||
return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"}
|
||||
}
|
||||
triggerType := normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
if triggerType != "metric" && triggerType != "geofence" && triggerType != "stationary" && triggerType != "offline" {
|
||||
return clientError{Code: "ALERT_RULE_TRIGGER_TYPE_INVALID", Message: "自动化触发类型无效"}
|
||||
}
|
||||
valueType := strings.ToLower(strings.TrimSpace(input.ValueType))
|
||||
if valueType != "numeric" && valueType != "boolean" {
|
||||
return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"}
|
||||
@@ -244,6 +670,37 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if strings.TrimSpace(input.Metric) == "" {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
if triggerType == "geofence" {
|
||||
if len(input.ScopeProtocols) != 1 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_PROTOCOL_REQUIRED", Message: "电子围栏必须指定唯一定位协议,避免多数据源坐标漂移或重复触发"}
|
||||
}
|
||||
if valueType != "numeric" || !strings.EqualFold(input.Metric, "geofence_distance_m") {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_TYPE_INVALID", Message: "电子围栏规则配置不完整"}
|
||||
}
|
||||
if operator != "enter" && operator != "exit" && operator != "inside" && operator != "outside" {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_MODE_INVALID", Message: "电子围栏仅支持进入、离开、围栏内或围栏外"}
|
||||
}
|
||||
if strings.TrimSpace(input.FenceName) == "" || len([]rune(strings.TrimSpace(input.FenceName))) > 80 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_NAME_INVALID", Message: "围栏名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
if input.FenceLongitude < -180 || input.FenceLongitude > 180 || input.FenceLatitude < -90 || input.FenceLatitude > 90 || (input.FenceLongitude == 0 && input.FenceLatitude == 0) {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_CENTER_INVALID", Message: "围栏中心坐标无效"}
|
||||
}
|
||||
if input.FenceRadiusM < 50 || input.FenceRadiusM > 100000 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_RADIUS_INVALID", Message: "围栏半径须在 50 米到 100 公里之间"}
|
||||
}
|
||||
if (operator == "enter" || operator == "exit") && input.DurationSec != 0 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_DURATION_INVALID", Message: "进入或离开围栏按状态变化立即触发,不能配置持续时间"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "重复间隔超出允许范围"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var metric *MetricDefinition
|
||||
definitions := catalog
|
||||
if len(definitions) == 0 {
|
||||
@@ -259,13 +716,12 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if metric == nil || !metric.Alertable {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
|
||||
}
|
||||
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
|
||||
if !alertMetricDefinitionSupported(*metric) {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"}
|
||||
}
|
||||
if metric.ValueType != valueType {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true}
|
||||
if !allowed[operator] {
|
||||
return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"}
|
||||
@@ -279,15 +735,81 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil {
|
||||
return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"}
|
||||
}
|
||||
if triggerType == "stationary" && (!strings.EqualFold(input.Metric, "speed_kmh") || (operator != "lt" && operator != "lte") || input.Threshold < 0 || input.Threshold > 10 || input.DurationSec < 300) {
|
||||
return clientError{Code: "ALERT_RULE_STATIONARY_INVALID", Message: "长时间静止须使用 0–10 km/h 的速度上限且持续至少 5 分钟"}
|
||||
}
|
||||
if triggerType == "offline" && (!strings.EqualFold(input.Metric, "freshness_sec") || (operator != "gt" && operator != "gte") || input.Threshold < 60) {
|
||||
return clientError{Code: "ALERT_RULE_OFFLINE_INVALID", Message: "长时间离线须配置至少 60 秒的离线阈值"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedAlertTriggerType(triggerType, metric string) string {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
if triggerType != "" {
|
||||
return triggerType
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") {
|
||||
return "offline"
|
||||
}
|
||||
return "metric"
|
||||
}
|
||||
|
||||
func normalizeAlertRuleTrigger(input *AlertRuleInput) {
|
||||
input.TriggerType = normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
switch input.TriggerType {
|
||||
case "geofence":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "geofence_distance_m"
|
||||
input.Threshold = input.FenceRadiusM
|
||||
input.ThresholdHigh = 0
|
||||
input.BooleanThreshold = nil
|
||||
input.RecoveryOperator = ""
|
||||
input.RecoveryThreshold = 0
|
||||
if strings.EqualFold(input.Operator, "enter") || strings.EqualFold(input.Operator, "exit") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
case "stationary":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "speed_kmh"
|
||||
if input.Operator != "lt" && input.Operator != "lte" {
|
||||
input.Operator = "lte"
|
||||
}
|
||||
input.RecoveryOperator = "gt"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
case "offline":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "freshness_sec"
|
||||
if input.Operator != "gt" && input.Operator != "gte" {
|
||||
input.Operator = "gt"
|
||||
}
|
||||
input.DurationSec = 0
|
||||
input.RecoveryOperator = "lte"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
}
|
||||
}
|
||||
|
||||
func alertMetricDefinitionSupported(metric MetricDefinition) bool {
|
||||
if _, supported := alertMetricValue(metric.Key, alertEvaluationEvidence{}); supported {
|
||||
return true
|
||||
}
|
||||
for protocol, source := range metric.SourceFields {
|
||||
switch strings.ToUpper(strings.TrimSpace(protocol)) {
|
||||
case "GB32960", "JT808", "YUTONG_MQTT":
|
||||
if strings.TrimSpace(source) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeAlertChannels(values []string) []string {
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
out := make([]string, 0, len(values)+1)
|
||||
@@ -299,12 +821,163 @@ func normalizeAlertChannels(values []string) []string {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
if !seen["in_app"] {
|
||||
out = append([]string{"in_app"}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertNotificationTargetIDValid(value string) bool {
|
||||
if len(value) < 2 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeAlertNotificationTargets(input *AlertRuleInput) error {
|
||||
if len(input.NotificationTargets) > 20 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LIMIT", Message: "单条自动化最多配置 20 个通知目标"}
|
||||
}
|
||||
if len(input.NotificationTargets) == 0 {
|
||||
channels := normalizeAlertChannels(input.NotificationChannels)
|
||||
if len(channels) == 0 {
|
||||
input.NotificationChannels = []string{}
|
||||
input.NotificationTargets = []AlertNotificationTarget{}
|
||||
return nil
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel != "in_app" {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_REQUIRED", Message: "短信、邮件和企业通讯必须选择明确的通知目标"}
|
||||
}
|
||||
}
|
||||
input.NotificationChannels = []string{"in_app"}
|
||||
input.NotificationTargets = []AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
targets := make([]AlertNotificationTarget, 0, len(input.NotificationTargets)+1)
|
||||
seen := map[string]bool{}
|
||||
hasInApp := false
|
||||
for _, target := range input.NotificationTargets {
|
||||
target.Channel = strings.ToLower(strings.TrimSpace(target.Channel))
|
||||
target.RecipientID = strings.TrimSpace(target.RecipientID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !allowed[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_INVALID", Message: "通知渠道无效"}
|
||||
}
|
||||
if !alertNotificationTargetIDValid(target.RecipientID) {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_INVALID", Message: "通知目标标识无效"}
|
||||
}
|
||||
if target.Label == "" || len([]rune(target.Label)) > 80 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LABEL_INVALID", Message: "通知目标名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
key := target.Channel + "\x00" + target.RecipientID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
hasInApp = hasInApp || target.Channel == "in_app"
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if len(targets) > 0 && !hasInApp {
|
||||
targets = append([]AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}, targets...)
|
||||
}
|
||||
channels := make([]string, 0, len(targets))
|
||||
channelSeen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
if !channelSeen[target.Channel] {
|
||||
channelSeen[target.Channel] = true
|
||||
channels = append(channels, target.Channel)
|
||||
}
|
||||
}
|
||||
input.NotificationTargets = targets
|
||||
input.NotificationChannels = channels
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeAlertNotificationConfig(config AlertNotificationConfig) AlertNotificationConfig {
|
||||
allowed := map[string]string{"in_app": "站内信", "sms": "短信", "email": "邮件", "wecom": "企业通讯"}
|
||||
channelConfigured := map[string]bool{"in_app": true}
|
||||
for _, channel := range config.Channels {
|
||||
key := strings.ToLower(strings.TrimSpace(channel.Channel))
|
||||
if _, ok := allowed[key]; ok {
|
||||
channelConfigured[key] = channel.Configured || key == "in_app"
|
||||
}
|
||||
}
|
||||
config.Channels = make([]AlertNotificationChannelCapability, 0, len(allowed))
|
||||
for _, key := range []string{"in_app", "sms", "email", "wecom"} {
|
||||
config.Channels = append(config.Channels, AlertNotificationChannelCapability{Channel: key, Label: allowed[key], Configured: channelConfigured[key]})
|
||||
}
|
||||
targets := make([]AlertNotificationTargetOption, 0, len(config.Targets)+1)
|
||||
seen := map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
target.ID = strings.TrimSpace(target.ID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !alertNotificationTargetIDValid(target.ID) || target.Label == "" || len([]rune(target.Label)) > 80 || seen[target.ID] {
|
||||
continue
|
||||
}
|
||||
channels := make([]string, 0, len(target.Channels))
|
||||
for _, raw := range target.Channels {
|
||||
channel := strings.ToLower(strings.TrimSpace(raw))
|
||||
if _, ok := allowed[channel]; ok && !containsString(channels, channel) {
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[target.ID] = true
|
||||
target.Channels = channels
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if !seen["platform-operators"] {
|
||||
targets = append([]AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}, targets...)
|
||||
}
|
||||
config.Targets = targets
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Service) validateAlertNotificationTargets(targets []AlertNotificationTarget) error {
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
capabilities := map[string]bool{}
|
||||
for _, channel := range config.Channels {
|
||||
capabilities[channel.Channel] = channel.Configured
|
||||
}
|
||||
options := map[string]map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
options[target.ID] = map[string]bool{}
|
||||
for _, channel := range target.Channels {
|
||||
options[target.ID][channel] = true
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
if !capabilities[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_UNAVAILABLE", Message: "所选外部通知渠道尚未配置发送网关"}
|
||||
}
|
||||
if len(options) > 0 && !options[target.RecipientID][target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_UNAVAILABLE", Message: "所选通知目标不支持当前渠道"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanUniqueStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -19,6 +19,7 @@ type alertEvaluationEvidence struct {
|
||||
SpeedKmh, SOCPercent, Longitude, Latitude float64
|
||||
AlarmFlag int64
|
||||
FreshnessSec, DataDelaySec int
|
||||
HasLocation bool
|
||||
}
|
||||
|
||||
type alertCandidateState struct {
|
||||
@@ -49,11 +50,11 @@ const (
|
||||
alertObservationLate
|
||||
)
|
||||
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,trigger_type,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
|
||||
func buildAlertEventInsert(id, fingerprint string, rule AlertRule, item alertEvaluationEvidence, value float64) (string, []any) {
|
||||
return alertEventInsertSQL, []any{
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity,
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity, normalizedAlertTriggerType(rule.TriggerType, rule.Metric),
|
||||
item.VIN, item.Plate, item.Protocol, rule.Metric, rule.Operator, value,
|
||||
rule.Threshold, rule.ThresholdHigh, alertMetricUnit(rule.Metric), rule.DurationSec,
|
||||
item.Location, item.Longitude, item.Latitude, item.SourceEventID,
|
||||
@@ -118,7 +119,7 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertMetricValue(rule.Metric, item)
|
||||
value, supported := alertRuleMetricValue(rule, item)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -138,22 +139,15 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
stateUpserts = append(stateUpserts, alertRuleStateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, LastValue: normalized, ObservedAt: observedAt})
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -193,12 +187,12 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','alert-evaluator','规则命中并达到持续时间')`, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -454,6 +448,7 @@ func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertE
|
||||
if delay.Valid {
|
||||
item.DataDelaySec = int(delay.Int64)
|
||||
}
|
||||
item.HasLocation = validAlertCoordinate(item.Longitude, item.Latitude)
|
||||
items = append(items, item)
|
||||
if len(items) > alertEvaluationVehicleLimit {
|
||||
return nil, fmt.Errorf("alert evaluation evidence exceeds safety limit %d", alertEvaluationVehicleLimit)
|
||||
@@ -506,6 +501,95 @@ func alertMetricValue(metric string, item alertEvaluationEvidence) (float64, boo
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func alertRuleMetricValue(rule AlertRule, item alertEvaluationEvidence) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
if !item.HasLocation || !validAlertCoordinate(rule.FenceLongitude, rule.FenceLatitude) || rule.FenceRadiusM <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return geofenceDistanceMeters(rule.FenceLongitude, rule.FenceLatitude, item.Longitude, item.Latitude), true
|
||||
}
|
||||
return alertMetricValue(rule.Metric, item)
|
||||
}
|
||||
|
||||
func alertRuleMatches(rule AlertRule, value float64, previous alertRuleState, previousExists bool) (bool, float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
inside := value <= rule.FenceRadiusM
|
||||
normalized := 0.0
|
||||
if inside {
|
||||
normalized = 1
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(rule.Operator)) {
|
||||
case "enter":
|
||||
return previousExists && previous.LastValue == 0 && inside, normalized, true
|
||||
case "exit":
|
||||
return previousExists && previous.LastValue != 0 && !inside, normalized, true
|
||||
case "inside":
|
||||
return inside, normalized, true
|
||||
case "outside":
|
||||
return !inside, normalized, true
|
||||
default:
|
||||
return false, normalized, true
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
return previousExists && previous.LastValue != normalized, normalized, true
|
||||
}
|
||||
return compareAlertRuleValue(value, rule), 0, false
|
||||
}
|
||||
|
||||
func validAlertCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
func geofenceDistanceMeters(centerLongitude, centerLatitude, longitude, latitude float64) float64 {
|
||||
const earthRadiusM = 6371008.8
|
||||
toRadians := math.Pi / 180
|
||||
lat1, lat2 := centerLatitude*toRadians, latitude*toRadians
|
||||
dLat := (latitude - centerLatitude) * toRadians
|
||||
dLon := (longitude - centerLongitude) * toRadians
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
func alertNotificationTitle(rule AlertRule) string {
|
||||
if strings.EqualFold(rule.Severity, "critical") {
|
||||
return "【高优先级】" + rule.Name
|
||||
}
|
||||
return rule.Name
|
||||
}
|
||||
|
||||
func alertNotificationContent(rule AlertRule, item alertEvaluationEvidence, value float64, unit string) string {
|
||||
vehicle := firstNonEmpty(item.Plate, item.VIN)
|
||||
switch normalizedAlertTriggerType(rule.TriggerType, rule.Metric) {
|
||||
case "geofence":
|
||||
mode := map[string]string{"enter": "进入", "exit": "离开", "inside": "位于", "outside": "持续位于围栏外"}[strings.ToLower(rule.Operator)]
|
||||
return fmt.Sprintf("%s %s电子围栏“%s”,距中心 %.0f m", vehicle, mode, rule.FenceName, value)
|
||||
case "stationary":
|
||||
return fmt.Sprintf("%s 已低速静止 %s,当前速度 %.2f km/h", vehicle, formatAlertDurationGo(rule.DurationSec), value)
|
||||
case "offline":
|
||||
return fmt.Sprintf("%s 已离线 %s", vehicle, formatAlertDurationGo(int(value)))
|
||||
default:
|
||||
return fmt.Sprintf("%s 触发%s:%.2f %s", vehicle, rule.Name, value, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func formatAlertDurationGo(seconds int) string {
|
||||
if seconds%86400 == 0 && seconds >= 86400 {
|
||||
return fmt.Sprintf("%d 天", seconds/86400)
|
||||
}
|
||||
if seconds%3600 == 0 && seconds >= 3600 {
|
||||
return fmt.Sprintf("%d 小时", seconds/3600)
|
||||
}
|
||||
if seconds%60 == 0 && seconds >= 60 {
|
||||
return fmt.Sprintf("%d 分钟", seconds/60)
|
||||
}
|
||||
return fmt.Sprintf("%d 秒", seconds)
|
||||
}
|
||||
func compareAlertValue(value float64, operator string, threshold float64) bool {
|
||||
switch strings.ToLower(operator) {
|
||||
case "gt":
|
||||
@@ -542,6 +626,30 @@ func alertMetricUnit(metric string) string {
|
||||
return "%"
|
||||
case "freshness_sec", "data_delay_sec":
|
||||
return "秒"
|
||||
case "total_mileage_km":
|
||||
return "km"
|
||||
case "total_voltage_v", "fuel_cell_voltage_v", "max_cell_voltage_v", "min_cell_voltage_v":
|
||||
return "V"
|
||||
case "total_current_a", "fuel_cell_current_a":
|
||||
return "A"
|
||||
case "insulation_kohm":
|
||||
return "kΩ"
|
||||
case "hydrogen_consumption_kg_per_100km":
|
||||
return "kg/100km"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "%"
|
||||
case "hydrogen_pressure_mpa":
|
||||
return "MPa"
|
||||
case "hydrogen_temperature_c", "max_battery_temperature_c", "min_battery_temperature_c":
|
||||
return "℃"
|
||||
case "geofence_distance_m":
|
||||
return "m"
|
||||
case "engine_speed_rpm":
|
||||
return "rpm"
|
||||
case "gnss_satellite_count":
|
||||
return "颗"
|
||||
case "fuel_l":
|
||||
return "L"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package platform
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -11,25 +12,55 @@ func (m *MockStore) seedAlertCenter() {
|
||||
now := time.Now()
|
||||
boolTrue := true
|
||||
m.alertRules = []AlertRule{
|
||||
{ID: "rule-speeding", Name: "持续超速告警", Description: "速度持续高于阈值", Severity: "critical", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 60, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808", "GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "离线超时", Description: "车辆超过阈值未上报", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, DurationSec: 0, RecoveryOperator: "lte", RecoveryThreshold: 300, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "协议告警位", Description: "原始协议告警位非零", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, DurationSec: 0, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-geofence", Name: "车辆驶出运营围栏", Description: "车辆从深圳运营区内移动到围栏外", TriggerType: "geofence", FenceName: "深圳运营区", FenceLongitude: 114.057868, FenceLatitude: 22.543099, FenceRadiusM: 5000, Severity: "critical", ValueType: "numeric", Metric: "geofence_distance_m", Operator: "exit", Threshold: 5000, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 3, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-low-soc", Name: "SOC 低于 20%", Description: "动力电池 SOC 持续低于 20%", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "lt", Threshold: 20, DurationSec: 300, RecoveryOperator: "gte", RecoveryThreshold: 25, RepeatIntervalSec: 3600, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "车辆在线状态处理", Description: "车辆离线时创建事件,恢复上报后自动完成", TriggerType: "offline", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, RecoveryOperator: "lte", RecoveryThreshold: 3600, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-speeding", Name: "车辆急加速", Description: "速度变化率超过运营阈值", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 10, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-mileage", Name: "里程数据完成日结", Description: "记录车辆每日里程结算完成事件", TriggerType: "metric", Severity: "minor", ValueType: "numeric", Metric: "daily_mileage_km", Operator: "gte", Threshold: 0, RepeatIntervalSec: 86400, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-12 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "最高氢浓度超限", Description: "燃料电池系统最高氢浓度超过安全阈值", TriggerType: "metric", Severity: "critical", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
}
|
||||
for i := range m.alertRules {
|
||||
m.alertRules[i].ScopeOEMs = []string{}
|
||||
m.alertRules[i].ScopeModels = []string{}
|
||||
m.alertRules[i].ScopeCompanies = []string{}
|
||||
input := alertRuleInputFromRule(m.alertRules[i])
|
||||
if err := normalizeAlertNotificationTargets(&input); err == nil {
|
||||
m.alertRules[i].NotificationChannels = input.NotificationChannels
|
||||
m.alertRules[i].NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
}
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
for _, rule := range m.alertRules {
|
||||
m.alertRuleRevisions[rule.ID] = []AlertRuleRevision{{RuleID: rule.ID, Version: rule.Version, Actor: rule.UpdatedBy, Action: "create", CreatedAt: rule.UpdatedAt, Snapshot: rule}}
|
||||
}
|
||||
locations := []string{"广东省深圳市南山区科技南路", "广东省广州市白云区机场高速", "广东省东莞市南城街道", "广东省佛山市顺德区伦教街道", "上海市临港新片区", "四川省成都市高新区"}
|
||||
statuses := []string{"unprocessed", "unprocessed", "processing", "recovered", "closed", "ignored"}
|
||||
severities := []string{"critical", "critical", "major", "major", "minor", "minor"}
|
||||
severities := []string{"critical", "major", "major", "major", "minor", "critical"}
|
||||
plates := []string{"粤AG18312", "粤B7C526", "粤C9D872", "粤E6P987", "豫A88888", "川AHTWO1"}
|
||||
vins := []string{"LB9A32A24R0LS1426", "LFP23A98V2P012345", "LS5A3A5E8N0123456", "LJ12BA3R1N0456789", "LMRKH9AC2R1004087", "LNXNEGRR7SR318212"}
|
||||
eventSpecs := []struct {
|
||||
ruleID, name, triggerType, protocol, metric, operator, unit string
|
||||
value, threshold float64
|
||||
duration int
|
||||
}{
|
||||
{"rule-geofence", "车辆驶出运营围栏", "geofence", "JT808", "geofence_distance_m", "exit", "m", 5230, 5000, 0},
|
||||
{"rule-low-soc", "SOC 低于 20%", "metric", "GB32960", "soc_percent", "lt", "%", 18, 20, 300},
|
||||
{"rule-speeding", "车辆急加速", "metric", "JT808", "speed_kmh", "gt", "km/h", 92, 80, 10},
|
||||
{"rule-offline", "车辆恢复在线", "offline", "YUTONG_MQTT", "freshness_sec", "gt", "s", 22, 3600, 0},
|
||||
{"rule-mileage", "里程数据完成日结", "metric", "GB32960", "daily_mileage_km", "gte", "km", 186.4, 0, 0},
|
||||
{"rule-alarm", "最高氢浓度超限", "metric", "GB32960", "alarm_active", "eq", "", 1, 1, 0},
|
||||
}
|
||||
for i := range statuses {
|
||||
triggered := now.Add(-time.Duration(8+i*11) * time.Minute)
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: "rule-speeding", RuleName: "持续超速告警", RuleVersion: 2, Severity: severities[i], Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: []string{"JT808", "JT808", "JT808", "GB32960", "YUTONG_MQTT", "GB32960"}[i], Metric: "speed_kmh", Operator: "gt", TriggerValue: 96 - float64(i*3), Threshold: 80, Unit: "km/h", DurationSec: 60, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
spec := eventSpecs[i]
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: spec.ruleID, RuleName: spec.name, RuleVersion: 2, Severity: severities[i], TriggerType: spec.triggerType, Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: spec.protocol, Metric: spec.metric, Operator: spec.operator, TriggerValue: spec.value, Threshold: spec.threshold, Unit: spec.unit, DurationSec: spec.duration, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
if statuses[i] == "processing" {
|
||||
event.Handler = "张三"
|
||||
}
|
||||
if statuses[i] == "recovered" || statuses[i] == "closed" || statuses[i] == "ignored" {
|
||||
event.RecoveredAt = triggered.Add(5 * time.Minute).Format(time.RFC3339)
|
||||
}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "alert-evaluator", Note: "规则命中并达到持续时间", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "event-evaluator", Note: "标准事件已匹配自动化", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
if statuses[i] != "unprocessed" {
|
||||
event.Actions = append(event.Actions, AlertAction{ID: int64(20 + i), Action: statuses[i], FromStatus: "unprocessed", ToStatus: statuses[i], Actor: firstNonEmpty(event.Handler, "platform-admin"), CreatedAt: triggered.Add(time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
@@ -37,9 +68,17 @@ func (m *MockStore) seedAlertCenter() {
|
||||
}
|
||||
m.nextAlertActionID = 100
|
||||
for i := 0; i < 9; i++ {
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 触发告警,请及时处理", Severity: severities[i%len(severities)], Channel: "in_app", Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 的车辆事件已执行通知动作", Severity: severities[i%len(severities)], Channel: "in_app", DeliveryStatus: "delivered", AttemptCount: 1, MaxAttempts: alertNotificationMaxAttempts, Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
m.nextNotificationID = 10
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{
|
||||
ID: 10, EventID: m.alertEvents[1].ID, Title: "SOC 低电量短信通知", Content: "粤B7C526 的 SOC 已低于 20%,短信网关连接超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", RecipientID: "night-shift", DeliveryStatus: "failed", AttemptCount: 2,
|
||||
VehiclePlate: "粤B7C526", VehicleVIN: m.alertEvents[1].VIN, Protocol: m.alertEvents[1].Protocol,
|
||||
CreatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339), LastAttemptAt: now.Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
LastError: "短信网关连接超时(provider_timeout)", RetryAvailable: true, MaxAttempts: alertNotificationMaxAttempts,
|
||||
})
|
||||
m.nextNotificationID = 11
|
||||
m.nextNotificationRetryAuditID = 0
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
@@ -66,7 +105,7 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
}
|
||||
for _, item := range m.alertNotifications {
|
||||
if !item.Read {
|
||||
if item.Channel == "in_app" && !item.Read {
|
||||
result.UnreadNotifications++
|
||||
}
|
||||
}
|
||||
@@ -161,7 +200,99 @@ func (m *MockStore) AlertEvent(_ context.Context, id string) (AlertEvent, error)
|
||||
func (m *MockStore) AlertRules(context.Context) ([]AlertRule, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
return append([]AlertRule(nil), m.alertRules...), nil
|
||||
items := make([]AlertRule, 0, len(m.alertRules))
|
||||
for _, rule := range m.alertRules {
|
||||
if rule.ArchivedAt == "" {
|
||||
items = append(items, rule)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRulePage(_ context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
summary := AlertRuleLibrarySummary{}
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
search := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, rule := range m.alertRules {
|
||||
archived := rule.ArchivedAt != ""
|
||||
if archived {
|
||||
summary.Archived++
|
||||
} else {
|
||||
summary.Current++
|
||||
if rule.Enabled {
|
||||
summary.Enabled++
|
||||
} else {
|
||||
summary.Disabled++
|
||||
}
|
||||
}
|
||||
if (query.Lifecycle == "archived") != archived {
|
||||
continue
|
||||
}
|
||||
if query.Status == "enabled" && !rule.Enabled || query.Status == "disabled" && rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if query.Protocol != "" && len(rule.ScopeProtocols) > 0 {
|
||||
matched := false
|
||||
for _, protocol := range rule.ScopeProtocols {
|
||||
if protocol == query.Protocol {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
rule.Name, rule.Description, rule.Metric, strings.Join(rule.ScopeVINs, " "),
|
||||
strings.Join(rule.ScopeOEMs, " "), strings.Join(rule.ScopeModels, " "),
|
||||
strings.Join(rule.ScopeCompanies, " "), rule.ArchiveReason,
|
||||
}, " "))
|
||||
if !strings.Contains(haystack, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, rule)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if query.Lifecycle == "archived" {
|
||||
if items[i].ArchivedAt != items[j].ArchivedAt {
|
||||
return items[i].ArchivedAt > items[j].ArchivedAt
|
||||
}
|
||||
} else if items[i].Enabled != items[j].Enabled {
|
||||
return items[i].Enabled
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
total := len(items)
|
||||
start := min(query.Offset, total)
|
||||
end := min(start+query.Limit, total)
|
||||
return AlertRulePage{Items: append([]AlertRule(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRuleRevisions(_ context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items, ok := m.alertRuleRevisions[id]
|
||||
if !ok {
|
||||
return nil, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
return append([]AlertRuleRevision(nil), items...), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) recordAlertRuleRevision(rule AlertRule, actor, action string, reasons ...string) {
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
reason := ""
|
||||
if len(reasons) > 0 {
|
||||
reason = reasons[0]
|
||||
}
|
||||
revision := AlertRuleRevision{RuleID: rule.ID, Version: rule.Version, Actor: actor, Action: action, Reason: reason, CreatedAt: rule.UpdatedAt, Snapshot: rule}
|
||||
m.alertRuleRevisions[rule.ID] = append([]AlertRuleRevision{revision}, m.alertRuleRevisions[rule.ID]...)
|
||||
}
|
||||
|
||||
func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
@@ -172,6 +303,9 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
if current.ID != input.ID {
|
||||
continue
|
||||
}
|
||||
if current.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if input.Version != current.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -182,6 +316,7 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.UpdatedAt = now
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules[i] = next
|
||||
m.recordAlertRuleRevision(next, input.Actor, firstNonEmpty(input.AuditAction, "update"))
|
||||
return next, nil
|
||||
}
|
||||
if input.Version != 0 {
|
||||
@@ -194,11 +329,15 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.CreatedBy = input.Actor
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules = append(m.alertRules, next)
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
m.recordAlertRuleRevision(next, input.Actor, "create")
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ruleFromInput(input AlertRuleInput) AlertRule {
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), Enabled: input.Enabled}
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, TriggerType: input.TriggerType, FenceName: input.FenceName, FenceLongitude: input.FenceLongitude, FenceLatitude: input.FenceLatitude, FenceRadiusM: input.FenceRadiusM, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), input.NotificationTargets...), Enabled: input.Enabled}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
@@ -206,6 +345,9 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
if m.alertRules[i].ID == id {
|
||||
if m.alertRules[i].ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if m.alertRules[i].Version != update.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -213,12 +355,56 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
m.alertRules[i].Version++
|
||||
m.alertRules[i].UpdatedBy = update.Actor
|
||||
m.alertRules[i].UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
m.recordAlertRuleRevision(m.alertRules[i], update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled])
|
||||
return m.alertRules[i], nil
|
||||
}
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "规则不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleArchived(_ context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
rule := &m.alertRules[i]
|
||||
if rule.ID != id {
|
||||
continue
|
||||
}
|
||||
if rule.Version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if rule.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if rule.Enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
rule.ArchivedAt = time.Now().Format(time.RFC3339)
|
||||
rule.ArchivedBy = request.Actor
|
||||
rule.ArchiveReason = request.Reason
|
||||
} else {
|
||||
if rule.ArchivedAt == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
rule.ArchivedAt = ""
|
||||
rule.ArchivedBy = ""
|
||||
rule.ArchiveReason = ""
|
||||
rule.Enabled = false
|
||||
}
|
||||
rule.Version++
|
||||
rule.UpdatedBy = request.Actor
|
||||
rule.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
m.recordAlertRuleRevision(*rule, request.Actor, action, request.Reason)
|
||||
return *rule, nil
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) ActOnAlert(_ context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -265,10 +451,46 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotification, 0, len(m.alertNotifications))
|
||||
search := strings.ToLower(query.Search)
|
||||
for _, item := range m.alertNotifications {
|
||||
if !query.UnreadOnly || !item.Read {
|
||||
items = append(items, item)
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(item.VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if query.UnreadOnly && (item.Channel != "in_app" || item.Read) {
|
||||
continue
|
||||
}
|
||||
deliveryStatus := strings.ToLower(strings.TrimSpace(item.DeliveryStatus))
|
||||
if deliveryStatus == "sent" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if deliveryStatus == "created" || deliveryStatus == "reserved" {
|
||||
deliveryStatus = "queued"
|
||||
}
|
||||
if deliveryStatus == "" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if query.DeliveryStatus != "" && deliveryStatus != query.DeliveryStatus {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(strings.Join([]string{item.Title, item.Content, item.EventID, item.VehiclePlate, item.VehicleVIN, item.Recipient, item.Protocol}, "\n")), search) {
|
||||
continue
|
||||
}
|
||||
item.DeliveryStatus = deliveryStatus
|
||||
if item.AttemptCount <= 0 {
|
||||
item.AttemptCount = 1
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = deliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
items = append(items, item)
|
||||
}
|
||||
total := len(items)
|
||||
start := query.Offset
|
||||
@@ -282,6 +504,48 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
return Page[AlertNotification]{Items: append([]AlertNotification(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationDeliveryHealth(_ context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
byChannel := map[string]*AlertNotificationChannelHealth{}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
channel := byChannel[notification.Channel]
|
||||
if channel == nil {
|
||||
channel = &AlertNotificationChannelHealth{Channel: notification.Channel}
|
||||
byChannel[notification.Channel] = channel
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(notification.DeliveryStatus))
|
||||
if status == "created" || status == "reserved" || status == "queued" {
|
||||
channel.Queued++
|
||||
health.Queued++
|
||||
if channel.OldestQueuedAt == "" || notification.CreatedAt < channel.OldestQueuedAt {
|
||||
channel.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
if health.OldestQueuedAt == "" || notification.CreatedAt < health.OldestQueuedAt {
|
||||
health.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
}
|
||||
if status == "failed" {
|
||||
channel.Failed++
|
||||
health.Failed++
|
||||
if notification.AttemptCount >= alertNotificationMaxAttempts {
|
||||
channel.DeadLetter++
|
||||
health.DeadLetter++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sms", "email", "wecom"} {
|
||||
if channel := byChannel[key]; channel != nil {
|
||||
health.Channels = append(health.Channels, *channel)
|
||||
}
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -292,7 +556,19 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
count := 0
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for i := range m.alertNotifications {
|
||||
if ids[m.alertNotifications[i].ID] && !m.alertNotifications[i].Read {
|
||||
if ids[m.alertNotifications[i].ID] && m.alertNotifications[i].Channel == "in_app" && !m.alertNotifications[i].Read {
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range request.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(m.alertNotifications[i].VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
m.alertNotifications[i].Read = true
|
||||
m.alertNotifications[i].ReadAt = now
|
||||
count++
|
||||
@@ -301,6 +577,85 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) RetryAlertNotification(_ context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if audit.IdempotencyKey == request.IdempotencyKey {
|
||||
if audit.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.ID == id {
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: audit, Idempotent: true}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range m.alertNotifications {
|
||||
notification := &m.alertNotifications[index]
|
||||
if notification.ID != id {
|
||||
continue
|
||||
}
|
||||
attemptCount := notification.AttemptCount
|
||||
if attemptCount <= 0 {
|
||||
attemptCount = 1
|
||||
}
|
||||
if notification.DeliveryStatus != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
nextStatus := "queued"
|
||||
auditNextStatus := "reserved"
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if notification.Channel == "in_app" {
|
||||
nextStatus = "delivered"
|
||||
auditNextStatus = "sent"
|
||||
notification.DeliveredAt = now
|
||||
notification.ProviderMessageID = "in_app:" + strconv.FormatInt(notification.ID, 10) + ":retry:" + strconv.Itoa(attemptCount+1)
|
||||
} else {
|
||||
notification.ProviderMessageID = ""
|
||||
notification.DeliveredAt = ""
|
||||
}
|
||||
notification.DeliveryStatus = nextStatus
|
||||
notification.AttemptCount = attemptCount + 1
|
||||
notification.LastAttemptAt = now
|
||||
notification.RetryRequestedBy = request.Actor
|
||||
notification.RetryRequestedAt = now
|
||||
notification.RetryAvailable = false
|
||||
notification.MaxAttempts = alertNotificationMaxAttempts
|
||||
m.nextNotificationRetryAuditID++
|
||||
audit := AlertNotificationRetryAudit{
|
||||
ID: m.nextNotificationRetryAuditID, NotificationID: id, IdempotencyKey: request.IdempotencyKey, Actor: request.Actor,
|
||||
Reason: request.Reason, PreviousStatus: "failed", NextStatus: auditNextStatus,
|
||||
AttemptCount: notification.AttemptCount, RequestedAt: now,
|
||||
}
|
||||
m.alertNotificationRetryAudits = append([]AlertNotificationRetryAudit{audit}, m.alertNotificationRetryAudits...)
|
||||
return AlertNotificationRetryResult{Notification: *notification, Receipt: audit}, nil
|
||||
}
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationRetryAudits(_ context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.NotificationID == id {
|
||||
items = append(items, audit)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateAlerts(context.Context) (AlertEvaluationResult, error) {
|
||||
return AlertEvaluationResult{RulesEvaluated: len(m.alertRules), VehiclesScanned: len(m.vehicles), AsOf: time.Now().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertNotificationDispatchItem struct {
|
||||
ID int64 `json:"notificationId"`
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Severity string `json:"severity"`
|
||||
Channel string `json:"channel"`
|
||||
RecipientID string `json:"recipientId"`
|
||||
Recipient string `json:"recipient"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
VehiclePlate string `json:"vehiclePlate"`
|
||||
VehicleVIN string `json:"vehicleVin"`
|
||||
Protocol string `json:"protocol"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LeaseToken string `json:"-"`
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchReceipt struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchBatchResult struct {
|
||||
Claimed int
|
||||
Sent int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type AlertNotificationGateway interface {
|
||||
Send(context.Context, AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error)
|
||||
}
|
||||
|
||||
type alertNotificationDispatchStore interface {
|
||||
ClaimAlertNotifications(context.Context, string, []string, int, time.Duration) ([]AlertNotificationDispatchItem, error)
|
||||
CompleteAlertNotificationDispatch(context.Context, AlertNotificationDispatchItem, AlertNotificationDispatchReceipt, error) error
|
||||
}
|
||||
|
||||
type AlertNotificationDispatcher struct {
|
||||
store alertNotificationDispatchStore
|
||||
gateways map[string]AlertNotificationGateway
|
||||
channels []string
|
||||
batchSize int
|
||||
lease time.Duration
|
||||
workerID string
|
||||
}
|
||||
|
||||
func NewAlertNotificationDispatcher(store alertNotificationDispatchStore, gateways map[string]AlertNotificationGateway, batchSize int, lease time.Duration, workerID string) *AlertNotificationDispatcher {
|
||||
normalized := map[string]AlertNotificationGateway{}
|
||||
channels := make([]string, 0, len(gateways))
|
||||
for _, channel := range []string{"sms", "email", "wecom"} {
|
||||
if gateway := gateways[channel]; gateway != nil {
|
||||
normalized[channel] = gateway
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 20
|
||||
}
|
||||
if batchSize > 200 {
|
||||
batchSize = 200
|
||||
}
|
||||
if lease < 5*time.Second {
|
||||
lease = 30 * time.Second
|
||||
}
|
||||
if lease > 5*time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
workerID = "notification-dispatcher"
|
||||
}
|
||||
return &AlertNotificationDispatcher{store: store, gateways: normalized, channels: channels, batchSize: batchSize, lease: lease, workerID: workerID}
|
||||
}
|
||||
|
||||
func (d *AlertNotificationDispatcher) RunOnce(ctx context.Context) (AlertNotificationDispatchBatchResult, error) {
|
||||
if len(d.channels) == 0 {
|
||||
return AlertNotificationDispatchBatchResult{}, nil
|
||||
}
|
||||
token, err := alertNotificationLeaseToken(d.workerID)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
items, err := d.store.ClaimAlertNotifications(ctx, token, d.channels, d.batchSize, d.lease)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
result := AlertNotificationDispatchBatchResult{Claimed: len(items)}
|
||||
var completionErrors []string
|
||||
for _, item := range items {
|
||||
receipt, sendErr := d.gateways[item.Channel].Send(ctx, item)
|
||||
if sendErr == nil {
|
||||
result.Sent++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
if completeErr := d.store.CompleteAlertNotificationDispatch(ctx, item, receipt, sendErr); completeErr != nil {
|
||||
completionErrors = append(completionErrors, completeErr.Error())
|
||||
}
|
||||
}
|
||||
if len(completionErrors) > 0 {
|
||||
return result, fmt.Errorf("complete notification dispatch: %s", strings.Join(completionErrors, "; "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func alertNotificationLeaseToken(workerID string) (string, error) {
|
||||
buf := make([]byte, 12)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.Map(func(char rune) rune {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' || char == ':' {
|
||||
return char
|
||||
}
|
||||
return '-'
|
||||
}, workerID) + ":" + hex.EncodeToString(buf)
|
||||
if len(token) > 96 {
|
||||
token = token[len(token)-96:]
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAlertNotifications(ctx context.Context, leaseToken string, channels []string, limit int, lease time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !alertNotificationTargetIDValid(leaseToken) {
|
||||
return nil, fmt.Errorf("invalid notification lease token")
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
return nil, fmt.Errorf("notification dispatch batch size must be between 1 and 200")
|
||||
}
|
||||
allowed := map[string]bool{"sms": true, "email": true, "wecom": true}
|
||||
cleanChannels := make([]string, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||
if allowed[channel] && !containsString(cleanChannels, channel) {
|
||||
cleanChannels = append(cleanChannels, channel)
|
||||
}
|
||||
}
|
||||
if len(cleanChannels) == 0 {
|
||||
return []AlertNotificationDispatchItem{}, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
placeholders := make([]string, len(cleanChannels))
|
||||
args := make([]any, 0, len(cleanChannels)+1)
|
||||
for index, channel := range cleanChannels {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, channel)
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,n.recipient,n.recipient_ref,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,''),DATE_FORMAT(n.created_at,'`+alertSQLTimestampFormat+`')
|
||||
FROM vehicle_alert_notification n
|
||||
LEFT JOIN vehicle_alert_event e ON e.id=n.event_id
|
||||
WHERE n.delivery_status='reserved'
|
||||
AND n.channel IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND n.recipient_ref<>''
|
||||
AND (n.lease_expires_at IS NULL OR n.lease_expires_at<=NOW(3))
|
||||
ORDER BY n.created_at,n.id
|
||||
LIMIT ? FOR UPDATE SKIP LOCKED`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AlertNotificationDispatchItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var item AlertNotificationDispatchItem
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient, &item.RecipientID, &item.AttemptCount, &item.VehiclePlate, &item.VehicleVIN, &item.Protocol, &item.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.LeaseToken = leaseToken
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaseSeconds := int(lease.Round(time.Second) / time.Second)
|
||||
if leaseSeconds < 5 {
|
||||
leaseSeconds = 30
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET lease_token=?,lease_expires_at=DATE_ADD(NOW(3),INTERVAL ? SECOND),last_attempt_at=NOW(3) WHERE id=? AND delivery_status='reserved'`, leaseToken, leaseSeconds, item.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CompleteAlertNotificationDispatch(ctx context.Context, item AlertNotificationDispatchItem, receipt AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
if item.ID <= 0 || item.LeaseToken == "" {
|
||||
return fmt.Errorf("notification dispatch receipt is missing its lease")
|
||||
}
|
||||
var result sql.Result
|
||||
var err error
|
||||
if sendErr == nil {
|
||||
messageID := strings.TrimSpace(receipt.MessageID)
|
||||
if len(messageID) > 160 {
|
||||
messageID = messageID[:160]
|
||||
}
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',provider_message_id=?,delivered_at=NOW(3),last_error='',lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, messageID, item.ID, item.LeaseToken)
|
||||
} else {
|
||||
lastError := sanitizeAlertNotificationDispatchError(sendErr)
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='failed',last_error=?,delivered_at=NULL,lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, lastError, item.ID, item.LeaseToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated != 1 {
|
||||
return fmt.Errorf("notification %d lease expired or was replaced", item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeAlertNotificationDispatchError(err error) string {
|
||||
value := strings.Join(strings.Fields(err.Error()), " ")
|
||||
if value == "" {
|
||||
value = "notification gateway failed without details"
|
||||
}
|
||||
if len([]rune(value)) > 500 {
|
||||
value = string([]rune(value)[:500])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type HTTPAlertNotificationGateway struct {
|
||||
endpoint string
|
||||
secret []byte
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPAlertNotificationGateway(endpoint, secret string, timeout time.Duration) (*HTTPAlertNotificationGateway, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
secret = strings.TrimSpace(secret)
|
||||
if endpoint == "" || secret == "" {
|
||||
return nil, fmt.Errorf("notification gateway endpoint and signing secret are required")
|
||||
}
|
||||
if timeout < time.Second {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if timeout > 30*time.Second {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &HTTPAlertNotificationGateway{endpoint: endpoint, secret: []byte(secret), client: &http.Client{Timeout: timeout}, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (g *HTTPAlertNotificationGateway) Send(ctx context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
body, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(g.now().UnixMilli(), 10)
|
||||
mac := hmac.New(sha256.New, g.secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("\n"))
|
||||
_, _ = mac.Write(body)
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Lingniu-Timestamp", timestamp)
|
||||
request.Header.Set("X-Lingniu-Signature", "sha256="+signature)
|
||||
request.Header.Set("X-Lingniu-Notification-ID", strconv.FormatInt(item.ID, 10))
|
||||
request.Header.Set("X-Lingniu-Idempotency-Key", fmt.Sprintf("notification:%d:attempt:%d", item.ID, item.AttemptCount))
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
if readErr != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway response read failed: %w", readErr)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
detail := strings.TrimSpace(string(responseBody))
|
||||
if len(detail) > 200 {
|
||||
detail = detail[:200]
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway returned HTTP %d%s", response.StatusCode, map[bool]string{true: ": " + detail, false: ""}[detail != ""])
|
||||
}
|
||||
var payload struct {
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &payload)
|
||||
if payload.MessageID == "" {
|
||||
payload.MessageID = response.Header.Get("X-Provider-Message-ID")
|
||||
}
|
||||
payload.MessageID = strings.TrimSpace(payload.MessageID)
|
||||
if payload.MessageID == "" {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway accepted the request without a message id")
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{MessageID: payload.MessageID}, nil
|
||||
}
|
||||
|
||||
var _ alertNotificationDispatchStore = (*ProductionStore)(nil)
|
||||
@@ -0,0 +1,133 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertNotificationDispatchStoreStub struct {
|
||||
items []AlertNotificationDispatchItem
|
||||
claimedWith []string
|
||||
completed []AlertNotificationDispatchItem
|
||||
sendErrors []error
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) ClaimAlertNotifications(_ context.Context, token string, channels []string, _ int, _ time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
s.claimedWith = append([]string(nil), channels...)
|
||||
items := make([]AlertNotificationDispatchItem, len(s.items))
|
||||
copy(items, s.items)
|
||||
for index := range items {
|
||||
items[index].LeaseToken = token
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) CompleteAlertNotificationDispatch(_ context.Context, item AlertNotificationDispatchItem, _ AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
s.completed = append(s.completed, item)
|
||||
s.sendErrors = append(s.sendErrors, sendErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
type alertNotificationGatewayStub struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (g alertNotificationGatewayStub) Send(_ context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
return AlertNotificationDispatchReceipt{MessageID: "provider-" + item.EventID}, g.err
|
||||
}
|
||||
|
||||
func TestAlertNotificationDispatcherUsesLeasesAndPersistsRealOutcomes(t *testing.T) {
|
||||
store := &alertNotificationDispatchStoreStub{items: []AlertNotificationDispatchItem{
|
||||
{ID: 11, EventID: "event-11", Channel: "sms", RecipientID: "night-shift", AttemptCount: 1},
|
||||
{ID: 12, EventID: "event-12", Channel: "email", RecipientID: "data-platform", AttemptCount: 2},
|
||||
}}
|
||||
dispatcher := NewAlertNotificationDispatcher(store, map[string]AlertNotificationGateway{
|
||||
"sms": alertNotificationGatewayStub{},
|
||||
"email": alertNotificationGatewayStub{err: errors.New("provider timeout")},
|
||||
}, 20, 30*time.Second, "worker-a")
|
||||
|
||||
result, err := dispatcher.RunOnce(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Claimed != 2 || result.Sent != 1 || result.Failed != 1 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if len(store.completed) != 2 || store.completed[0].LeaseToken == "" || store.completed[1].LeaseToken == "" {
|
||||
t.Fatalf("claimed items must retain their leases: %+v", store.completed)
|
||||
}
|
||||
if store.sendErrors[0] != nil || store.sendErrors[1] == nil {
|
||||
t.Fatalf("real gateway outcomes were not preserved: %+v", store.sendErrors)
|
||||
}
|
||||
if len(store.claimedWith) != 2 || store.claimedWith[0] != "sms" || store.claimedWith[1] != "email" {
|
||||
t.Fatalf("unexpected claimed channels: %+v", store.claimedWith)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewaySignsIdempotentPayload(t *testing.T) {
|
||||
const secret = "notification-secret"
|
||||
now := time.Date(2026, 7, 23, 8, 30, 0, 0, time.UTC)
|
||||
var received AlertNotificationDispatchItem
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(request.Header.Get("X-Lingniu-Timestamp") + "\n"))
|
||||
_, _ = mac.Write(body)
|
||||
if request.Header.Get("X-Lingniu-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
|
||||
t.Errorf("invalid signature: %s", request.Header.Get("X-Lingniu-Signature"))
|
||||
}
|
||||
if request.Header.Get("X-Lingniu-Idempotency-Key") != "notification:42:attempt:2" {
|
||||
t.Errorf("invalid idempotency key: %s", request.Header.Get("X-Lingniu-Idempotency-Key"))
|
||||
}
|
||||
if err := json.Unmarshal(body, &received); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"messageId":"sms-provider-42"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, secret, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateway.now = func() time.Time { return now }
|
||||
receipt, err := gateway.Send(t.Context(), AlertNotificationDispatchItem{
|
||||
ID: 42, EventID: "event-42", Title: "低电量", Content: "SOC 低于 20%", Channel: "sms",
|
||||
RecipientID: "night-shift", Recipient: "夜班负责人", AttemptCount: 2, LeaseToken: "must-not-leak",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.MessageID != "sms-provider-42" || received.RecipientID != "night-shift" || received.LeaseToken != "" {
|
||||
t.Fatalf("unexpected gateway receipt or payload: receipt=%+v payload=%+v", receipt, received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewayRejectsAmbiguousSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, "secret", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = gateway.Send(t.Context(), AlertNotificationDispatchItem{ID: 7, Channel: "sms", RecipientID: "night-shift", AttemptCount: 1})
|
||||
if err == nil {
|
||||
t.Fatal("2xx without provider message id must not be marked sent")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -106,16 +107,20 @@ func buildAlertWhere(query AlertQuery) (string, []any) {
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.status,e.vin,e.plate,e.protocol,
|
||||
const alertSQLTimestampFormat = `%Y-%m-%dT%H:%i:%s.%f+08:00`
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.trigger_type,e.status,e.vin,e.plate,e.protocol,
|
||||
e.metric,e.operator,e.trigger_value,e.threshold_value,e.threshold_high,e.unit,e.duration_sec,e.location_text,e.longitude,e.latitude,
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),COALESCE(DATE_FORMAT(e.received_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),
|
||||
DATE_FORMAT(e.triggered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(e.recovered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),e.handler,e.version
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(e.received_at,'` + alertSQLTimestampFormat + `'),''),
|
||||
DATE_FORMAT(e.triggered_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(e.recovered_at,'` + alertSQLTimestampFormat + `'),''),e.handler,e.version
|
||||
FROM vehicle_alert_event e `
|
||||
|
||||
const alertActionSelect = `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`
|
||||
|
||||
func scanAlertEvent(scanner interface{ Scan(...any) error }) (AlertEvent, error) {
|
||||
var event AlertEvent
|
||||
var longitude, latitude sql.NullFloat64
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.TriggerType, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
&event.Metric, &event.Operator, &event.TriggerValue, &event.Threshold, &event.ThresholdHigh, &event.Unit, &event.DurationSec, &event.Location, &longitude, &latitude,
|
||||
&event.SourceEventID, &event.EventAt, &event.ReceivedAt, &event.TriggeredAt, &event.RecoveredAt, &event.Handler, &event.Version)
|
||||
if longitude.Valid {
|
||||
@@ -164,7 +169,7 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`, id)
|
||||
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
@@ -179,15 +184,73 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
return event, rows.Err()
|
||||
}
|
||||
|
||||
const alertRuleSelect = `SELECT id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_rule `
|
||||
const alertRuleSelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_rule `
|
||||
|
||||
const alertRuleLibrarySelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `'),COALESCE(archived_by,''),COALESCE(DATE_FORMAT(archived_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(archive_reason,'') FROM vehicle_alert_rule `
|
||||
|
||||
const alertNotificationSelect = `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,COALESCE(n.recipient,''),COALESCE(n.recipient_ref,''),n.delivery_status,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(n.provider_message_id,''),n.is_read,DATE_FORMAT(n.created_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(n.delivered_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(n.read_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.last_error,''),COALESCE(DATE_FORMAT(n.last_attempt_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.retry_requested_by,''),COALESCE(DATE_FORMAT(n.retry_requested_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,'') FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `
|
||||
|
||||
const alertNotificationRetryAuditSelect = `SELECT id,notification_id,actor,reason,previous_status,next_status,attempt_count,DATE_FORMAT(requested_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_notification_retry_audit WHERE `
|
||||
|
||||
const alertNotificationMaxAttempts = 3
|
||||
|
||||
type alertRowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanAlertNotification(scanner alertRowScanner) (AlertNotification, error) {
|
||||
var item AlertNotification
|
||||
var deliveryStatus string
|
||||
err := scanner.Scan(
|
||||
&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient,
|
||||
&item.RecipientID, &deliveryStatus, &item.AttemptCount, &item.ProviderMessageID, &item.Read, &item.CreatedAt, &item.DeliveredAt, &item.ReadAt,
|
||||
&item.LastError, &item.LastAttemptAt, &item.RetryRequestedBy, &item.RetryRequestedAt,
|
||||
&item.VehiclePlate, &item.VehicleVIN, &item.Protocol,
|
||||
)
|
||||
if err != nil {
|
||||
return AlertNotification{}, err
|
||||
}
|
||||
switch deliveryStatus {
|
||||
case "failed":
|
||||
item.DeliveryStatus = "failed"
|
||||
case "created", "reserved":
|
||||
item.DeliveryStatus = "queued"
|
||||
default:
|
||||
item.DeliveryStatus = "delivered"
|
||||
}
|
||||
if item.Recipient == "" {
|
||||
if item.Channel == "in_app" {
|
||||
item.Recipient = "运营值班组"
|
||||
} else {
|
||||
item.Recipient = "规则通知目标"
|
||||
}
|
||||
}
|
||||
if item.ProviderMessageID == "" && item.Channel == "in_app" {
|
||||
item.ProviderMessageID = fmt.Sprintf("in_app:%d", item.ID)
|
||||
}
|
||||
if item.DeliveryStatus == "delivered" && item.DeliveredAt == "" {
|
||||
item.DeliveredAt = item.CreatedAt
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = item.DeliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanAlertNotificationRetryAudit(scanner alertRowScanner) (AlertNotificationRetryAudit, error) {
|
||||
var item AlertNotificationRetryAudit
|
||||
err := scanner.Scan(&item.ID, &item.NotificationID, &item.Actor, &item.Reason, &item.PreviousStatus, &item.NextStatus, &item.AttemptCount, &item.RequestedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
@@ -210,15 +273,58 @@ func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
if e := json.Unmarshal([]byte(channels), &rule.NotificationChannels); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
if e := json.Unmarshal([]byte(targets), &rule.NotificationTargets); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if e := normalizeAlertNotificationTargets(&input); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
return rule, err
|
||||
}
|
||||
|
||||
func scanAlertRuleLibrary(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt, &rule.ArchivedBy, &rule.ArchivedAt, &rule.ArchiveReason)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
for _, item := range []struct {
|
||||
raw string
|
||||
target *[]string
|
||||
}{
|
||||
{protocols, &rule.ScopeProtocols}, {vins, &rule.ScopeVINs}, {oems, &rule.ScopeOEMs},
|
||||
{models, &rule.ScopeModels}, {companies, &rule.ScopeCompanies}, {channels, &rule.NotificationChannels},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(item.raw), item.target); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(targets), &rule.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`ORDER BY enabled DESC,severity,name`)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`WHERE archived_at IS NULL ORDER BY enabled DESC,severity,name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -234,39 +340,138 @@ func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, error) {
|
||||
func buildAlertRuleWhere(query AlertRuleQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.Lifecycle == "archived" {
|
||||
where = append(where, "archived_at IS NOT NULL")
|
||||
} else {
|
||||
where = append(where, "archived_at IS NULL")
|
||||
}
|
||||
if query.Status == "enabled" {
|
||||
where = append(where, "enabled=1")
|
||||
} else if query.Status == "disabled" {
|
||||
where = append(where, "enabled=0")
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
where = append(where, "(JSON_LENGTH(scope_protocols_json)=0 OR JSON_CONTAINS(scope_protocols_json,?))")
|
||||
encoded, _ := json.Marshal(query.Protocol)
|
||||
args = append(args, string(encoded))
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(name LIKE ? OR description LIKE ? OR metric LIKE ? OR scope_vins_json LIKE ? OR scope_oems_json LIKE ? OR scope_models_json LIKE ? OR scope_companies_json LIKE ? OR archive_reason LIKE ?)")
|
||||
for range 8 {
|
||||
args = append(args, like)
|
||||
}
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
var summary AlertRuleLibrarySummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=1 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=0 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END),0)
|
||||
FROM vehicle_alert_rule`).Scan(&summary.Current, &summary.Enabled, &summary.Disabled, &summary.Archived); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
where, args := buildAlertRuleWhere(query)
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
order := `enabled DESC,severity,updated_at DESC,id`
|
||||
if query.Lifecycle == "archived" {
|
||||
order = `archived_at DESC,id`
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleLibrarySelect+`WHERE `+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
item, err := scanAlertRuleLibrary(rows)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AlertRulePage{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT rule_id,rule_version,actor,action,COALESCE(reason,''),snapshot_json,DATE_FORMAT(created_at,'`+alertSQLTimestampFormat+`') FROM vehicle_alert_rule_audit WHERE rule_id=? ORDER BY rule_version DESC,id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertRuleRevision{}
|
||||
for rows.Next() {
|
||||
var item AlertRuleRevision
|
||||
var snapshot string
|
||||
if err := rows.Scan(&item.RuleID, &item.Version, &item.Actor, &item.Action, &item.Reason, &snapshot, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(snapshot), &item.Snapshot); err != nil {
|
||||
return nil, fmt.Errorf("parse alert rule revision %d: %w", item.Version, err)
|
||||
}
|
||||
item.Snapshot.ID = item.RuleID
|
||||
item.Snapshot.Version = item.Version
|
||||
item.Snapshot.UpdatedBy = item.Actor
|
||||
item.Snapshot.UpdatedAt = item.CreatedAt
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, string, error) {
|
||||
p, e := json.Marshal(input.ScopeProtocols)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
v, e := json.Marshal(input.ScopeVINs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
o, e := json.Marshal(input.ScopeOEMs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
m, e := json.Marshal(input.ScopeModels)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
co, e := json.Marshal(input.ScopeCompanies)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
c, e := json.Marshal(input.NotificationChannels)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), e
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
t, e := json.Marshal(input.NotificationTargets)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), string(t), e
|
||||
}
|
||||
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,notification_targets_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,trigger_type=?,fence_name=?,fence_longitude=?,fence_latitude=?,fence_radius_m=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,notification_targets_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`
|
||||
|
||||
func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
p, v, o, m, co, c, err := marshalAlertRuleLists(input)
|
||||
p, v, o, m, co, c, t, err := marshalAlertRuleLists(input)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -276,20 +481,24 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current int
|
||||
err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t)
|
||||
var archivedAt sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t, &archivedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
if input.Version != 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.Actor)
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.Actor)
|
||||
current = 0
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
} else {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if current != input.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.ID, current)
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.ID, current)
|
||||
err = e
|
||||
if err == nil {
|
||||
n, _ := res.RowsAffected()
|
||||
@@ -301,8 +510,11 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(input)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, input.ID, current+1, input.Actor, firstNonEmpty(map[bool]string{true: "create", false: "update"}[current == 0], "update"), string(snapshot)); err != nil {
|
||||
auditSnapshot := input
|
||||
auditSnapshot.Version = current + 1
|
||||
snapshot, _ := json.Marshal(auditSnapshot)
|
||||
action := firstNonEmpty(strings.TrimSpace(input.AuditAction), map[bool]string{true: "create", false: "update"}[current == 0], "update")
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, input.ID, current+1, input.Actor, action, "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -320,7 +532,7 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`, update.Enabled, update.Actor, id, update.Version)
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, update.Enabled, update.Actor, id, update.Version)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -341,7 +553,70 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
}
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], string(snapshot)); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var enabled bool
|
||||
var version int
|
||||
var archivedAt sql.NullTime
|
||||
if err = tx.QueryRowContext(ctx, `SELECT enabled,version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, id).Scan(&enabled, &version, &archivedAt); err == sql.ErrNoRows {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, request.Actor, request.Reason, request.Actor, id, version)
|
||||
} else {
|
||||
if !archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NULL,archived_by='',archive_reason='',enabled=0,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NOT NULL`, request.Actor, id, version)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if archived {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
rule, err := scanAlertRuleLibrary(tx.QueryRowContext(ctx, alertRuleLibrarySelect+`WHERE id=?`, id))
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, request.Actor, action, request.Reason, string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -412,23 +687,46 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
where := "channel='in_app'"
|
||||
conditions := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.UnreadOnly {
|
||||
where += " AND is_read=0"
|
||||
conditions = append(conditions, "n.channel='in_app'", "n.is_read=0")
|
||||
}
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
placeholders := make([]string, len(query.AllowedVINs))
|
||||
for index, vin := range query.AllowedVINs {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
conditions = append(conditions, "e.vin IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if query.DeliveryStatus == "failed" {
|
||||
conditions = append(conditions, "n.delivery_status='failed'")
|
||||
} else if query.DeliveryStatus == "queued" {
|
||||
conditions = append(conditions, "n.delivery_status IN ('created','reserved')")
|
||||
} else if query.DeliveryStatus == "delivered" {
|
||||
conditions = append(conditions, "n.delivery_status NOT IN ('created','reserved','failed')")
|
||||
}
|
||||
if query.Search != "" {
|
||||
like := "%" + query.Search + "%"
|
||||
conditions = append(conditions, "(n.title LIKE ? OR n.content LIKE ? OR n.event_id LIKE ? OR e.plate LIKE ? OR e.vin LIKE ? OR e.protocol LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE `+where).Scan(&total); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,title,content,severity,channel,is_read,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(read_at,'%Y-%m-%dT%H:%i:%s.%fZ'),'') FROM vehicle_alert_notification WHERE `+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, query.Limit, query.Offset)
|
||||
queryArgs := append(append([]any{}, args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationSelect+where+` ORDER BY n.created_at DESC,n.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertNotification{}
|
||||
for rows.Next() {
|
||||
var item AlertNotification
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Read, &item.CreatedAt, &item.ReadAt); err != nil {
|
||||
item, err := scanAlertNotification(rows)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -436,6 +734,42 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
return Page[AlertNotification]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT channel,
|
||||
COALESCE(SUM(CASE WHEN delivery_status IN ('created','reserved') THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' AND GREATEST(COALESCE(attempt_count,1),1)>=? THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN lease_token<>'' AND lease_expires_at>NOW(3) THEN 1 ELSE 0 END),0),
|
||||
COALESCE(DATE_FORMAT(MIN(CASE WHEN delivery_status IN ('created','reserved') THEN created_at END),'`+alertSQLTimestampFormat+`'),'')
|
||||
FROM vehicle_alert_notification
|
||||
WHERE channel<>'in_app'
|
||||
GROUP BY channel
|
||||
ORDER BY FIELD(channel,'sms','email','wecom'),channel`, alertNotificationMaxAttempts)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
for rows.Next() {
|
||||
var channel AlertNotificationChannelHealth
|
||||
if err := rows.Scan(&channel.Channel, &channel.Queued, &channel.Failed, &channel.DeadLetter, &channel.ActiveLeases, &channel.OldestQueuedAt); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
health.Queued += channel.Queued
|
||||
health.Failed += channel.Failed
|
||||
health.DeadLetter += channel.DeadLetter
|
||||
health.ActiveLeases += channel.ActiveLeases
|
||||
if channel.OldestQueuedAt != "" && (health.OldestQueuedAt == "" || channel.OldestQueuedAt < health.OldestQueuedAt) {
|
||||
health.OldestQueuedAt = channel.OldestQueuedAt
|
||||
}
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
return health, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return 0, err
|
||||
@@ -447,10 +781,137 @@ func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, reques
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET is_read=1,read_by=?,read_at=CURRENT_TIMESTAMP(3) WHERE channel='in_app' AND is_read=0 AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
||||
query := `UPDATE vehicle_alert_notification n`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
query += ` JOIN vehicle_alert_event e ON e.id=n.event_id`
|
||||
}
|
||||
query += ` SET n.is_read=1,n.read_by=?,n.read_at=CURRENT_TIMESTAMP(3) WHERE n.channel='in_app' AND n.is_read=0 AND n.id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
vinPlaceholders := make([]string, len(request.AllowedVINs))
|
||||
for index, vin := range request.AllowedVINs {
|
||||
vinPlaceholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
query += ` AND e.vin IN (` + strings.Join(vinPlaceholders, ",") + `)`
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) alertNotificationByID(ctx context.Context, id int64) (AlertNotification, error) {
|
||||
item, err := scanAlertNotification(s.db.QueryRowContext(ctx, alertNotificationSelect+`n.id=?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotification{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationRetryAuditSelect+`notification_id=? ORDER BY requested_at DESC,id DESC LIMIT 20`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanAlertNotificationRetryAudit(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var channel, status string
|
||||
var attemptCount int
|
||||
err = tx.QueryRowContext(ctx, `SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`, id).Scan(&channel, &status, &attemptCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
|
||||
existing, existingErr := scanAlertNotificationRetryAudit(tx.QueryRowContext(ctx, alertNotificationRetryAuditSelect+`idempotency_key=?`, request.IdempotencyKey))
|
||||
if existingErr == nil {
|
||||
if existing.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, loadErr := s.alertNotificationByID(ctx, id)
|
||||
if loadErr != nil {
|
||||
return AlertNotificationRetryResult{}, loadErr
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: existing, Idempotent: true}, nil
|
||||
}
|
||||
if !errors.Is(existingErr, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, existingErr
|
||||
}
|
||||
if status != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
|
||||
nextStatus := "reserved"
|
||||
if channel == "in_app" {
|
||||
nextStatus = "sent"
|
||||
}
|
||||
nextAttempt := attemptCount + 1
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`,
|
||||
id, request.IdempotencyKey, request.Actor, request.Reason, status, nextStatus, nextAttempt)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
auditID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if channel == "in_app" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',attempt_count=?,provider_message_id=CONCAT('in_app:',id,':retry:',?),delivered_at=CURRENT_TIMESTAMP(3),last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, nextAttempt, request.Actor, id)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, request.Actor, id)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, err := s.alertNotificationByID(ctx, id)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
receipt := AlertNotificationRetryAudit{
|
||||
ID: auditID, NotificationID: id, Actor: request.Actor, Reason: request.Reason,
|
||||
PreviousStatus: status, NextStatus: nextStatus, AttemptCount: nextAttempt,
|
||||
RequestedAt: notification.RetryRequestedAt,
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: receipt}, nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
value, supported := alertStreamRuleMetricValue(rule, item, record, mappings)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -75,22 +75,15 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
observedAt = record.ReceivedAt
|
||||
}
|
||||
fingerprint := rule.ID + "|" + record.VIN + "|" + record.Protocol
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
stateUpserts[fingerprint] = alertRuleStateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol, LastValue: normalized, ObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -125,12 +118,12 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
return result, err
|
||||
}
|
||||
unit := alertMetricUnit(rule.Metric)
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -313,7 +306,7 @@ func loadAlertStreamLastTriggered(ctx context.Context, tx *sql.Tx, rules []Alert
|
||||
}
|
||||
|
||||
func loadActiveAlertStreamRules(ctx context.Context, tx *sql.Tx) ([]AlertRule, error) {
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,8 +379,8 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A
|
||||
}
|
||||
|
||||
func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMetadata, mappings alertStreamMetricMapping) alertEvaluationEvidence {
|
||||
longitude, _ := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, _ := alertStreamLocationValue("latitude", record, mappings)
|
||||
longitude, longitudeOK := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, latitudeOK := alertStreamLocationValue("latitude", record, mappings)
|
||||
plate := firstNonEmpty(record.Plate, metadata.Plate)
|
||||
return alertEvaluationEvidence{
|
||||
VIN: record.VIN, Plate: plate, Protocol: record.Protocol, OEM: metadata.OEM, Model: metadata.Model, Company: metadata.Company,
|
||||
@@ -395,10 +388,18 @@ func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMe
|
||||
EventAt: record.EventAt.Format("2006-01-02 15:04:05.999"),
|
||||
ReceivedAt: record.ReceivedAt.Format("2006-01-02 15:04:05.999"),
|
||||
Longitude: longitude, Latitude: latitude,
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
HasLocation: longitudeOK && latitudeOK && validAlertCoordinate(longitude, latitude),
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
}
|
||||
}
|
||||
|
||||
func alertStreamRuleMetricValue(rule AlertRule, item alertEvaluationEvidence, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
return alertRuleMetricValue(rule, item)
|
||||
}
|
||||
return alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
}
|
||||
|
||||
func alertStreamLocationValue(metric string, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if source := mappings[metric][strings.ToUpper(record.Protocol)]; source != "" && !strings.EqualFold(source, "vehicle_realtime_location."+metric) {
|
||||
return alertStreamRawNumber(record.Fields[source])
|
||||
|
||||
@@ -39,14 +39,16 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
record := AlertStreamRecord{
|
||||
Protocol: "GB32960", EventAt: time.Unix(100, 0), ReceivedAt: time.Unix(130, 0),
|
||||
Fields: map[string]json.RawMessage{
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_percent": json.RawMessage(`0.032`),
|
||||
},
|
||||
}
|
||||
mappings := alertStreamMetricMapping{
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"hydrogen_concentration_percent": {"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"},
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("speed_kmh", record, mappings); !ok || value != 82.5 {
|
||||
t.Fatalf("mapped numeric field = %v,%v", value, ok)
|
||||
@@ -60,6 +62,9 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
if value, ok := alertStreamMetricValue("data_delay_sec", record, mappings); !ok || value != 30 {
|
||||
t.Fatalf("derived delay = %v,%v", value, ok)
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("hydrogen_concentration_percent", record, mappings); !ok || value != 0.032 {
|
||||
t.Fatalf("mapped hydrogen concentration = %v,%v", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStreamStateQueriesAreScopedToBatchRulesVehiclesAndProtocols(t *testing.T) {
|
||||
@@ -86,7 +91,7 @@ func TestActiveAlertStreamCommitsRuleEffectsBeforeCheckpointInSameTransaction(t
|
||||
checkpointQuery := `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_stream_checkpoint`).WithArgs("alert-active", record.Topic, 0, int64(8), int64(8), int64(1), int64(1), int64(0), int64(0), int64(0), record.EventAt, record.ReceivedAt, "evt-7", "", "").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
result, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record})
|
||||
@@ -108,7 +113,7 @@ func TestActiveAlertStreamRollsBackCheckpointWhenRuleEvaluationFails(t *testing.
|
||||
record := AlertStreamRecord{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 9, HighWatermark: 10, Protocol: "JT808", VIN: "VIN1", SourceEventID: "evt-9", EventAt: time.Now(), ReceivedAt: time.Now(), Fields: map[string]json.RawMessage{"jt808.location.speed_kmh": json.RawMessage(`10`)}, Valid: true}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectRollback()
|
||||
if _, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record}); err == nil {
|
||||
t.Fatal("rule evaluation failure must abort checkpoint transaction")
|
||||
|
||||
@@ -43,6 +43,36 @@ func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertSQLTimestampsDeclareShanghaiOffset(t *testing.T) {
|
||||
if alertSQLTimestampFormat != "%Y-%m-%dT%H:%i:%s.%f+08:00" {
|
||||
t.Fatalf("unexpected alert timestamp format %q", alertSQLTimestampFormat)
|
||||
}
|
||||
for name, query := range map[string]string{
|
||||
"event": alertEventSelect,
|
||||
"action": alertActionSelect,
|
||||
"rule": alertRuleSelect,
|
||||
"notification": alertNotificationSelect,
|
||||
} {
|
||||
if !strings.Contains(query, alertSQLTimestampFormat) {
|
||||
t.Fatalf("%s query does not use the alert timestamp format", name)
|
||||
}
|
||||
if strings.Contains(query, "%fZ") {
|
||||
t.Fatalf("%s query still labels local database time as UTC", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVehicleEventPublishesCanonicalContract(t *testing.T) {
|
||||
event := normalizeVehicleEvent(AlertEvent{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "exit", Status: "unprocessed"})
|
||||
if event.EventType != "vehicle.geofence.exited" || event.EventCategory != "geofence" || event.ExecutionState != "pending" {
|
||||
t.Fatalf("unexpected canonical event: %#v", event)
|
||||
}
|
||||
recovered := normalizeVehicleEvent(AlertEvent{TriggerType: "offline", Metric: "freshness_sec", Status: "recovered"})
|
||||
if recovered.EventType != "vehicle.connectivity.offline" || recovered.ExecutionState != "recovered" {
|
||||
t.Fatalf("unexpected recovered event: %#v", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
type configuredMetricStore struct {
|
||||
*MockStore
|
||||
definitions []MetricDefinition
|
||||
@@ -145,9 +175,12 @@ func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{AlertNotificationConfig: AlertNotificationConfig{
|
||||
Targets: []AlertNotificationTargetOption{{ID: "night-shift", Label: "夜班负责人", Channels: []string{"sms"}}},
|
||||
Channels: []AlertNotificationChannelCapability{{Channel: "in_app", Label: "站内信", Configured: true}, {Channel: "sms", Label: "短信", Configured: true}},
|
||||
}})
|
||||
truth := true
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: true})
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationTargets: []AlertNotificationTarget{{Channel: "sms", RecipientID: "night-shift", Label: "夜班负责人"}}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -167,6 +200,167 @@ func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRollbackCreatesRevisionAndPreservesEnabledState(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
rules, err := service.AlertRules(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original AlertRule
|
||||
for _, rule := range rules {
|
||||
if rule.ID == "rule-speeding" {
|
||||
original = rule
|
||||
break
|
||||
}
|
||||
}
|
||||
changed := alertRuleInputFromRule(original)
|
||||
changed.Name = "车辆持续超速"
|
||||
changed.Threshold = 96
|
||||
changed.Actor = "editor-a"
|
||||
updated, err := service.SaveAlertRule(t.Context(), changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := service.SetAlertRuleEnabled(t.Context(), updated.ID, AlertRuleEnabledUpdate{Version: updated.Version, Enabled: false, Actor: "operator-b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := service.RollbackAlertRule(t.Context(), disabled.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: disabled.Version, Actor: "reviewer-c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.Name != original.Name || restored.Threshold != original.Threshold {
|
||||
t.Fatalf("target configuration was not restored: %+v", restored)
|
||||
}
|
||||
if restored.Enabled || restored.Version != disabled.Version+1 {
|
||||
t.Fatalf("rollback must preserve disabled state and create a new version: %+v", restored)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(t.Context(), restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) < 4 || revisions[0].Action != "rollback" || revisions[0].Version != restored.Version || revisions[0].Actor != "reviewer-c" {
|
||||
t.Fatalf("rollback revision missing: %+v", revisions)
|
||||
}
|
||||
_, err = service.RollbackAlertRule(t.Context(), restored.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: restored.Version, Actor: "reviewer-c"})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_ROLLBACK_NO_CHANGES" {
|
||||
t.Fatalf("expected no-op rollback rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleLibraryArchivesAndRestoresDisabledRuleWithAudit(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(t.Context(), Principal{Name: "规则管理员", Username: "rule-admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
current, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "current", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Total != 6 || current.Summary.Current != 6 || current.Summary.Archived != 0 {
|
||||
t.Fatalf("unexpected initial library: %+v", current)
|
||||
}
|
||||
|
||||
archived, err := service.SetAlertRuleArchived(admin, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "安全阈值规则已由新版替代", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == "" || archived.ArchivedBy != "rule-admin" || archived.ArchiveReason == "" || archived.Version != 2 {
|
||||
t.Fatalf("archive receipt incomplete: %+v", archived)
|
||||
}
|
||||
|
||||
activeRules, err := service.AlertRules(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range activeRules {
|
||||
if rule.ID == archived.ID {
|
||||
t.Fatalf("archived rule leaked into active evaluator list: %+v", rule)
|
||||
}
|
||||
}
|
||||
archivePage, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "archived", Keyword: "新版替代", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivePage.Total != 1 || archivePage.Summary.Current != 5 || archivePage.Summary.Archived != 1 || archivePage.Items[0].ID != archived.ID {
|
||||
t.Fatalf("unexpected archive page: %+v", archivePage)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(admin, archived.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "archive" || revisions[0].Reason != "安全阈值规则已由新版替代" {
|
||||
t.Fatalf("archive audit missing: %+v", revisions[0])
|
||||
}
|
||||
|
||||
restored, err := service.SetAlertRuleArchived(admin, archived.ID, false, AlertRuleLifecycleRequest{Version: archived.Version, Reason: "恢复复核通知接收人配置", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ArchivedAt != "" || restored.Enabled || restored.Version != 3 {
|
||||
t.Fatalf("restored rule must return disabled at next version: %+v", restored)
|
||||
}
|
||||
revisions, err = service.AlertRuleRevisions(admin, restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "restore" || revisions[0].Reason != "恢复复核通知接收人配置" {
|
||||
t.Fatalf("restore audit missing: %+v", revisions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleGovernanceRequiresAdministrator(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "规则值班员", Role: "operator", UserType: "operator"})
|
||||
if _, err := service.AlertRulePage(operator, AlertRuleQuery{Limit: 10}); err == nil {
|
||||
t.Fatal("operator must not list the governance rule library")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected library permission error: %v", err)
|
||||
}
|
||||
if _, err := service.SetAlertRuleArchived(operator, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "规则已经由新版替代"}); err == nil {
|
||||
t.Fatal("operator must not archive automation rules")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected archive permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionRoutes(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v2/alerts/rules/rule-speeding/revisions", nil))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"version":2`) {
|
||||
t.Fatalf("revision route status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
rollback := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/rules/rule-speeding/rollback", strings.NewReader(`{"targetVersion":2,"currentVersion":2}`)))
|
||||
if rollback.Code != http.StatusBadRequest || !strings.Contains(rollback.Body.String(), "ALERT_RULE_ROLLBACK_TARGET_INVALID") {
|
||||
t.Fatalf("rollback validation status=%d body=%s", rollback.Code, rollback.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionsParsesAuditSnapshots(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT rule_id,rule_version,actor,action,COALESCE\\(reason,''\\),snapshot_json").WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"rule_id", "rule_version", "actor", "action", "reason", "snapshot_json", "created_at"}).AddRow("rule-1", 3, "admin-a", "update", "", `{"id":"rule-1","name":"超速","severity":"major","valueType":"numeric","metric":"speed_kmh","operator":"gt","threshold":90,"scopeProtocols":["JT808"],"scopeVins":[],"scopeOems":[],"scopeModels":[],"scopeCompanies":[],"notificationChannels":["in_app"],"enabled":true}`, "2026-07-23T10:00:00.000000+08:00"))
|
||||
revisions, err := store.AlertRuleRevisions(t.Context(), "rule-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) != 1 || revisions[0].Snapshot.Version != 3 || revisions[0].Snapshot.Threshold != 90 || revisions[0].Snapshot.UpdatedBy != "admin-a" {
|
||||
t.Fatalf("unexpected revisions: %+v", revisions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
before, err := service.AlertSummary(t.Context(), AlertQuery{})
|
||||
@@ -190,6 +384,139 @@ func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsFiltersTheCompleteRangeBeforePagination(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
store.alertNotifications = append(store.alertNotifications, AlertNotification{
|
||||
ID: 999, EventID: "event-delivery-failed", Title: "夜班短信失败", Content: "短信网关超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", DeliveryStatus: "failed",
|
||||
VehiclePlate: "浙A·失败", VehicleVIN: "VIN-DELIVERY-FAILED", Protocol: "JT808",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
service := NewService(store)
|
||||
|
||||
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "VIN-DELIVERY-FAILED", DeliveryStatus: "failed", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 999 {
|
||||
t.Fatalf("server-wide notification filter mismatch: %+v", page)
|
||||
}
|
||||
delivered, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "短信网关超时", DeliveryStatus: "delivered", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivered.Total != 0 || len(delivered.Items) != 0 {
|
||||
t.Fatalf("failed delivery leaked into delivered filter: %+v", delivered)
|
||||
}
|
||||
if _, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{DeliveryStatus: "unknown", Limit: 20}); err == nil {
|
||||
t.Fatal("invalid delivery status should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationRetryIsPermissionedIdempotentAndAudited(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
||||
request := AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 2,
|
||||
Reason: "已确认短信网关恢复",
|
||||
IdempotencyKey: "notification-retry-10-attempt-3",
|
||||
}
|
||||
result, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Idempotent || result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 3 || result.Notification.RetryRequestedBy != "operator-a" {
|
||||
t.Fatalf("unexpected retry result: %+v", result)
|
||||
}
|
||||
if result.Receipt.NotificationID != 10 || result.Receipt.AttemptCount != 3 || result.Receipt.Reason != request.Reason {
|
||||
t.Fatalf("unexpected retry receipt: %+v", result.Receipt)
|
||||
}
|
||||
repeated, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repeated.Idempotent || repeated.Notification.AttemptCount != 3 || repeated.Receipt.ID != result.Receipt.ID {
|
||||
t.Fatalf("same key must reuse the original retry: %+v", repeated)
|
||||
}
|
||||
audit, err := service.AlertNotificationRetryAudits(operator, 10)
|
||||
if err != nil || len(audit) != 1 || audit[0].ID != result.Receipt.ID {
|
||||
t.Fatalf("retry audit mismatch: %+v err=%v", audit, err)
|
||||
}
|
||||
viewer := WithPrincipal(t.Context(), Principal{Name: "只读审计员", Username: "viewer-a", Role: "viewer", UserType: "operator"})
|
||||
if _, err := service.RetryAlertNotification(viewer, 10, request); err == nil {
|
||||
t.Fatal("read-only role must not retry notifications")
|
||||
}
|
||||
if _, err := service.RetryAlertNotification(operator, 10, AlertNotificationRetryRequest{ExpectedAttemptCount: 2, Reason: "短", IdempotencyKey: "notification-retry-invalid"}); err == nil {
|
||||
t.Fatal("retry reason must be auditable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsScopeCustomerToGrantedVehicles(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
allowed := WithPrincipal(t.Context(), Principal{
|
||||
Name: "客户账号", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LFP23A98V2P012345"},
|
||||
})
|
||||
page, err := service.AlertNotifications(allowed, AlertNotificationQuery{DeliveryStatus: "failed", Limit: 20})
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 10 {
|
||||
t.Fatalf("customer notification scope mismatch: %+v err=%v", page, err)
|
||||
}
|
||||
denied := WithPrincipal(t.Context(), Principal{Name: "无授权客户", Role: "customer", UserType: "customer"})
|
||||
empty, err := service.AlertNotifications(denied, AlertNotificationQuery{Limit: 20})
|
||||
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
|
||||
t.Fatalf("customer without grants must receive an empty scope: %+v err=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionAlertNotificationRetryQueuesOneAuditedAttempt(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"channel", "delivery_status", "attempt_count"}).AddRow("sms", "failed", 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationRetryAuditSelect + `idempotency_key=?`)).
|
||||
WithArgs("notification-retry-42-attempt-2").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "notification_id", "actor", "reason", "previous_status", "next_status", "attempt_count", "requested_at"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`)).
|
||||
WithArgs(int64(42), "notification-retry-42-attempt-2", "operator-a", "短信网关已经恢复", "failed", "reserved", 2).
|
||||
WillReturnResult(sqlmock.NewResult(81, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`)).
|
||||
WithArgs(2, "operator-a", int64(42)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
notificationColumns := []string{"id", "event_id", "title", "content", "severity", "channel", "recipient", "recipient_ref", "delivery_status", "attempt_count", "provider_message_id", "is_read", "created_at", "delivered_at", "read_at", "last_error", "last_attempt_at", "retry_requested_by", "retry_requested_at", "plate", "vin", "protocol"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationSelect + `n.id=?`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows(notificationColumns).AddRow(42, "event-42", "低电量短信", "SOC 低于 20%", "major", "sms", "夜班负责人", "night-shift", "reserved", 2, "", false, "2026-07-23T10:00:00+08:00", "", "", "provider_timeout", "2026-07-23T10:05:00+08:00", "operator-a", "2026-07-23T10:05:00+08:00", "粤A0042", "VIN42", "GB32960"))
|
||||
|
||||
result, err := store.RetryAlertNotification(t.Context(), 42, AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 1,
|
||||
Reason: "短信网关已经恢复",
|
||||
IdempotencyKey: "notification-retry-42-attempt-2",
|
||||
Actor: "operator-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 2 || result.Receipt.ID != 81 || result.Receipt.NextStatus != "reserved" {
|
||||
t.Fatalf("unexpected production retry result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
|
||||
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
|
||||
@@ -228,10 +555,10 @@ func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
|
||||
}
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
|
||||
}
|
||||
}
|
||||
@@ -321,12 +648,12 @@ func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "trigger_type", "fence_name", "fence_longitude", "fence_latitude", "fence_radius_m", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "notification_targets_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "metric", "", 0.0, 0.0, 0.0, "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, `[{"channel":"in_app","recipientId":"platform-operators","label":"平台值班组"}]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", "", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
|
||||
if err != nil {
|
||||
@@ -380,7 +707,7 @@ func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
|
||||
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
|
||||
}
|
||||
if len(args) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
if len(args) != 23 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +735,114 @@ func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleValidationAcceptsStreamMappedHydrogenMetric(t *testing.T) {
|
||||
var hydrogen MetricDefinition
|
||||
for _, definition := range metricDefinitions() {
|
||||
if definition.Key == "hydrogen_concentration_percent" {
|
||||
hydrogen = definition
|
||||
break
|
||||
}
|
||||
}
|
||||
if hydrogen.Key == "" {
|
||||
t.Fatal("hydrogen concentration metric missing from catalog")
|
||||
}
|
||||
input := AlertRuleInput{
|
||||
Name: "氢气浓度报警提示", Severity: "critical", ValueType: "numeric",
|
||||
Metric: hydrogen.Key, Operator: "gt", Threshold: 0.05,
|
||||
}
|
||||
if err := validateAlertRule(input, hydrogen); err != nil {
|
||||
t.Fatalf("stream-mapped hydrogen rule rejected: %v", err)
|
||||
}
|
||||
if unit := alertMetricUnit(hydrogen.Key); unit != "%" {
|
||||
t.Fatalf("hydrogen alert unit=%q want %%", unit)
|
||||
}
|
||||
|
||||
unmapped := hydrogen
|
||||
unmapped.Key = "unmapped_dynamic_metric"
|
||||
unmapped.SourceFields = nil
|
||||
input.Metric = unmapped.Key
|
||||
err := validateAlertRule(input, unmapped)
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_METRIC_UNSUPPORTED" {
|
||||
t.Fatalf("unmapped dynamic metric should remain rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAutomationTriggerValidationAndNormalization(t *testing.T) {
|
||||
geofence := AlertRuleInput{
|
||||
Name: "离开临港停车场", TriggerType: "geofence", FenceName: "临港停车场",
|
||||
FenceLongitude: 121.9, FenceLatitude: 30.9, FenceRadiusM: 500,
|
||||
Severity: "critical", Operator: "exit", RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"},
|
||||
}
|
||||
normalizeAlertRuleTrigger(&geofence)
|
||||
if geofence.Metric != "geofence_distance_m" || geofence.ValueType != "numeric" || geofence.Threshold != 500 {
|
||||
t.Fatalf("geofence normalization incomplete: %+v", geofence)
|
||||
}
|
||||
if err := validateAlertRule(geofence); err != nil {
|
||||
t.Fatalf("valid geofence rejected: %v", err)
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808", "GB32960"}
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("multi-source geofence should be rejected to prevent coordinate drift")
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808"}
|
||||
geofence.FenceRadiusM = 20
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("unsafe geofence radius should be rejected")
|
||||
}
|
||||
|
||||
stationary := AlertRuleInput{Name: "长时间不动", TriggerType: "stationary", Severity: "major", Operator: "lte", Threshold: 1, DurationSec: 1800}
|
||||
normalizeAlertRuleTrigger(&stationary)
|
||||
if err := validateAlertRule(stationary); err != nil {
|
||||
t.Fatalf("valid stationary rule rejected: %v", err)
|
||||
}
|
||||
offline := AlertRuleInput{Name: "长时间离线", TriggerType: "offline", Severity: "major", Operator: "gt", Threshold: 36000}
|
||||
normalizeAlertRuleTrigger(&offline)
|
||||
if err := validateAlertRule(offline); err != nil || offline.RecoveryThreshold != 36000 {
|
||||
t.Fatalf("offline rule did not normalize: rule=%+v err=%v", offline, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeofenceDistanceAndDirectionalTransitions(t *testing.T) {
|
||||
rule := AlertRule{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "enter", FenceLongitude: 121.4737, FenceLatitude: 31.2304, FenceRadiusM: 500}
|
||||
outside := alertEvaluationEvidence{Longitude: 121.49, Latitude: 31.2304, HasLocation: true}
|
||||
inside := alertEvaluationEvidence{Longitude: 121.4738, Latitude: 31.2304, HasLocation: true}
|
||||
outsideDistance, ok := alertRuleMetricValue(rule, outside)
|
||||
if !ok || outsideDistance <= 500 {
|
||||
t.Fatalf("outside distance=%f ok=%t", outsideDistance, ok)
|
||||
}
|
||||
insideDistance, ok := alertRuleMetricValue(rule, inside)
|
||||
if !ok || insideDistance >= 500 {
|
||||
t.Fatalf("inside distance=%f ok=%t", insideDistance, ok)
|
||||
}
|
||||
matched, state, stateful := alertRuleMatches(rule, insideDistance, alertRuleState{LastValue: 0}, true)
|
||||
if !matched || !stateful || state != 1 {
|
||||
t.Fatalf("enter transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
rule.Operator = "exit"
|
||||
matched, state, stateful = alertRuleMatches(rule, outsideDistance, alertRuleState{LastValue: 1}, true)
|
||||
if !matched || !stateful || state != 0 {
|
||||
t.Fatalf("exit transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationPolicySupportsRecordOnlyAndHighPriority(t *testing.T) {
|
||||
if channels := normalizeAlertChannels(nil); len(channels) != 0 {
|
||||
t.Fatalf("record-only automation unexpectedly created channels: %+v", channels)
|
||||
}
|
||||
if channels := normalizeAlertChannels([]string{"sms"}); len(channels) != 2 || channels[0] != "in_app" {
|
||||
t.Fatalf("reserved external channel should retain in-app evidence: %+v", channels)
|
||||
}
|
||||
rule := AlertRule{Name: "车辆离开围栏", TriggerType: "geofence", FenceName: "临港停车场", Operator: "exit", Severity: "critical"}
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "沪A00001F"}
|
||||
if title := alertNotificationTitle(rule); !strings.HasPrefix(title, "【高优先级】") {
|
||||
t.Fatalf("critical notification title=%q", title)
|
||||
}
|
||||
if content := alertNotificationContent(rule, item, 800, "m"); !strings.Contains(content, "离开电子围栏") || !strings.Contains(content, "临港停车场") {
|
||||
t.Fatalf("geofence notification content=%q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
|
||||
definitions := metricDefinitions()
|
||||
for index := range definitions {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const vehicleBusinessRelationSelect = `SELECT
|
||||
s.source_version,
|
||||
CAST(s.vehicle_id AS CHAR),
|
||||
s.vin,
|
||||
s.plate_number,
|
||||
CAST(s.customer_id AS CHAR),
|
||||
s.customer_name,
|
||||
COALESCE(CAST(s.contract_id AS CHAR), ''),
|
||||
s.contract_code,
|
||||
s.project_name,
|
||||
s.department_id,
|
||||
s.department_name,
|
||||
s.responsible_user_id,
|
||||
s.responsible_user_name,
|
||||
s.operation_status,
|
||||
COALESCE(DATE_FORMAT(s.scope_start_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.source_updated_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.published_at, '%Y-%m-%d %H:%i:%s'), '')
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s
|
||||
ON BINARY s.source_version = BINARY st.active_version
|
||||
WHERE st.id = 1 AND BINARY s.vin = BINARY ?
|
||||
LIMIT 1`
|
||||
|
||||
func (s *ProductionStore) ensureBusinessScopeSchema(ctx context.Context) error {
|
||||
return s.businessScopeSchema.ensure(func() error {
|
||||
var tables int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('business_scope_state','business_customer_vehicle_scope')`).Scan(&tables); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables != 2 {
|
||||
return fmt.Errorf("business scope schema unavailable; apply deploy/migrations/012_business_scope_projection.sql and 016_business_scope_dimensions.sql")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessRelation(ctx context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
relation := VehicleBusinessRelation{SourceSystem: "oneos"}
|
||||
err := s.db.QueryRowContext(ctx, vehicleBusinessRelationSelect, vin).Scan(
|
||||
&relation.SourceVersion,
|
||||
&relation.VehicleID,
|
||||
&relation.VIN,
|
||||
&relation.PlateNumber,
|
||||
&relation.CustomerID,
|
||||
&relation.CustomerName,
|
||||
&relation.ContractID,
|
||||
&relation.ContractCode,
|
||||
&relation.ProjectName,
|
||||
&relation.DepartmentID,
|
||||
&relation.DepartmentName,
|
||||
&relation.ResponsibleUserID,
|
||||
&relation.ResponsibleUserName,
|
||||
&relation.OperationStatus,
|
||||
&relation.ScopeStartAt,
|
||||
&relation.SourceUpdatedAt,
|
||||
&relation.PublishedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return VehicleBusinessRelation{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
return relation, true, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessFilters(ctx context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
where := []string{"st.id = 1"}
|
||||
args := []any{}
|
||||
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
s.department_id,s.department_name,s.responsible_user_id,s.responsible_user_name,
|
||||
CAST(s.customer_id AS CHAR),s.customer_name,s.operation_status,s.vin
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
|
||||
WHERE `+strings.Join(where, " AND "), args...)
|
||||
if err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type optionKey struct{ value, label string }
|
||||
departments := map[optionKey]map[string]bool{}
|
||||
responsibleUsers := map[optionKey]map[string]bool{}
|
||||
customers := map[optionKey]map[string]bool{}
|
||||
statuses := map[optionKey]map[string]bool{}
|
||||
add := func(target map[optionKey]map[string]bool, value, label, vin string) {
|
||||
value, label = strings.TrimSpace(value), strings.TrimSpace(label)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if label == "" {
|
||||
label = value
|
||||
}
|
||||
key := optionKey{value: value, label: label}
|
||||
if target[key] == nil {
|
||||
target[key] = map[string]bool{}
|
||||
}
|
||||
target[key][vin] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var departmentID, departmentName, responsibleID, responsibleName, customerID, customerName, status, vin string
|
||||
if err := rows.Scan(&departmentID, &departmentName, &responsibleID, &responsibleName, &customerID, &customerName, &status, &vin); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
add(departments, departmentID, departmentName, vin)
|
||||
add(responsibleUsers, responsibleID, responsibleName, vin)
|
||||
add(customers, customerID, customerName, vin)
|
||||
add(statuses, status, status, vin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
toOptions := func(source map[optionKey]map[string]bool) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for key, vins := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: key.value, Label: key.label, Count: len(vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Label == result[j].Label {
|
||||
return result[i].Value < result[j].Value
|
||||
}
|
||||
return result[i].Label < result[j].Label
|
||||
})
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: toOptions(departments), ResponsibleUsers: toOptions(responsibleUsers),
|
||||
Customers: toOptions(customers), Statuses: toOptions(statuses),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestVehicleBusinessRelationReadsOnlyTheActiveSnapshotAndKeepsBigIntIDsAsStrings(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM information_schema\.tables`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleBusinessRelationSelect)).
|
||||
WithArgs("LTEST000000000001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"source_version", "vehicle_id", "vin", "plate_number", "customer_id", "customer_name",
|
||||
"contract_id", "contract_code", "project_name", "department_id", "department_name",
|
||||
"responsible_user_id", "responsible_user_name", "operation_status", "scope_start_at",
|
||||
"source_updated_at", "published_at",
|
||||
}).AddRow(
|
||||
"oneos-v1:abc", "9223372036854775806", "LTEST000000000001", "粤A12345",
|
||||
"9223372036854775805", "示例客户", "9223372036854775804", "HT-001", "示范项目",
|
||||
"20", "华南运营部", "30", "张经理", "运营中",
|
||||
"2026-07-01 08:00:00", "2026-07-24 08:00:00", "2026-07-24 08:02:00",
|
||||
))
|
||||
|
||||
relation, found, err := store.VehicleBusinessRelation(context.Background(), "LTEST000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || relation.SourceSystem != "oneos" || relation.CustomerName != "示例客户" {
|
||||
t.Fatalf("unexpected business relation: %+v", relation)
|
||||
}
|
||||
if relation.VehicleID != "9223372036854775806" || relation.CustomerID != "9223372036854775805" {
|
||||
t.Fatalf("BIGINT identifiers must remain exact strings: %+v", relation)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleDetailIncludesOneOSBusinessRelation(t *testing.T) {
|
||||
detail, err := NewService(NewMockStore()).VehicleDetail(context.Background(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.BusinessRelation == nil {
|
||||
t.Fatal("vehicle detail omitted the OneOS business relation")
|
||||
}
|
||||
if detail.BusinessRelation.CustomerName != "岭牛示范客户" || detail.BusinessRelation.ContractCode != "HT-2026-001" {
|
||||
t.Fatalf("unexpected business relation: %+v", detail.BusinessRelation)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage/summary", h.handleVehicleCoverageSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicles/business-filters", h.handleVehicleBusinessFilters)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
||||
@@ -43,7 +45,9 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||
@@ -64,21 +68,48 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
|
||||
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/summary", h.handleReconciliationSummary)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/assignees", h.handleReconciliationAssignees)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues", h.handleReconciliationIssues)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/export", h.handleReconciliationIssuesExport)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-actions", h.handleReconciliationBatchAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-assignments", h.handleReconciliationBatchAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-archive", h.handleReconciliationBatchArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-restore", h.handleReconciliationBatchRestore)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/issues/{id}", h.handleReconciliationIssue)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/actions", h.handleReconciliationAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/assignment", h.handleReconciliationAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/archive", h.handleReconciliationArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/restore", h.handleReconciliationRestore)
|
||||
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
|
||||
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
|
||||
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/metrics", h.handleHistoryMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/query", h.handleHistoryData)
|
||||
h.mux.HandleFunc("GET /api/v2/history/series", h.handleHistorySeries)
|
||||
h.mux.HandleFunc("GET /api/v2/history/preferences", h.handleHistoryPreferences)
|
||||
h.mux.HandleFunc("PUT /api/v2/history/preferences", h.handleUpdateHistoryPreferences)
|
||||
h.mux.HandleFunc("POST /api/v2/exports", h.handleCreateHistoryExport)
|
||||
h.mux.HandleFunc("GET /api/v2/exports", h.handleListHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/page", h.handleListHistoryExportsPage)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/preview", h.handlePreviewHistoryExportCleanup)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/audit", h.handleHistoryExportCleanupAudit)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup", h.handleCleanupHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/automation", h.handleHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/cleanup/automation", h.handleUpdateHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/approve", h.handleApproveHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/reject", h.handleRejectHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/retry", h.handleRetryHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/batch", h.handleBatchHistoryExports)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/cancel", h.handleCancelHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/rebuild", h.handleRebuildHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/archive", h.handleArchiveHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/restore", h.handleRestoreHistoryExport)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/{id}/cleanup-protection", h.handleHistoryExportCleanupProtection)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/{id}/download", h.handleDownloadHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/access/summary", h.handleAccessSummary)
|
||||
h.mux.HandleFunc("POST /api/v2/access/vehicles", h.handleAccessVehicles)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities", h.handleAccessUnresolvedIdentities)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities/{id}/claim", h.handleClaimAccessIdentity)
|
||||
h.mux.HandleFunc("GET /api/v2/access/thresholds", h.handleAccessThresholds)
|
||||
h.mux.HandleFunc("PUT /api/v2/access/thresholds", h.handleUpdateAccessThresholds)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/summary", h.handleAlertSummary)
|
||||
@@ -86,11 +117,30 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/events/{id}", h.handleAlertEvent)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/events/{id}/actions", h.handleAlertAction)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules", h.handleAlertRules)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/library", h.handleAlertRuleLibrary)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}/enabled", h.handleAlertRuleEnabled)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/{id}/revisions", h.handleAlertRuleRevisions)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/rollback", h.handleAlertRuleRollback)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/archive", h.handleArchiveAlertRule)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/restore", h.handleRestoreAlertRule)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notification-config", h.handleAlertNotificationConfig)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/health", h.handleAlertNotificationDeliveryHealth)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications", h.handleAlertNotifications)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/{id}/retry", h.handleAlertNotificationRetry)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/{id}/retry-audit", h.handleAlertNotificationRetryAudit)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationConfig(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationDeliveryHealth(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationDeliveryHealth(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,6 +157,29 @@ func (h *Handler) handleReconciliationIssues(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignees(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationAssignees(r.Context(), r.URL.Query().Get("search"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssuesExport(w http.ResponseWriter, r *http.Request) {
|
||||
var query ReconciliationQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
return
|
||||
}
|
||||
file, err := h.service.ExportReconciliationIssues(r.Context(), query)
|
||||
if err != nil {
|
||||
h.write(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="quality-issues.csv"; filename*=UTF-8''`+url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Name", url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Count", strconv.Itoa(file.RowCount))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(file.Content)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssue(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationIssue(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -121,6 +194,67 @@ func (h *Handler) handleReconciliationAction(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.AssignReconciliationIssue(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchAssignReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchUpdateReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetReconciliationIssueArchived(r.Context(), r.PathValue("id"), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationBatchLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchSetReconciliationIssuesArchived(r.Context(), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessUnresolvedIdentityQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -130,6 +264,16 @@ func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *htt
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleClaimAccessIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
var input AccessIdentityClaimInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.ClaimAccessIdentity(r.Context(), r.PathValue("id"), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleProfile(r.Context(), r.PathValue("vin"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -224,6 +368,16 @@ func (h *Handler) handleAlertRules(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
query := AlertRuleQuery{
|
||||
Keyword: r.URL.Query().Get("keyword"), Status: r.URL.Query().Get("status"),
|
||||
Protocol: r.URL.Query().Get("protocol"), Lifecycle: r.URL.Query().Get("lifecycle"),
|
||||
Limit: parsePositive(r.URL.Query().Get("limit"), 10), Offset: parsePositive(r.URL.Query().Get("offset"), 0),
|
||||
}
|
||||
data, err := h.service.AlertRulePage(r.Context(), query)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSaveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var input AlertRuleInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
@@ -247,11 +401,52 @@ func (h *Handler) handleAlertRuleEnabled(w http.ResponseWriter, r *http.Request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertRuleRevisions(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRollback(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleRollbackRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.RollbackAlertRule(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), true, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), false, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 20)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
unread := strings.EqualFold(r.URL.Query().Get("unreadOnly"), "true") || r.URL.Query().Get("unreadOnly") == "1"
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{UnreadOnly: unread, Limit: limit, Offset: offset})
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{
|
||||
UnreadOnly: unread,
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
||||
DeliveryStatus: strings.TrimSpace(r.URL.Query().Get("deliveryStatus")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
@@ -265,6 +460,30 @@ func (h *Handler) handleAlertNotificationsRead(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, map[string]int{"updated": count}, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetry(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
var request AlertNotificationRetryRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryAlertNotification(r.Context(), id, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetryAudit(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
data, err := h.service.AlertNotificationRetryAudits(r.Context(), id)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessSummary(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -324,6 +543,129 @@ func (h *Handler) handleListHistoryExports(w http.ResponseWriter, r *http.Reques
|
||||
h.write(w, r, h.service.ListHistoryExports(r.Context()), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListHistoryExportsPage(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 10)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
h.write(w, r, h.service.ListHistoryExportsPage(r.Context(), HistoryExportQuery{
|
||||
Search: r.URL.Query().Get("search"), Status: r.URL.Query().Get("status"), Scope: r.URL.Query().Get("scope"), OwnerScope: r.URL.Query().Get("ownerScope"), Sort: r.URL.Query().Get("sort"), Limit: limit, Offset: offset,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePreviewHistoryExportCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.PreviewHistoryExportCleanup(r.Context(), HistoryExportCleanupQuery{
|
||||
OlderThanDays: parsePositive(r.URL.Query().Get("olderThanDays"), 180),
|
||||
OwnerScope: r.URL.Query().Get("ownerScope"),
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAudit(r.Context(), parsePositive(r.URL.Query().Get("limit"), 20))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCleanupHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CleanupHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAutomation(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationPolicyRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryExportCleanupAutomation(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleApproveHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ApproveHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRejectHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RejectHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRetryHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryPreferences(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
var input HistoryPreferences
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryPreferences(r.Context(), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCancelHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.CancelHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRebuildHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.RebuildHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), true)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), false)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupProtection(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupProtectionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetHistoryExportCleanupProtection(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBatchHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportBatchRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDownloadHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
path, name, err := h.service.HistoryExportFile(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -401,6 +743,11 @@ func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleBusinessFilters(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleBusinessFilters(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleServiceSummary(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
@@ -487,6 +834,15 @@ func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDailyMileagePost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.DailyMileage(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.MileageSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
@@ -497,6 +853,62 @@ func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.MileageStatistics(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
||||
defer r.Body.Close()
|
||||
var query MileageQuery
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&query); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "里程查询 JSON 解析失败", err.Error(), traceID(r))
|
||||
return MileageQuery{}, false
|
||||
}
|
||||
return query, true
|
||||
}
|
||||
|
||||
func mileageQueryValues(query MileageQuery) url.Values {
|
||||
values := url.Values{}
|
||||
if query.DateFrom != "" {
|
||||
values.Set("dateFrom", query.DateFrom)
|
||||
}
|
||||
if query.DateTo != "" {
|
||||
values.Set("dateTo", query.DateTo)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
values.Set("keyword", query.Keyword)
|
||||
}
|
||||
if len(query.VINs) > 0 {
|
||||
values.Set("vins", strings.Join(query.VINs, ","))
|
||||
}
|
||||
if query.VehicleScope != "" {
|
||||
values.Set("vehicleScope", query.VehicleScope)
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
values.Set("protocol", query.Protocol)
|
||||
}
|
||||
if len(query.Protocols) > 0 {
|
||||
values.Set("protocols", strings.Join(query.Protocols, ","))
|
||||
}
|
||||
if query.Deduplicate {
|
||||
values.Set("deduplicate", "1")
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
values.Set("limit", strconv.Itoa(query.Limit))
|
||||
}
|
||||
if query.Offset > 0 {
|
||||
values.Set("offset", strconv.Itoa(query.Offset))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -29,6 +29,45 @@ func TestHandlerDashboardSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRetriesFailedNotificationAndReturnsPersistentAudit(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
principal := Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"}
|
||||
retry := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/notifications/10/retry", strings.NewReader(`{"expectedAttemptCount":2,"reason":"已确认短信网关恢复","idempotencyKey":"notification-retry-10-attempt-3"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), principal))
|
||||
handler.ServeHTTP(retry, request)
|
||||
if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `"attemptCount":3`) || !strings.Contains(retry.Body.String(), `"nextStatus":"reserved"`) {
|
||||
t.Fatalf("retry status=%d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
audit := httptest.NewRecorder()
|
||||
auditRequest := httptest.NewRequest(http.MethodGet, "/api/v2/alerts/notifications/10/retry-audit", nil)
|
||||
auditRequest = auditRequest.WithContext(WithPrincipal(auditRequest.Context(), principal))
|
||||
handler.ServeHTTP(audit, auditRequest)
|
||||
if audit.Code != http.StatusOK || !strings.Contains(audit.Body.String(), `"reason":"已确认短信网关恢复"`) {
|
||||
t.Fatalf("audit status=%d body=%s", audit.Code, audit.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryCleanupAutomationExposesVersionedApprovalPolicy(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
handler := NewHandler(service)
|
||||
admin := WithPrincipal(context.Background(), Principal{SubjectID: "admin-1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
get := httptest.NewRecorder()
|
||||
handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v2/exports/cleanup/automation", nil).WithContext(admin))
|
||||
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), `"revision":1`) || !strings.Contains(get.Body.String(), `"approvalWindowHours":48`) {
|
||||
t.Fatalf("automation status=%d body=%s", get.Code, get.Body.String())
|
||||
}
|
||||
|
||||
update := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v2/exports/cleanup/automation", strings.NewReader(`{"enabled":true,"olderThanDays":180,"intervalDays":7,"approvalWindowHours":24,"expectedRevision":1}`)).WithContext(admin)
|
||||
handler.ServeHTTP(update, request)
|
||||
if update.Code != http.StatusOK || !strings.Contains(update.Body.String(), `"enabled":true`) || !strings.Contains(update.Body.String(), `"revision":2`) || !strings.Contains(update.Body.String(), `"status":"no_candidates"`) {
|
||||
t.Fatalf("automation update status=%d body=%s", update.Code, update.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
@@ -59,6 +98,111 @@ func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchReviewReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-actions", strings.NewReader(`{
|
||||
"items":[
|
||||
{"id":"reconciliation-demo-position","version":1},
|
||||
{"id":"missing-issue","version":1}
|
||||
],
|
||||
"status":"fixed",
|
||||
"note":"已核对定位设备与原始报文,修复结果通过复测"
|
||||
}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维乙", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch review status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"id":"reconciliation-demo-position"`, `"actor":"运维乙"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch review response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationAssignmentAndOwnerFilter(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/assignment", strings.NewReader(fmt.Sprintf(`{"version":1,"assignee":"定位运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"assignee":"定位运维组"`) || !strings.Contains(response.Body.String(), `"action":"assign"`) {
|
||||
t.Fatalf("assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","owner":"assigned","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"assignee":"定位运维组"`) {
|
||||
t.Fatalf("assigned filter status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchAssignmentReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
dueAt := time.Now().Add(8 * time.Hour).UTC().Format(time.RFC3339)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-assignments", strings.NewReader(fmt.Sprintf(`{"items":[{"id":"reconciliation-demo-position","version":1},{"id":"missing-issue","version":1}],"assignee":"夜班运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "值班主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"assignee":"夜班运维组"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch assignment response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationDirectoryAndFilteredExport(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
directory := httptest.NewRecorder()
|
||||
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/assignees?search=current", nil)
|
||||
directoryRequest = directoryRequest.WithContext(WithPrincipal(directoryRequest.Context(), Principal{Name: "Current Operator", Username: "current", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(directory, directoryRequest)
|
||||
if directory.Code != http.StatusOK || !strings.Contains(directory.Body.String(), `"current":true`) || !strings.Contains(directory.Body.String(), `"name":"Current Operator"`) {
|
||||
t.Fatalf("directory status=%d body=%s", directory.Code, directory.Body.String())
|
||||
}
|
||||
|
||||
exported := httptest.NewRecorder()
|
||||
handler.ServeHTTP(exported, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/export", strings.NewReader(`{"status":"active","owner":"unassigned"}`)))
|
||||
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "text/csv; charset=utf-8" || exported.Header().Get("X-Export-Count") != "1" || !strings.Contains(exported.Body.String(), "多来源实时位置漂移") {
|
||||
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationArchiveAndRestoreExposeAuditReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
admin := Principal{Name: "质量治理管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}
|
||||
|
||||
archive := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/archive", strings.NewReader(`{"version":2,"reason":"已完成周期复核,转入审计归档"}`))
|
||||
handler.ServeHTTP(archive, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if archive.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", archive.Code, archive.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"archivedBy":"质量治理管理员"`, `"archiveReason":"已完成周期复核,转入审计归档"`, `"action":"archive"`, `"version":3`} {
|
||||
if !strings.Contains(archive.Body.String(), expected) {
|
||||
t.Fatalf("archive response missing %s: %s", expected, archive.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"scope":"archived","keyword":"周期复核","status":"all","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"id":"reconciliation-demo-source"`) {
|
||||
t.Fatalf("archive list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
|
||||
restore := httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/restore", strings.NewReader(`{"version":3,"reason":"新的来源证据需要重新核对"}`))
|
||||
handler.ServeHTTP(restore, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if restore.Code != http.StatusOK || !strings.Contains(restore.Body.String(), `"action":"restore"`) || !strings.Contains(restore.Body.String(), `"archivedAt":""`) {
|
||||
t.Fatalf("restore status=%d body=%s", restore.Code, restore.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct {
|
||||
@@ -119,6 +263,29 @@ func TestHandlerV2AccessManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClaimsAccessIdentityWithAuthenticatedActor(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
claim := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities/mock-unresolved-jt808/claim", strings.NewReader(`{"vin":"LNXNEGRR7SR318212","note":"已核对来源注册信息与车辆档案"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "治理管理员", Role: "admin", UserType: "operator"}))
|
||||
handler.ServeHTTP(claim, request)
|
||||
if claim.Code != http.StatusOK {
|
||||
t.Fatalf("claim status=%d body=%s", claim.Code, claim.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"vin":"LNXNEGRR7SR318212"`, `"claimedBy":"治理管理员"`, `"profileComplete":false`, `"auditId":`} {
|
||||
if !strings.Contains(claim.Body.String(), expected) {
|
||||
t.Fatalf("claim response missing %s: %s", expected, claim.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
queue := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queue, httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities", strings.NewReader(`{"limit":20}`)))
|
||||
if queue.Code != http.StatusOK || !strings.Contains(queue.Body.String(), `"total":0`) {
|
||||
t.Fatalf("claimed identity must leave unresolved queue: status=%d body=%s", queue.Code, queue.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2TrackPlayback(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -166,14 +333,46 @@ func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySeriesGrainAndBounds(t *testing.T) {
|
||||
func TestHandlerV2HistoryReturnsPerVehicleQueryReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,NO_HISTORY_VEHICLE&category=location&limit=10", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data HistoryDataResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode history response: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Data.Summary.RequestedVehicleCount != 2 || len(body.Data.Summary.Vehicles) != 2 {
|
||||
t.Fatalf("history query receipt must preserve both requested vehicles: %+v", body.Data.Summary)
|
||||
}
|
||||
if body.Data.Summary.Vehicles[0].Status != "matched" || body.Data.Summary.Vehicles[0].RowCount == 0 {
|
||||
t.Fatalf("known vehicle should expose matched row evidence: %+v", body.Data.Summary.Vehicles[0])
|
||||
}
|
||||
if body.Data.Summary.Vehicles[1].Keyword != "NO_HISTORY_VEHICLE" || body.Data.Summary.Vehicles[1].Status != "no_data" || body.Data.Summary.Vehicles[1].RowCount != 0 {
|
||||
t.Fatalf("empty vehicle should remain visible as a no-data receipt: %+v", body.Data.Summary.Vehicles[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryWindowGrainAndBounds(t *testing.T) {
|
||||
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
|
||||
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
|
||||
}
|
||||
if got := historySeriesGrain(30*24*time.Hour, 240); got != 21600 {
|
||||
t.Fatalf("30d / 240 should use six-hour buckets, got %d", got)
|
||||
}
|
||||
if _, _, _, _, err := historySeriesWindow("2026-06-01", "2026-07-02", time.Now()); err != nil {
|
||||
t.Fatalf("an exact 31-day history window should remain valid: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct{ path, code string }{
|
||||
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
|
||||
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
@@ -403,6 +602,21 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleDetail `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !containsString(body.Data.Sources, "GB32960") || !containsString(body.Data.Sources, "JT808") {
|
||||
t.Fatalf("vehicle detail should expose protocols backed by observed data, got %+v", body.Data.Sources)
|
||||
}
|
||||
if containsString(body.Data.Sources, "YUTONG_MQTT") {
|
||||
t.Fatalf("vehicle detail must not expose an empty canonical slot as an available protocol, got %+v", body.Data.Sources)
|
||||
}
|
||||
if len(body.Data.SourceStatus) < len(canonicalVehicleProtocols) {
|
||||
t.Fatalf("diagnostic source status should retain canonical readiness slots, got %+v", body.Data.SourceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleSourceEvidence(t *testing.T) {
|
||||
@@ -1129,6 +1343,47 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostCarriesLargeArrayQuery(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
handler := NewHandler(NewService(store))
|
||||
vins := make([]string, 250)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
body, err := json.Marshal(MileageQuery{
|
||||
DateFrom: "2026-07-01", DateTo: "2026-07-31", VINs: vins,
|
||||
Protocols: []string{"GB32960", "JT808"}, Deduplicate: true, Limit: 10_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := len(strings.Split(store.lastDailyMileageQuery.Get("vins"), ",")); got != len(vins) {
|
||||
t.Fatalf("daily mileage VIN count = %d, want %d", got, len(vins))
|
||||
}
|
||||
if got := store.lastMileageStatisticsQuery.Get("protocols"); got != "GB32960,JT808" {
|
||||
t.Fatalf("statistics protocols = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostRejectsUnknownJSONField(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/statistics/mileage", bytes.NewBufferString(`{"dateFrom":"2026-07-01","unknown":true}`))
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BAD_JSON") {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
historyExportCleanupAutomationRunLimit = 100
|
||||
historyExportCleanupAutomationAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
allowedHistoryExportCleanupIntervals = map[int]bool{7: true, 14: true, 30: true}
|
||||
allowedHistoryExportCleanupApprovalWindows = map[int]bool{24: true, 48: true, 72: true}
|
||||
)
|
||||
|
||||
type historyExportCleanupAutomationFile struct {
|
||||
Policy HistoryExportCleanupAutomationPolicy `json:"policy"`
|
||||
Runs []HistoryExportCleanupAutomationRun `json:"runs"`
|
||||
}
|
||||
|
||||
type HistoryExportCleanupAutomationTick struct {
|
||||
LockAcquired bool `json:"lockAcquired"`
|
||||
CreatedRunID string `json:"createdRunId,omitempty"`
|
||||
ClaimedRunID string `json:"claimedRunId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func defaultHistoryExportCleanupAutomationPolicy() HistoryExportCleanupAutomationPolicy {
|
||||
return HistoryExportCleanupAutomationPolicy{
|
||||
OlderThanDays: 180,
|
||||
IntervalDays: 7,
|
||||
ApprovalWindowHours: 48,
|
||||
Revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.json")
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationLockPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.lock")
|
||||
}
|
||||
|
||||
func (s *Service) acquireHistoryExportCleanupAutomationLock(nonBlocking bool) (*os.File, error) {
|
||||
if err := os.MkdirAll(s.exportDir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lockFile, err := os.OpenFile(s.historyExportCleanupAutomationLockPath(), os.O_CREATE|os.O_RDWR, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation := syscall.LOCK_EX
|
||||
if nonBlocking {
|
||||
operation |= syscall.LOCK_NB
|
||||
}
|
||||
if err := syscall.Flock(int(lockFile.Fd()), operation); err != nil {
|
||||
_ = lockFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
return lockFile, nil
|
||||
}
|
||||
|
||||
func releaseHistoryExportCleanupAutomationLock(lockFile *os.File) {
|
||||
if lockFile == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
||||
_ = lockFile.Close()
|
||||
}
|
||||
|
||||
func (s *Service) loadHistoryExportCleanupAutomationLocked() (historyExportCleanupAutomationFile, error) {
|
||||
state := historyExportCleanupAutomationFile{Policy: defaultHistoryExportCleanupAutomationPolicy(), Runs: []HistoryExportCleanupAutomationRun{}}
|
||||
contents, err := os.ReadFile(s.historyExportCleanupAutomationPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := json.Unmarshal(contents, &state); err != nil {
|
||||
return historyExportCleanupAutomationFile{}, fmt.Errorf("decode cleanup automation state: %w", err)
|
||||
}
|
||||
if state.Policy.Revision <= 0 {
|
||||
state.Policy = defaultHistoryExportCleanupAutomationPolicy()
|
||||
}
|
||||
if state.Runs == nil {
|
||||
state.Runs = []HistoryExportCleanupAutomationRun{}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistHistoryExportCleanupAutomationLocked(state historyExportCleanupAutomationFile) error {
|
||||
if len(state.Runs) > historyExportCleanupAutomationRunLimit {
|
||||
state.Runs = state.Runs[:historyExportCleanupAutomationRunLimit]
|
||||
}
|
||||
contents, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := s.historyExportCleanupAutomationPath() + ".tmp"
|
||||
if err := os.WriteFile(temporary, contents, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, s.historyExportCleanupAutomationPath())
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationState(state historyExportCleanupAutomationFile, now time.Time) HistoryExportCleanupAutomationState {
|
||||
return HistoryExportCleanupAutomationState{
|
||||
Policy: state.Policy,
|
||||
Runs: append([]HistoryExportCleanupAutomationRun{}, state.Runs...),
|
||||
ServerTime: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HistoryExportCleanupAutomation(ctx context.Context) (HistoryExportCleanupAutomationState, error) {
|
||||
if _, err := historyExportCleanupPrincipal(ctx); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation state: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, time.Now().UTC()), nil
|
||||
}
|
||||
|
||||
func normalizedHistoryExportCleanupAutomationPolicy(request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationPolicyRequest, error) {
|
||||
if !allowedHistoryExportCleanupDays[request.OlderThanDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_WINDOW_INVALID", Message: "自动清理周期仅支持 30、90、180 或 365 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupIntervals[request.IntervalDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_INTERVAL_INVALID", Message: "自动复核频率仅支持每 7、14 或 30 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupApprovalWindows[request.ApprovalWindowHours] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_INVALID", Message: "审批窗口仅支持 24、48 或 72 小时"}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateHistoryExportCleanupAutomation(ctx context.Context, request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
request, err = normalizedHistoryExportCleanupAutomationPolicy(request)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation policy: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
if request.ExpectedRevision != state.Policy.Revision {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REVISION_CONFLICT", Message: "自动清理策略已被其他管理员修改,请刷新后重试"}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
changed := state.Policy.Enabled != request.Enabled ||
|
||||
state.Policy.OlderThanDays != request.OlderThanDays ||
|
||||
state.Policy.IntervalDays != request.IntervalDays ||
|
||||
state.Policy.ApprovalWindowHours != request.ApprovalWindowHours
|
||||
state.Policy.Enabled = request.Enabled
|
||||
state.Policy.OlderThanDays = request.OlderThanDays
|
||||
state.Policy.IntervalDays = request.IntervalDays
|
||||
state.Policy.ApprovalWindowHours = request.ApprovalWindowHours
|
||||
state.Policy.Revision++
|
||||
state.Policy.UpdatedAt = now.Format(time.RFC3339)
|
||||
state.Policy.UpdatedBy = firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
if request.Enabled && changed {
|
||||
state.Policy.NextReviewAt = now.Format(time.RFC3339)
|
||||
}
|
||||
if !request.Enabled {
|
||||
state.Policy.NextReviewAt = ""
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("persist cleanup automation policy: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if request.Enabled {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, "policy-update", 5*time.Minute)
|
||||
}
|
||||
return s.HistoryExportCleanupAutomation(ctx)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunID(now time.Time) string {
|
||||
if identifier, err := randomExportID(); err == nil {
|
||||
return strings.Replace(identifier, "exp_", "cleanup_run_", 1)
|
||||
}
|
||||
return "cleanup_run_" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunFromPreview(policy HistoryExportCleanupAutomationPolicy, preview HistoryExportCleanupPreview, now time.Time) HistoryExportCleanupAutomationRun {
|
||||
status := "awaiting_approval"
|
||||
if preview.PlannedCount == 0 {
|
||||
status = "no_candidates"
|
||||
}
|
||||
run := HistoryExportCleanupAutomationRun{
|
||||
ID: historyExportCleanupAutomationRunID(now), Status: status, Revision: 1, PolicyRevision: policy.Revision,
|
||||
OlderThanDays: preview.OlderThanDays, Cutoff: preview.Cutoff, CandidateCount: preview.CandidateCount,
|
||||
PlannedCount: preview.PlannedCount, ProtectedCount: preview.ProtectedCount, FileCount: preview.FileCount,
|
||||
FileSizeBytes: preview.FileSizeBytes, PreviewToken: preview.PreviewToken, CreatedAt: now.Format(time.RFC3339),
|
||||
ApprovalDeadline: now.Add(time.Duration(policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339),
|
||||
MaxAttempts: historyExportCleanupAutomationAttempts, LastActionAt: now.Format(time.RFC3339), LastActionBy: "scheduler",
|
||||
}
|
||||
if status == "no_candidates" {
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionReason = "本轮没有符合策略且未受保护的归档任务"
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationNextReview(policy HistoryExportCleanupAutomationPolicy, now time.Time) string {
|
||||
next := now.Add(time.Duration(policy.IntervalDays) * 24 * time.Hour)
|
||||
return next.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationDue(value string, now time.Time) bool {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
return err != nil || !parsed.After(now)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRefreshExpired(state *historyExportCleanupAutomationFile, now time.Time) {
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.Status == "awaiting_approval" || run.Status == "needs_review" {
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
run.Status = "expired"
|
||||
run.Revision++
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
run.LastActionReason = "审批窗口已结束,未执行任何清理"
|
||||
}
|
||||
}
|
||||
if run.Status == "running" && historyExportCleanupAutomationDue(run.LeaseExpiresAt, now) {
|
||||
run.Status = "failed"
|
||||
run.Revision++
|
||||
run.Error = "执行实例租约已过期,本轮等待安全恢复"
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
run.NextRetryAt = now.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) automationPreview(now time.Time, olderThanDays int) HistoryExportCleanupPreview {
|
||||
principal := Principal{SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler", Role: "admin", UserType: "admin", AuthProvider: "system"}
|
||||
s.exportsMu.Lock()
|
||||
defer s.exportsMu.Unlock()
|
||||
if s.refreshHistoryExportAvailabilityLocked(now) {
|
||||
_ = s.persistHistoryExportsLocked()
|
||||
}
|
||||
preview, _ := s.historyExportCleanupPreviewLocked(principal, HistoryExportCleanupQuery{OlderThanDays: olderThanDays, OwnerScope: "all"}, now)
|
||||
return preview
|
||||
}
|
||||
|
||||
func (s *Service) claimHistoryExportCleanupAutomationRun(state *historyExportCleanupAutomationFile, workerID string, lease time.Duration, now time.Time) *HistoryExportCleanupAutomationRun {
|
||||
for index := len(state.Runs) - 1; index >= 0; index-- {
|
||||
run := &state.Runs[index]
|
||||
runnable := run.Status == "approved" ||
|
||||
run.Status == "failed" && run.AttemptCount < run.MaxAttempts && historyExportCleanupAutomationDue(run.NextRetryAt, now)
|
||||
if !runnable {
|
||||
continue
|
||||
}
|
||||
run.Status = "running"
|
||||
run.Revision++
|
||||
run.AttemptCount++
|
||||
run.StartedAt = now.Format(time.RFC3339)
|
||||
run.LeaseOwner = workerID + ":" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
run.LeaseExpiresAt = now.Add(lease).Format(time.RFC3339)
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
copy := *run
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RunHistoryExportCleanupAutomationOnce(ctx context.Context, workerID string, lease time.Duration) (HistoryExportCleanupAutomationTick, error) {
|
||||
if strings.TrimSpace(workerID) == "" {
|
||||
workerID = "cleanup-scheduler"
|
||||
}
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(true)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return HistoryExportCleanupAutomationTick{LockAcquired: false}, nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("lock cleanup automation tick: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, err
|
||||
}
|
||||
historyExportCleanupAutomationRefreshExpired(&state, now)
|
||||
tick := HistoryExportCleanupAutomationTick{LockAcquired: true}
|
||||
if state.Policy.Enabled && historyExportCleanupAutomationDue(state.Policy.NextReviewAt, now) {
|
||||
preview := s.automationPreview(now, state.Policy.OlderThanDays)
|
||||
run := historyExportCleanupAutomationRunFromPreview(state.Policy, preview, now)
|
||||
state.Runs = append([]HistoryExportCleanupAutomationRun{run}, state.Runs...)
|
||||
state.Policy.NextReviewAt = historyExportCleanupAutomationNextReview(state.Policy, now)
|
||||
tick.CreatedRunID = run.ID
|
||||
tick.Status = run.Status
|
||||
}
|
||||
claim := s.claimHistoryExportCleanupAutomationRun(&state, workerID, lease, now)
|
||||
if claim != nil {
|
||||
tick.ClaimedRunID = claim.ID
|
||||
tick.Status = claim.Status
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("persist cleanup automation tick: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if claim == nil {
|
||||
return tick, nil
|
||||
}
|
||||
|
||||
schedulerContext := WithPrincipal(ctx, Principal{
|
||||
SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler",
|
||||
Role: "admin", UserType: "admin", AuthProvider: "system",
|
||||
})
|
||||
result, executionErr := s.CleanupHistoryExports(schedulerContext, HistoryExportCleanupRequest{
|
||||
OlderThanDays: claim.OlderThanDays, OwnerScope: "all", PreviewToken: claim.PreviewToken,
|
||||
})
|
||||
if err := s.finishHistoryExportCleanupAutomationRun(claim, result, executionErr); err != nil {
|
||||
return tick, err
|
||||
}
|
||||
return tick, executionErr
|
||||
}
|
||||
|
||||
func (s *Service) finishHistoryExportCleanupAutomationRun(claim *HistoryExportCleanupAutomationRun, result HistoryExportCleanupResult, executionErr error) error {
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock cleanup automation completion: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != claim.ID || run.Status != "running" || run.LeaseOwner != claim.LeaseOwner {
|
||||
continue
|
||||
}
|
||||
run.Revision++
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
if executionErr == nil {
|
||||
run.Status = "completed"
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.CleanupRecordID = result.Record.ID
|
||||
run.CleanedCount = result.Record.Cleaned
|
||||
run.LastActionReason = "审批后的清理批次已完成"
|
||||
} else if clientErr, ok := asClientError(executionErr); ok && clientErr.Code == "EXPORT_CLEANUP_PREVIEW_STALE" {
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Status = "needs_review"
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.ApprovedAt = ""
|
||||
run.ApprovedBy = ""
|
||||
run.ApprovalReason = ""
|
||||
run.AttemptCount = 0
|
||||
run.Error = "候选范围已变化,需要按最新影响重新审批"
|
||||
run.LastActionReason = run.Error
|
||||
} else {
|
||||
run.Status = "failed"
|
||||
run.Error = executionErr.Error()
|
||||
run.LastActionReason = "执行失败,保留审批与候选证据"
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
delay := time.Duration(1<<min(run.AttemptCount-1, 4)) * time.Minute
|
||||
run.NextRetryAt = now.Add(delay).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return s.persistHistoryExportCleanupAutomationLocked(state)
|
||||
}
|
||||
|
||||
func (s *Service) mutateHistoryExportCleanupAutomationRun(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest, action string) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
reason := truncateHistoryPreferenceText(request.Reason, 160)
|
||||
if reason == "" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REASON_REQUIRED", Message: "审批、驳回或恢复执行都需要填写原因"}
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation action: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != strings.TrimSpace(id) {
|
||||
continue
|
||||
}
|
||||
if run.Revision != request.ExpectedRevision {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_CONFLICT", Message: "清理审批已经变化,请刷新后重试"}
|
||||
}
|
||||
switch action {
|
||||
case "approve":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_APPROVABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_EXPIRED", Message: "审批窗口已结束,系统不会执行该批次"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
if preview.PreviewToken != run.PreviewToken {
|
||||
run.Status = "needs_review"
|
||||
run.Revision++
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.Error = "候选范围已变化,请按最新影响再次确认"
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.Error = ""
|
||||
case "reject":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_REJECTABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
run.Status = "rejected"
|
||||
run.RejectedAt = now.Format(time.RFC3339)
|
||||
run.RejectedBy = actor
|
||||
run.RejectionReason = reason
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
case "retry":
|
||||
if run.Status != "failed" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_RETRYABLE", Message: "只有失败批次可以人工恢复执行"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.AttemptCount = 0
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
default:
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_ACTION_INVALID", Message: "不支持的自动清理操作"}
|
||||
}
|
||||
run.Revision++
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_NOT_FOUND", Message: "自动清理批次不存在"}
|
||||
}
|
||||
|
||||
func (s *Service) ApproveHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "approve")
|
||||
}
|
||||
|
||||
func (s *Service) RejectHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "reject")
|
||||
}
|
||||
|
||||
func (s *Service) RetryHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "retry")
|
||||
}
|
||||
|
||||
func (s *Service) StartHistoryExportCleanupAutomation(ctx context.Context) {
|
||||
if !s.runtime.HistoryCleanupAutomation {
|
||||
return
|
||||
}
|
||||
poll := s.runtime.HistoryCleanupPoll
|
||||
if poll < 5*time.Second {
|
||||
poll = time.Minute
|
||||
}
|
||||
if poll > time.Hour {
|
||||
poll = time.Hour
|
||||
}
|
||||
lease := s.runtime.HistoryCleanupLease
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID := firstNonEmpty(strings.TrimSpace(s.runtime.HistoryCleanupWorkerID), "cleanup-scheduler")
|
||||
go func() {
|
||||
ticker := time.NewTicker(poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, workerID, lease)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func addAutomationCleanupCandidate(t *testing.T, service *Service, id string, archivedAt time.Time) {
|
||||
t.Helper()
|
||||
service.exportsMu.Lock()
|
||||
defer service.exportsMu.Unlock()
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: "自动清理候选 " + id, Status: "expired", Format: "csv", Category: "raw",
|
||||
OwnerID: "owner-1", OwnerUsername: "customer-a", CreatedAt: archivedAt.Add(-24 * time.Hour).Format(time.RFC3339),
|
||||
UpdatedAt: archivedAt.Format(time.RFC3339), ArchivedAt: archivedAt.Format(time.RFC3339),
|
||||
RetentionDays: 7, Evidence: "自动清理测试归档",
|
||||
}
|
||||
if err := service.persistHistoryExportsLocked(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRequiresApprovalAndCompletes(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
addAutomationCleanupCandidate(t, service, "automation-old", time.Now().UTC().Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 48, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Policy.Enabled || len(state.Runs) != 1 || state.Runs[0].Status != "awaiting_approval" || state.Runs[0].PlannedCount != 1 {
|
||||
t.Fatalf("saving an enabled policy should schedule a gated review: %+v", state)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "已核对候选、保护例外和文件影响,同意本批执行",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "approved" || state.Runs[0].ApprovedBy != "admin" {
|
||||
t.Fatalf("approval evidence missing: %+v", state.Runs[0])
|
||||
}
|
||||
tick, err := service.RunHistoryExportCleanupAutomationOnce(ctx, "scheduler-a", 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.ClaimedRunID != run.ID {
|
||||
t.Fatalf("approved run was not claimed: %+v", tick)
|
||||
}
|
||||
state, err = service.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "completed" || state.Runs[0].CleanedCount != 1 || state.Runs[0].CleanupRecordID == "" || state.Runs[0].AttemptCount != 1 {
|
||||
t.Fatalf("completed run should retain execution evidence: %+v", state.Runs[0])
|
||||
}
|
||||
if _, exists := service.historyExportJob("automation-old"); exists {
|
||||
t.Fatal("approved automation run should remove the planned archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRefreshesChangedScopeBeforeApproval(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
now := time.Now().UTC()
|
||||
addAutomationCleanupCandidate(t, service, "automation-first", now.Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 24, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
addAutomationCleanupCandidate(t, service, "automation-late", now.Add(-190*24*time.Hour))
|
||||
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "审批前复核自动清理影响",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed := state.Runs[0]
|
||||
if refreshed.Status != "needs_review" || refreshed.PlannedCount != 2 || refreshed.PreviewToken == run.PreviewToken || refreshed.ApprovedAt != "" {
|
||||
t.Fatalf("changed candidates must force a fresh approval: before=%+v after=%+v", run, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationUsesCrossProcessFileLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
lockFile, err := first.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
tick, err := second.RunHistoryExportCleanupAutomationOnce(exportAdminContext(), "scheduler-b", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.LockAcquired {
|
||||
t.Fatalf("second scheduler instance must not enter a locked tick: %+v", tick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationStateSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
addAutomationCleanupCandidate(t, first, "automation-persisted", time.Now().UTC().Add(-365*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := first.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 365, IntervalDays: 14, ApprovalWindowHours: 72, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "cleanup-automation.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloaded, err := second.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Policy.Revision != state.Policy.Revision || len(reloaded.Runs) != 1 || reloaded.Runs[0].ID != state.Runs[0].ID {
|
||||
t.Fatalf("automation state did not survive restart: first=%+v reloaded=%+v", state, reloaded)
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,29 @@ import (
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
accessIdentities []AccessUnresolvedIdentity
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
businessRelations map[string]VehicleBusinessRelation
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertRuleRevisions map[string][]AlertRuleRevision
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
alertNotificationRetryAudits []AlertNotificationRetryAudit
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
nextNotificationRetryAuditID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
}
|
||||
|
||||
func NewMockStore() *MockStore {
|
||||
@@ -40,14 +45,47 @@ func NewMockStore() *MockStore {
|
||||
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
|
||||
}
|
||||
store := &MockStore{
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
accessIdentities: []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}},
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
businessRelations: map[string]VehicleBusinessRelation{
|
||||
"LB9A32A24R0LS1426": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10001",
|
||||
VIN: "LB9A32A24R0LS1426", PlateNumber: "粤AG18312",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", ContractID: "30001", ContractCode: "HT-2026-001",
|
||||
ProjectName: "氢能物流示范项目", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "运营中",
|
||||
ScopeStartAt: "2026-03-01 08:00:00", SourceUpdatedAt: "2026-07-24 08:00:00", PublishedAt: "2026-07-24 08:02:00",
|
||||
},
|
||||
"LNXNEGRR7SR318212": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10002",
|
||||
VIN: "LNXNEGRR7SR318212", PlateNumber: "川AHTWO1",
|
||||
CustomerID: "20002", CustomerName: "成都氢运客户", DepartmentID: "40002", DepartmentName: "业务二部",
|
||||
ResponsibleUserID: "50002", ResponsibleUserName: "李业务", OperationStatus: "运营中",
|
||||
},
|
||||
"LMRKH9AC2R1004087": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10003",
|
||||
VIN: "LMRKH9AC2R1004087", PlateNumber: "豫A88888",
|
||||
CustomerID: "20003", CustomerName: "中原物流客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50003", ResponsibleUserName: "王业务", OperationStatus: "待交付",
|
||||
},
|
||||
"LB9A32A24P0LS1230": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10004",
|
||||
VIN: "LB9A32A24P0LS1230", PlateNumber: "粤AFF7936",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "已停运",
|
||||
},
|
||||
},
|
||||
locations: []RealtimeLocationRow{
|
||||
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
|
||||
{VIN: vehicles[1].VIN, Plate: vehicles[1].Plate, Protocol: vehicles[1].Protocol, Longitude: 104.0668, Latitude: 30.5728, SpeedKmh: 18.3, SOCPercent: 64.8, TotalMileageKm: 119925, LastSeen: vehicles[1].LastSeen},
|
||||
@@ -106,6 +144,11 @@ func (m *MockStore) ReconciliationSummary(_ context.Context, _ int) (Reconciliat
|
||||
rules := map[string]int{}
|
||||
severities := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt != "" {
|
||||
result.Archived++
|
||||
continue
|
||||
}
|
||||
result.Current++
|
||||
switch item.Status {
|
||||
case "pending":
|
||||
result.Active++
|
||||
@@ -137,7 +180,13 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
items := make([]ReconciliationIssue, 0, len(m.reconciliationIssues))
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary), strings.ToLower(query.Keyword)) {
|
||||
if query.Scope == "archived" && item.ArchivedAt == "" {
|
||||
continue
|
||||
}
|
||||
if query.Scope != "archived" && item.ArchivedAt != "" {
|
||||
continue
|
||||
}
|
||||
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary+" "+item.ArchivedBy+" "+item.ArchiveReason), strings.ToLower(query.Keyword)) {
|
||||
continue
|
||||
}
|
||||
if query.RuleCode != "" && query.RuleCode != "all" && item.RuleCode != query.RuleCode {
|
||||
@@ -155,6 +204,27 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
if query.Status != "" && query.Status != "all" && query.Status != "active" && item.Status != query.Status {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "unassigned" && item.Assignee != "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "assigned" && item.Assignee == "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner != "" && query.Owner != "all" && query.Owner != "unassigned" && query.Owner != "assigned" && item.Assignee != query.Owner {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "overdue" && (item.DueAt == "" || item.DueAt >= time.Now().Format("2006-01-02 15:04:05")) {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "due_soon" {
|
||||
if item.DueAt == "" {
|
||||
continue
|
||||
}
|
||||
due, _ := time.Parse("2006-01-02 15:04:05", item.DueAt)
|
||||
if due.Before(time.Now()) || due.After(time.Now().Add(8*time.Hour)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cloned := cloneReconciliationIssue(item)
|
||||
cloned.Actions = nil
|
||||
items = append(items, cloned)
|
||||
@@ -169,6 +239,28 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationAssignees(_ context.Context) ([]ReconciliationAssignee, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
counts := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt == "" && item.Assignee != "" && (item.Status == "pending" || strings.HasPrefix(item.Status, "confirmed_source_")) {
|
||||
counts[item.Assignee]++
|
||||
}
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(counts))
|
||||
for name, count := range counts {
|
||||
items = append(items, ReconciliationAssignee{Name: name, Source: "history", ActiveCount: count})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationIssue(_ context.Context, id string) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
@@ -191,6 +283,9 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
from := item.Status
|
||||
item.Status = request.Status
|
||||
item.ResolutionNote = request.Note
|
||||
@@ -210,6 +305,80 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AssignReconciliationIssue(_ context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
due, err := time.Parse(time.RFC3339, request.DueAt)
|
||||
if strings.TrimSpace(request.Assignee) == "" || err != nil {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_INVALID", Message: "负责人或处理期限无效"}
|
||||
}
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if item.Status != "pending" && !strings.HasPrefix(item.Status, "confirmed_source_") {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
item.Assignee = strings.TrimSpace(request.Assignee)
|
||||
item.AssignedBy = request.Actor
|
||||
item.AssignedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.DueAt = due.UTC().Format("2006-01-02 15:04:05")
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{ID: int64(len(item.Actions) + 1), Action: "assign", Actor: request.Actor, Note: "交接给 " + item.Assignee, CreatedAt: item.AssignedAt})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetReconciliationIssueArchived(_ context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
currentlyArchived := item.ArchivedAt != ""
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if item.Status != "fixed" && item.Status != "no_action" && item.Status != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
item.ArchivedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.ArchivedBy = request.Actor
|
||||
item.ArchiveReason = request.Reason
|
||||
} else {
|
||||
item.ArchivedAt = ""
|
||||
item.ArchivedBy = ""
|
||||
item.ArchiveReason = ""
|
||||
}
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{
|
||||
ID: int64(len(item.Actions) + 1), Action: action, FromStatus: item.Status, ToStatus: item.Status,
|
||||
Actor: request.Actor, Note: request.Reason, CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateReconciliation(_ context.Context) (ReconciliationEvaluationResult, error) {
|
||||
summary, _ := m.ReconciliationSummary(context.Background(), 30)
|
||||
return ReconciliationEvaluationResult{
|
||||
@@ -298,6 +467,11 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil
|
||||
return profile, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessRelation(_ context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
relation, ok := m.businessRelations[vin]
|
||||
return relation, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
if vin != "LB9A32A24R0LS1426" {
|
||||
return VehicleSourceEvidence{
|
||||
@@ -420,15 +594,81 @@ func (m *MockStore) AccessEvidence(context.Context) ([]AccessEvidenceRow, error)
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessUnresolvedIdentities(_ context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
|
||||
items := []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}}
|
||||
if query.Offset >= len(items) {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
m.accessMu.RLock()
|
||||
defer m.accessMu.RUnlock()
|
||||
items := make([]AccessUnresolvedIdentity, 0, len(m.accessIdentities))
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, item := range m.accessIdentities {
|
||||
if query.Protocol != "" && !strings.EqualFold(item.Protocol, query.Protocol) {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(item.IdentifierMasked+" "+item.Plate+" "+item.Manufacturer+" "+item.SourceEndpoint), keyword) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
total := len(items)
|
||||
if query.Offset >= total {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
end := query.Offset + query.Limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: append([]AccessUnresolvedIdentity(nil), items[query.Offset:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ClaimAccessIdentity(_ context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
m.accessMu.Lock()
|
||||
identityIndex := -1
|
||||
var identity AccessUnresolvedIdentity
|
||||
for index, item := range m.accessIdentities {
|
||||
if item.ID == identityID {
|
||||
identityIndex = index
|
||||
identity = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if identityIndex < 0 {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
plate := ""
|
||||
vehicleFound := false
|
||||
for _, vehicle := range m.vehicles {
|
||||
if strings.EqualFold(vehicle.VIN, input.VIN) {
|
||||
vehicleFound = true
|
||||
if plate == "" {
|
||||
plate = vehicle.Plate
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vehicleFound {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
m.accessIdentities = append(m.accessIdentities[:identityIndex], m.accessIdentities[identityIndex+1:]...)
|
||||
m.accessMu.Unlock()
|
||||
|
||||
m.profileMu.RLock()
|
||||
profile, hasProfile := m.profiles[input.VIN]
|
||||
m.profileMu.RUnlock()
|
||||
missing := []string{}
|
||||
checks := []struct{ label, value string }{
|
||||
{"车辆品牌", profile.BrandName}, {"车型", profile.ModelName}, {"车辆类型", profile.VehicleType}, {"所属企业", profile.CompanyName},
|
||||
{"运营状态", profile.OperationStatus}, {"接入服务商", profile.AccessProvider}, {"首次接入", profile.FirstAccessAt},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !hasProfile || strings.TrimSpace(check.value) == "" || (check.label == "运营状态" && strings.EqualFold(check.value, "unknown")) {
|
||||
missing = append(missing, check.label)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identity.ID, Protocol: identity.Protocol, IdentifierMasked: identity.IdentifierMasked,
|
||||
VIN: input.VIN, Plate: plate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: now.Format(time.RFC3339), AuditID: now.UnixNano(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessThresholds(context.Context) (AccessThresholdConfig, error) {
|
||||
@@ -575,7 +815,8 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen)
|
||||
row.ServiceStatus = buildVehicleCoverageServiceStatus(*row)
|
||||
row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row)
|
||||
if keepCoverageRow(*row, query) {
|
||||
relation, hasRelation := m.businessRelations[row.VIN]
|
||||
if matchesBusinessFilters(relation, hasRelation, query) && keepCoverageRow(*row, query) {
|
||||
items = append(items, *row)
|
||||
}
|
||||
}
|
||||
@@ -583,6 +824,75 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func matchesBusinessFilters(relation VehicleBusinessRelation, found bool, query url.Values) bool {
|
||||
filters := []struct {
|
||||
raw string
|
||||
value string
|
||||
}{
|
||||
{query.Get("departmentIds"), relation.DepartmentID},
|
||||
{query.Get("responsibleUserIds"), relation.ResponsibleUserID},
|
||||
{query.Get("customerIds"), relation.CustomerID},
|
||||
{query.Get("operationStatuses"), relation.OperationStatus},
|
||||
}
|
||||
for _, filter := range filters {
|
||||
values := splitCSV(filter.raw)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if !found || !containsString(values, filter.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessFilters(_ context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
scope := map[string]bool{}
|
||||
if values := splitCSV(query.Get("scopeVins")); len(values) > 0 {
|
||||
for _, vin := range values {
|
||||
scope[vin] = true
|
||||
}
|
||||
}
|
||||
type counter struct {
|
||||
label string
|
||||
vins map[string]bool
|
||||
}
|
||||
departments := map[string]*counter{}
|
||||
responsibleUsers := map[string]*counter{}
|
||||
customers := map[string]*counter{}
|
||||
statuses := map[string]*counter{}
|
||||
add := func(target map[string]*counter, value, label, vin string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
if target[value] == nil {
|
||||
target[value] = &counter{label: firstNonEmpty(label, value), vins: map[string]bool{}}
|
||||
}
|
||||
target[value].vins[vin] = true
|
||||
}
|
||||
for vin, relation := range m.businessRelations {
|
||||
if len(scope) > 0 && !scope[vin] {
|
||||
continue
|
||||
}
|
||||
add(departments, relation.DepartmentID, relation.DepartmentName, vin)
|
||||
add(responsibleUsers, relation.ResponsibleUserID, relation.ResponsibleUserName, vin)
|
||||
add(customers, relation.CustomerID, relation.CustomerName, vin)
|
||||
add(statuses, relation.OperationStatus, relation.OperationStatus, vin)
|
||||
}
|
||||
options := func(source map[string]*counter) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for value, item := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: value, Label: item.label, Count: len(item.vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Label < result[j].Label })
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: options(departments), ResponsibleUsers: options(responsibleUsers),
|
||||
Customers: options(customers), Statuses: options(statuses),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
allQuery := copyValues(query)
|
||||
allQuery.Set("limit", "100000")
|
||||
@@ -898,12 +1208,23 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items = append(items, *row)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := strings.ToUpper(firstNonEmpty(items[i].Plate, items[i].VIN))
|
||||
right := strings.ToUpper(firstNonEmpty(items[j].Plate, items[j].VIN))
|
||||
if left == right {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
} else {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
}
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
@@ -1347,20 +1668,37 @@ func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
||||
}
|
||||
|
||||
func filterVehicles(rows []VehicleRow, query url.Values) []VehicleRow {
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Get("keyword")))
|
||||
keywords := vehicleSearchKeywords(query)
|
||||
batch := strings.TrimSpace(query.Get("keywords")) != ""
|
||||
protocol := strings.TrimSpace(query.Get("protocol"))
|
||||
if keyword == "" && protocol == "" {
|
||||
scopeRaw := strings.TrimSpace(query.Get("scopeVins"))
|
||||
scope := map[string]bool{}
|
||||
for _, vin := range splitCSV(scopeRaw) {
|
||||
scope[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if len(keywords) == 0 && protocol == "" && scopeRaw == "" {
|
||||
return rows
|
||||
}
|
||||
return keep(rows, func(row VehicleRow) bool {
|
||||
if scopeRaw != "" && !scope[strings.ToUpper(strings.TrimSpace(row.VIN))] {
|
||||
return false
|
||||
}
|
||||
if protocol != "" && row.Protocol != protocol {
|
||||
return false
|
||||
}
|
||||
if keyword == "" {
|
||||
if len(keywords) == 0 {
|
||||
return true
|
||||
}
|
||||
if batch {
|
||||
for _, keyword := range keywords {
|
||||
if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(row.Phone, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
value := strings.ToLower(row.VIN + row.Plate + row.Phone + row.OEM)
|
||||
return strings.Contains(value, keyword)
|
||||
return strings.Contains(value, strings.ToLower(keywords[0]))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,10 +76,22 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
if strings.TrimSpace(query.Get("keywords")) != "" {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
|
||||
where = append(where, "(v.vin IN ("+placeholders+") OR s.plate IN ("+placeholders+") OR b.vin IN ("+placeholders+") OR b.plate IN ("+placeholders+") OR b.phone IN ("+placeholders+"))")
|
||||
for range 5 {
|
||||
for _, keyword := range keywords {
|
||||
args = append(args, keyword)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
keyword := keywords[0]
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "s.protocol = ?")
|
||||
@@ -148,6 +160,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
|
||||
havingSQL
|
||||
@@ -175,6 +189,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
@@ -257,6 +272,8 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
`FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin ` + havingSQL
|
||||
return SQLQuery{
|
||||
@@ -275,6 +292,14 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func appendBusinessCoverageFilters(where []string, args []any, query url.Values) ([]string, []any) {
|
||||
where, args = appendCSVListFilter(where, args, "bs.department_id", query.Get("departmentIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.responsible_user_id", query.Get("responsibleUserIds"))
|
||||
where, args = appendCSVListFilter(where, args, "CAST(bs.customer_id AS CHAR)", query.Get("customerIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.operation_status", query.Get("operationStatuses"))
|
||||
return where, args
|
||||
}
|
||||
|
||||
func buildVehicleCoverageSetSQL(scope string) (string, []any) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
@@ -449,10 +474,15 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`WHERE stat_date = CURDATE() GROUP BY vin, protocol` +
|
||||
`) m ON m.vin = v.vin AND m.protocol = l.protocol ` +
|
||||
groupSuffixSQL
|
||||
todayMileageOrderExpr := `CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END, ` + orderExpr
|
||||
// Keep one authoritative coordinate source while all protocols are healthy.
|
||||
// Ordering only by updated_at makes the selected point flap whenever protocols
|
||||
// report at different cadences. When every source is stale, recency remains the
|
||||
// safest fallback so an old high-priority source cannot mask newer evidence.
|
||||
vehicleOrderSQL := `MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC`
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC`
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
||||
@@ -476,12 +506,12 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS today_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
|
||||
groupSQL +
|
||||
`ORDER BY MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
|
||||
`ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
|
||||
CountArgs: countArgs,
|
||||
@@ -521,36 +551,57 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
fromSQL := `FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK'
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageProtocolOrder("m.protocol", protocols)
|
||||
selectionOrder = mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, m.protocol ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
|
||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
@@ -584,7 +635,8 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
return SQLQuery{
|
||||
Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` +
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km ` + fromSQL,
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km, ` +
|
||||
`COALESCE(SUM(m.pure_hydrogen_mileage_km), 0) AS total_pure_hydrogen_mileage_km ` + fromSQL,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
@@ -624,28 +676,43 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder := mileageProtocolOrder("m.protocol", protocols)
|
||||
selectionOrder := mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK' ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
|
||||
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||
}
|
||||
|
||||
@@ -729,24 +796,41 @@ func mileageProtocolOrder(column string, protocols []string) string {
|
||||
return strings.Join(parts, " ") + ", " + column + " ASC"
|
||||
}
|
||||
|
||||
// mileageDailySelectionOrder keeps the configured protocol priority among
|
||||
// usable daily facts while preventing a zero-increment source from masking a
|
||||
// positive result reported by another enabled protocol. If every enabled
|
||||
// protocol reports zero, the configured priority still decides the source.
|
||||
func mileageDailySelectionOrder(mileageColumn, protocolColumn string, protocols []string) string {
|
||||
return "CASE WHEN COALESCE(" + mileageColumn + ", 0) > 0 THEN 0 ELSE 1 END, " +
|
||||
mileageProtocolOrder(protocolColumn, protocols)
|
||||
}
|
||||
|
||||
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 0)
|
||||
}
|
||||
|
||||
func appendCSVListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 200)
|
||||
}
|
||||
|
||||
func appendListFilter(where []string, args []any, column string, raw string, maxValues int) ([]string, []any) {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
value := strings.TrimSpace(item)
|
||||
if value == "" || seen[value] || (maxValues > 0 && len(values) >= maxValues) {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
values = append(values, vin)
|
||||
seen[value] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return where, args
|
||||
}
|
||||
placeholders := make([]string, len(values))
|
||||
for index, vin := range values {
|
||||
for index, value := range values {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
args = append(args, value)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
@@ -13,25 +13,29 @@ type VehicleGrant struct {
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleGrants []VehicleGrant `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
SubjectID string `json:"subjectId,omitempty"`
|
||||
SessionID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Role string `json:"role"`
|
||||
UserType string `json:"userType"`
|
||||
CustomerRef string `json:"customerRef,omitempty"`
|
||||
TenantRef string `json:"tenantRef,omitempty"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"-"`
|
||||
VehicleGrants []VehicleGrant `json:"-"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
BusinessScopeLevel string `json:"businessScopeLevel,omitempty"`
|
||||
DepartmentIDs []string `json:"departmentIds,omitempty"`
|
||||
ResponsibleUserID string `json:"responsibleUserId,omitempty"`
|
||||
}
|
||||
|
||||
func (p Principal) Clone() Principal {
|
||||
p.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...)
|
||||
p.DepartmentIDs = append([]string(nil), p.DepartmentIDs...)
|
||||
p.VehicleCount = len(p.VehicleVINs)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ProductionStore struct {
|
||||
accessSchema schemaReadyGate
|
||||
alertSchema schemaReadyGate
|
||||
profileSchema schemaReadyGate
|
||||
businessScopeSchema schemaReadyGate
|
||||
reconciliationSchema schemaReadyGate
|
||||
}
|
||||
|
||||
@@ -628,10 +629,11 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
|
||||
}
|
||||
limit, offset := buildLimitOffset(query)
|
||||
tdQuery := map[string]string{
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"skipCount": query.Get("skipCount"),
|
||||
}
|
||||
if value := strings.TrimSpace(query.Get("dateFrom")); value != "" {
|
||||
tdQuery["dateFrom"] = value
|
||||
@@ -1027,7 +1029,7 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
|
||||
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
||||
built := buildMileageSummarySQL(query)
|
||||
var summary MileageSummary
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm, &summary.TotalPureHydrogenMileageKm); err != nil {
|
||||
return MileageSummary{}, err
|
||||
}
|
||||
if summary.VehicleCount > 0 {
|
||||
@@ -1052,9 +1054,15 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
items := make([]DailyMileageRow, 0)
|
||||
for rows.Next() {
|
||||
var row DailyMileageRow
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.Source); err != nil {
|
||||
var hydrogen sql.NullFloat64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if hydrogen.Valid {
|
||||
consumption := hydrogen.Float64
|
||||
row.HydrogenConsumptionKg = &consumption
|
||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1071,12 +1079,26 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
result := MileageStatistics{
|
||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重)/ vehicle_realtime_location(最新里程表)",
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的总里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||
}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||
&result.VehicleCount,
|
||||
&result.RecordCount,
|
||||
&result.PeriodMileageKm,
|
||||
&result.PeriodPureHydrogenMileageKm,
|
||||
&result.HydrogenDataDays,
|
||||
&result.PeriodHydrogenConsumptionKg,
|
||||
&result.HydrogenMatchedMileageKm,
|
||||
&result.AverageDailyMileageKm,
|
||||
); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
result.PeriodHydrogenConsumptionKg,
|
||||
result.HydrogenMatchedMileageKm,
|
||||
result.HydrogenDataDays,
|
||||
)
|
||||
if result.VehicleCount > 0 {
|
||||
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
|
||||
}
|
||||
@@ -1095,10 +1117,27 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
}
|
||||
for rows.Next() {
|
||||
var point MileageTrendPoint
|
||||
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
|
||||
var hydrogenConsumptionKg float64
|
||||
if err := rows.Scan(
|
||||
&point.Date,
|
||||
&point.MileageKm,
|
||||
&point.PureHydrogenMileageKm,
|
||||
&point.HydrogenDataDays,
|
||||
&hydrogenConsumptionKg,
|
||||
&point.HydrogenMatchedMileageKm,
|
||||
&point.Vehicles,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if point.HydrogenDataDays > 0 {
|
||||
point.HydrogenConsumptionKg = &hydrogenConsumptionKg
|
||||
point.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
hydrogenConsumptionKg,
|
||||
point.HydrogenMatchedMileageKm,
|
||||
point.HydrogenDataDays,
|
||||
)
|
||||
}
|
||||
result.Trend = append(result.Trend, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1128,6 +1167,14 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hydrogenRatePer100Km(consumptionKg, dailyMileageKm float64, dataDays int) *float64 {
|
||||
if dataDays <= 0 || consumptionKg < 0 || dailyMileageKm <= 0 {
|
||||
return nil
|
||||
}
|
||||
rate := consumptionKg * 100 / dailyMileageKm
|
||||
return &rate
|
||||
}
|
||||
|
||||
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
|
||||
@@ -141,6 +141,29 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
consumption float64
|
||||
mileage float64
|
||||
dataDays int
|
||||
}{
|
||||
{name: "no quality-approved days", consumption: 7.3, mileage: 128.4},
|
||||
{name: "no matched daily mileage", consumption: 7.3, dataDays: 2},
|
||||
{name: "negative consumption", consumption: -1, mileage: 128.4, dataDays: 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hydrogenRatePer100Km(test.consumption, test.mileage, test.dataDays); got != nil {
|
||||
t.Fatalf("hydrogen rate = %v, want nil", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTDengineTableNotExistErrorIsRecognized(t *testing.T) {
|
||||
err := errors.New("[0x2603] Fail to get table info, error: Table does not exist")
|
||||
if !isTDengineTableNotExist(err) {
|
||||
|
||||
@@ -28,6 +28,35 @@ func TestBuildVehicleListSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLAppliesBusinessMultiSelects(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"},
|
||||
"customerIds": {"20001,20002"}, "operationStatuses": {"运营中,待交付"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"business_scope_state", "business_customer_vehicle_scope",
|
||||
"bs.department_id IN (?,?)", "bs.responsible_user_id IN (?,?)",
|
||||
"CAST(bs.customer_id AS CHAR) IN (?,?)", "bs.operation_status IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("business filter SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 10 || len(built.CountArgs) != 8 {
|
||||
t.Fatalf("unexpected business filter args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReconciliationWhereSupportsExactOwner(t *testing.T) {
|
||||
where, args := buildReconciliationWhere(ReconciliationQuery{Status: "active", Owner: "定位运维组"})
|
||||
if !strings.Contains(where, "i.assignee=?") || !strings.Contains(where, "i.status IN") {
|
||||
t.Fatalf("exact owner filter missing from where clause: %s", where)
|
||||
}
|
||||
if len(args) != 1 || args[0] != "定位运维组" {
|
||||
t.Fatalf("unexpected owner args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleListSQL(query)
|
||||
@@ -67,6 +96,30 @@ func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLSupportsExactBatchIdentitySearch(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"keywords": {"粤AG18312, LMRKH9AC2R1004087, 粤AG18312"},
|
||||
"limit": {"100"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"v.vin IN (?,?)",
|
||||
"s.plate IN (?,?)",
|
||||
"b.vin IN (?,?)",
|
||||
"b.plate IN (?,?)",
|
||||
"b.phone IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("batch coverage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 12 || len(built.CountArgs) != 10 {
|
||||
t.Fatalf("unexpected batch coverage args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
if built.Args[0] != "粤AG18312" || built.Args[1] != "LMRKH9AC2R1004087" || built.Args[10] != 100 {
|
||||
t.Fatalf("batch coverage args should preserve deduplicated identities: %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
@@ -275,11 +328,16 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
"location_source",
|
||||
"location_conflict",
|
||||
"location_conflict_distance_m",
|
||||
"GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("realtime source arbitration missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
mileageOrder := strings.Index(built.Text, "CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END")
|
||||
if mileageOrder < 0 || strings.Index(built.Text[mileageOrder:], "WHEN 'GB32960' THEN 10") < 0 {
|
||||
t.Fatalf("today mileage must fall through a zero primary-location source before protocol location priority: %s", built.Text)
|
||||
}
|
||||
if len(built.Args) != 8 || built.Args[0] != "JT808" || built.Args[1] != "%粤A%" || built.Args[6] != 10 || built.Args[7] != 20 {
|
||||
t.Fatalf("args = %#v", built.Args)
|
||||
}
|
||||
@@ -288,6 +346,16 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLSupportsStableIdentityOrder(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"sort": {"identity"}, "limit": {"20"}})
|
||||
if !strings.Contains(built.Text, "ORDER BY COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("identity sort should keep mobile list membership and order stable: %s", built.Text)
|
||||
}
|
||||
if strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) IS NULL ASC") {
|
||||
t.Fatalf("identity sort should not reorder the list on every realtime report: %s", built.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
|
||||
for _, text := range []string{built.Text, built.CountText} {
|
||||
@@ -323,6 +391,30 @@ func TestBuildVehicleRealtimeSQLBoundsCopiedPlateSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLPreservesLargeAuthorizedVINScope(t *testing.T) {
|
||||
const authorizedVehicleCount = 1024
|
||||
vins := make([]string, authorizedVehicleCount)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
built := buildVehicleRealtimeSQL(url.Values{
|
||||
"scopeVins": {strings.Join(vins, ",")},
|
||||
"limit": {"10000"},
|
||||
})
|
||||
if len(built.CountArgs) != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN count args = %d, want %d", len(built.CountArgs), authorizedVehicleCount)
|
||||
}
|
||||
if len(built.Args) != authorizedVehicleCount+2 {
|
||||
t.Fatalf("authorized VIN query args = %d, want %d", len(built.Args), authorizedVehicleCount+2)
|
||||
}
|
||||
if built.CountArgs[0] != vins[0] || built.CountArgs[authorizedVehicleCount-1] != vins[authorizedVehicleCount-1] {
|
||||
t.Fatalf("authorized VIN scope was truncated: first=%v last=%v", built.CountArgs[0], built.CountArgs[authorizedVehicleCount-1])
|
||||
}
|
||||
if strings.Count(built.CountText, "?") != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN placeholders = %d, want %d", strings.Count(built.CountText, "?"), authorizedVehicleCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
@@ -390,6 +482,14 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
if strings.Contains(built.Text, "m.first_total_mileage_km") || !strings.Contains(built.Text, "m.latest_total_mileage_km - m.daily_mileage_km") {
|
||||
t.Fatalf("daily mileage must follow the current projection schema and derive its start value: %s", built.Text)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
|
||||
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text)
|
||||
}
|
||||
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
|
||||
t.Fatalf("count SQL = %s", built.CountText)
|
||||
}
|
||||
@@ -468,7 +568,7 @@ func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *te
|
||||
|
||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))", "MAX(h.consumption_kg)", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
@@ -511,6 +611,7 @@ func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
daily := buildDailyMileageSQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"ELSE 999 END, m.protocol ASC",
|
||||
"GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY",
|
||||
@@ -526,6 +627,7 @@ func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
statistics := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"SUBSTRING_INDEX(GROUP_CONCAT",
|
||||
} {
|
||||
@@ -534,7 +636,7 @@ func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if strings.Contains(statistics.Text, "MAX(COALESCE(m.daily_mileage_km, 0))") {
|
||||
t.Fatalf("configured priority must select the preferred source instead of the maximum mileage: %s", statistics.Text)
|
||||
t.Fatalf("configured priority must select the preferred usable source instead of the maximum mileage: %s", statistics.Text)
|
||||
}
|
||||
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
@@ -543,6 +645,24 @@ func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileagePriorityFallsThroughZeroOnlyWhenAnotherSourceMoved(t *testing.T) {
|
||||
query := url.Values{
|
||||
"protocols": {"YUTONG_MQTT,JT808"},
|
||||
"dateFrom": {"2026-07-19"},
|
||||
"dateTo": {"2026-07-19"},
|
||||
"deduplicate": {"1"},
|
||||
}
|
||||
daily := buildDailyMileageSQL(query)
|
||||
effective := strings.Index(daily.Text, "CASE WHEN COALESCE(m.daily_mileage_km, 0) > 0 THEN 0 ELSE 1 END")
|
||||
priority := strings.Index(daily.Text, "CASE m.protocol WHEN 'YUTONG_MQTT' THEN 1 WHEN 'JT808' THEN 2 ELSE 999 END")
|
||||
if effective < 0 || priority < 0 || effective > priority {
|
||||
t.Fatalf("effective mileage must be evaluated before configured protocol priority: %s", daily.Text)
|
||||
}
|
||||
if !strings.Contains(daily.Text, "GROUP BY m.vin, m.stat_date") {
|
||||
t.Fatalf("zero fallback must remain scoped to one vehicle day: %s", daily.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
protocols := parseMileageProtocols("JT808,invalid');DROP TABLE vehicle_daily_mileage;--,JT808,YUTONG_MQTT")
|
||||
if fmt.Sprint(protocols) != "[JT808 YUTONG_MQTT]" {
|
||||
@@ -553,7 +673,7 @@ func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildMileageSummarySQL(query)
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "SUM(m.pure_hydrogen_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
@@ -569,7 +689,18 @@ func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
|
||||
for _, want := range []string{
|
||||
"GROUP BY m.vin, m.stat_date",
|
||||
"MAX(COALESCE(m.daily_mileage_km",
|
||||
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
||||
"MAX(h.consumption_kg)",
|
||||
"COUNT(DISTINCT d.vin)",
|
||||
"SUM(d.daily_mileage_km)",
|
||||
"SUM(d.pure_hydrogen_mileage_km)",
|
||||
"COUNT(d.hydrogen_consumption_kg)",
|
||||
"SUM(d.hydrogen_consumption_kg)",
|
||||
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
|
||||
} {
|
||||
if !strings.Contains(summary.Text, want) {
|
||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||
}
|
||||
@@ -578,7 +709,8 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
t.Fatalf("statistics args = %#v", summary.Args)
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
||||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
|
||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
@@ -621,12 +753,12 @@ func TestNormalizeMileageVINSelection(t *testing.T) {
|
||||
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {
|
||||
t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err)
|
||||
}
|
||||
tooMany := make([]string, 21)
|
||||
tooMany := make([]string, 50_001)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("VIN%03d", index)
|
||||
}
|
||||
if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil {
|
||||
t.Fatal("more than 20 selected vehicles should be rejected")
|
||||
t.Fatal("more than 50,000 selected vehicles should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,6 +798,23 @@ func TestBuildRawFrameSQLCanSkipUnneededCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQLCanSkipUnneededCount(t *testing.T) {
|
||||
for _, deduplicate := range []string{"", "1"} {
|
||||
built := buildDailyMileageSQL(url.Values{
|
||||
"vins": {"VIN001"},
|
||||
"limit": {"20"},
|
||||
"skipCount": {"1"},
|
||||
"deduplicate": {deduplicate},
|
||||
})
|
||||
if built.CountText != "" || len(built.CountArgs) != 0 {
|
||||
t.Fatalf("bounded mileage preview should skip count for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.vin IN (?)") || !strings.Contains(built.Text, "LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("bounded mileage preview should keep exact VIN pagination for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"protocol": "JT808",
|
||||
@@ -692,6 +841,15 @@ func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLCanSkipUnneededCount(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "limit": "20", "skipCount": "1",
|
||||
})
|
||||
if built.CountText != "" || !strings.Contains(built.Text, "LIMIT 20") {
|
||||
t.Fatalf("bounded latest query should skip count: %+v", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLNormalizesDatetimeLocalMinutePrecision(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "dateFrom": "2026-07-14T00:00", "dateTo": "2026-07-14T05:56", "limit": "10",
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReconciliationExportFile struct {
|
||||
Name string
|
||||
Content []byte
|
||||
RowCount int
|
||||
}
|
||||
|
||||
func (s *Service) reconciliationStore() (ReconciliationStore, error) {
|
||||
store, ok := s.store.(ReconciliationStore)
|
||||
if !ok {
|
||||
@@ -31,10 +41,16 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
query.Scope = strings.ToLower(strings.TrimSpace(query.Scope))
|
||||
if query.Scope != "archived" {
|
||||
query.Scope = "current"
|
||||
}
|
||||
query.RuleCode = strings.TrimSpace(query.RuleCode)
|
||||
query.Category = strings.TrimSpace(query.Category)
|
||||
query.Severity = strings.TrimSpace(query.Severity)
|
||||
query.Status = strings.TrimSpace(query.Status)
|
||||
query.Owner = strings.TrimSpace(query.Owner)
|
||||
query.SLA = strings.TrimSpace(query.SLA)
|
||||
if query.Limit <= 0 || query.Limit > 200 {
|
||||
query.Limit = 50
|
||||
}
|
||||
@@ -44,6 +60,256 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return store.ReconciliationIssues(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationAssignees(ctx context.Context, search string) ([]ReconciliationAssignee, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
search = strings.TrimSpace(search)
|
||||
if len([]rune(search)) > 80 {
|
||||
return nil, clientError{Code: "RECONCILIATION_ASSIGNEE_SEARCH_TOO_LONG", Message: "负责人搜索不能超过 80 个字符"}
|
||||
}
|
||||
items, err := store.ReconciliationAssignees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]ReconciliationAssignee, len(items)+1)
|
||||
for _, item := range items {
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok {
|
||||
name := strings.TrimSpace(principal.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
if name != "" {
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "current"}
|
||||
}
|
||||
if item.Username == "" {
|
||||
item.Username = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
item.Current = true
|
||||
byName[key] = item
|
||||
}
|
||||
}
|
||||
needle := strings.ToLower(search)
|
||||
result := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
if needle != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Username), needle) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Current != result[j].Current {
|
||||
return result[i].Current
|
||||
}
|
||||
if result[i].ActiveCount != result[j].ActiveCount {
|
||||
return result[i].ActiveCount > result[j].ActiveCount
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportReconciliationIssues(ctx context.Context, query ReconciliationQuery) (ReconciliationExportFile, error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 200
|
||||
first, err := s.ReconciliationIssues(ctx, query)
|
||||
if err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
if first.Total > reconciliationFindingLimit {
|
||||
return ReconciliationExportFile{}, clientError{Code: "RECONCILIATION_EXPORT_TOO_LARGE", Message: "当前筛选超过 50,000 条,请缩小范围后导出"}
|
||||
}
|
||||
items := append([]ReconciliationIssue(nil), first.Items...)
|
||||
for offset := len(first.Items); offset < first.Total; offset += query.Limit {
|
||||
query.Offset = offset
|
||||
page, pageErr := s.ReconciliationIssues(ctx, query)
|
||||
if pageErr != nil {
|
||||
return ReconciliationExportFile{}, pageErr
|
||||
}
|
||||
items = append(items, page.Items...)
|
||||
if len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"差异编号", "等级", "状态", "规则", "问题", "VIN", "车牌", "来源 A", "来源 B", "负责人", "处理期限", "首次发现", "最近发现", "命中次数", "结论说明", "处置人", "归档时间", "归档人", "归档原因", "版本"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{
|
||||
reconciliationCSVCell(item.ID), reconciliationCSVCell(reconciliationSeverityLabel(item.Severity)), reconciliationCSVCell(reconciliationStatusLabel(item.Status)),
|
||||
reconciliationCSVCell(item.RuleCode), reconciliationCSVCell(item.Title), reconciliationCSVCell(item.VIN), reconciliationCSVCell(item.Plate),
|
||||
reconciliationCSVCell(item.ProtocolA), reconciliationCSVCell(item.ProtocolB), reconciliationCSVCell(item.Assignee), reconciliationCSVCell(item.DueAt),
|
||||
reconciliationCSVCell(item.FirstSeenAt), reconciliationCSVCell(item.LastSeenAt), fmt.Sprintf("%d", item.OccurrenceCount),
|
||||
reconciliationCSVCell(item.ResolutionNote), reconciliationCSVCell(item.ResolvedBy),
|
||||
reconciliationCSVCell(item.ArchivedAt), reconciliationCSVCell(item.ArchivedBy), reconciliationCSVCell(item.ArchiveReason),
|
||||
fmt.Sprintf("%d", item.Version),
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
return ReconciliationExportFile{
|
||||
Name: fmt.Sprintf("质量差异_%s.csv", time.Now().Format("20060102_150405")),
|
||||
Content: buffer.Bytes(),
|
||||
RowCount: len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func reconciliationCSVCell(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && strings.ContainsRune("=+-@", rune(value[0])) {
|
||||
return "'" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationSeverityLabel(value string) string {
|
||||
if label := map[string]string{"critical": "严重", "major": "重要", "minor": "一般"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationStatusLabel(value string) string {
|
||||
if label := map[string]string{"pending": "待处理", "confirmed_source_a": "确认来源 A", "confirmed_source_b": "确认来源 B", "no_action": "无需处理", "fixed": "已修复", "recovered": "已恢复"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeReconciliationLifecycleRequest(ctx context.Context, request ReconciliationLifecycleRequest) (ReconciliationLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "RECONCILIATION_VERSION_REQUIRED", Message: "差异记录版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 500 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 500 字"}
|
||||
}
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return request, clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以整理差异审计归档"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
normalized, err := normalizeReconciliationLifecycleRequest(ctx, request)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.SetReconciliationIssueArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) BatchSetReconciliationIssuesArchived(ctx context.Context, archived bool, request ReconciliationBatchLifecycleRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多整理 20 条差异记录"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量整理中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量整理不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.SetReconciliationIssueArchived(ctx, item.ID, archived, ReconciliationLifecycleRequest{
|
||||
Version: item.Version,
|
||||
Reason: request.Reason,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "整理失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.AssignReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchAssignReconciliationIssues(ctx context.Context, request ReconciliationBatchAssignmentRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{Requested: len(request.Items), Succeeded: []ReconciliationIssue{}, Skipped: []ReconciliationBatchActionFailure{}}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多交接 20 条差异记录"}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 || seen[id] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_ITEM_INVALID", Message: "批量交接包含无效或重复记录"}
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.AssignReconciliationIssue(ctx, item.ID, ReconciliationAssignmentRequest{Version: item.Version, Assignee: request.Assignee, DueAt: request.DueAt})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "交接失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
@@ -69,6 +335,61 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ
|
||||
return store.UpdateReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchUpdateReconciliationIssues(ctx context.Context, request ReconciliationBatchActionRequest) (ReconciliationBatchActionResult, error) {
|
||||
request.Status = strings.TrimSpace(request.Status)
|
||||
request.Note = strings.TrimSpace(request.Note)
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多处置 20 条差异记录"}
|
||||
}
|
||||
allowed := map[string]bool{"pending": true, "no_action": true, "fixed": true}
|
||||
if !allowed[request.Status] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_STATUS_INVALID", Message: "批量处置仅支持退回待复核、无需处理或已修复"}
|
||||
}
|
||||
if request.Status != "pending" && request.Note == "" {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "批量处置必须填写说明"}
|
||||
}
|
||||
if len([]rune(request.Note)) > 500 {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量处置中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量处置不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.UpdateReconciliationIssue(ctx, strings.TrimSpace(item.ID), ReconciliationActionRequest{
|
||||
Version: item.Version,
|
||||
Status: request.Status,
|
||||
Note: request.Note,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: strings.TrimSpace(item.ID), Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "处置失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code = itemError.Code
|
||||
failure.Message = itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -16,8 +17,11 @@ const reconciliationFindingLimit = 50000
|
||||
type ReconciliationStore interface {
|
||||
ReconciliationSummary(context.Context, int) (ReconciliationSummary, error)
|
||||
ReconciliationIssues(context.Context, ReconciliationQuery) (Page[ReconciliationIssue], error)
|
||||
ReconciliationAssignees(context.Context) ([]ReconciliationAssignee, error)
|
||||
ReconciliationIssue(context.Context, string) (ReconciliationIssue, error)
|
||||
UpdateReconciliationIssue(context.Context, string, ReconciliationActionRequest) (ReconciliationIssue, error)
|
||||
AssignReconciliationIssue(context.Context, string, ReconciliationAssignmentRequest) (ReconciliationIssue, error)
|
||||
SetReconciliationIssueArchived(context.Context, string, bool, ReconciliationLifecycleRequest) (ReconciliationIssue, error)
|
||||
EvaluateReconciliation(context.Context) (ReconciliationEvaluationResult, error)
|
||||
}
|
||||
|
||||
@@ -36,9 +40,10 @@ type reconciliationFinding struct {
|
||||
}
|
||||
|
||||
type reconciliationExisting struct {
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
Archived bool
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
|
||||
@@ -118,26 +123,31 @@ issue_id,action,from_status,to_status,actor,note
|
||||
continue
|
||||
}
|
||||
nextStatus := current.Status
|
||||
if current.Status == "recovered" || current.Status == "fixed" {
|
||||
if current.Archived || current.Status == "recovered" || current.Status == "fixed" {
|
||||
nextStatus = "pending"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
rule_code=?,category=?,severity=?,status=?,vin=?,plate=?,protocol_a=?,protocol_b=?,title=?,summary=?,evidence_json=?,
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,version=version+1
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=?`, finding.RuleCode, finding.Category, finding.Severity, nextStatus, finding.VIN, finding.Plate,
|
||||
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, current.ID); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
if nextStatus != current.Status {
|
||||
if nextStatus != current.Status || current.Archived {
|
||||
note := "已恢复或已修复的差异再次出现"
|
||||
if current.Archived {
|
||||
note = "已归档差异再次出现,自动恢复到当前队列等待复核"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator','已恢复或已修复的差异再次出现')`, current.ID, current.Status, nextStatus); err != nil {
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator',?)`, current.ID, current.Status, nextStatus, note); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for fingerprint, current := range existing {
|
||||
if seen[fingerprint] || !reconciliationAutoRecoverable(current.Status) {
|
||||
if seen[fingerprint] || current.Archived || !reconciliationAutoRecoverable(current.Status) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
@@ -152,7 +162,7 @@ issue_id,action,from_status,to_status,actor,note
|
||||
result.Recovered++
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
ruleCountsJSON, _ := json.Marshal(result.RuleCounts)
|
||||
@@ -181,7 +191,7 @@ func reconciliationFingerprint(finding reconciliationFinding) string {
|
||||
}
|
||||
|
||||
func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]reconciliationExisting, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +200,7 @@ func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]rec
|
||||
for rows.Next() {
|
||||
var fingerprint string
|
||||
var item reconciliationExisting
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version); err != nil {
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version, &item.Archived); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[fingerprint] = item
|
||||
@@ -380,12 +390,15 @@ LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
|
||||
|
||||
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
where := []string{"i.archived_at IS NULL"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
where[0] = "i.archived_at IS NOT NULL"
|
||||
}
|
||||
if value := strings.TrimSpace(query.Keyword); value != "" {
|
||||
like := "%" + value + "%"
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ? OR i.archived_by LIKE ? OR i.archive_reason LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"i.rule_code": query.RuleCode, "i.category": query.Category, "i.severity": query.Severity,
|
||||
@@ -401,13 +414,28 @@ func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where = append(where, "i.status=?")
|
||||
args = append(args, status)
|
||||
}
|
||||
if owner := strings.TrimSpace(query.Owner); owner == "unassigned" {
|
||||
where = append(where, "i.assignee=''")
|
||||
} else if owner == "assigned" {
|
||||
where = append(where, "i.assignee<>''")
|
||||
} else if owner != "" && owner != "all" {
|
||||
where = append(where, "i.assignee=?")
|
||||
args = append(args, owner)
|
||||
}
|
||||
if sla := strings.TrimSpace(query.SLA); sla == "overdue" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at<NOW() AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
} else if sla == "due_soon" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at>=NOW() AND i.due_at<=DATE_ADD(NOW(),INTERVAL 8 HOUR) AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b,
|
||||
i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'),
|
||||
DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count,
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,
|
||||
i.assignee,i.assigned_by,COALESCE(DATE_FORMAT(i.assigned_at,'%Y-%m-%d %H:%i:%s'),''),COALESCE(DATE_FORMAT(i.due_at,'%Y-%m-%d %H:%i:%s'),''),
|
||||
COALESCE(DATE_FORMAT(i.archived_at,'%Y-%m-%d %H:%i:%s'),''),i.archived_by,i.archive_reason,i.version
|
||||
FROM vehicle_reconciliation_issue i `
|
||||
|
||||
func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) {
|
||||
@@ -415,7 +443,9 @@ func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (Reconcili
|
||||
var evidence string
|
||||
err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate,
|
||||
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt,
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version)
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy,
|
||||
&item.Assignee, &item.AssignedBy, &item.AssignedAt, &item.DueAt,
|
||||
&item.ArchivedAt, &item.ArchivedBy, &item.ArchiveReason, &item.Version)
|
||||
if err == nil {
|
||||
item.Evidence = map[string]any{}
|
||||
err = json.Unmarshal([]byte(evidence), &item.Evidence)
|
||||
@@ -439,8 +469,12 @@ func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query Reconc
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
orderBy := "FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC"
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
orderBy = "i.archived_at DESC,i.id DESC"
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+`
|
||||
ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...)
|
||||
ORDER BY `+orderBy+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
@@ -456,6 +490,79 @@ ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id D
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationAssignees(ctx context.Context) ([]ReconciliationAssignee, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]ReconciliationAssignee{}
|
||||
accountRows, err := s.db.QueryContext(ctx, `SELECT display_name,username FROM platform_user
|
||||
WHERE user_type='admin' AND status='enabled' ORDER BY display_name,username LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for accountRows.Next() {
|
||||
var item ReconciliationAssignee
|
||||
if err := accountRows.Scan(&item.Name, &item.Username); err != nil {
|
||||
accountRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Username = strings.TrimSpace(item.Username)
|
||||
item.Source = "account"
|
||||
if item.Name == "" {
|
||||
item.Name = item.Username
|
||||
}
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if err := accountRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
historyRows, err := s.db.QueryContext(ctx, `SELECT assignee,
|
||||
SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),
|
||||
COALESCE(DATE_FORMAT(MAX(assigned_at),'%Y-%m-%d %H:%i:%s'),'')
|
||||
FROM vehicle_reconciliation_issue WHERE assignee<>'' AND archived_at IS NULL GROUP BY assignee
|
||||
ORDER BY MAX(assigned_at) DESC,assignee LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for historyRows.Next() {
|
||||
var name, lastAssignedAt string
|
||||
var activeCount int
|
||||
if err := historyRows.Scan(&name, &activeCount, &lastAssignedAt); err != nil {
|
||||
historyRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "history"}
|
||||
}
|
||||
item.ActiveCount = activeCount
|
||||
item.LastAssignedAt = lastAssignedAt
|
||||
byName[key] = item
|
||||
}
|
||||
if err := historyRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
@@ -511,7 +618,8 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion); err != nil {
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
@@ -520,6 +628,9 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
status=?,resolution_note=?,resolved_by=?,
|
||||
recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END,
|
||||
@@ -543,6 +654,115 @@ issue_id,action,from_status,to_status,actor,note
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
request.Assignee = strings.TrimSpace(request.Assignee)
|
||||
if request.Assignee == "" || len([]rune(request.Assignee)) > 128 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNEE_INVALID", Message: "请输入有效负责人"}
|
||||
}
|
||||
dueAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.DueAt))
|
||||
if err != nil || !dueAt.After(time.Now()) {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_DUE_AT_INVALID", Message: "请选择有效的未来处理期限"}
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if currentStatus != "pending" && currentStatus != "confirmed_source_a" && currentStatus != "confirmed_source_b" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET assignee=?,assigned_by=?,assigned_at=NOW(3),due_at=?,version=version+1 WHERE id=? AND version=?`, request.Assignee, request.Actor, dueAt.UTC(), id, request.Version)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"}
|
||||
}
|
||||
note := fmt.Sprintf("交接给 %s,处理期限 %s", request.Assignee, dueAt.In(time.Local).Format("2006-01-02 15:04"))
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(issue_id,action,from_status,to_status,actor,note) VALUES(?,'assign','','',?,?)`, id, request.Actor, note); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var currentlyArchived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL
|
||||
FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, ¤tlyArchived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if currentStatus != "fixed" && currentStatus != "no_action" && currentStatus != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1
|
||||
WHERE id=? AND version=?`, request.Actor, request.Reason, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=? AND version=?`, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,?,?,?,?,?)`, id, action, currentStatus, currentStatus, request.Actor, request.Reason); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'),
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
|
||||
SUM(new_count),
|
||||
@@ -561,12 +781,14 @@ func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) (
|
||||
}
|
||||
var result ReconciliationSummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='pending'),0),
|
||||
COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='recovered'),0),
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at<DATE_SUB(NOW(),INTERVAL 24 HOUR)),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
COALESCE(SUM(archived_at IS NULL),0),
|
||||
COALESCE(SUM(archived_at IS NOT NULL),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='pending'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='recovered'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b') AND COALESCE(due_at,DATE_ADD(first_seen_at,INTERVAL 24 HOUR))<NOW()),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Current, &result.Archived, &result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
return ReconciliationSummary{}, err
|
||||
}
|
||||
var err error
|
||||
@@ -600,7 +822,7 @@ func (s *ProductionStore) reconciliationBuckets(ctx context.Context, column stri
|
||||
return nil, fmt.Errorf("unsupported reconciliation bucket")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user