Compare commits

24 Commits

Author SHA1 Message Date
lingniu
22f3904ad7 fix(open-platform): align realtime online status across protocols 2026-08-03 21:49:33 +08:00
lingniu
8539af13e6 feat: expose vehicle map data APIs 2026-08-03 19:55:38 +08:00
lingniu
3c4bece72c feat: expand vehicle data platform capabilities 2026-07-27 16:46:15 +08:00
lingniu
e3a1f80f86 docs(ops): record latest mileage reconciliation 2026-07-20 11:29:18 +08:00
lingniu
5a3b2e188e feat(web): refine semi application shell 2026-07-20 11:14:19 +08:00
lingniu
9d9185790f fix(web): harden monitor protocol list 2026-07-20 10:54:10 +08:00
lingniu
ddbba18431 docs(ops): record latest mileage backfill 2026-07-20 10:00:09 +08:00
lingniu
492c48cf47 docs(ops): record latest mileage reconciliation 2026-07-20 09:25:02 +08:00
lingniu
70211e1d7c feat(web): condense mobile protocol health 2026-07-20 09:12:28 +08:00
lingniu
ebf682d863 fix(web): align alert queue table 2026-07-20 09:00:38 +08:00
lingniu
6574b49ff9 fix(mileage): reconcile track and daily semantics 2026-07-20 08:52:27 +08:00
lingniu
6245a9b979 feat(web): refine mileage summary hierarchy 2026-07-20 08:30:51 +08:00
lingniu
02472dc4b4 docs(ops): record incremental mileage reconciliation 2026-07-20 08:13:41 +08:00
lingniu
1a5bfba20e docs(ops): record latest mileage backfill 2026-07-20 07:58:48 +08:00
lingniu
19b187d942 feat(web): add vehicle directory views 2026-07-20 07:58:45 +08:00
lingniu
3c9eea527c feat(web): focus track empty workspace 2026-07-20 07:35:44 +08:00
lingniu
19c66dc1f6 docs(ops): record same-day mileage backfill 2026-07-20 07:30:04 +08:00
lingniu
3d4e1fc01b feat(web): unify guided workspace states 2026-07-20 07:15:07 +08:00
lingniu
1a73b8bbd4 feat(web): refine mileage workspace shell 2026-07-20 07:03:01 +08:00
lingniu
1eba0ad294 docs(ops): record mileage incident recheck 2026-07-20 06:55:18 +08:00
lingniu
34d643cc87 docs(ops): record same-day mileage recovery 2026-07-20 06:35:03 +08:00
lingniu
8e54e08dba feat(web): refine track replay controls 2026-07-20 06:19:49 +08:00
lingniu
a8bbbbc5c4 feat(web): improve vehicle detail readability 2026-07-20 06:11:33 +08:00
lingniu
4de487e057 feat(web): polish account login workspace 2026-07-20 05:57:05 +08:00
651 changed files with 64856 additions and 2726 deletions

View File

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

View File

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

View File

@@ -513,6 +513,12 @@ BACKFILL_DATE_TO=2026-07-12 \
三类协议都优先采用终端上报的累计里程字段。部分终端只有坐标、速度而累计里程稀疏或停止刷新时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

View File

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

View File

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

View File

@@ -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)
@@ -108,6 +115,13 @@ func main() {
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() {
@@ -132,6 +146,46 @@ func main() {
}
}
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)
}
@@ -790,6 +844,23 @@ func recordStatSampleMetrics(registry *metrics.Registry, message kafka.Message,
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) {
@@ -950,6 +1021,10 @@ type config struct {
ReconcileProjectionsOnStart bool
ReconcileProjectionsTimeout time.Duration
QuarantineDir string
HydrogenCapacitySyncEnabled bool
HydrogenCapacitySourceSchema string
HydrogenCapacitySyncInterval time.Duration
HydrogenCapacitySyncTimeout time.Duration
}
func (c config) Validate() error {
@@ -996,6 +1071,10 @@ func loadConfig() config {
ReconcileProjectionsOnStart: env("STATS_RECONCILE_PROJECTIONS_ON_START", "true") != "false",
ReconcileProjectionsTimeout: time.Duration(envInt("STATS_RECONCILE_PROJECTIONS_TIMEOUT_SECONDS", 30)) * time.Second,
QuarantineDir: env("STATS_QUARANTINE_DIR", "/var/lib/lingniu-go-native/stat-writer-quarantine"),
HydrogenCapacitySyncEnabled: env("HYDROGEN_CAPACITY_SYNC_ENABLED", "true") != "false",
HydrogenCapacitySourceSchema: env("HYDROGEN_CAPACITY_SOURCE_SCHEMA", "ln_asset_management"),
HydrogenCapacitySyncInterval: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_INTERVAL_SECONDS", 21600)) * time.Second,
HydrogenCapacitySyncTimeout: time.Duration(envInt("HYDROGEN_CAPACITY_SYNC_TIMEOUT_SECONDS", 60)) * time.Second,
}
}

View File

@@ -57,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 {
@@ -266,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
}
@@ -289,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
@@ -303,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
@@ -338,23 +363,27 @@ func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*met
}
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
@@ -1266,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:

View File

@@ -763,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"
@@ -773,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,
@@ -791,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) {
@@ -863,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",
).
@@ -994,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", "")

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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{

View File

@@ -53,20 +53,23 @@ type Writer 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 {
@@ -91,6 +94,11 @@ type AppendResult struct {
ProjectionsAttempted int
ProjectionsWritten int
ProjectionsSkippedThrottled int
HydrogenSamplesFound int
HydrogenSamplesWritten int
HydrogenSamplesDuplicate int
HydrogenSamplesInvalid int
HydrogenSamplesNotCurrent int
}
type ProjectionFlushResult struct {
@@ -161,6 +169,7 @@ func NewWriter(exec Execer, loc *time.Location) *Writer {
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 {
@@ -169,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()
@@ -273,6 +301,8 @@ func (w *Writer) EnsureSchema(ctx context.Context) error {
GPSMileageStateTableSQL,
DailyMileageSourceTableSQL,
DailyMileageTableSQL,
HydrogenTankCapacityTableSQL,
HydrogenStreamStateTableSQL,
} {
if _, err := w.exec.ExecContext(ctx, statement); err != nil {
return err
@@ -300,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) {
@@ -316,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
@@ -1191,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
}

View File

@@ -118,6 +118,40 @@ func TestSamplesFromEnvelopeMapsProtocolMileageFields(t *testing.T) {
}
}
func TestSamplesFromEnvelopeCarriesPureHydrogenWorkMode(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
tests := []struct {
name string
protocol envelope.Protocol
mode int
}{
{name: "gb32960 fuel cell engine running", protocol: envelope.ProtocolGB32960, mode: 2},
{name: "yutong fuel cell driving", protocol: envelope.ProtocolYutongMQTT, mode: 11},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fields := map[string]any{envelope.FieldFuelCellWorkMode: test.mode}
if test.protocol == envelope.ProtocolGB32960 {
fields["gb32960.vehicle.total_mileage_km"] = 12000.5
} else {
fields["yutong_mqtt.data.total_mileage"] = 12000500
}
samples, err := SamplesFromEnvelope(envelope.FrameEnvelope{
Protocol: test.protocol,
VIN: "LNBVIN00000000001",
EventTimeMS: time.Date(2026, 7, 23, 8, 30, 0, 0, loc).UnixMilli(),
Fields: fields,
}, loc)
if err != nil || len(samples) != 1 {
t.Fatalf("SamplesFromEnvelope() samples=%d error=%v", len(samples), err)
}
if !samples[0].PureHydrogenModeKnown || !samples[0].PureHydrogenActive {
t.Fatalf("pure hydrogen evidence missing: %#v", samples[0])
}
})
}
}
func TestSamplesFromEnvelopeFallsBackToReceivedTimeWhenEventTimeIsFuture(t *testing.T) {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
received := time.Date(2026, 7, 8, 23, 59, 0, 0, loc)
@@ -1035,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,
).
@@ -1115,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,
).
@@ -1161,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,
).
@@ -1356,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,
).
@@ -1441,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,
).
@@ -1776,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,
).
@@ -1836,7 +1894,7 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
t.Fatalf("Append() error = %v", err)
}
schemaCalls := 4 + len(DailyMileageAlterSQL)
schemaCalls := 6 + len(DailyMileageAlterSQL)
if len(exec.calls) != schemaCalls+5 {
t.Fatalf("exec calls = %d", len(exec.calls))
}
@@ -1845,6 +1903,8 @@ func TestWriterEnsuresSchemaAndUpsertsDailyMileage(t *testing.T) {
"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)
@@ -2040,7 +2100,7 @@ func TestWriterCarriesPreviousYutongMileageIntoStationaryDay(t *testing.T) {
if got := sourceWrite.args[11]; got != float64(0) {
t.Fatalf("daily mileage = %#v, want explicit zero", got)
}
if got := sourceWrite.args[16]; got != QualityReasonStationaryCarry {
if got := sourceWrite.args[20]; got != QualityReasonStationaryCarry {
t.Fatalf("quality reason = %#v, want %q", got, QualityReasonStationaryCarry)
}
}
@@ -2249,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,
).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,48 +58,51 @@ type MetricDiagnosisQuery struct {
}
type MetricRow struct {
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`

View File

@@ -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",
))

View File

@@ -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,

View File

@@ -24,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 {
@@ -80,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)
@@ -203,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,
)
@@ -422,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 + `,
@@ -456,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,
@@ -471,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 + `'
@@ -530,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 + `,
@@ -673,13 +733,14 @@ const selectedSourceIdentitySampleCountSQL = `(
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
@@ -688,6 +749,7 @@ 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'
@@ -710,6 +772,7 @@ 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
`

View File

@@ -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)
@@ -515,6 +536,7 @@ func TestProjectDailyMileageSelectsCandidateAndMarksSource(t *testing.T) {
"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) = '')",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)

View File

@@ -38,8 +38,18 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/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=

View File

@@ -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"

View File

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

View File

@@ -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 != "" {

View File

@@ -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)
}
}

View File

@@ -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"},

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import (
"math"
"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"`

View File

@@ -44,6 +44,40 @@ func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
}
}
func TestAlertNotificationConfigExposesOnlyReadyChannelsAndTargetReferences(t *testing.T) {
cfg := config.Config{
AlertNotificationTargetsJSON: `[{"id":"night-shift","label":"夜班负责人","channels":["sms","wecom"]}]`,
AlertNotificationSMSURL: "https://gateway.example.test/sms",
AlertNotificationSMSSecret: "signing-secret",
AlertNotificationEmailURL: "https://gateway.example.test/email",
}
result := alertNotificationConfig(cfg, "production")
if len(result.Channels) != 4 || !result.Channels[0].Configured || !result.Channels[1].Configured || result.Channels[2].Configured || result.Channels[3].Configured {
t.Fatalf("gateway readiness must require both endpoint and secret: %+v", result.Channels)
}
if len(result.Targets) != 2 || result.Targets[0].ID != "platform-operators" || result.Targets[1].ID != "night-shift" {
t.Fatalf("target catalog was not normalized: %+v", result.Targets)
}
encoded, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "signing-secret") || strings.Contains(string(encoded), "gateway.example.test") {
t.Fatalf("public notification config leaked gateway credentials: %s", encoded)
}
}
func TestOpenPlatformDocsAreServedOnlyByStandaloneService(t *testing.T) {
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
for _, path := range []string{"/open-api/docs/", "/open-api/swagger/", "/open-api/openapi.yaml"} {
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("internal service unexpectedly served standalone docs: path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
}
}
}
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
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())
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 &lt;32位appKey&gt;
Content-Type: application/json</pre>
<p class="note">appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。</p>
<h2>开放接口</h2>
<h3><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</h3>
<p>查询指定车辆的单日用氢量,单位 kg。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/query</h3>
<p>查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。</p>
<p class="note">以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</h3>
<p>按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。</p>
<p class="note">两个里程接口均可传 protocolPriority唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议未列出的协议完全禁用。省略字段时保持现有默认选源行为。</p>
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0累计总里程、来源协议和数据时间沿用最近有效统计updatedAt 显示上一个统计周期的计算时间。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
<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 &gt; YUTONG_MQTT &gt; JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。</p>
<h2>请求示例</h2>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/hydrogen-consumption/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"plateNumbers": ["粤A12345", "粤B67890"],
"date": "2026-07-01"
}'</pre>
<h3>查询全部授权车辆的单日数据</h3>
<pre>{
"date": "2026-07-01"
}</pre>
<h3>按自定义协议优先级查询单日里程</h3>
<pre>{
"plateNumbers": ["粤A12345"],
"date": "2026-07-01",
"protocolPriority": ["JT808", "GB32960", "MQTT"]
}</pre>
<h3>指定时刻总里程</h3>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/total-mileage/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"vin": "LA9GG68L2PBAF4790",
"time": "2026-07-21 09:30:00",
"protocol": "GB32960"
}'</pre>
<h3>车辆区间日里程</h3>
<pre>curl -X POST 'https://your-host/api/v1/vehicles/mileage/range/query' \
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
-H 'Content-Type: application/json' \
-d '{
"startDate": "2026-07-01",
"endDate": "2026-07-23",
"protocolPriority": ["GB32960", "MQTT"],
"pageSize": 5000
}'</pre>
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor同一次分页查询的 snapshotId 保持不变。</p>
<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 &gt; YUTONG_MQTT &gt; JT808",
"status": "NORMAL"
},
"traceId": "95bddca78133474fa2bf56ecdf758e22"
}</pre>
<h2>状态与错误码</h2>
<table>
<thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
<tbody>
<tr><td>200</td><td>SUCCESS</td><td>查询成功;无统计数据的车辆以 NO_DATA 返回</td></tr>
<tr><td>400</td><td>INVALID_REQUEST</td><td>请求格式、车牌或数量不正确</td></tr>
<tr><td>400</td><td>INVALID_DATE_FORMAT</td><td>日期不是 yyyy-MM-dd</td></tr>
<tr><td>400</td><td>INVALID_DATETIME_FORMAT</td><td>时间不是 yyyy-MM-dd HH:mm:ss</td></tr>
<tr><td>401</td><td>UNAUTHORIZED</td><td>appKey 不存在、停用或过期</td></tr>
<tr><td>403</td><td>FORBIDDEN</td><td>Key 或车辆授权未覆盖查询自然日</td></tr>
<tr><td>500</td><td>INTERNAL_ERROR</td><td>服务内部异常</td></tr>
</tbody>
</table>
</main>
</body>
</html>

View File

@@ -0,0 +1,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: 当日所选协议最后有效累计总里程kmstatus=NORMAL 时必定有值NO_DATA 时为 null
dataTime:
type: string
format: date-time
nullable: true
description: 本次统计实际采用的最后一条车辆源数据时间status=NO_DATA 时为 null
updatedAt:
type: string
format: date-time
nullable: true
description: 本行所依据统计周期的计算时间前向填充时为上一统计周期计算时间status=NO_DATA 时为 null
sourceProtocol:
type: string
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议status=NO_DATA 时为 null
status:
$ref: '#/components/schemas/DataStatus'
MileageRangeResult:
type: object
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
properties:
vin:
type: string
plateNumber:
type: string
date:
type: string
format: date
dailyMileageKm:
type: number
format: double
nullable: true
minimum: 0
description: 当日无记录但存在历史累计里程时为 0
totalMileageKm:
type: number
format: double
nullable: true
minimum: 0
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
dataTime:
type: string
format: date-time
nullable: true
updatedAt:
type: string
format: date-time
nullable: true
description: 本行所依据统计周期的计算时间;缺日补齐时为上一统计周期计算时间
sourceProtocol:
type: string
enum: [GB32960, MQTT, JT808]
nullable: true
description: 本行实际选中的来源协议status=NO_DATA 时为 null
status:
$ref: '#/components/schemas/DataStatus'
DataStatus:
type: string
enum: [NORMAL, NO_DATA]
HydrogenQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/HydrogenResult'
MileageQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
type: array
items:
$ref: '#/components/schemas/MileageResult'
MileageRangeQueryResponse:
type: object
required: [code, message, data, snapshotId, nextCursor, traceId]
properties:
code:
type: string
enum: [SUCCESS]
message:
type: string
example: success
data:
type: array
items:
$ref: '#/components/schemas/MileageRangeResult'
snapshotId:
type: string
description: 同一次分页查询保持不变的授权车辆快照标识
nextCursor:
type: string
nullable: true
description: 下一页游标;最后一页为 null
traceId:
type: string
TotalMileageResult:
type: object
required: [vin, queryTime, totalMileageKm, selectionPolicy, status]
properties:
vin:
type: string
queryTime:
type: string
description: 请求时刻,北京时间
totalMileageKm:
type: number
format: double
nullable: true
description: 总里程km无数据时为 null
protocol:
type: string
enum: [GB32960, YUTONG_MQTT, JT808]
description: 实际采用的采集协议
protocolInput:
type: string
description: 请求中指定的协议原值;未指定时省略
mileageMeaning:
type: string
description: 当前协议总里程的业务含义
recordTime:
type: string
description: 命中的实际采集记录时间,北京时间
timeDifferenceSeconds:
type: integer
format: int64
minimum: 0
description: 请求时间减实际记录时间,单位秒
selectionPolicy:
type: string
example: GB32960 > YUTONG_MQTT > JT808
status:
$ref: '#/components/schemas/DataStatus'
TotalMileageQueryResponse:
allOf:
- $ref: '#/components/schemas/SuccessEnvelope'
- type: object
required: [data]
properties:
data:
$ref: '#/components/schemas/TotalMileageResult'
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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: "座",
},
}
}

View 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"`
}

View 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(&timestampMS, &point.TotalMileageKm, &point.Protocol)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return nil, err
}
point.VIN = vin
point.ObservedAt = time.UnixMilli(timestampMS)
return &point, nil
}
return nil, nil
}
func (r *MySQLRepository) 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])
}

View File

@@ -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)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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
}

View File

@@ -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())
}
}

View File

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

View File

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

View File

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

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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, &currentPhone)
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 {

View File

@@ -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 {

View File

@@ -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: "长时间静止须使用 010 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{}

View File

@@ -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 ""
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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(&current)
var archivedAt sql.NullTime
err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(&current, &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
}

View File

@@ -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])

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 &copy
}
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:
}
}
}()
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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 == "" {
@@ -454,6 +479,10 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
// 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, ` +
@@ -482,7 +511,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
`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,
@@ -522,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 = 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 {
@@ -585,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,
}
}
@@ -625,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 := 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}
}
@@ -740,23 +806,31 @@ func mileageDailySelectionOrder(mileageColumn, protocolColumn string, 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
}

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -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) {

View File

@@ -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)
@@ -293,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} {
@@ -328,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)
@@ -395,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)
}
@@ -473,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)
}
@@ -578,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)
}
@@ -594,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)
}
@@ -603,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)
@@ -646,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")
}
}
@@ -691,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",
@@ -717,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",

View File

@@ -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 {

View File

@@ -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(&currentStatus, &currentVersion); 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(&currentStatus, &currentVersion, &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(&currentStatus, &currentVersion, &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(&currentStatus, &currentVersion, &currentlyArchived); 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

View File

@@ -17,6 +17,193 @@ func exportAdminContext() context.Context {
return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"})
}
type concurrentHistoryStore struct {
*MockStore
mu sync.Mutex
active int
max int
}
func (s *concurrentHistoryStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
s.mu.Lock()
s.active++
if s.active > s.max {
s.max = s.active
}
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.active--
s.mu.Unlock()
}()
time.Sleep(20 * time.Millisecond)
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
}
func TestHistoryDataQueriesMultipleVehiclesConcurrentlyAndPreservesReceiptOrder(t *testing.T) {
store := &concurrentHistoryStore{MockStore: NewMockStore()}
response, err := NewService(store).HistoryData(context.Background(), url.Values{
"keywords": {"粤AG18312,川AHTWO1,豫A88888"},
"category": {"location"},
"dateFrom": {"2026-06-23T00:00"},
"dateTo": {"2026-07-23T00:00"},
"limit": {"10"},
})
if err != nil {
t.Fatal(err)
}
if store.max < 2 {
t.Fatalf("multi-vehicle history requests should overlap, max concurrency=%d", store.max)
}
want := []string{"粤AG18312", "川AHTWO1", "豫A88888"}
if len(response.Summary.Vehicles) != len(want) {
t.Fatalf("unexpected receipt count: %+v", response.Summary.Vehicles)
}
for index, keyword := range want {
if response.Summary.Vehicles[index].Keyword != keyword {
t.Fatalf("receipt order changed at %d: %+v", index, response.Summary.Vehicles)
}
}
}
func TestBatchUpdateReconciliationIssuesValidatesScopeAndKeepsPartialSuccess(t *testing.T) {
service := NewService(NewMockStore())
ctx := WithPrincipal(context.Background(), Principal{Name: "批量处置员", Role: "operator", UserType: "operator"})
result, err := service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
Items: []ReconciliationBatchActionItem{
{ID: "reconciliation-demo-position", Version: 1},
{ID: "missing-issue", Version: 1},
},
Status: "no_action",
Note: "已核对本页所选问题,确认无需修改原始数据",
})
if err != nil {
t.Fatal(err)
}
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
t.Fatalf("unexpected result: %+v", result)
}
if result.Succeeded[0].ResolvedBy != "批量处置员" || result.Skipped[0].Code != "RECONCILIATION_NOT_FOUND" {
t.Fatalf("unexpected per-item evidence: %+v", result)
}
_, err = service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
Items: []ReconciliationBatchActionItem{{ID: "reconciliation-demo-position", Version: 2}, {ID: "reconciliation-demo-position", Version: 2}},
Status: "fixed",
Note: "重复选择应在写入前被拒绝",
})
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_BATCH_DUPLICATE_ID" {
t.Fatalf("duplicate ids should be rejected, err=%v", err)
}
}
func TestReconciliationDirectoryIncludesCurrentPrincipalAndHistoricalOwners(t *testing.T) {
store := NewMockStore()
service := NewService(store)
ctx := WithPrincipal(context.Background(), Principal{Name: "当前值班员", Username: "operator-a", Role: "operator", UserType: "operator"})
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
if _, err := service.AssignReconciliationIssue(ctx, "reconciliation-demo-position", ReconciliationAssignmentRequest{Version: 1, Assignee: "定位运维组", DueAt: dueAt}); err != nil {
t.Fatal(err)
}
items, err := service.ReconciliationAssignees(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || !items[0].Current || items[0].Name != "当前值班员" || items[1].Name != "定位运维组" || items[1].ActiveCount != 1 {
t.Fatalf("unexpected assignee directory: %+v", items)
}
filtered, err := service.ReconciliationAssignees(ctx, "operator-a")
if err != nil || len(filtered) != 1 || filtered[0].Name != "当前值班员" {
t.Fatalf("search should match username: items=%+v err=%v", filtered, err)
}
}
func TestExportReconciliationIssuesUsesFullFilterAndSafeCSV(t *testing.T) {
store := NewMockStore()
store.reconciliationIssues[0].Plate = "=FORMULA"
service := NewService(store)
file, err := service.ExportReconciliationIssues(context.Background(), ReconciliationQuery{Status: "active", Owner: "unassigned"})
if err != nil {
t.Fatal(err)
}
text := string(file.Content)
if file.RowCount != 1 || !strings.HasPrefix(text, "\xEF\xBB\xBF") || !strings.Contains(text, "差异编号,等级,状态") || !strings.Contains(text, "'=FORMULA") {
t.Fatalf("unexpected export file: rows=%d name=%q content=%q", file.RowCount, file.Name, text)
}
}
func TestReconciliationArchiveLifecycleSeparatesCurrentAndAuditScopes(t *testing.T) {
store := NewMockStore()
service := NewService(store)
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Username: "quality-admin", Role: "admin", UserType: "admin"})
archived, err := service.SetReconciliationIssueArchived(admin, "reconciliation-demo-source", true, ReconciliationLifecycleRequest{
Version: 2,
Reason: "问题已自动恢复并完成月度复核,转入长期审计留存",
})
if err != nil {
t.Fatal(err)
}
if archived.ArchivedAt == "" || archived.ArchivedBy != "质量管理员" || archived.Version != 3 {
t.Fatalf("archive receipt incomplete: %+v", archived)
}
current, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "current", Status: "all", Limit: 20})
if err != nil || current.Total != 1 {
t.Fatalf("current scope should exclude archived issue: page=%+v err=%v", current, err)
}
audit, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "archived", Keyword: "月度复核", Status: "all", Limit: 20})
if err != nil || audit.Total != 1 || audit.Items[0].ID != archived.ID {
t.Fatalf("archive scope should search audit evidence: page=%+v err=%v", audit, err)
}
if _, err := service.UpdateReconciliationIssue(admin, archived.ID, ReconciliationActionRequest{Version: archived.Version, Status: "pending"}); err == nil {
t.Fatal("archived issue must be read-only")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_ARCHIVED_READ_ONLY" {
t.Fatalf("unexpected read-only error: %v", err)
}
operator := WithPrincipal(context.Background(), Principal{Name: "质量操作员", Role: "operator", UserType: "operator"})
if _, err := service.SetReconciliationIssueArchived(operator, archived.ID, false, ReconciliationLifecycleRequest{Version: archived.Version, Reason: "重新进入当前队列复核"}); err == nil {
t.Fatal("operator must not restore audit records")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
t.Fatalf("unexpected permission error: %v", err)
}
restored, err := service.SetReconciliationIssueArchived(admin, archived.ID, false, ReconciliationLifecycleRequest{
Version: archived.Version,
Reason: "收到新的来源接入证据,需要重新核对",
})
if err != nil {
t.Fatal(err)
}
if restored.ArchivedAt != "" || restored.Status != "recovered" || restored.Version != 4 {
t.Fatalf("restore must preserve conclusion while returning to current scope: %+v", restored)
}
if len(restored.Actions) < 2 || restored.Actions[len(restored.Actions)-1].Action != "restore" {
t.Fatalf("restore audit missing: %+v", restored.Actions)
}
}
func TestBatchReconciliationArchiveKeepsPartialFailuresRecoverable(t *testing.T) {
service := NewService(NewMockStore())
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Role: "admin", UserType: "admin"})
result, err := service.BatchSetReconciliationIssuesArchived(admin, true, ReconciliationBatchLifecycleRequest{
Items: []ReconciliationBatchActionItem{
{ID: "reconciliation-demo-source", Version: 2},
{ID: "reconciliation-demo-position", Version: 1},
},
Reason: "已完成周期复核,批量整理到审计归档",
})
if err != nil {
t.Fatal(err)
}
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
t.Fatalf("unexpected batch archive result: %+v", result)
}
if result.Succeeded[0].ID != "reconciliation-demo-source" || result.Skipped[0].Code != "RECONCILIATION_ARCHIVE_OPEN_ISSUE" {
t.Fatalf("batch archive should keep open issue unchanged: %+v", result)
}
}
func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
dir := t.TempDir()
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
@@ -51,6 +238,309 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
}
}
func TestHistoryExportPageSearchesAndPaginatesBeyondFirstTwenty(t *testing.T) {
exportDir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: exportDir})
now := time.Now().UTC()
service.exportsMu.Lock()
for index := 0; index < 36; index++ {
status := "completed"
if index%5 == 0 {
status = "failed"
}
id := fmt.Sprintf("exp_%02d", index+1)
filePath := ""
if status == "completed" {
filePath = filepath.Join(exportDir, id+".csv")
if err := os.WriteFile(filePath, []byte("vin\n"), 0o640); err != nil {
service.exportsMu.Unlock()
t.Fatal(err)
}
}
ownerID := "local:subject:1"
ownerUsername := "admin"
if index >= 30 {
ownerID = "local:subject:2"
ownerUsername = "audit-peer"
}
service.exports[id] = &HistoryExportJob{
ID: id, Name: fmt.Sprintf("历史归档 %02d", index+1), Status: status, Category: "location", Format: "csv",
Keywords: []string{fmt.Sprintf("VIN%03d", index+1)}, VehicleVINs: []string{fmt.Sprintf("VIN%03d", index+1)},
OwnerID: ownerID, OwnerUsername: ownerUsername, CreatedAt: now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339),
filePath: filePath,
}
}
service.exportsMu.Unlock()
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 10, Offset: 20})
if page.Total != 36 || len(page.Items) != 10 || page.Items[0].ID != "exp_21" || page.Summary.Total != 36 || page.Summary.Recoverable != 8 {
t.Fatalf("unexpected page: %+v", page)
}
filtered := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Search: "VIN035", Status: "completed", Limit: 10})
if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0].ID != "exp_35" {
t.Fatalf("unexpected filtered page: %+v", filtered)
}
mine := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
if mine.Total != 30 || len(mine.Items) != 10 || mine.Summary.Total != 30 || mine.Items[0].OwnerUsername != "admin" {
t.Fatalf("admin mine scope should isolate the current account without weakening global audit access: %+v", mine)
}
emptyMine := service.ListHistoryExportsPage(WithPrincipal(context.Background(), Principal{SubjectID: "missing-admin", Username: "missing-admin", Role: "admin", UserType: "admin", AuthProvider: "local"}), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
if emptyMine.Items == nil || emptyMine.Total != 0 || emptyMine.Summary.Total != 0 {
t.Fatalf("empty owner scope should return a stable empty collection and zero summary: %+v", emptyMine)
}
}
func TestHistoryExportPageKeepsStableDeepPaginationAcrossTenThousandTasks(t *testing.T) {
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
now := time.Now().UTC()
service.exportsMu.Lock()
for index := 0; index < 10_005; index++ {
id := fmt.Sprintf("scale_%05d", index+1)
createdAt := now.Add(-time.Duration(index) * time.Second).Format(time.RFC3339)
service.exports[id] = &HistoryExportJob{
ID: id, Name: "规模任务 " + id, Status: "cancelled", Category: "location", Format: "csv",
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
CreatedAt: createdAt, UpdatedAt: createdAt, CancelledAt: createdAt, CancelledBy: "admin",
}
}
service.exportsMu.Unlock()
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 9_950})
if page.Total != 10_005 || page.Limit != 50 || page.Offset != 9_950 || len(page.Items) != 50 {
t.Fatalf("unexpected deep page metadata: total=%d limit=%d offset=%d items=%d", page.Total, page.Limit, page.Offset, len(page.Items))
}
if page.Items[0].ID != "scale_09951" || page.Items[49].ID != "scale_10000" || page.Summary.Cancelled != 10_005 {
t.Fatalf("deep page must keep stable order and complete summary: first=%s last=%s summary=%+v", page.Items[0].ID, page.Items[49].ID, page.Summary)
}
tail := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 10_000})
if tail.Total != 10_005 || len(tail.Items) != 5 || tail.Items[0].ID != "scale_10001" || tail.Items[4].ID != "scale_10005" {
t.Fatalf("unexpected tail page: %+v", tail)
}
}
func TestHistoryExportArchiveSeparatesCurrentWorkFromAuditRecords(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
ctx := exportAdminContext()
nowTime := time.Now().UTC()
now := nowTime.Format(time.RFC3339)
archiveFile := filepath.Join(dir, "exp_completed_archive.csv")
if err := os.WriteFile(archiveFile, []byte("vin\nVIN-ARCHIVE\n"), 0o640); err != nil {
t.Fatal(err)
}
service.exportsMu.Lock()
service.exports["exp_completed_archive"] = &HistoryExportJob{
ID: "exp_completed_archive", Name: "月度历史审计包", Status: "completed", Format: "csv", Category: "location",
Keywords: []string{"VIN-ARCHIVE"}, VehicleVINs: []string{"VIN-ARCHIVE"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
DateFrom: "2026-06-01T00:00", DateTo: "2026-06-30T23:59", CreatedAt: now, UpdatedAt: now, CompletedAt: now,
ExpiresAt: nowTime.Add(12 * time.Hour).Format(time.RFC3339), Evidence: "车辆范围与账号权限已固化", filePath: archiveFile,
}
service.exports["exp_running_visible"] = &HistoryExportJob{
ID: "exp_running_visible", Name: "执行中任务", Status: "running", Format: "csv", Category: "location",
Keywords: []string{"VIN-RUNNING"}, VehicleVINs: []string{"VIN-RUNNING"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: now, UpdatedAt: now,
}
service.exportsMu.Unlock()
archived, err := service.SetHistoryExportArchived(ctx, "exp_completed_archive", true)
if err != nil || archived.ArchivedAt == "" || archived.ArchivedBy != "admin" || !strings.Contains(archived.Evidence, "归档") {
t.Fatalf("archive failed: job=%+v err=%v", archived, err)
}
current := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "current", Limit: 10})
if current.Total != 1 || len(current.Items) != 1 || current.Items[0].ID != "exp_running_visible" || current.Summary.Current != 1 || current.Summary.Archived != 1 || current.Summary.Active != 1 || current.Summary.Completed != 0 || current.Summary.Expiring != 0 {
t.Fatalf("current queue should exclude archived audit records: %+v", current)
}
audit := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Search: "车辆范围与账号权限", Limit: 10})
if audit.Total != 1 || len(audit.Items) != 1 || audit.Items[0].ID != "exp_completed_archive" || audit.Summary.Active != 0 || audit.Summary.Completed != 1 || audit.Summary.Expiring != 1 {
t.Fatalf("archived evidence should remain searchable: %+v", audit)
}
expiring := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Status: "expiring", Limit: 10})
if expiring.Total != 1 || len(expiring.Items) != 1 || expiring.Items[0].ID != "exp_completed_archive" {
t.Fatalf("expiring filter should isolate downloadable files inside the selected archive scope: %+v", expiring)
}
if _, err := service.SetHistoryExportArchived(ctx, "exp_running_visible", true); err == nil {
t.Fatal("active jobs must remain visible and cannot be archived")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_NOT_ARCHIVABLE" {
t.Fatalf("unexpected active archive error: %v", err)
}
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_completed_archive"}, Action: "restore"})
if err != nil || len(batch.Succeeded) != 1 || batch.Succeeded[0].ArchivedAt != "" || !strings.Contains(batch.Succeeded[0].Evidence, "移回当前") {
t.Fatalf("restore batch failed: result=%+v err=%v", batch, err)
}
}
func TestHistoryExportCleanupPreviewsProtectsAndAuditsPermanentRemoval(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
ctx := exportAdminContext()
now := time.Now().UTC()
oldArchive := now.Add(-200 * 24 * time.Hour).Format(time.RFC3339)
recentArchive := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
service.exportsMu.Lock()
service.exports["cleanup-old"] = &HistoryExportJob{
ID: "cleanup-old", Name: "待清理年度包", Status: "expired", Category: "location", Format: "csv",
Keywords: []string{"VIN-CLEAN"}, VehicleVINs: []string{"VIN-CLEAN"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 2048, Evidence: "原范围保留",
}
service.exports["cleanup-protected"] = &HistoryExportJob{
ID: "cleanup-protected", Name: "长期合规包", Status: "expired", Category: "raw", Format: "csv",
Keywords: []string{"VIN-PROTECT"}, VehicleVINs: []string{"VIN-PROTECT"}, OwnerID: "local:subject:other", OwnerUsername: "audit-peer",
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 4096, Evidence: "原范围保留",
CleanupProtectedAt: now.Add(-24 * time.Hour).Format(time.RFC3339), CleanupProtectedBy: "admin", CleanupProtectionReason: "年度监管复核",
}
service.exports["cleanup-recent"] = &HistoryExportJob{
ID: "cleanup-recent", Name: "近期归档", Status: "cancelled", Category: "location", Format: "csv",
Keywords: []string{"VIN-RECENT"}, VehicleVINs: []string{"VIN-RECENT"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
CreatedAt: recentArchive, UpdatedAt: recentArchive, ArchivedAt: recentArchive, ArchivedBy: "admin", Evidence: "近期归档",
}
service.exportsMu.Unlock()
preview, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
if err != nil {
t.Fatal(err)
}
if preview.ArchivedCount != 3 || preview.CandidateCount != 1 || preview.PlannedCount != 1 || preview.ProtectedCount != 1 || len(preview.Candidates) != 1 || preview.Candidates[0].ID != "cleanup-old" || len(preview.Protected) != 1 || preview.Protected[0].ID != "cleanup-protected" {
t.Fatalf("unexpected cleanup preview: %+v", preview)
}
protected, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: true, Reason: "诉讼证据保全"})
if err != nil || protected.CleanupProtectedAt == "" || protected.CleanupProtectionReason != "诉讼证据保全" || !strings.Contains(protected.Evidence, "长期保留") {
t.Fatalf("protect cleanup candidate failed: job=%+v err=%v", protected, err)
}
if _, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: preview.PreviewToken}); err == nil {
t.Fatal("stale preview token must be rejected after protection changes")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_PREVIEW_STALE" {
t.Fatalf("unexpected stale preview error: %v", err)
}
if _, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: false}); err != nil {
t.Fatal(err)
}
fresh, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
if err != nil || fresh.CandidateCount != 1 || fresh.ProtectedCount != 1 {
t.Fatalf("unexpected refreshed cleanup preview: %+v err=%v", fresh, err)
}
result, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: fresh.PreviewToken})
if err != nil {
t.Fatal(err)
}
if result.Record.Cleaned != 1 || result.Record.Protected != 1 || result.Record.Actor != "admin" || len(result.Deleted) != 1 || result.Deleted[0].ID != "cleanup-old" {
t.Fatalf("unexpected cleanup result: %+v", result)
}
page := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Limit: 10})
if page.Total != 2 || page.Summary.Archived != 2 {
t.Fatalf("cleanup must leave protected and recent records: %+v", page)
}
audit, err := service.HistoryExportCleanupAudit(ctx, 20)
if err != nil || len(audit) != 1 || audit[0].ID != result.Record.ID || audit[0].CandidateDigest != fresh.PreviewToken {
t.Fatalf("cleanup audit must remain independently searchable: %+v err=%v", audit, err)
}
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
reloadedAudit, err := reloaded.HistoryExportCleanupAudit(ctx, 20)
if err != nil || len(reloadedAudit) != 1 || reloadedAudit[0].ID != result.Record.ID {
t.Fatalf("cleanup audit must survive restart: %+v err=%v", reloadedAudit, err)
}
if _, exists := reloaded.historyExportJob("cleanup-old"); exists {
t.Fatal("cleaned record must not return after restart")
}
}
func TestHistoryExportCleanupRequiresAdministratorAndPreservesMoreThanFiveHundredRecords(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
oldArchive := time.Now().UTC().Add(-400 * 24 * time.Hour).Format(time.RFC3339)
service.exportsMu.Lock()
for index := 1; index <= 550; index++ {
id := fmt.Sprintf("persist-%03d", index)
service.exports[id] = &HistoryExportJob{
ID: id, Name: id, Status: "cancelled", Category: "location", Format: "csv",
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin",
}
}
if err := service.persistHistoryExportsLocked(); err != nil {
service.exportsMu.Unlock()
t.Fatal(err)
}
service.exportsMu.Unlock()
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
if page := reloaded.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Limit: 50}); page.Summary.Archived != 550 {
t.Fatalf("explicit cleanup must replace silent 500-record truncation: %+v", page.Summary)
}
operator := WithPrincipal(context.Background(), Principal{SubjectID: "operator-1", Username: "operator", Role: "operator", UserType: "operator", AuthProvider: "local"})
if _, err := reloaded.PreviewHistoryExportCleanup(operator, HistoryExportCleanupQuery{OlderThanDays: 365}); err == nil {
t.Fatal("operator must not preview permanent archive cleanup")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_ADMIN_REQUIRED" {
t.Fatalf("unexpected cleanup permission error: %v", err)
}
}
func TestHistoryExportPageSortsByScopeActivityAndFutureExpiry(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
now := time.Now().UTC()
writeJob := func(id string, createdAt, archivedAt, expiresAt time.Time) {
path := filepath.Join(dir, id+".csv")
if err := os.WriteFile(path, []byte("vin\n"+id+"\n"), 0o640); err != nil {
t.Fatal(err)
}
service.exports[id] = &HistoryExportJob{
ID: id, Name: id, Status: "completed", Format: "csv", Category: "location",
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
CreatedAt: createdAt.Format(time.RFC3339), UpdatedAt: archivedAt.Format(time.RFC3339), CompletedAt: createdAt.Format(time.RFC3339),
ArchivedAt: archivedAt.Format(time.RFC3339), ExpiresAt: expiresAt.Format(time.RFC3339), filePath: path,
}
}
service.exportsMu.Lock()
writeJob("old-created-recently-archived", now.Add(-72*time.Hour), now.Add(-time.Minute), now.Add(12*time.Hour))
writeJob("new-created-older-archive", now.Add(-24*time.Hour), now.Add(-2*time.Hour), now.Add(4*time.Hour))
service.exportsMu.Unlock()
recent := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "recent", Limit: 10})
if len(recent.Items) != 2 || recent.Items[0].ID != "old-created-recently-archived" {
t.Fatalf("archived scope should prioritize the latest archive activity, not original creation time: %+v", recent.Items)
}
expiry := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "expiry", Limit: 10})
if len(expiry.Items) != 2 || expiry.Items[0].ID != "new-created-older-archive" {
t.Fatalf("expiry sort should prioritize the first downloadable file to expire: %+v", expiry.Items)
}
}
func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
owner := exportAdminContext()
preference, err := service.UpdateHistoryPreferences(owner, HistoryPreferences{
RetentionDays: 30,
FieldViews: []HistoryFieldView{{ID: "location:交付核验", Name: "交付核验", Category: "location", Keys: []string{"speedKmh", "socPercent"}}},
})
if err != nil || preference.Revision != 1 || preference.RetentionDays != 30 || len(preference.FieldViews) != 1 {
t.Fatalf("save preferences: preference=%+v err=%v", preference, err)
}
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
restored, err := reloaded.HistoryPreferences(owner)
if err != nil || restored.RetentionDays != 30 || len(restored.FieldViews) != 1 || restored.FieldViews[0].Name != "交付核验" {
t.Fatalf("restored preferences: preference=%+v err=%v", restored, err)
}
reloaded.exportSlots <- struct{}{}
job, err := reloaded.CreateHistoryExport(owner, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
if err != nil {
<-reloaded.exportSlots
t.Fatal(err)
}
if job.RetentionDays != 30 || !strings.Contains(job.Evidence, "文件保留 30 天") {
<-reloaded.exportSlots
t.Fatalf("account retention not applied: %+v", job)
}
if _, err := reloaded.CancelHistoryExport(owner, job.ID); err != nil {
<-reloaded.exportSlots
t.Fatal(err)
}
<-reloaded.exportSlots
}
type countingStore struct {
*MockStore
vehiclesCalls int
@@ -59,14 +549,81 @@ type countingStore struct {
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
lastHistoryQuery url.Values
historyQueries []url.Values
rawFrameQueries []RawFrameQuery
lastDailyMileageQuery url.Values
lastMileageStatisticsQuery url.Values
}
type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
consumption, rate := 3.1, 5.5
return Page[DailyMileageRow]{Items: []DailyMileageRow{{
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}}}, nil
}
func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (MileageSummary, error) {
return MileageSummary{TotalMileageKm: 88.7, TotalPureHydrogenMileageKm: 56.2}, nil
}
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
consumption, rate := 3.1, 5.5
return MileageStatistics{
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
Trend: []MileageTrendPoint{{
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}},
}, nil
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T) {
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
customer := WithPrincipal(context.Background(), Principal{
Name: "业务客户", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
daily, err := service.DailyMileage(customer, query)
if err != nil {
t.Fatal(err)
}
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil {
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
}
summary, err := service.MileageStatistics(customer, query)
if err != nil {
t.Fatal(err)
}
if summary.PeriodPureHydrogenMileageKm != 0 || summary.HydrogenMatchedMileageKm != 0 || summary.HydrogenDataDays != 0 || summary.PeriodHydrogenConsumptionKg != 0 || summary.HydrogenConsumptionKgPer100Km != nil ||
len(summary.Trend) != 1 || summary.Trend[0].PureHydrogenMileageKm != 0 || summary.Trend[0].HydrogenMatchedMileageKm != 0 || summary.Trend[0].HydrogenConsumptionKg != nil {
t.Fatalf("customer statistics exposed hydrogen metrics: %+v", summary)
}
}
func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.T) {
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
daily, err := service.DailyMileage(exportAdminContext(), query)
if err != nil {
t.Fatal(err)
}
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
s.vehiclesCalls++
s.lastVehicleQuery = cloneValues(query)
@@ -81,14 +638,60 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (
func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
s.lastHistoryQuery = cloneValues(query)
s.historyQueries = append(s.historyQueries, cloneValues(query))
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
}
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.rawFrameQueries = append(s.rawFrameQueries, query)
return s.MockStore.RawFrames(ctx, query)
}
func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
s.lastDailyMileageQuery = cloneValues(query)
return s.MockStore.DailyMileage(ctx, query)
}
func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
store := newCountingStore()
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if !detail.LookupResolved {
t.Fatalf("vehicle detail should resolve the requested VIN: %+v", detail)
}
if len(store.historyQueries) == 0 {
t.Fatal("vehicle detail should query bounded TDengine history previews")
}
for _, query := range store.historyQueries {
if query.Get("skipCount") != "1" {
t.Fatalf("every vehicle detail history preview should skip the full TDengine count: %+v", store.historyQueries)
}
}
rawPreviewFound := false
for _, query := range store.rawFrameQueries {
if query.IncludeFields && query.Limit == 10 {
rawPreviewFound = true
}
if !query.SkipCount {
t.Fatalf("every vehicle detail raw preview should skip the full TDengine count: %+v", store.rawFrameQueries)
}
}
if !rawPreviewFound {
t.Fatalf("vehicle detail raw preview was not queried: %+v", store.rawFrameQueries)
}
if got := store.lastDailyMileageQuery.Get("vins"); got != "LB9A32A24R0LS1426" {
t.Fatalf("vehicle detail mileage preview should use an exact VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
}
if got := store.lastDailyMileageQuery.Get("vin"); got != "" {
t.Fatalf("vehicle detail mileage preview must not use the fuzzy VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
}
if got := store.lastDailyMileageQuery.Get("skipCount"); got != "1" {
t.Fatalf("vehicle detail mileage preview should skip its unused count, got %q query=%+v", got, store.lastDailyMileageQuery)
}
}
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query)
@@ -815,6 +1418,133 @@ func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA))
}
func TestHistoryExportCancellationIsOwnerScopedAuditedAndPersistent(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
vin := "LNXNEGRR7SR318212"
owner := exportCustomerContext("101", "customer-a", vin)
other := exportCustomerContext("102", "customer-b", vin)
service.exportSlots <- struct{}{}
job, err := service.CreateHistoryExport(owner, HistoryExportRequest{
Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
})
if err != nil {
<-service.exportSlots
t.Fatal(err)
}
if _, err := service.CancelHistoryExport(other, job.ID); err == nil {
<-service.exportSlots
t.Fatal("another customer should not cancel the owner's export")
}
cancelled, err := service.CancelHistoryExport(owner, job.ID)
if err != nil {
<-service.exportSlots
t.Fatal(err)
}
if cancelled.Status != "cancelled" || cancelled.CancelledBy != "customer-a" || cancelled.CancelledAt == "" || !strings.Contains(cancelled.Evidence, "主动取消") {
<-service.exportSlots
t.Fatalf("cancellation audit metadata missing: %+v", cancelled)
}
<-service.exportSlots
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
jobs := service.ListHistoryExports(owner)
if len(jobs) == 1 && jobs[0].Status == "cancelled" {
break
}
time.Sleep(10 * time.Millisecond)
}
jobs := service.ListHistoryExports(owner)
if len(jobs) != 1 || jobs[0].Status != "cancelled" {
t.Fatalf("cancelled queued task must never start later: %+v", jobs)
}
if _, _, err := service.HistoryExportFile(owner, job.ID); err == nil {
t.Fatal("cancelled export must not expose a download")
}
if repeated, err := service.CancelHistoryExport(owner, job.ID); err != nil || repeated.Status != "cancelled" {
t.Fatalf("cancelling an already cancelled task should be idempotent: job=%+v err=%v", repeated, err)
}
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
reloadedJobs := reloaded.ListHistoryExports(owner)
if len(reloadedJobs) != 1 || reloadedJobs[0].Status != "cancelled" || reloadedJobs[0].CancelledBy != "customer-a" {
t.Fatalf("cancelled state should survive restart: %+v", reloadedJobs)
}
}
func TestHistoryExportExpiryRebuildAndSafeBatchActions(t *testing.T) {
dir := t.TempDir()
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
ctx := exportAdminContext()
vin := "LNXNEGRR7SR318212"
filePath := filepath.Join(dir, "exp_expired.csv")
if err := os.WriteFile(filePath, []byte("vin\n"+vin+"\n"), 0o640); err != nil {
t.Fatal(err)
}
past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
createdAt := time.Now().UTC().Add(-8 * 24 * time.Hour).Format(time.RFC3339)
service.exportsMu.Lock()
service.exports["exp_expired"] = &HistoryExportJob{
ID: "exp_expired", Name: "过期任务", Status: "completed", Progress: 100, Format: "csv", Category: "location",
Keywords: []string{vin}, Metrics: []string{"speedKmh"}, VehicleVINs: []string{vin}, DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00",
OwnerID: "local:subject:1", OwnerUsername: "admin", RowCount: 12, CreatedAt: createdAt, UpdatedAt: createdAt, CompletedAt: createdAt,
ExpiresAt: past, DownloadURL: "/api/v2/exports/exp_expired/download", Evidence: "原范围已固化", filePath: filePath,
}
service.exports["exp_running_batch"] = &HistoryExportJob{
ID: "exp_running_batch", Name: "执行中任务", Status: "running", Format: "csv", Category: "location", Keywords: []string{vin},
OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt, UpdatedAt: createdAt, Evidence: "执行中",
}
service.exportsMu.Unlock()
jobs := service.ListHistoryExports(ctx)
var expired HistoryExportJob
for _, job := range jobs {
if job.ID == "exp_expired" {
expired = job
}
}
if expired.Status != "expired" || expired.DownloadURL != "" || expired.ExpiredAt == "" || !strings.Contains(expired.Error, "保留期") {
t.Fatalf("completed file should become an auditable expired task: %+v", expired)
}
if _, _, err := service.HistoryExportFile(ctx, expired.ID); err == nil {
t.Fatal("expired file should not remain downloadable")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_EXPIRED" {
t.Fatalf("expired download error=%v", err)
}
service.exportSlots <- struct{}{}
rebuilt, err := service.RebuildHistoryExport(ctx, expired.ID)
if err != nil {
<-service.exportSlots
t.Fatal(err)
}
if rebuilt.Status != "queued" || rebuilt.RebuiltFrom != expired.ID || !strings.Contains(rebuilt.Evidence, expired.ID) {
<-service.exportSlots
t.Fatalf("rebuild should create a linked queued task: %+v", rebuilt)
}
if _, err := service.RebuildHistoryExport(ctx, "exp_running_batch"); err == nil {
<-service.exportSlots
t.Fatal("active task must not be rebuildable")
}
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_running_batch", "exp_expired"}, Action: "cancel"})
if err != nil {
<-service.exportSlots
t.Fatal(err)
}
if batch.Requested != 2 || len(batch.Succeeded) != 1 || batch.Succeeded[0].ID != "exp_running_batch" || len(batch.Skipped) != 1 || batch.Skipped[0].Code != "EXPORT_NOT_CANCELLABLE" {
<-service.exportSlots
t.Fatalf("batch cancellation must explicitly preserve ineligible tasks: %+v", batch)
}
if _, cancelErr := service.CancelHistoryExport(ctx, rebuilt.ID); cancelErr != nil {
<-service.exportSlots
t.Fatal(cancelErr)
}
<-service.exportSlots
}
type revocableHistoryExportStore struct {
*MockStore
active bool
@@ -969,6 +1699,114 @@ func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
}
}
func TestRawHistorySegmentsReuseLatestTelemetryCategorySource(t *testing.T) {
definitions := []MetricDefinition{
{Key: "speed_kmh", Category: "driving", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}},
{Key: "stack_voltage_v", Category: "fuel-cell", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.stack_1.avg_voltage_v"}},
}
rows := []HistoryDataRow{{Values: map[string]any{
"gb32960.vehicle.speed_kmh": 38.0,
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
"jt808.location.additional.total_mileage_km": 1200.0,
}}}
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows, definitions)
categories := map[string]string{}
for _, column := range columns {
categories[column.Key] = column.Category
}
if categories["gb32960.vehicle.speed_kmh"] != "vehicle" || categories["gb32960.fuel_cell.stack_1.avg_voltage_v"] != "fuel-cell" || categories["jt808.location.additional.total_mileage_km"] != "location" {
t.Fatalf("raw history did not reuse telemetry categories: %+v", categories)
}
segments := historyRawSegments(columns)
if len(segments) != 3 || segments[0].Key != "vehicle" || segments[0].Label != "整车数据" || segments[1].Key != "fuel-cell" || segments[1].Label != "燃料电池" || segments[2].Key != "location" || segments[2].Label != "定位" {
t.Fatalf("unexpected shared raw segments: %+v", segments)
}
}
type exactRawHistoryStore struct {
*MockStore
rows []HistoryDataRow
}
func (s *exactRawHistoryStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
if query.Category != "raw" {
return s.MockStore.HistoryExportBatch(context.Background(), query, cursor, limit)
}
start := cursor.Offset
if start > len(s.rows) {
start = len(s.rows)
}
end := start + limit
if end > len(s.rows) {
end = len(s.rows)
}
result := append([]HistoryDataRow(nil), s.rows[start:end]...)
cursor.Offset = end
return result, cursor, nil
}
func TestHistoryRawDataFiltersOnServerAndPaginatesBeyondOneThousand(t *testing.T) {
const totalRows = 1205
rows := make([]HistoryDataRow, 0, totalRows)
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
for index := 0; index < totalRows; index++ {
values := map[string]any{
"frameType": "realtime",
"rawSizeBytes": 430,
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
}
if index%2 == 0 {
values["gb32960.vehicle.speed_kmh"] = float64(index % 120)
}
at := start.Add(time.Duration(index) * time.Second).Format(time.RFC3339)
rows = append(rows, HistoryDataRow{
ID: "row-" + fmt.Sprintf("%04d", index), VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960",
DeviceTime: at, ServerTime: at, Quality: "normal", Values: values,
})
}
service := NewService(&exactRawHistoryStore{MockStore: NewMockStore(), rows: rows})
response, err := service.HistoryData(context.Background(), url.Values{
"keywords": {"LB9A32A24R0LS1426"},
"category": {"raw"},
"rawSegment": {"fuel-cell"},
"dateFrom": {"2026-07-01T00:00"},
"dateTo": {"2026-07-02T00:00"},
"limit": {"2"},
"offset": {"1000"},
})
if err != nil {
t.Fatal(err)
}
if !response.SegmentCountsExact || response.AllTotal != totalRows || response.Total != totalRows || len(response.Rows) != 2 {
t.Fatalf("unexpected exact raw page: exact=%v all=%d total=%d rows=%d", response.SegmentCountsExact, response.AllTotal, response.Total, len(response.Rows))
}
if response.Rows[0].ID != "row-0204" || response.Rows[1].ID != "row-0203" {
t.Fatalf("deep raw page was truncated or unstable: %+v", response.Rows)
}
counts := map[string]int{}
for _, segment := range response.Segments {
counts[segment.Key] = segment.Count
}
if counts["fuel-cell"] != totalRows || counts["vehicle"] != 603 {
t.Fatalf("segment counts should cover the complete time range: %+v", counts)
}
vehicle, err := service.HistoryData(context.Background(), url.Values{
"keywords": {"LB9A32A24R0LS1426"},
"category": {"raw"},
"rawSegment": {"vehicle"},
"dateFrom": {"2026-07-01T00:00"},
"dateTo": {"2026-07-02T00:00"},
"limit": {"2"},
})
if err != nil {
t.Fatal(err)
}
if vehicle.Total != 603 || len(vehicle.Rows) != 2 || vehicle.Rows[0].ID != "row-1204" || vehicle.Rows[1].ID != "row-1202" {
t.Fatalf("server-side category filtering failed: total=%d rows=%+v", vehicle.Total, vehicle.Rows)
}
}
func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) {
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
definitions := []MetricDefinition{
@@ -1120,3 +1958,63 @@ func BenchmarkLatestTelemetryHundredFrames(b *testing.B) {
}
}
}
func TestVehicleCoverageSupportsCombinedBusinessMultiSelectFilters(t *testing.T) {
service := NewService(NewMockStore())
query := url.Values{
"departmentIds": {"40001,40002"},
"responsibleUserIds": {"50001,50002"},
"customerIds": {"20001,20002"},
"operationStatuses": {"运营中,已停运"},
"limit": {"20"},
}
result, err := service.VehicleCoverage(context.Background(), query)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, item := range result.Items {
got[item.VIN] = true
}
for _, vin := range []string{"LB9A32A24R0LS1426", "LNXNEGRR7SR318212", "LB9A32A24P0LS1230"} {
if !got[vin] {
t.Fatalf("combined multi-select omitted %s: %+v", vin, got)
}
}
if got["LMRKH9AC2R1004087"] {
t.Fatalf("responsible/status filters leaked unrelated vehicle: %+v", got)
}
}
func TestVehicleBusinessFiltersStayInsidePrincipalVINScope(t *testing.T) {
service := NewService(NewMockStore())
ctx := WithPrincipal(context.Background(), Principal{
UserType: "customer", Role: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426", "LB9A32A24P0LS1230"},
})
result, err := service.VehicleBusinessFilters(ctx)
if err != nil {
t.Fatal(err)
}
if len(result.Departments) != 1 || result.Departments[0].Value != "40001" || result.Departments[0].Count != 2 {
t.Fatalf("department options escaped principal scope: %+v", result.Departments)
}
if len(result.ResponsibleUsers) != 1 || result.ResponsibleUsers[0].Value != "50001" {
t.Fatalf("responsible options escaped principal scope: %+v", result.ResponsibleUsers)
}
}
func TestBusinessFilterCannotWidenPrincipalVINScope(t *testing.T) {
service := NewService(NewMockStore())
ctx := WithPrincipal(context.Background(), Principal{
UserType: "customer", Role: "customer", VehicleVINs: []string{"LNXNEGRR7SR318212"},
})
result, err := service.VehicleCoverage(ctx, url.Values{
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"}, "limit": {"20"},
})
if err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 || result.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("client business filters widened principal scope: %+v", result.Items)
}
}

View File

@@ -82,6 +82,9 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
if len(where) > 0 {
countText += ` WHERE ` + strings.Join(where, " AND ")
}
if strings.TrimSpace(query["skipCount"]) == "1" {
countText = ""
}
text += ` ORDER BY ts DESC, vin ASC, protocol ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
return SQLQuery{Text: text, Args: args, CountText: countText}
}

View File

@@ -0,0 +1,34 @@
// Package vehicleprotocol owns the canonical protocol identifiers shared by
// the vehicle data platform, statistics services and the open platform API.
package vehicleprotocol
import "strings"
const (
GB32960 = "GB32960"
YutongMQTT = "YUTONG_MQTT"
JT808 = "JT808"
)
// All returns a copy so callers cannot mutate the platform-wide catalog.
func All() []string {
return []string{GB32960, JT808, YutongMQTT}
}
// MileagePriority is the source-selection order for total mileage.
func MileagePriority() []string {
return []string{GB32960, YutongMQTT, JT808}
}
// Canonical accepts only the identifiers persisted by ingestion and
// statistics. Public aliases are deliberately rejected to avoid a second
// protocol vocabulary at the API boundary.
func Canonical(value string) (string, bool) {
value = strings.TrimSpace(value)
switch value {
case GB32960, YutongMQTT, JT808:
return value, true
default:
return "", false
}
}

View File

@@ -0,0 +1,21 @@
package vehicleprotocol
import "testing"
func TestCanonicalCatalogIsUniqueAndRejectsAliases(t *testing.T) {
seen := map[string]bool{}
for _, value := range All() {
if seen[value] {
t.Fatalf("duplicate protocol %q", value)
}
seen[value] = true
if canonical, ok := Canonical(value); !ok || canonical != value {
t.Fatalf("canonical(%q) = %q, %v", value, canonical, ok)
}
}
for _, alias := range []string{"32960", "mqtt", "808"} {
if canonical, ok := Canonical(alias); ok {
t.Fatalf("alias %q unexpectedly maps to %q", alias, canonical)
}
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="羚牛车辆数据开放平台,面向合作伙伴提供稳定、安全、可审计的车辆数据 API。" />
<title>羚牛车辆数据开放平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 20311",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"@vitejs/plugin-react": "^4.3.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vite": "^6.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"jsdom": "^25.0.1",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}

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