diff --git a/design-qa.md b/design-qa.md index 85ce88d6..26e39f77 100644 --- a/design-qa.md +++ b/design-qa.md @@ -107,3 +107,446 @@ required by the operational task instead of copying the reference's team rail. this shell's rendered result. final result: passed + +--- + +# Access portrait evidence viewport — Design QA + +- Source visual truth: + `/tmp/access-mobile-density-20260720/01-production-320-before.png` +- Local implementation: + `/tmp/access-mobile-density-20260720/02-local-320-after.png` +- Local detail interaction: + `/tmp/access-mobile-density-20260720/03-local-320-detail.png` +- Local 390px regression: + `/tmp/access-mobile-density-20260720/04-local-390-after.png` +- Deployed 320px evidence: + `/tmp/access-mobile-density-20260720/05-production-320-after.png` +- Deployed detail interaction: + `/tmp/access-mobile-density-20260720/06-production-320-detail.png` +- Deployed 390px evidence: + `/tmp/access-mobile-density-20260720/07-production-390-after.png` +- Same-viewport production comparison: + `/tmp/access-mobile-density-20260720/08-production-320-comparison.png` +- Viewports: 320 × 568 and 390 × 844 +- State: authenticated administrator; Access directory with all vehicles + selected and live protocol evidence + +## Findings and comparison history + +### Baseline + +- **[P1] The portrait mobile evidence list was almost entirely clipped.** + - Evidence: at 320 × 568 the page reserved 72px of redundant bottom padding, + a 72px filter trigger, a 58px repeated panel heading and a 92px metric + rail. Only 19px remained for the vehicle scroller, so the first 155px + vehicle card could not be read or selected reliably. + - Impact: the primary Access task—identifying a vehicle and opening its + protocol evidence—was effectively blocked on narrow mobile devices. + - Fix: removed the redundant portrait-only page reserve, reduced the filter + trigger to 56px, reduced the metric rail to 60px minimum height and hid the + repeated mobile panel heading. The established command bar, filters, + metrics, pagination and bottom navigation remain present. + +### Final pass + +- No actionable P0, P1 or P2 issue remains in this mobile Access flow. +- At 320 × 568 the evidence scroller increased from 19px to 195px, a 176px + gain and more than tenfold improvement. One complete vehicle card and the + next-card affordance are visible. +- At 390 × 844 the scroller is 471px tall and three evidence cards are visible + before pagination. +- Both viewports report document width equal to viewport width. The bottom + navigation, pagination and detail sheet do not overlap or introduce + horizontal scrolling. + +## Required fidelity surfaces + +- **Fonts and typography:** existing Semi UI typography, plate hierarchy and + protocol metadata remain unchanged and readable. +- **Spacing and layout rhythm:** only portrait Access density changed. Existing + short-landscape rules and Web layout are untouched. +- **Colors and visual tokens:** the existing neutral, blue, green and amber + semantic states are preserved, including partial-source warning evidence. +- **Image quality and assets:** no new imagery, icon approximation or + replacement asset was introduced. +- **Copy and content:** command, filter, vehicle, source and evidence labels are + unchanged; only the duplicate `接入差异清单` mobile heading is visually + removed. +- **Icons:** existing Semi icons and controls retain their current sizing and + active states. +- **Responsiveness:** 320px and 390px portrait layouts have no horizontal + overflow. The portrait-only media query does not alter desktop or landscape + composition. +- **Accessibility:** every vehicle remains a semantic button and the selected + vehicle opens a labeled `车辆接入详情` dialog with a labeled close action. + This visual and DOM pass does not replace a full assistive-technology audit. + +## Interaction and console checks + +- Production Access → first vehicle card → vehicle detail → close was exercised + successfully at 320px. +- The detail sheet measured 320px wide and 477px tall, retained its internal + scroller, and exposed `接入健康概览` plus `协议证据`. +- Automated gate: 73 test files and 383 tests passed. The targeted Access and + production-entry gate passed 14 tests. +- TypeScript, Vite, distribution preparation and the release build verifier + completed successfully with 74 current assets. +- Release `access-mobile-list-20260720182508` is active. `/access` returned + HTTP 200, the service is active, and the deployed + `app-DC_UXKeU.css` SHA-256 matches the local production asset: + `8e723387c096e156fb70e2a234ade464c0d1faca000c2d54ef8a65e402638c78`. +- No application runtime failure or visible error state was observed during + local or deployed interaction testing. Existing development-only Semi UI + `findDOMNode` warnings remain unchanged. + +## Follow-up polish + +- P3: revisit Access card vertical density only if future protocol evidence + adds a fourth simultaneous source; the current three-source layout is + readable at 320px. + +final result: passed + +--- + +# Account governance clarity pass — 2026-07-20 + +## Scope + +- Route: `/users` +- Surfaces: desktop directory, mobile directory, desktop configuration + SideSheet, mobile configuration SideSheet. +- Production release: `users-clarity-20260720181338`. + +## Findings and fixes + +- **P1 — Mobile directory hierarchy consumed too much of the first viewport.** + - Before: at 320 × 568 the directory began at y=291, so only about three + complete customer rows were visible. + - Fix: removed the repeated mobile directory heading, reduced the metric + rail to 56px and kept the list itself as a single-row directory. + - After: the directory begins at y=227. Four complete rows plus the next-row + affordance are visible at 320 × 568; all five current accounts fit at + 390 × 844. + +- **P1 — Mobile configuration repeated identity information before the first + editable field.** + - Before: header, three-column summary, numbered tabs and identity overview + pushed the first editable field to roughly y=425. + - Fix: kept only the decision-critical permission scope and account-status + summary on mobile, removed duplicate step circles and hid the repeated + identity overview. + - After: the first identity field begins at y=253 in production. Permission + scope, account enable/disable state, section tabs and persistent save state + remain visible. + +- **P2 — Narrow directory scrolling reduced usable row width.** + - Fix: hid the decorative inner scrollbar on mobile while preserving the + keyboard-focusable scroll region and native touch scrolling. + +## Fidelity and interaction checks + +- Desktop remains a true one-row directory with dedicated columns for menu + permissions, vehicle permissions, recent login and access state. +- Desktop configuration retains the complete three-part summary and detailed + identity overview; mobile removes only duplicated information. +- The mobile vehicle-permission tab was exercised in production: assigned + plates remain primary, VIN secondary, and effective periods, adjust/remove + actions, pagination and history remain reachable. +- No horizontal document overflow occurred at 390px or 1440px. +- Production console: no warnings or errors for the deployed route. +- Automated gate: 73 test files and 382 tests passed. Production build gate + passed with 74 release assets. +- Local and ECS `UsersPage-CNmDqRKT.css` SHA-256 matched: + `6d5e0f80489967fc5f4eac7c30236091969e66730e68031713740c3f8c603ba3`. + +## Evidence + +- `/tmp/responsive-audit-20260720/users-list-mobile-production.png` +- `/tmp/responsive-audit-20260720/users-config-mobile-production.png` +- `/tmp/responsive-audit-20260720/users-config-vehicles-mobile-after.png` +- `/tmp/responsive-audit-20260720/users-list-desktop-production.png` + +final result: passed + +# 2026-07-20 Operations mobile clarity audit + +## Audit scope + +- Surface: authenticated `/operations` quality workspace. +- User goal: scan the active reconciliation queue, understand the two urgent + counts, and move between reconciliation and diagnosis without leaving the + viewport or losing mobile navigation. +- Production baseline: + `/tmp/cross-route-audit-20260720/mobile-operations-stable.png`. +- Local implementation: + `/tmp/cross-route-audit-20260720/mobile-operations-local-after-stable.png`. +- Production verification: + `/tmp/cross-route-audit-20260720/mobile-operations-production-final.png`. + +## Findings and resolution + +1. **P1 — the operations command surfaces exceeded the phone viewport.** + - Baseline measurement: the 390px-wide command bar started at x=8 and + ended at x=398 because a desktop `width: 100%` combined with 8px mobile + margins. The adjacent workspace navigation inherited the same geometry. + - Impact: the global refresh action and right edge of the segmented + workspace control were visibly clipped. + - Fix: the mobile operations command bar and workspace navigation now use + their available flex width (`width: auto`) and remain inset from x=8 to + x=382. + +2. **P1 — half of the reconciliation metric rail was reserved for hidden + desktop context.** + - Baseline measurement: the rail body retained `199px 180px` columns at + 390px even though the context column was hidden. The two visible metrics + were compressed to 99.5px each, truncating both supporting notes. + - Impact: users could see the large numbers but not understand whether + `1,327` represented active items or what caused `1,189` SLA breaches. + - Fix: the hidden context column now collapses on phones. The visible list + occupies all 379px with two equal 189.5px columns, and each metric uses a + clear label/value/note stack. + +## Final pass + +- 390 × 844 production document width equals viewport width. +- Command bar and workspace navigation are both fully inside the viewport. +- `待复核 1,327 / 活跃 1,327 项` and + `SLA 超时 1,189 / 超过 24 小时` are fully legible. +- The `差异处置 → 单车诊断 → 差异处置` interaction was exercised against + ECS production. Both panels rendered correctly and document width remained + 390px. +- 1440 × 900 retained the existing five-column reconciliation table, summary + context, side navigation and pagination without reflow. +- No production console warning or error was emitted during the smoke test. + The known Semi UI `findDOMNode` message remains development-only. +- Automated gate: 73 test files and 381 tests passed. +- Production build gate: 74 release assets passed. +- Release `ops-mobile-clarity-20260720175459` is active. The served + `app-BUsvckVz.css` SHA-256 matches the local build: + `feb4f945ff7dc586a31d1c65f985048b7ef6399574911db182767fc813c28121`. + +## Evidence limits + +- Screenshot and DOM evidence verifies responsive reflow, visible hierarchy, + interaction state and console health. It does not by itself prove full WCAG + conformance or screen-reader announcements. +- The diagnosis vehicle picker was not used to mutate production state; its + empty state and workspace transition were verified read-only. + +final result: passed + +--- + +# Account management guided onboarding — Design QA + +- Source visual truth: + `/tmp/account-management-audit-20260720/07-users-mobile-create-production-baseline.png` +- Rendered implementation: + `/tmp/account-management-audit-20260720/08-users-mobile-create-local-after.png` +- Deployed production implementation: + `/tmp/account-management-audit-20260720/14-users-mobile-create-production-after.png` +- Full-view comparison: + `/tmp/account-management-audit-20260720/12-users-mobile-create-full-comparison.png` +- Focused summary/footer comparison: + `/tmp/account-management-audit-20260720/13-users-mobile-create-focus-comparison.png` +- Web implementation: + `/tmp/account-management-audit-20260720/11-users-desktop-create-local-after.png` +- Follow-on menu step: + `/tmp/account-management-audit-20260720/09-users-mobile-create-menu-local-after.png` +- Follow-on vehicle step: + `/tmp/account-management-audit-20260720/10-users-mobile-create-vehicles-local-after.png` +- Viewports: 390 × 844 and 1440 × 900 +- State: authenticated administrator; customer directory, existing customer + editor and new-customer draft + +## Findings and comparison history + +### Baseline + +- **[P1] The new-account default contradicted the page's least-privilege + promise.** + - Evidence: the production source capture opened a new customer with all + four customer menus already granted, while the header and supporting copy + said permissions were opened by minimum necessary scope. + - Impact: an administrator could create a broadly authorized account without + explicitly reviewing menu access. + - Fix: new drafts now begin with zero menu and zero vehicle permissions. + Existing accounts retain their saved menu and vehicle grants unchanged. + - Post-fix evidence: the implementation summary shows `0 菜单 · 0 辆`, the + badge reads `待开放菜单`, and every menu row begins unchecked. +- **[P2] Draft state and the primary action implied that an unsaved account was + already synchronized.** + - Evidence: before the fix, the new-account footer showed `已同步` and the + disabled action was `创建账号`, even though no server-side account existed. + - Impact: administrators could not distinguish a local draft from a saved + identity and were not prompted to review permissions in order. + - Fix: new drafts show `新建草稿`; the primary action advances through + `下一步:菜单权限`, `下一步:车辆权限`, then `创建账号`. The first step + remains disabled until username, display name and a 10–128 character + password with at least three character categories are complete. + - Post-fix evidence: the focused comparison shows the corrected state badge, + zero-permission summary and step-specific action. + +### Final pass + +- No actionable P0, P1 or P2 issue remains in the audited account-management + flow. +- The existing Web single-line customer directory, mobile dense customer list, + identity editor, menu editor, vehicle paging and grant-window editor were + preserved. +- A production smoke used a disposable client-side draft, advanced from + identity to the zero-menu state, then discarded the draft. No customer was + created or modified. + +## Required fidelity surfaces + +- **Fonts and typography:** the existing PingFang/system stack, title weights, + compact metadata and tab hierarchy remain unchanged. Step labels fit at + 390px without wrapping or truncating the primary action. +- **Spacing and layout rhythm:** the established full-width mobile sheet, + sticky summary, three-step tabs, scrollable form and sticky footer remain + aligned. The Web sheet retains its 820px maximum width and two-column fields. +- **Colors and visual tokens:** existing Semi blue, neutral and semantic + green/amber states are retained. Amber now consistently communicates an + incomplete permission scope; blue communicates a local new draft. +- **Image quality and assets:** no new imagery or replacement asset was + introduced. The product's supplied vector brand mark and Semi icon family + remain unchanged. +- **Copy and content:** `新建草稿`, `待开放菜单` and the step-specific actions + accurately describe server state and the administrator's next task. +- **Icons:** the existing Semi close, eye, plus and checkbox icons remain + aligned and use the same active/disabled treatments. +- **Responsiveness:** 390px and 1440px captures show no horizontal overflow, + clipped footer, hidden close action or obscured form field. +- **Accessibility:** the flow exposes a dialog, three semantic tabs, labeled + fields, checkboxes and buttons. Disabled state prevents advancing with an + incomplete or weak identity. Screenshot and DOM review do not replace a + full assistive-technology audit. + +## Interaction and console checks + +- Production customer directory → new account → identity draft → menu step → + discard draft was exercised without a write. +- Local identity fields were completed with a compliant preview password; the + primary action became enabled and advanced to a zero-menu state. +- Menu and vehicle tabs rendered their unchecked/empty states and the final + create action without layout shift. +- Existing-customer identity, menu and 44-vehicle states were captured on Web + and mobile before implementation. +- Local development emitted only Semi UI's known React `findDOMNode` + deprecation warning. Deployed production emitted no warning or error. +- Automated gate: 73 test files and 380 tests passed. TypeScript, Vite and the + release build verifier completed successfully. +- Production release `account-onboarding-ux-20260720173338` is active. The + immutable release gate verified 74 current and 116 compatibility assets; the + served UsersPage bundle is byte-for-byte identical to the local build. + +## Follow-up polish + +- P3: add a compact password-rule checklist to the administrator's initial + password field when the shared password-input pattern is next consolidated. + +final result: passed + +--- + +# Intermediate sidebar continuity — Design QA + +- Source visual truth: `/tmp/sidebar-continuity-20260720/02-monitor-901-production.png` +- Rendered implementation: `/tmp/sidebar-continuity-20260720/11-monitor-901-local-settled.png` +- Full-view comparison: `/tmp/sidebar-continuity-20260720/12-monitor-901-full-comparison.png` +- Focused navigation comparison: `/tmp/sidebar-continuity-20260720/13-monitor-901-focus-comparison.png` +- Cross-route implementation evidence: `/tmp/sidebar-continuity-20260720/09-routes-1024-local-montage.png` +- Mobile regression evidence: `/tmp/sidebar-continuity-20260720/10-statistics-390-mobile-regression.png` +- Viewports: 680 × 700, 681 × 700, 900 × 700, 901 × 700, + 1024 × 700, 1100 × 700, 1101 × 700, 1280 × 700 and 390 × 844 +- State: authenticated customer; Monitor map mode, plus Vehicles, Tracks and + Statistics default workspaces + +## Findings and comparison history + +### Baseline + +- **[P2] A one-pixel viewport change caused a disproportionate workspace + reflow.** + - Evidence: production at 900px used a 72px compact rail and an 828px main + region, while 901px immediately restored a 228px rail and reduced the main + region to 673px. The full-view source capture shows the map losing about + one third of its width even though the device width increased. + - Impact: tablet and browser-zoom users could cross the breakpoint and see + filters, telemetry and the map recompose abruptly. + - Fix: aligned the React shell state and shared CSS breakpoint so the 72px + Semi icon rail remains active from 681px through 1100px. Full navigation + returns at 1101px, where the main region still has 873px. + - Post-fix evidence: the 901px implementation has a 72px rail, 829px main + region and complete 284px account action region. The focused comparison + shows the recovered workspace width without changing the page hierarchy. + +### Final pass + +- No actionable P0, P1 or P2 mismatch remains. +- Boundary measurements: + - 680px: mobile shell, 680px main region. + - 681px: 72px rail, 609px main region. + - 900px: 72px rail, 828px main region. + - 901px: 72px rail, 829px main region. + - 1024px: 72px rail, 952px main region. + - 1100px: 72px rail, 1028px main region. + - 1101px: 228px rail, 873px main region. + - 1280px: 228px rail, 1052px main region. +- Vehicles, Tracks and Statistics all retained the same shell geometry at + 1024px and 1101px with document width equal to viewport width. + +## Required fidelity surfaces + +- **Fonts and typography:** the existing PingFang/system stack, weights and + hierarchy remain unchanged. Labels stay readable in the compact rail and + the 901px topbar has no wrapping or clipping. +- **Spacing and layout rhythm:** the change preserves the established 72px and + 228px shell tokens and removes the unstable intermediate reflow. Page + margins, card spacing and content density remain consistent across routes. +- **Colors and visual tokens:** no palette or semantic state color changed. + Existing white surfaces, neutral borders and Semi blue selection states + remain faithful to the current production design. +- **Image quality and assets:** the supplied vector brand mark remains the + single shell asset. No raster substitute, custom SVG or CSS-drawn asset was + introduced. +- **Copy and content:** no product copy changed. Compact navigation uses the + existing accessible menu labels while hiding only visible text. +- **Icons:** the existing Semi icon set remains optically centered in the 72px + rail; selected and inactive states were checked at 901px and 1024px. +- **Responsiveness:** no horizontal document overflow occurred at any tested + boundary. The 390px mileage matrix retained fixed vehicle and total columns, + horizontal date navigation and bottom navigation. +- **Accessibility:** complementary navigation remains present at 681–1100px; + mobile navigation remains available below 681px; the account menu remains + fully inside the 901px viewport. This visual pass does not replace an + assistive-technology audit. + +## Interaction and console checks + +- Monitor, Vehicles, Tracks and Statistics were navigated in the authenticated + browser at 1024px and measured again at 1101px. +- Route changes retained the shell, active navigation and full-width work area. +- The 390px Statistics page preserved usable pagination and bottom navigation. +- No application runtime error was observed. Semi UI's known React + `findDOMNode` development StrictMode deprecation warning remains. +- Automated gate: 73 test files and 379 tests passed; production build and + release verification completed successfully. +- Deployed production evidence: + `/tmp/sidebar-continuity-20260720/14-monitor-901-production-after.png`. + The authenticated ECS page measured a 72px rail and 829px main region at + 901px, with no horizontal overflow and no browser console warning or error. +- Release `sidebar-continuity-ux-20260720171458` is active. The immutable + release gate verified 74 current assets and 116 compatibility assets; the + served shell stylesheet is byte-for-byte identical to the local build. + +## Follow-up polish + +- P3: replace the remaining Semi UI components that trigger React + `findDOMNode` development warnings when the library exposes ref-safe + alternatives. + +final result: passed diff --git a/docs/ops/vehicle-ingest-runbook.md b/docs/ops/vehicle-ingest-runbook.md index cf264690..eb8dd5f9 100644 --- a/docs/ops/vehicle-ingest-runbook.md +++ b/docs/ops/vehicle-ingest-runbook.md @@ -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 diff --git a/go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go b/go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go new file mode 100644 index 00000000..b1df01c3 --- /dev/null +++ b/go/vehicle-gateway/cmd/legacy-mileage-migrate/main.go @@ -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 +} diff --git a/go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go b/go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go new file mode 100644 index 00000000..0300fc26 --- /dev/null +++ b/go/vehicle-gateway/cmd/legacy-mileage-migrate/main_test.go @@ -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) + } +} diff --git a/go/vehicle-gateway/cmd/stat-writer/main.go b/go/vehicle-gateway/cmd/stat-writer/main.go index 92be79f7..fe2ab923 100644 --- a/go/vehicle-gateway/cmd/stat-writer/main.go +++ b/go/vehicle-gateway/cmd/stat-writer/main.go @@ -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, } } diff --git a/go/vehicle-gateway/cmd/stats-backfill/main.go b/go/vehicle-gateway/cmd/stats-backfill/main.go index dcd8de9e..ca755963 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main.go @@ -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: diff --git a/go/vehicle-gateway/cmd/stats-backfill/main_test.go b/go/vehicle-gateway/cmd/stats-backfill/main_test.go index 38d1b02f..93978e47 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main_test.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main_test.go @@ -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", "") diff --git a/go/vehicle-gateway/internal/envelope/envelope.go b/go/vehicle-gateway/internal/envelope/envelope.go index 9077537d..016ac3d2 100644 --- a/go/vehicle-gateway/internal/envelope/envelope.go +++ b/go/vehicle-gateway/internal/envelope/envelope.go @@ -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 { diff --git a/go/vehicle-gateway/internal/feichibridge/encoder.go b/go/vehicle-gateway/internal/feichibridge/encoder.go index bd116d27..0e1be2da 100644 --- a/go/vehicle-gateway/internal/feichibridge/encoder.go +++ b/go/vehicle-gateway/internal/feichibridge/encoder.go @@ -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) } diff --git a/go/vehicle-gateway/internal/feichibridge/encoder_test.go b/go/vehicle-gateway/internal/feichibridge/encoder_test.go index 8543b8fa..c4f51e45 100644 --- a/go/vehicle-gateway/internal/feichibridge/encoder_test.go +++ b/go/vehicle-gateway/internal/feichibridge/encoder_test.go @@ -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) { diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser.go b/go/vehicle-gateway/internal/protocol/gb32960/parser.go index 219ffd60..e066be36 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser.go @@ -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"] diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go index 0e8c4dc1..07ed6fd4 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser_test.go @@ -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) diff --git a/go/vehicle-gateway/internal/protocol/yutongmqtt/parser.go b/go/vehicle-gateway/internal/protocol/yutongmqtt/parser.go index 07fe7e07..0184461f 100644 --- a/go/vehicle-gateway/internal/protocol/yutongmqtt/parser.go +++ b/go/vehicle-gateway/internal/protocol/yutongmqtt/parser.go @@ -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 } diff --git a/go/vehicle-gateway/internal/protocol/yutongmqtt/parser_test.go b/go/vehicle-gateway/internal/protocol/yutongmqtt/parser_test.go index 802dfcb3..053bbfbb 100644 --- a/go/vehicle-gateway/internal/protocol/yutongmqtt/parser_test.go +++ b/go/vehicle-gateway/internal/protocol/yutongmqtt/parser_test.go @@ -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") } diff --git a/go/vehicle-gateway/internal/realtime/openapi.go b/go/vehicle-gateway/internal/realtime/openapi.go index 15c56a6b..3cd8869e 100644 --- a/go/vehicle-gateway/internal/realtime/openapi.go +++ b/go/vehicle-gateway/internal/realtime/openapi.go @@ -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{ diff --git a/go/vehicle-gateway/internal/stats/daily_metric.go b/go/vehicle-gateway/internal/stats/daily_metric.go index 5de535e6..d723da54 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric.go +++ b/go/vehicle-gateway/internal/stats/daily_metric.go @@ -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 } diff --git a/go/vehicle-gateway/internal/stats/daily_metric_test.go b/go/vehicle-gateway/internal/stats/daily_metric_test.go index 7c67a064..b6704b76 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric_test.go +++ b/go/vehicle-gateway/internal/stats/daily_metric_test.go @@ -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, ). diff --git a/go/vehicle-gateway/internal/stats/hydrogen_capacity.go b/go/vehicle-gateway/internal/stats/hydrogen_capacity.go new file mode 100644 index 00000000..9c8dfafc --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_capacity.go @@ -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() +} diff --git a/go/vehicle-gateway/internal/stats/hydrogen_capacity_test.go b/go/vehicle-gateway/internal/stats/hydrogen_capacity_test.go new file mode 100644 index 00000000..7facaf2f --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_capacity_test.go @@ -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) + } +} diff --git a/go/vehicle-gateway/internal/stats/hydrogen_stream.go b/go/vehicle-gateway/internal/stats/hydrogen_stream.go new file mode 100644 index 00000000..ba84e3e0 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_stream.go @@ -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)` diff --git a/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go b/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go new file mode 100644 index 00000000..302ace19 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/hydrogen_stream_test.go @@ -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) + } + } +} diff --git a/go/vehicle-gateway/internal/stats/pure_hydrogen.go b/go/vehicle-gateway/internal/stats/pure_hydrogen.go new file mode 100644 index 00000000..5580e0c6 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/pure_hydrogen.go @@ -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 + } +} diff --git a/go/vehicle-gateway/internal/stats/pure_hydrogen_test.go b/go/vehicle-gateway/internal/stats/pure_hydrogen_test.go new file mode 100644 index 00000000..064ff4d6 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/pure_hydrogen_test.go @@ -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) + } + }) + } +} diff --git a/go/vehicle-gateway/internal/stats/query.go b/go/vehicle-gateway/internal/stats/query.go index 5ddabc30..a6f9fb1a 100644 --- a/go/vehicle-gateway/internal/stats/query.go +++ b/go/vehicle-gateway/internal/stats/query.go @@ -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` diff --git a/go/vehicle-gateway/internal/stats/query_test.go b/go/vehicle-gateway/internal/stats/query_test.go index 8e305c6e..7aa2cb50 100644 --- a/go/vehicle-gateway/internal/stats/query_test.go +++ b/go/vehicle-gateway/internal/stats/query_test.go @@ -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", )) diff --git a/go/vehicle-gateway/internal/stats/schema.go b/go/vehicle-gateway/internal/stats/schema.go index 40e046c3..bfb76b38 100644 --- a/go/vehicle-gateway/internal/stats/schema.go +++ b/go/vehicle-gateway/internal/stats/schema.go @@ -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, diff --git a/go/vehicle-gateway/internal/stats/source_mileage.go b/go/vehicle-gateway/internal/stats/source_mileage.go index 3e5f6725..d62a348b 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage.go +++ b/go/vehicle-gateway/internal/stats/source_mileage.go @@ -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 ` diff --git a/go/vehicle-gateway/internal/stats/source_mileage_test.go b/go/vehicle-gateway/internal/stats/source_mileage_test.go index d1d7ffc6..44bb41af 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage_test.go +++ b/go/vehicle-gateway/internal/stats/source_mileage_test.go @@ -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) = '')", diff --git a/go/vehicle-gateway/internal/stats/source_test.go b/go/vehicle-gateway/internal/stats/source_test.go index 72ac14fb..67add067 100644 --- a/go/vehicle-gateway/internal/stats/source_test.go +++ b/go/vehicle-gateway/internal/stats/source_test.go @@ -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) { diff --git a/vehicle-data-platform/apps/api/cmd/alert-notification-dispatcher/main.go b/vehicle-data-platform/apps/api/cmd/alert-notification-dispatcher/main.go new file mode 100644 index 00000000..4adeeffc --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/alert-notification-dispatcher/main.go @@ -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: + } + } +} diff --git a/vehicle-data-platform/apps/api/cmd/open-platform-api/main.go b/vehicle-data-platform/apps/api/cmd/open-platform-api/main.go new file mode 100644 index 00000000..b1ba5aab --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/open-platform-api/main.go @@ -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 +} diff --git a/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go new file mode 100644 index 00000000..22a29bbd --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/open-platform-stat/main.go @@ -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 +} diff --git a/vehicle-data-platform/apps/api/go.mod b/vehicle-data-platform/apps/api/go.mod index 4218a1a8..6b05de49 100644 --- a/vehicle-data-platform/apps/api/go.mod +++ b/vehicle-data-platform/apps/api/go.mod @@ -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 ) diff --git a/vehicle-data-platform/apps/api/go.sum b/vehicle-data-platform/apps/api/go.sum index 972db51b..864bce02 100644 --- a/vehicle-data-platform/apps/api/go.sum +++ b/vehicle-data-platform/apps/api/go.sum @@ -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= diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index 140fbeeb..e5a6534d 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -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" diff --git a/vehicle-data-platform/apps/api/internal/app/auth_demo.go b/vehicle-data-platform/apps/api/internal/app/auth_demo.go new file mode 100644 index 00000000..e87cfd5b --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/auth_demo.go @@ -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) +} diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go index 1aca1b94..25fe1f66 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -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 != "" { diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go index 378ddc02..0ca1585e 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/auth_test.go b/vehicle-data-platform/apps/api/internal/app/auth_test.go index f1f798e1..0db1da93 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_test.go @@ -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"}, diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter.go b/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter.go new file mode 100644 index 00000000..5e23aa8a --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter.go @@ -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 + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter_test.go new file mode 100644 index 00000000..f039ed74 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter_test.go @@ -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) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 4eac09be..fda8f55c 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -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"` diff --git a/vehicle-data-platform/apps/api/internal/app/server_test.go b/vehicle-data-platform/apps/api/internal/app/server_test.go index 84e0d970..5311d9e1 100644 --- a/vehicle-data-platform/apps/api/internal/app/server_test.go +++ b/vehicle-data-platform/apps/api/internal/app/server_test.go @@ -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()) + } +} diff --git a/vehicle-data-platform/apps/api/internal/businessscope/source.go b/vehicle-data-platform/apps/api/internal/businessscope/source.go index b5fb3577..47cc677b 100644 --- a/vehicle-data-platform/apps/api/internal/businessscope/source.go +++ b/vehicle-data-platform/apps/api/internal/businessscope/source.go @@ -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 diff --git a/vehicle-data-platform/apps/api/internal/businessscope/source_test.go b/vehicle-data-platform/apps/api/internal/businessscope/source_test.go index 77bf135b..6d4cc80f 100644 --- a/vehicle-data-platform/apps/api/internal/businessscope/source_test.go +++ b/vehicle-data-platform/apps/api/internal/businessscope/source_test.go @@ -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) { diff --git a/vehicle-data-platform/apps/api/internal/config/config.go b/vehicle-data-platform/apps/api/internal/config/config.go index 1fbc8770..0a9e859e 100644 --- a/vehicle-data-platform/apps/api/internal/config/config.go +++ b/vehicle-data-platform/apps/api/internal/config/config.go @@ -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 + } +} diff --git a/vehicle-data-platform/apps/api/internal/config/config_test.go b/vehicle-data-platform/apps/api/internal/config/config_test.go index 43bc75a0..d71cab2c 100644 --- a/vehicle-data-platform/apps/api/internal/config/config_test.go +++ b/vehicle-data-platform/apps/api/internal/config/config_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html new file mode 100644 index 00000000..3ff47126 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html @@ -0,0 +1,186 @@ + + + + + + 车辆数据开放平台 + + + +
+
+

车辆数据开放平台

+

面向合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。接口使用独立的 32 位 appKey 认证。

+ +
+ +

认证

+
Authorization: Bearer <32位appKey>
+Content-Type: application/json
+

appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。

+ +

开放接口

+

POST/api/v1/vehicles/hydrogen-consumption/query

+

查询指定车辆的单日用氢量,单位 kg。

+

POST/api/v1/vehicles/mileage/query

+

查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。

+

以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。

+

POST/api/v1/vehicles/mileage/range/query

+

按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。

+

两个里程接口均可传 protocolPriority,唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议;未列出的协议完全禁用。省略字段时保持现有默认选源行为。

+

查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。

+

POST/api/v1/vehicles/total-mileage/query

+

按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。

+ +

总里程协议口径

+ + + + + + + +
protocol 唯一规范值总里程含义
GB32960车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程
YUTONG_MQTT车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报
JT808定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程
+

protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。

+ +

请求示例

+
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"
+  }'
+ +

查询全部授权车辆的单日数据

+
{
+  "date": "2026-07-01"
+}
+ +

按自定义协议优先级查询单日里程

+
{
+  "plateNumbers": ["粤A12345"],
+  "date": "2026-07-01",
+  "protocolPriority": ["JT808", "GB32960", "MQTT"]
+}
+ +

指定时刻总里程

+
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"
+  }'
+ +

车辆区间日里程

+
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
+  }'
+

下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。

+ +

响应示例

+

车辆单日里程

+
{
+  "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"
+}
+

里程状态为 NORMAL 时,dailyMileageKm、totalMileageKm、dataTime 与 updatedAt 均有值;NO_DATA 时相关数据字段为 null,真实零里程仍为 NORMAL。

+

车辆区间日里程响应

+
{
+  "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"
+}
+

车辆单日用氢量

+
{
+  "code": "SUCCESS",
+  "message": "success",
+  "data": [{
+    "plateNumber": "粤A12345",
+    "date": "2026-07-01",
+    "hydrogenConsumptionKg": 12.315,
+    "status": "NORMAL"
+  }],
+  "traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
+}
+ +

指定时刻总里程响应

+
{
+  "code": "SUCCESS",
+  "message": "success",
+  "data": {
+    "vin": "LA9GG68L2PBAF4790",
+    "queryTime": "2026-07-21 09:30:00",
+    "totalMileageKm": 12345.678,
+    "protocol": "GB32960",
+    "protocolInput": "GB32960",
+    "mileageMeaning": "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)",
+    "recordTime": "2026-07-21 09:29:45",
+    "timeDifferenceSeconds": 15,
+    "selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
+    "status": "NORMAL"
+  },
+  "traceId": "95bddca78133474fa2bf56ecdf758e22"
+}
+ +

状态与错误码

+ + + + + + + + + + + +
HTTPcode说明
200SUCCESS查询成功;无统计数据的车辆以 NO_DATA 返回
400INVALID_REQUEST请求格式、车牌或数量不正确
400INVALID_DATE_FORMAT日期不是 yyyy-MM-dd
400INVALID_DATETIME_FORMAT时间不是 yyyy-MM-dd HH:mm:ss
401UNAUTHORIZEDappKey 不存在、停用或过期
403FORBIDDENKey 或车辆授权未覆盖查询自然日
500INTERNAL_ERROR服务内部异常
+
+ + diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml new file mode 100644 index 00000000..da20ba74 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -0,0 +1,743 @@ +openapi: 3.0.3 +info: + title: 车辆数据开放平台 API + version: 1.5.0 + license: + name: Proprietary + description: | + 向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。 + appKey 和逐车授权必须完整覆盖查询自然日。 +servers: + - url: / + description: 当前服务 +tags: + - name: 合作方数据接口 + description: 使用 appKey 查询已授权车辆的日统计数据 + - name: 开放平台管理 + description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口 +paths: + /api/v1/vehicles/hydrogen-consumption/query: + post: + tags: [合作方数据接口] + summary: 查询车辆单日用氢量 + description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 + operationId: queryDailyHydrogenConsumption + security: + - AppKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VehicleDailyQuery' + example: + date: '2026-07-01' + responses: + '200': + description: 查询成功;无数据车辆仍保留在结果中 + content: + application/json: + schema: + $ref: '#/components/schemas/HydrogenQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/vehicles/mileage/query: + post: + tags: [合作方数据接口] + summary: 查询车辆单日里程 + description: | + plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。 + protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。 + NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。 + 当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。 + operationId: queryDailyMileage + security: + - AppKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MileageDailyQuery' + example: + date: '2026-07-01' + protocolPriority: [GB32960, MQTT, JT808] + responses: + '200': + description: 查询成功;无数据车辆仍保留在结果中 + content: + application/json: + schema: + $ref: '#/components/schemas/MileageQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/vehicles/mileage/range/query: + post: + tags: [合作方数据接口] + summary: 分页查询车辆区间日里程 + description: | + 最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。 + 首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。 + 快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。 + protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。 + 某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。 + operationId: queryDailyMileageRange + security: + - AppKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MileageRangeQuery' + example: + startDate: '2026-07-01' + endDate: '2026-07-23' + plateNumbers: [沪A00001, 沪A00002] + protocolPriority: [JT808, GB32960, MQTT] + cursor: null + pageSize: 5000 + responses: + '200': + description: 查询成功;每辆授权车辆每天均有一条 NORMAL 或 NO_DATA 记录 + content: + application/json: + schema: + $ref: '#/components/schemas/MileageRangeQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v1/vehicles/total-mileage/query: + post: + tags: [合作方数据接口] + summary: 查询指定时刻的车辆总里程 + description: | + 返回不晚于请求时间的最近一条有效总里程记录、实际记录时间和时间差秒数。 + protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。 + GB32960 和 YUTONG_MQTT 为车辆仪表盘或车端累计里程;JT808 为定位终端/GPS侧累计里程。 + operationId: queryTotalMileageAtTime + security: + - AppKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TotalMileageQuery' + example: + vin: LA9GG68L2PBAF4790 + time: '2026-07-21 09:30:00' + protocol: GB32960 + responses: + '200': + description: 查询成功;没有可用记录时返回 NO_DATA + content: + application/json: + schema: + $ref: '#/components/schemas/TotalMileageQueryResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalError' + /api/v2/open-platform/apps: + get: + tags: [开放平台管理] + summary: 查询开放平台应用 + operationId: listOpenPlatformApps + security: + - AdminBearer: [] + responses: + '200': + description: 应用列表 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminAppListResponse' + '401': + description: 未登录 + '403': + description: 非管理员 + post: + tags: [开放平台管理] + summary: 创建应用和 appKey + description: appKey 明文仅在本次响应中返回。 + operationId: createOpenPlatformApp + security: + - AdminBearer: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppInput' + responses: + '200': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminAppCreatedResponse' + '400': + description: 参数错误 + '401': + description: 未登录 + '403': + description: 非管理员 + /api/v2/open-platform/apps/{id}: + put: + tags: [开放平台管理] + summary: 更新应用状态和有效期 + operationId: updateOpenPlatformApp + security: + - AdminBearer: [] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppInput' + responses: + '200': + description: 更新成功 + '400': + description: 参数错误 + '404': + description: 应用不存在 + /api/v2/open-platform/apps/{id}/rotate-key: + post: + tags: [开放平台管理] + summary: 轮换 appKey + description: 旧 Key 立即失效,新 Key 明文仅在本次响应中返回。 + operationId: rotateOpenPlatformAppKey + security: + - AdminBearer: [] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: 轮换成功 + content: + application/json: + schema: + $ref: '#/components/schemas/AdminAppCreatedResponse' + '404': + description: 应用不存在 + /api/v2/open-platform/apps/{id}/vehicles: + get: + tags: [开放平台管理] + summary: 查询应用的车辆授权 + operationId: listOpenPlatformVehicleGrants + security: + - AdminBearer: [] + parameters: + - $ref: '#/components/parameters/AppId' + responses: + '200': + description: 授权列表 + '404': + description: 应用不存在 + put: + tags: [开放平台管理] + summary: 完整替换车辆授权 + operationId: replaceOpenPlatformVehicleGrants + security: + - AdminBearer: [] + parameters: + - $ref: '#/components/parameters/AppId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VehicleGrantRequest' + responses: + '200': + description: 替换成功 + '400': + description: VIN 或有效期不正确 + '404': + description: 应用不存在 +components: + securitySchemes: + AppKeyAuth: + type: http + scheme: bearer + bearerFormat: 32-character appKey + description: 32 位无连字符 UUID appKey + AdminBearer: + type: http + scheme: bearer + bearerFormat: Platform session token + description: 车辆数据平台管理员令牌 + parameters: + AppId: + name: id + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + BadRequest: + description: 请求参数不正确 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidDate: + value: + code: INVALID_DATE_FORMAT + message: date格式必须为yyyy-MM-dd + traceId: 4ccf63c4e51d4d4ab9107d931783a53e + Unauthorized: + description: appKey 不存在、停用或过期 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Forbidden: + description: Key 或车辆授权未完整覆盖查询自然日 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalError: + description: 服务内部异常 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + VehicleDailyQuery: + type: object + additionalProperties: false + required: [date] + properties: + plateNumbers: + type: array + maxItems: 200 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 32 + description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆 + date: + type: string + format: date + description: 查询自然日,yyyy-MM-dd + MileageDailyQuery: + type: object + additionalProperties: false + required: [date] + properties: + plateNumbers: + type: array + maxItems: 200 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 32 + description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆 + date: + type: string + format: date + description: 查询自然日,yyyy-MM-dd + protocolPriority: + $ref: '#/components/schemas/ProtocolPriority' + MileageRangeQuery: + type: object + additionalProperties: false + required: [startDate, endDate] + properties: + startDate: + type: string + format: date + description: 区间开始自然日,Asia/Shanghai + endDate: + type: string + format: date + description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天 + plateNumbers: + type: array + maxItems: 5000 + uniqueItems: true + items: + type: string + minLength: 1 + maxLength: 32 + description: 可选;省略或传空数组时查询整个区间均有效授权的全部车辆 + protocolPriority: + $ref: '#/components/schemas/ProtocolPriority' + cursor: + type: string + nullable: true + description: 首次查询传 null 或省略;翻页时原样传入上一页 nextCursor + pageSize: + type: integer + minimum: 1 + maximum: 5000 + default: 5000 + ProtocolPriority: + type: array + minItems: 1 + maxItems: 3 + uniqueItems: true + items: + type: string + enum: [GB32960, MQTT, JT808] + description: 可选;按数组顺序逐车逐日选择第一个有效协议。未列出的协议被禁用且不会作为兜底;省略字段时保持平台默认选源行为 + TotalMileageQuery: + type: object + additionalProperties: false + required: [vin, time] + properties: + vin: + type: string + pattern: '^[A-HJ-NPR-Z0-9]{17}$' + description: 已授权车辆 VIN + time: + type: string + pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$' + description: 请求时刻,北京时间,固定格式 yyyy-MM-dd HH:mm:ss + protocol: + type: string + enum: [GB32960, YUTONG_MQTT, JT808] + description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808 + HydrogenResult: + type: object + required: [plateNumber, date, hydrogenConsumptionKg, status] + properties: + plateNumber: + type: string + date: + type: string + format: date + hydrogenConsumptionKg: + type: number + format: double + nullable: true + description: 单日用氢量,kg;无数据时为 null + status: + $ref: '#/components/schemas/DataStatus' + MileageResult: + type: object + required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status] + properties: + vin: + type: string + plateNumber: + type: string + date: + type: string + format: date + dailyMileageKm: + type: number + format: double + nullable: true + description: 单日里程,km;当日无记录但存在历史累计里程时为 0 + totalMileageKm: + type: number + format: double + nullable: true + description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null + dataTime: + type: string + format: date-time + nullable: true + description: 本次统计实际采用的最后一条车辆源数据时间;status=NO_DATA 时为 null + updatedAt: + type: string + format: date-time + nullable: true + description: 本行所依据统计周期的计算时间;前向填充时为上一统计周期计算时间,status=NO_DATA 时为 null + sourceProtocol: + type: string + enum: [GB32960, MQTT, JT808] + nullable: true + description: 本行实际选中的来源协议;status=NO_DATA 时为 null + status: + $ref: '#/components/schemas/DataStatus' + MileageRangeResult: + type: object + required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status] + properties: + vin: + type: string + plateNumber: + type: string + date: + type: string + format: date + dailyMileageKm: + type: number + format: double + nullable: true + minimum: 0 + description: 当日无记录但存在历史累计里程时为 0 + totalMileageKm: + type: number + format: double + nullable: true + minimum: 0 + description: 当日所选协议累计总里程;缺日时沿用此前最近有效值 + dataTime: + type: string + format: date-time + nullable: true + updatedAt: + type: string + format: date-time + nullable: true + description: 本行所依据统计周期的计算时间;缺日补齐时为上一统计周期计算时间 + sourceProtocol: + type: string + enum: [GB32960, MQTT, JT808] + nullable: true + description: 本行实际选中的来源协议;status=NO_DATA 时为 null + status: + $ref: '#/components/schemas/DataStatus' + DataStatus: + type: string + enum: [NORMAL, NO_DATA] + HydrogenQueryResponse: + allOf: + - $ref: '#/components/schemas/SuccessEnvelope' + - type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/HydrogenResult' + MileageQueryResponse: + allOf: + - $ref: '#/components/schemas/SuccessEnvelope' + - type: object + required: [data] + properties: + data: + type: array + items: + $ref: '#/components/schemas/MileageResult' + MileageRangeQueryResponse: + type: object + required: [code, message, data, snapshotId, nextCursor, traceId] + properties: + code: + type: string + enum: [SUCCESS] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/MileageRangeResult' + snapshotId: + type: string + description: 同一次分页查询保持不变的授权车辆快照标识 + nextCursor: + type: string + nullable: true + description: 下一页游标;最后一页为 null + traceId: + type: string + TotalMileageResult: + type: object + required: [vin, queryTime, totalMileageKm, selectionPolicy, status] + properties: + vin: + type: string + queryTime: + type: string + description: 请求时刻,北京时间 + totalMileageKm: + type: number + format: double + nullable: true + description: 总里程,km;无数据时为 null + protocol: + type: string + enum: [GB32960, YUTONG_MQTT, JT808] + description: 实际采用的采集协议 + protocolInput: + type: string + description: 请求中指定的协议原值;未指定时省略 + mileageMeaning: + type: string + description: 当前协议总里程的业务含义 + recordTime: + type: string + description: 命中的实际采集记录时间,北京时间 + timeDifferenceSeconds: + type: integer + format: int64 + minimum: 0 + description: 请求时间减实际记录时间,单位秒 + selectionPolicy: + type: string + example: GB32960 > YUTONG_MQTT > JT808 + status: + $ref: '#/components/schemas/DataStatus' + TotalMileageQueryResponse: + allOf: + - $ref: '#/components/schemas/SuccessEnvelope' + - type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/TotalMileageResult' + 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 diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger-init.js b/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger-init.js new file mode 100644 index 00000000..6ccccca3 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger-init.js @@ -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" + }); +}); diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger.html new file mode 100644 index 00000000..612ae0d7 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/swagger.html @@ -0,0 +1,15 @@ + + + + + + 车辆数据开放平台 Swagger + + + +
+ + + + + diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs.go b/vehicle-data-platform/apps/api/internal/openplatform/docs.go new file mode 100644 index 00000000..764b00c2 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go new file mode 100644 index 00000000..3a9dcb9d --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go @@ -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) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/handler.go b/vehicle-data-platform/apps/api/internal/openplatform/handler.go new file mode 100644 index 00000000..ce89543f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/handler.go @@ -0,0 +1,729 @@ +package openplatform + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/httpx" + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +const ( + HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query" + MileageQueryPath = "/api/v1/vehicles/mileage/query" + MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query" + TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query" +) + +type Handler struct { + service *Service + portal *PortalService + mux *http.ServeMux +} + +func NewHandler(service *Service) *Handler { + handler := &Handler{service: service, mux: http.NewServeMux()} + handler.registerExternalDataRoutes() + handler.registerAdminAppRoutes() + return handler +} + +func NewAdminHandler(service *Service, portal *PortalService) *Handler { + handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()} + handler.registerAdminAppRoutes() + if portal != nil { + handler.registerAdminUserRoutes() + } + return handler +} + +func NewExternalHandler(service *Service, portal *PortalService) *Handler { + handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()} + handler.registerExternalDataRoutes() + if portal != nil { + handler.registerPortalRoutes() + } + return handler +} + +func (h *Handler) registerExternalDataRoutes() { + h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen) + h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage) + h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange) + h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage) +} + +func (h *Handler) registerAdminAppRoutes() { + h.mux.HandleFunc("GET /api/v2/open-platform/apps", h.listApps) + h.mux.HandleFunc("POST /api/v2/open-platform/apps", h.createApp) + h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}", h.updateApp) + h.mux.HandleFunc("POST /api/v2/open-platform/apps/{id}/rotate-key", h.rotateKey) + h.mux.HandleFunc("GET /api/v2/open-platform/apps/{id}/vehicles", h.listVehicleGrants) + h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}/vehicles", h.replaceVehicleGrants) +} + +func (h *Handler) registerAdminUserRoutes() { + h.mux.HandleFunc("GET /api/v2/open-platform/users", h.listPortalUsers) + h.mux.HandleFunc("POST /api/v2/open-platform/users", h.createPortalUser) + h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}", h.updatePortalUser) + h.mux.HandleFunc("GET /api/v2/open-platform/users/{id}/apps", h.listPortalUserApps) + h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}/apps", h.replacePortalUserApps) +} + +func (h *Handler) registerPortalRoutes() { + h.mux.HandleFunc("POST /portal-api/auth/login", h.portalLogin) + h.mux.HandleFunc("POST /portal-api/auth/logout", h.portalLogout) + h.mux.HandleFunc("GET /portal-api/session", h.portalSession) + h.mux.HandleFunc("GET /portal-api/catalog", h.portalCatalog) + h.mux.HandleFunc("GET /portal-api/apps", h.portalApps) + h.mux.HandleFunc("GET /portal-api/apps/{id}/vehicles", h.portalVehicles) + h.mux.HandleFunc("GET /portal-api/apps/{id}/audit", h.portalAudit) + h.mux.HandleFunc("POST /portal-api/apps/{id}/rotate-key", h.portalRotateKey) + h.mux.HandleFunc("PUT /portal-api/account/password", h.portalChangePassword) + h.mux.HandleFunc("GET /portal-api/admin/apps", h.portalAdminListApps) + h.mux.HandleFunc("POST /portal-api/admin/apps", h.portalAdminCreateApp) + h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}", h.portalAdminUpdateApp) + h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}/vehicles", h.portalAdminReplaceVehicles) + h.mux.HandleFunc("GET /portal-api/admin/vehicles", h.portalAdminVehicleCatalog) + h.mux.HandleFunc("GET /portal-api/admin/users", h.portalAdminListUsers) + h.mux.HandleFunc("POST /portal-api/admin/users", h.portalAdminCreateUser) + h.mux.HandleFunc("PUT /portal-api/admin/users/{id}", h.portalAdminUpdateUser) + h.mux.HandleFunc("GET /portal-api/admin/users/{id}/apps", h.portalAdminListUserApps) + h.mux.HandleFunc("PUT /portal-api/admin/users/{id}/apps", h.portalAdminReplaceUserApps) +} + +func NewDataHandler(service *Service) *Handler { + handler := &Handler{service: service, mux: http.NewServeMux()} + handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen) + handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage) + handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange) + handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage) + return handler +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} + +func IsPublicPath(path string) bool { + return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath +} + +func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) { + traceID := externalTraceID(r) + var request QueryRequest + if !decodeExternalBody(w, r, traceID, &request) { + return + } + data, err := h.service.QueryHydrogen(r.Context(), externalBearer(r), traceID, request) + if err != nil { + writeExternalError(w, traceID, err) + return + } + writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID}) +} + +func (h *Handler) mileage(w http.ResponseWriter, r *http.Request) { + traceID := externalTraceID(r) + var request QueryRequest + if !decodeExternalBody(w, r, traceID, &request) { + return + } + data, err := h.service.QueryMileage(r.Context(), externalBearer(r), traceID, request) + if err != nil { + writeExternalError(w, traceID, err) + return + } + writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID}) +} + +func (h *Handler) mileageRange(w http.ResponseWriter, r *http.Request) { + traceID := externalTraceID(r) + var request MileageRangeRequest + if !decodeExternalBody(w, r, traceID, &request) { + return + } + response, err := h.service.QueryMileageRange(r.Context(), externalBearer(r), traceID, request) + if err != nil { + writeExternalError(w, traceID, err) + return + } + writeExternal(w, http.StatusOK, response) +} + +func (h *Handler) totalMileage(w http.ResponseWriter, r *http.Request) { + traceID := externalTraceID(r) + var request TotalMileageQueryRequest + if !decodeExternalBody(w, r, traceID, &request) { + return + } + data, err := h.service.QueryTotalMileage(r.Context(), externalBearer(r), traceID, request) + if err != nil { + writeExternalError(w, traceID, err) + return + } + writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID}) +} + +func (h *Handler) listApps(w http.ResponseWriter, r *http.Request) { + data, err := h.service.ListApps(r.Context()) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) createApp(w http.ResponseWriter, r *http.Request) { + var input AppInput + if !decodeAdminBody(w, r, &input) { + return + } + data, err := h.service.CreateApp(r.Context(), input, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) updateApp(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + var input AppInput + if !decodeAdminBody(w, r, &input) { + return + } + data, err := h.service.UpdateApp(r.Context(), id, input, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + data, err := h.service.RotateKey(r.Context(), id, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) listVehicleGrants(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + data, err := h.service.ListVehicleGrants(r.Context(), id) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) replaceVehicleGrants(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + var request VehicleGrantRequest + if !decodeAdminBody(w, r, &request) { + return + } + data, err := h.service.ReplaceVehicleGrants(r.Context(), id, request, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) listPortalUsers(w http.ResponseWriter, r *http.Request) { + data, err := h.portal.ListUsers(r.Context()) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) createPortalUser(w http.ResponseWriter, r *http.Request) { + var input PortalUserInput + if !decodeAdminBody(w, r, &input) { + return + } + data, err := h.portal.CreateUser(r.Context(), input, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) updatePortalUser(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + var input PortalUserInput + if !decodeAdminBody(w, r, &input) { + return + } + data, err := h.portal.UpdateUser(r.Context(), id, input, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) listPortalUserApps(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + data, err := h.portal.ListUserApps(r.Context(), id) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) replacePortalUserApps(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + var request PortalUserAppRequest + if !decodeAdminBody(w, r, &request) { + return + } + data, err := h.portal.ReplaceUserApps(r.Context(), id, request, platform.ActorFromContext(r.Context())) + h.writeAdmin(w, r, data, err) +} + +func (h *Handler) portalLogin(w http.ResponseWriter, r *http.Request) { + var input PortalLoginRequest + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.portal.Login(r.Context(), input, requestRemoteAddress(r), r.UserAgent()) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalLogout(w http.ResponseWriter, r *http.Request) { + h.portal.Logout(r.Context(), externalBearer(r)) + h.writePortal(w, r, map[string]bool{"loggedOut": true}, nil) +} + +func (h *Handler) portalSession(w http.ResponseWriter, r *http.Request) { + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + h.writePortal(w, r, session, err) +} + +func (h *Handler) portalCatalog(w http.ResponseWriter, r *http.Request) { + h.writePortal(w, r, dataProducts(), nil) +} + +func (h *Handler) portalApps(w http.ResponseWriter, r *http.Request) { + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err != nil { + h.writePortal(w, r, nil, err) + return + } + data, err := h.portal.Apps(r.Context(), session) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalVehicles(w http.ResponseWriter, r *http.Request) { + appID, ok := parsePortalID(w, r) + if !ok { + return + } + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err != nil { + h.writePortal(w, r, nil, err) + return + } + data, err := h.portal.Vehicles(r.Context(), session, appID) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAudit(w http.ResponseWriter, r *http.Request) { + appID, ok := parsePortalID(w, r) + if !ok { + return + } + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err != nil { + h.writePortal(w, r, nil, err) + return + } + data, err := h.portal.Audit(r.Context(), session, appID) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalRotateKey(w http.ResponseWriter, r *http.Request) { + appID, ok := parsePortalID(w, r) + if !ok { + return + } + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err == nil { + err = h.portal.CanRotateKey(r.Context(), session, appID) + } + if err != nil { + h.writePortal(w, r, nil, err) + return + } + data, err := h.service.RotateKey(r.Context(), appID, "portal:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalChangePassword(w http.ResponseWriter, r *http.Request) { + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err != nil { + h.writePortal(w, r, nil, err) + return + } + var input struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if !decodePortalBody(w, r, &input) { + return + } + err = h.portal.ChangePassword(r.Context(), session, input.CurrentPassword, input.NewPassword) + h.writePortal(w, r, map[string]bool{"changed": err == nil}, err) +} + +func (h *Handler) portalAdminListApps(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePortalAdmin(w, r); !ok { + return + } + data, err := h.service.ListApps(r.Context()) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminCreateApp(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + var input AppInput + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.service.CreateApp(r.Context(), input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminUpdateApp(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + id, ok := parsePortalID(w, r) + if !ok { + return + } + var input AppInput + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.service.UpdateApp(r.Context(), id, input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminReplaceVehicles(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + id, ok := parsePortalID(w, r) + if !ok { + return + } + var input VehicleGrantRequest + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.service.ReplaceVehicleGrants(r.Context(), id, input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminVehicleCatalog(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePortalAdmin(w, r); !ok { + return + } + data, err := h.portal.VehicleCatalog(r.Context()) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminListUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePortalAdmin(w, r); !ok { + return + } + data, err := h.portal.ListUsers(r.Context()) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminCreateUser(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + var input PortalUserInput + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.portal.CreateUser(r.Context(), input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminUpdateUser(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + id, ok := parsePortalID(w, r) + if !ok { + return + } + var input PortalUserInput + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.portal.UpdateUser(r.Context(), id, input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminListUserApps(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePortalAdmin(w, r); !ok { + return + } + id, ok := parsePortalID(w, r) + if !ok { + return + } + data, err := h.portal.ListUserApps(r.Context(), id) + h.writePortal(w, r, data, err) +} + +func (h *Handler) portalAdminReplaceUserApps(w http.ResponseWriter, r *http.Request) { + session, ok := h.requirePortalAdmin(w, r) + if !ok { + return + } + id, ok := parsePortalID(w, r) + if !ok { + return + } + var input PortalUserAppRequest + if !decodePortalBody(w, r, &input) { + return + } + data, err := h.portal.ReplaceUserApps(r.Context(), id, input, "portal-admin:"+session.Username) + h.writePortal(w, r, data, err) +} + +func (h *Handler) requirePortalAdmin(w http.ResponseWriter, r *http.Request) (PortalSession, bool) { + session, err := h.portal.Authenticate(r.Context(), externalBearer(r)) + if err == nil && session.UserType != "admin" { + err = ErrForbidden + } + if err != nil { + h.writePortal(w, r, nil, err) + return PortalSession{}, false + } + return session, true +} + +func (h *Handler) writeAdmin(w http.ResponseWriter, r *http.Request, data any, err error) { + traceID := externalTraceID(r) + switch { + case err == nil: + httpx.WriteOK(w, traceID, data) + case errors.Is(err, ErrInvalidRequest): + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID) + case errors.Is(err, ErrNotFound): + httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "开放平台应用不存在", "", traceID) + default: + httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", err.Error(), traceID) + } +} + +func (h *Handler) writePortal(w http.ResponseWriter, r *http.Request, data any, err error) { + traceID := externalTraceID(r) + w.Header().Set("Cache-Control", "no-store") + switch { + case err == nil: + httpx.WriteOK(w, traceID, data) + case errors.Is(err, ErrUnauthorized): + w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-open-platform-portal"`) + httpx.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "登录状态无效或已过期", "", traceID) + case errors.Is(err, ErrForbidden): + httpx.WriteError(w, http.StatusForbidden, "FORBIDDEN", "当前账号无此操作权限", "", traceID) + case errors.Is(err, ErrInvalidRequest): + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID) + case errors.Is(err, ErrNotFound): + httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "资源不存在", "", traceID) + default: + httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", "", traceID) + } +} + +func decodeExternalBody(w http.ResponseWriter, r *http.Request, traceID string, output any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + message := "请求参数不正确" + if strings.Contains(err.Error(), "protocolPriority") { + message = "protocolPriority必须是非空字符串数组" + } + writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: message, TraceID: traceID}) + return false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求体只能包含一个JSON对象", TraceID: traceID}) + return false + } + return true +} + +func decodeAdminBody(w http.ResponseWriter, r *http.Request, output any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), externalTraceID(r)) + return false + } + return true +} + +func decodePortalBody(w http.ResponseWriter, r *http.Request, output any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", portalDecodeDetail(err), externalTraceID(r)) + return false + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求体只能包含一个JSON对象", "", externalTraceID(r)) + return false + } + return true +} + +func portalDecodeDetail(err error) string { + var syntaxError *json.SyntaxError + var typeError *json.UnmarshalTypeError + switch { + case errors.As(err, &syntaxError): + return "请求内容不是有效的 JSON" + case errors.As(err, &typeError): + if typeError.Field != "" { + return fmt.Sprintf("字段 %s 的数据类型不正确", typeError.Field) + } + return "请求字段的数据类型不正确" + case errors.Is(err, io.EOF): + return "请求内容不能为空" + case strings.HasPrefix(err.Error(), "json: unknown field "): + return "请求包含未支持的字段 " + strings.TrimPrefix(err.Error(), "json: unknown field ") + default: + return "请求内容无法解析" + } +} + +func writeExternalError(w http.ResponseWriter, traceID string, err error) { + response := ExternalResponse{TraceID: traceID} + status := http.StatusInternalServerError + switch { + case errors.Is(err, ErrUnauthorized): + status, response.Code, response.Message = http.StatusUnauthorized, "UNAUTHORIZED", "身份认证失败" + case errors.Is(err, ErrForbidden): + status, response.Code, response.Message = http.StatusForbidden, "FORBIDDEN", "无数据访问权限" + case errors.Is(err, ErrInvalidRequest): + status, response.Code, response.Message = http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确" + if strings.Contains(err.Error(), "invalid datetime") { + response.Code, response.Message = "INVALID_DATETIME_FORMAT", "time格式必须为yyyy-MM-dd HH:mm:ss" + } else if strings.Contains(err.Error(), "invalid date") { + response.Code, response.Message = "INVALID_DATE_FORMAT", "date格式必须为yyyy-MM-dd" + } else if strings.Contains(err.Error(), "protocolPriority must not be empty") { + response.Message = "protocolPriority不能为空" + } else if strings.Contains(err.Error(), "protocolPriority contains duplicate protocol") { + response.Message = "protocolPriority不能包含重复协议" + } else if strings.Contains(err.Error(), "protocolPriority contains unsupported protocol") { + response.Message = "protocolPriority仅允许GB32960、MQTT、JT808" + } + default: + response.Code, response.Message = "INTERNAL_ERROR", "服务内部异常" + } + if status == http.StatusUnauthorized { + w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-open-platform"`) + } + writeExternal(w, status, response) +} + +func writeExternal(w http.ResponseWriter, status int, response any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(response) +} + +func externalBearer(r *http.Request) string { + header := strings.TrimSpace(r.Header.Get("Authorization")) + if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") { + return "" + } + return strings.TrimSpace(header[7:]) +} + +func externalTraceID(r *http.Request) string { + if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 { + return traceID + } + var value [16]byte + if _, err := rand.Read(value[:]); err == nil { + return hex.EncodeToString(value[:]) + } + return strconv.FormatInt(time.Now().UnixNano(), 10) +} + +func parseID(w http.ResponseWriter, r *http.Request) (uint64, bool) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil || id == 0 { + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r)) + return 0, false + } + return id, true +} + +func parsePortalID(w http.ResponseWriter, r *http.Request) (uint64, bool) { + id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) + if err != nil || id == 0 { + httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r)) + return 0, false + } + return id, true +} + +func requestRemoteAddress(r *http.Request) string { + for _, header := range []string{"X-Forwarded-For", "X-Real-Ip"} { + if value := strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0]); value != "" { + return value + } + } + return r.RemoteAddr +} + +func dataProducts() []DataProduct { + return []DataProduct{ + { + Code: "daily_hydrogen", Name: "单日用氢量", + Description: "按车牌和自然日查询授权车辆的氢气消耗量。", + Version: "v1", Status: "available", Method: http.MethodPost, + Path: HydrogenQueryPath, Unit: "kg", + }, + { + Code: "daily_mileage", Name: "单日里程", + Description: "按车牌和自然日查询日里程、累计里程、实际来源协议与数据时间,支持自定义协议优先级。", + Version: "v1", Status: "available", Method: http.MethodPost, + Path: MileageQueryPath, Unit: "km", + }, + { + Code: "mileage_range", Name: "区间日里程", + Description: "按最长366天区间分页查询逐日里程,支持逐车逐日自定义协议优先级。", + Version: "v1", Status: "available", Method: http.MethodPost, + Path: MileageRangeQueryPath, Unit: "km", + }, + { + Code: "total_mileage_at_time", Name: "指定时刻总里程", + Description: "按VIN和北京时间查询最近一条总里程、采集协议及记录时间差。", + Version: "v1", Status: "available", Method: http.MethodPost, + Path: TotalMileageQueryPath, Unit: "km", + }, + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go new file mode 100644 index 00000000..a78486eb --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -0,0 +1,330 @@ +package openplatform + +import ( + "encoding/json" + "time" +) + +const ( + StatusNormal = "NORMAL" + StatusNoData = "NO_DATA" +) + +type QueryRequest struct { + PlateNumbers []string `json:"plateNumbers"` + Date string `json:"date"` + ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"` +} + +type MileageRangeRequest struct { + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + PlateNumbers []string `json:"plateNumbers"` + ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"` + Cursor string `json:"cursor"` + PageSize int `json:"pageSize"` +} + +type ProtocolPriority struct { + Values []string + Present bool +} + +func (p *ProtocolPriority) UnmarshalJSON(data []byte) error { + p.Present = true + if string(data) == "null" { + p.Values = []string{} + return nil + } + var values []string + if err := json.Unmarshal(data, &values); err != nil { + return err + } + p.Values = values + return nil +} + +type HydrogenResult struct { + PlateNumber string `json:"plateNumber"` + Date string `json:"date"` + HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` + Status string `json:"status"` +} + +type MileageResult struct { + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + Date string `json:"date"` + DailyMileageKm *float64 `json:"dailyMileageKm"` + TotalMileageKm *float64 `json:"totalMileageKm"` + DataTime *string `json:"dataTime"` + UpdatedAt *string `json:"updatedAt"` + SourceProtocol *string `json:"sourceProtocol"` + Status string `json:"status"` +} + +type MileageRangeResult struct { + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + Date string `json:"date"` + DailyMileageKm *float64 `json:"dailyMileageKm"` + TotalMileageKm *float64 `json:"totalMileageKm"` + DataTime *string `json:"dataTime"` + UpdatedAt *string `json:"updatedAt"` + SourceProtocol *string `json:"sourceProtocol"` + Status string `json:"status"` +} + +type TotalMileageQueryRequest struct { + VIN string `json:"vin"` + Time string `json:"time"` + Protocol string `json:"protocol,omitempty"` +} + +type TotalMileageResult struct { + VIN string `json:"vin"` + QueryTime string `json:"queryTime"` + TotalMileageKm *float64 `json:"totalMileageKm"` + Protocol string `json:"protocol,omitempty"` + ProtocolInput string `json:"protocolInput,omitempty"` + MileageMeaning string `json:"mileageMeaning,omitempty"` + RecordTime string `json:"recordTime,omitempty"` + TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"` + SelectionPolicy string `json:"selectionPolicy"` + Status string `json:"status"` +} + +type TotalMileagePoint struct { + VIN string + Protocol string + ObservedAt time.Time + TotalMileageKm float64 +} + +type ExternalResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` + TraceID string `json:"traceId"` +} + +type MileageRangeResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Data []MileageRangeResult `json:"data"` + SnapshotID string `json:"snapshotId"` + NextCursor *string `json:"nextCursor"` + TraceID string `json:"traceId"` +} + +type App struct { + ID uint64 `json:"id"` + Name string `json:"name"` + AppKeyPrefix string `json:"appKeyPrefix"` + Status string `json:"status"` + ValidFrom time.Time `json:"validFrom"` + ValidTo *time.Time `json:"validTo,omitempty"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AppCreated struct { + App + AppKey string `json:"appKey"` +} + +type AppInput struct { + Name string `json:"name"` + Status string `json:"status"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` +} + +type VehicleGrantInput struct { + VIN string `json:"vin"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` +} + +type VehicleGrantRequest struct { + Vehicles []VehicleGrantInput `json:"vehicles"` +} + +type VehicleGrant struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + ValidFrom time.Time `json:"validFrom"` + ValidTo *time.Time `json:"validTo,omitempty"` + GrantedBy string `json:"grantedBy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type VehicleCatalogItem struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + OEM string `json:"oem"` + Status string `json:"status"` + Source string `json:"source"` +} + +type AuthorizedVehicle struct { + VIN string + Plate string +} + +type AppCredential struct { + ID uint64 + Name string +} + +type DailyHydrogen struct { + VIN string + Date string + ConsumptionKg float64 + SampleCount int + QualityStatus string +} + +type DailyMileage struct { + VIN string + Date string + Protocol string + MileageKm float64 + TotalMileageKm float64 + DataTime string + UpdatedAt string +} + +type MileageSnapshot struct { + ID string + AppID uint64 + RequestHash []byte + StartDate string + EndDate string + VehicleCount int + ExpiresAt time.Time + Vehicles []AuthorizedVehicle +} + +type HydrogenObservation struct { + VIN string + Source string + ObservedAt time.Time + MassKg float64 + TankCapacityLiter float64 + PressureMPa float64 + TemperatureC float64 + NoiseKg float64 + RefuelThresholdKg float64 +} + +type HydrogenRateObservation struct { + VIN string + Source string + ObservedAt time.Time + Rate float64 + MileageKm float64 +} + +type HydrogenRateDailyStat struct { + VIN string + Source string + Date string + ConsumptionKg float64 + SampleCount int + QualityStatus string + QualityReason string +} + +type HydrogenDailyStat struct { + VIN string + Source string + Date string + ConsumptionKg float64 + FirstMassKg float64 + LastMassKg float64 + SampleCount int + RefuelCount int + QualityStatus string + QualityReason string +} + +type PortalUser struct { + ID uint64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Status string `json:"status"` + ValidFrom time.Time `json:"validFrom"` + ValidTo *time.Time `json:"validTo,omitempty"` + LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PortalUserInput struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Status string `json:"status"` + ValidFrom string `json:"validFrom"` + ValidTo string `json:"validTo"` +} + +type PortalUserAppInput struct { + AppID uint64 `json:"appId"` + Role string `json:"role"` +} + +type PortalUserAppRequest struct { + Apps []PortalUserAppInput `json:"apps"` +} + +type PortalUserApp struct { + AppID uint64 `json:"appId"` + AppName string `json:"appName"` + AppKeyPrefix string `json:"appKeyPrefix"` + AppStatus string `json:"appStatus"` + Role string `json:"role"` + ValidFrom time.Time `json:"validFrom"` + ValidTo *time.Time `json:"validTo,omitempty"` +} + +type PortalSession struct { + UserID uint64 `json:"userId"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + UserType string `json:"userType"` + ExpiresAt time.Time `json:"expiresAt"` +} + +type PortalLoginResponse struct { + AccessToken string `json:"accessToken"` + ExpiresAt time.Time `json:"expiresAt"` + Session PortalSession `json:"session"` +} + +type PortalLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type PortalAuditItem struct { + TraceID string `json:"traceId"` + Endpoint string `json:"endpoint"` + Result string `json:"result"` + VehicleCount int `json:"vehicleCount"` + CreatedAt time.Time `json:"createdAt"` +} + +type DataProduct struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Status string `json:"status"` + Method string `json:"method"` + Path string `json:"path"` + Unit string `json:"unit"` +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go new file mode 100644 index 00000000..e7ebba3f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -0,0 +1,611 @@ +package openplatform + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type MySQLRepository struct { + db *sql.DB + tdengine *sql.DB + tdDatabase string +} + +func (r *MySQLRepository) WithTDengine(db *sql.DB, database string) *MySQLRepository { + if db == nil { + panic("open platform TDengine database is required") + } + if database == "" { + database = "lingniu_vehicle_ts" + } + for _, char := range database { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '_' { + panic("invalid TDengine database identifier") + } + } + r.tdengine = db + r.tdDatabase = database + return r +} + +func NewMySQLRepository(db *sql.DB) *MySQLRepository { + if db == nil { + panic("open platform database is required") + } + return &MySQLRepository{db: db} +} + +func (r *MySQLRepository) Authenticate(ctx context.Context, hash [sha256.Size]byte, now, dayStart, dayEnd time.Time) (AppCredential, error) { + var app AppCredential + err := r.db.QueryRowContext(ctx, ` +SELECT id,name +FROM vehicle_open_app +WHERE app_key_hash=? + AND status='enabled' + AND valid_from<=? + AND (valid_to IS NULL OR valid_to>?) + AND valid_from<=? + AND (valid_to IS NULL OR valid_to>=?)`, + hash[:], now, now, dayStart, dayEnd, + ).Scan(&app.ID, &app.Name) + if errors.Is(err, sql.ErrNoRows) { + return AppCredential{}, ErrUnauthorized + } + return app, err +} + +func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) { + args := make([]any, 0, len(plates)+3) + args = append(args, appID, dayStart, dayEnd) + query := ` +SELECT UPPER(b.plate),MIN(g.vin) +FROM vehicle_open_app_vehicle g +JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin +WHERE g.app_id=? + AND g.valid_from<=? + AND (g.valid_to IS NULL OR g.valid_to>=?) + AND TRIM(COALESCE(b.plate,''))<>''` + if len(plates) > 0 { + placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",") + query += ` + AND UPPER(b.plate) IN (` + placeholders + `)` + for _, plate := range plates { + args = append(args, plate) + } + } + query += ` +GROUP BY UPPER(b.plate) +HAVING COUNT(DISTINCT g.vin)=1 +ORDER BY UPPER(b.plate)` + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[string]AuthorizedVehicle, len(plates)) + for rows.Next() { + var vehicle AuthorizedVehicle + if err := rows.Scan(&vehicle.Plate, &vehicle.VIN); err != nil { + return nil, err + } + out[vehicle.Plate] = vehicle + } + return out, rows.Err() +} + +func (r *MySQLRepository) AuthorizedVIN(ctx context.Context, appID uint64, vin string, at time.Time) (bool, error) { + var count int + err := r.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM vehicle_open_app_vehicle +WHERE app_id=? AND BINARY vin=BINARY ? + AND valid_from<=? + AND (valid_to IS NULL OR valid_to>?)`, appID, vin, at, at).Scan(&count) + return count == 1, err +} + +func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.Time, protocols []string) (*TotalMileagePoint, error) { + if r.tdengine == nil || r.tdDatabase == "" { + return nil, errors.New("TDengine is not configured for total mileage query") + } + table := r.tdDatabase + ".vehicle_locations" + timeLiteral := strings.ReplaceAll(at.Format(time.RFC3339), "'", "''") + for _, protocol := range protocols { + query := `SELECT CAST(ts AS BIGINT),total_mileage_km,protocol FROM ` + table + + ` WHERE vin='` + vin + `' AND protocol='` + protocol + `'` + + ` AND ts<='` + timeLiteral + `' AND total_mileage_km IS NOT NULL AND total_mileage_km>=0` + + ` ORDER BY ts DESC LIMIT 1` + var timestampMS int64 + var point TotalMileagePoint + err := r.tdengine.QueryRowContext(ctx, query).Scan(×tampMS, &point.TotalMileageKm, &point.Protocol) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return nil, err + } + point.VIN = vin + point.ObservedAt = time.UnixMilli(timestampMS) + return &point, nil + } + return nil, nil +} + +func (r *MySQLRepository) 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=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]) +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go new file mode 100644 index 00000000..f53f7793 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go @@ -0,0 +1,212 @@ +package openplatform + +import ( + "context" + "regexp" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) { + query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"}) + if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") { + t.Fatalf("date format was corrupted: %s", query) + } + if !strings.Contains(query, "vin IN (?,?)") { + t.Fatalf("VIN placeholders missing: %s", query) + } + if len(args) != 3 || args[0] != "2026-07-21" || args[1] != "VIN1" || args[2] != "VIN2" { + t.Fatalf("unexpected args: %#v", args) + } +} + +func TestAuthorizedVehiclesUsesBinaryVINJoin(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local) + end := start.Add(24*time.Hour - time.Nanosecond) + mock.ExpectQuery(regexp.QuoteMeta("JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")). + WithArgs(uint64(1), start, end, "辽A00001"). + WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).AddRow("辽A00001", "LTEST000000000001")) + + repository := NewMySQLRepository(db) + vehicles, err := repository.AuthorizedVehicles(context.Background(), 1, []string{"辽A00001"}, start, end) + if err != nil { + t.Fatal(err) + } + if vehicles["辽A00001"].VIN != "LTEST000000000001" { + t.Fatalf("unexpected vehicles: %#v", vehicles) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local) + end := start.Add(24 * time.Hour) + mock.ExpectQuery("JOIN vehicle_identity_binding.*TRIM.*ORDER BY UPPER"). + WithArgs(uint64(7), start, end). + WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}). + AddRow("粤A12345", "LTEST32960VIN0001"). + AddRow("粤B67890", "LTEST32960VIN0002")) + + vehicles, err := NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, nil, start, end) + if err != nil { + t.Fatal(err) + } + if len(vehicles) != 2 || vehicles["粤A12345"].VIN != "LTEST32960VIN0001" || vehicles["粤B67890"].VIN != "LTEST32960VIN0002" { + t.Fatalf("vehicles=%#v", vehicles) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0"). + WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}). + AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00")) + + values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil) + if err != nil { + t.Fatal(err) + } + first := values["LTEST32960VIN0001"] + if first.MileageKm != 101.235 || first.TotalMileageKm != 12345.679 || first.DataTime != "2026-07-21T23:58:45+08:00" || first.UpdatedAt == "" { + t.Fatalf("first=%#v", first) + } + if _, ok := values["LTEST32960VIN0002"]; ok { + t.Fatalf("vehicle without a cumulative total must not be returned: %#v", values) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2"). + WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}). + AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"). + AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00")) + + values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"}) + if err != nil { + t.Fatal(err) + } + selected := values["LTEST32960VIN0001"] + if selected.Protocol != "JT808" || selected.MileageKm != 0 { + t.Fatalf("selected=%#v", selected) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestLatestMileageBeforeUsesRequestedProtocolPriority(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*SELECT prior.vin,prior.protocol,MAX\\(prior.stat_date\\).*FROM vehicle_daily_mileage prior\\s+WHERE prior.stat_date<\\?.*prior.protocol IN \\(\\?,\\?\\).*ORDER BY m.vin,CASE m.protocol"). + WithArgs("2026-07-22", "LTEST32960VIN0001", "JT808", "YUTONG_MQTT", "JT808", "YUTONG_MQTT"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}). + AddRow("LTEST32960VIN0001", "2026-07-20", "JT808", 18.5, 9008.5, "2026-07-20T22:00:00+08:00", "2026-07-21T01:00:00+08:00"). + AddRow("LTEST32960VIN0001", "2026-07-21", "YUTONG_MQTT", 20.0, 12020.0, "2026-07-21T23:00:00+08:00", "2026-07-22T01:00:00+08:00")) + + values, err := NewMySQLRepository(db).LatestMileageBefore(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-22", []string{"JT808", "YUTONG_MQTT"}) + if err != nil { + t.Fatal(err) + } + selected := values["LTEST32960VIN0001"] + if selected.Protocol != "JT808" || + selected.MileageKm != 18.5 || + selected.TotalMileageKm != 9008.5 || + selected.DataTime != "2026-07-20T22:00:00+08:00" || + selected.UpdatedAt != "2026-07-21T01:00:00+08:00" { + t.Fatalf("selected=%#v", selected) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testing.T) { + mysqlDB, _, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer mysqlDB.Close() + tdDB, tdMock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer tdDB.Close() + at := time.Date(2026, 7, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600)) + tdMock.ExpectQuery("protocol='GB32960'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1"). + WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"})) + tdMock.ExpectQuery("protocol='YUTONG_MQTT'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1"). + WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}).AddRow(at.Add(-15*time.Second).UnixMilli(), 12345.678, "YUTONG_MQTT")) + repository := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts") + point, err := repository.TotalMileage(context.Background(), "LA9GG68L2PBAF4790", at, []string{"GB32960", "YUTONG_MQTT", "JT808"}) + if err != nil { + t.Fatal(err) + } + if point == nil || point.Protocol != "YUTONG_MQTT" || point.TotalMileageKm != 12345.678 || !point.ObservedAt.Equal(at.Add(-15*time.Second)) { + t.Fatalf("point=%#v", point) + } + if err := tdMock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + + now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local) + mock.ExpectQuery(regexp.QuoteMeta("LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")). + WithArgs(uint64(1)). + WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "valid_from", "valid_to", "granted_by", "updated_at"}). + AddRow("LTEST000000000001", "辽A00001", now, now.AddDate(1, 0, 0), "admin", now)) + + repository := NewMySQLRepository(db) + grants, err := repository.ListVehicleGrants(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if len(grants) != 1 || grants[0].Plate != "辽A00001" { + t.Fatalf("unexpected grants: %#v", grants) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/portal.go b/vehicle-data-platform/apps/api/internal/openplatform/portal.go new file mode 100644 index 00000000..06da77e6 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/portal.go @@ -0,0 +1,675 @@ +package openplatform + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + portalMaxLoginFailures = 5 + portalLoginLock = 15 * time.Minute +) + +var portalUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{3,64}$`) + +type PortalService struct { + db *sql.DB + sessionTTL time.Duration + now func() time.Time +} + +type portalCredential struct { + PortalUser + PasswordHash string + FailedLoginCount int + LockedUntil sql.NullTime + UserType string +} + +func NewPortalService(db *sql.DB, sessionTTL time.Duration) *PortalService { + if db == nil { + panic("open platform portal database is required") + } + if sessionTTL <= 0 { + sessionTTL = 12 * time.Hour + } + return &PortalService{db: db, sessionTTL: sessionTTL, now: time.Now} +} + +func (s *PortalService) CreateUser(ctx context.Context, input PortalUserInput, actor string) (PortalUser, error) { + from, to, err := validatePortalUserInput(&input, true) + if err != nil { + return PortalUser{}, err + } + if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil { + return PortalUser{}, err + } else if reserved { + return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest) + } + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12) + if err != nil { + return PortalUser{}, err + } + result, err := s.db.ExecContext(ctx, ` +INSERT INTO vehicle_open_user( + username,display_name,password_hash,status,valid_from,valid_to,created_by,updated_by +) VALUES(?,?,?,?,?,?,?,?)`, + input.Username, input.DisplayName, string(hash), input.Status, from, nullableTime(to), actor, actor) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest) + } + return PortalUser{}, err + } + id, err := result.LastInsertId() + if err != nil { + return PortalUser{}, err + } + _ = s.userAudit(ctx, uint64(id), actor, "user.create", "success", map[string]any{"username": input.Username}, "") + return s.user(ctx, uint64(id)) +} + +func (s *PortalService) ListUsers(ctx context.Context) ([]PortalUser, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at +FROM vehicle_open_user ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + users := make([]PortalUser, 0) + for rows.Next() { + user, err := scanPortalUser(rows) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +func (s *PortalService) UpdateUser(ctx context.Context, id uint64, input PortalUserInput, actor string) (PortalUser, error) { + from, to, err := validatePortalUserInput(&input, false) + if err != nil { + return PortalUser{}, err + } + if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil { + return PortalUser{}, err + } else if reserved { + return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest) + } + args := []any{input.Username, input.DisplayName, input.Status, from, nullableTime(to), actor} + query := `UPDATE vehicle_open_user SET username=?,display_name=?,status=?,valid_from=?,valid_to=?,updated_by=?` + if input.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12) + if err != nil { + return PortalUser{}, err + } + query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL` + args = append(args, string(hash)) + } + query += ` WHERE id=?` + args = append(args, id) + result, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest) + } + return PortalUser{}, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return PortalUser{}, ErrNotFound + } + if input.Status == "disabled" || input.Password != "" { + _, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id) + } + _ = s.userAudit(ctx, id, actor, "user.update", "success", map[string]any{"status": input.Status, "passwordReset": input.Password != ""}, "") + return s.user(ctx, id) +} + +func (s *PortalService) ReplaceUserApps(ctx context.Context, userID uint64, request PortalUserAppRequest, actor string) ([]PortalUserApp, error) { + if len(request.Apps) > 100 { + return nil, fmt.Errorf("%w: app memberships exceed 100", ErrInvalidRequest) + } + seen := map[uint64]bool{} + for _, item := range request.Apps { + item.Role = strings.ToLower(strings.TrimSpace(item.Role)) + if item.AppID == 0 || seen[item.AppID] || !validPortalRole(item.Role) { + return nil, fmt.Errorf("%w: invalid or duplicate app membership", ErrInvalidRequest) + } + seen[item.AppID] = true + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_user WHERE id=? FOR UPDATE`, userID).Scan(&exists); err != nil { + return nil, err + } + if exists == 0 { + return nil, ErrNotFound + } + for _, item := range request.Apps { + var appExists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, item.AppID).Scan(&appExists); err != nil { + return nil, err + } + if appExists == 0 { + return nil, fmt.Errorf("%w: app %d", ErrInvalidRequest, item.AppID) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_user_app WHERE user_id=?`, userID); err != nil { + return nil, err + } + for _, item := range request.Apps { + if _, err := tx.ExecContext(ctx, ` +INSERT INTO vehicle_open_user_app(user_id,app_id,role,granted_by) VALUES(?,?,?,?)`, + userID, item.AppID, strings.ToLower(strings.TrimSpace(item.Role)), actor); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + _ = s.userAudit(ctx, userID, actor, "user.apps.replace", "success", map[string]any{"count": len(request.Apps)}, "") + return s.ListUserApps(ctx, userID) +} + +func (s *PortalService) ListUserApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) { + return s.portalApps(ctx, userID) +} + +func (s *PortalService) Login(ctx context.Context, input PortalLoginRequest, remoteAddr, userAgent string) (PortalLoginResponse, error) { + input.Username = strings.TrimSpace(input.Username) + if !portalUsernamePattern.MatchString(input.Username) || len(input.Password) > 128 { + return PortalLoginResponse{}, ErrUnauthorized + } + credential, err := s.loginCredential(ctx, input.Username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + _ = s.userAudit(ctx, 0, input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddr) + return PortalLoginResponse{}, ErrUnauthorized + } + return PortalLoginResponse{}, err + } + now := s.now() + if credential.Status != "enabled" || + (credential.UserType != "admin" && (credential.ValidFrom.After(now) || (credential.ValidTo != nil && !credential.ValidTo.After(now)))) { + _ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "disabled_or_expired"}, remoteAddr) + return PortalLoginResponse{}, ErrForbidden + } + if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) { + return PortalLoginResponse{}, fmt.Errorf("%w: account locked", ErrForbidden) + } + if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil { + failures := credential.FailedLoginCount + 1 + var lockedUntil any + if failures >= portalMaxLoginFailures { + lockedUntil = now.Add(portalLoginLock) + } + _, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=?,locked_until=? WHERE id=?`, failures, lockedUntil, credential.ID) + _ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddr) + return PortalLoginResponse{}, ErrUnauthorized + } + rawToken, tokenHash, err := newPortalSessionToken() + if err != nil { + return PortalLoginResponse{}, err + } + sessionID, err := randomHexBytes(16) + if err != nil { + return PortalLoginResponse{}, err + } + expiresAt := now.Add(s.sessionTTL) + var portalUserID, platformUserID any + if credential.UserType == "admin" { + platformUserID = credential.ID + } else { + portalUserID = credential.ID + } + _, err = s.db.ExecContext(ctx, ` +INSERT INTO vehicle_open_user_session( + id,user_id,platform_user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent +) VALUES(?,?,?,?,?,?,?,?,?)`, + sessionID, portalUserID, platformUserID, tokenHash[:], now, expiresAt, now, truncate(remoteAddr, 96), truncate(userAgent, 255)) + if err != nil { + return PortalLoginResponse{}, err + } + _, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID) + _ = s.userAudit(ctx, auditPortalUserID(credential), credential.Username, "login", "success", map[string]any{"userType": credential.UserType}, remoteAddr) + session := PortalSession{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, UserType: credential.UserType, ExpiresAt: expiresAt} + return PortalLoginResponse{AccessToken: rawToken, ExpiresAt: expiresAt, Session: session}, nil +} + +func (s *PortalService) Authenticate(ctx context.Context, rawToken string) (PortalSession, error) { + if len(rawToken) != 64 { + return PortalSession{}, ErrUnauthorized + } + hash := sha256.Sum256([]byte(rawToken)) + var session PortalSession + err := s.db.QueryRowContext(ctx, ` +SELECT COALESCE(u.id,p.id),COALESCE(u.username,p.username),COALESCE(u.display_name,p.display_name), + CASE WHEN p.id IS NOT NULL THEN 'admin' ELSE 'partner' END,se.expires_at +FROM vehicle_open_user_session se +LEFT JOIN vehicle_open_user u ON u.id=se.user_id +LEFT JOIN platform_user p ON p.id=se.platform_user_id +WHERE se.token_hash=? + AND se.revoked_at IS NULL + AND se.expires_at>NOW(3) + AND ((u.id IS NOT NULL AND u.status='enabled' AND u.valid_from<=NOW(3) + AND (u.valid_to IS NULL OR u.valid_to>NOW(3))) + OR (p.id IS NOT NULL AND p.user_type='admin' AND p.status='enabled' + AND p.auth_provider='local'))`, hash[:]). + Scan(&session.UserID, &session.Username, &session.DisplayName, &session.UserType, &session.ExpiresAt) + if errors.Is(err, sql.ErrNoRows) { + return PortalSession{}, ErrUnauthorized + } + if err != nil { + return PortalSession{}, err + } + _, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET last_seen_at=NOW(3) WHERE token_hash=?`, hash[:]) + return session, nil +} + +func (s *PortalService) Logout(ctx context.Context, rawToken string) { + hash := sha256.Sum256([]byte(rawToken)) + _, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:]) +} + +func (s *PortalService) ChangePassword(ctx context.Context, session PortalSession, currentPassword, newPassword string) error { + if session.UserType == "admin" { + return fmt.Errorf("%w: platform administrators must change passwords on the internal platform", ErrInvalidRequest) + } + if err := validatePortalPassword(newPassword); err != nil { + return err + } + var currentHash string + table := credentialTable(session.UserType) + if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM `+table+` WHERE id=? AND status='enabled'`, session.UserID).Scan(¤tHash); err != nil { + return ErrUnauthorized + } + if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(currentPassword)) != nil { + return ErrUnauthorized + } + if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(newPassword)) == nil { + return fmt.Errorf("%w: password unchanged", ErrInvalidRequest) + } + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET password_hash=?,password_changed_at=NOW(3),updated_by=? WHERE id=?`, string(hash), session.Username, session.UserID); err != nil { + return err + } + identityColumn := "user_id" + if session.UserType == "admin" { + identityColumn = "platform_user_id" + if _, err := tx.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, session.UserID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE `+identityColumn+`=? AND revoked_at IS NULL`, session.UserID); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + auditUserID := session.UserID + if session.UserType == "admin" { + auditUserID = 0 + } + _ = s.userAudit(ctx, auditUserID, session.Username, "password.change", "success", map[string]any{"userType": session.UserType}, "") + return nil +} + +func (s *PortalService) Apps(ctx context.Context, session PortalSession) ([]PortalUserApp, error) { + if session.UserType == "admin" { + return s.adminApps(ctx) + } + return s.portalApps(ctx, session.UserID) +} + +func (s *PortalService) Vehicles(ctx context.Context, session PortalSession, appID uint64) ([]VehicleGrant, error) { + if _, err := s.MembershipRole(ctx, session, appID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at +FROM vehicle_open_app_vehicle g +LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin +WHERE g.app_id=? +GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at +ORDER BY g.vin`, appID) + if err != nil { + return nil, err + } + defer rows.Close() + grants := make([]VehicleGrant, 0) + for rows.Next() { + var grant VehicleGrant + var validTo sql.NullTime + if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil { + return nil, err + } + if validTo.Valid { + grant.ValidTo = &validTo.Time + } + grants = append(grants, grant) + } + return grants, rows.Err() +} + +func (s *PortalService) VehicleCatalog(ctx context.Context) ([]VehicleCatalogItem, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT b.vin, + COALESCE(MAX(NULLIF(b.plate,'')),''), + COALESCE(MAX(NULLIF(b.oem,'')),'') +FROM vehicle_identity_binding b +WHERE b.vin IS NOT NULL AND b.vin<>'' +GROUP BY b.vin +ORDER BY COALESCE(MAX(NULLIF(b.plate,'')),''),b.vin`) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]VehicleCatalogItem, 0) + for rows.Next() { + var item VehicleCatalogItem + if err := rows.Scan(&item.VIN, &item.Plate, &item.OEM); err != nil { + return nil, err + } + item.Status = "available" + item.Source = "车辆主数据" + items = append(items, item) + } + return items, rows.Err() +} + +func (s *PortalService) Audit(ctx context.Context, session PortalSession, appID uint64) ([]PortalAuditItem, error) { + if _, err := s.MembershipRole(ctx, session, appID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` +SELECT trace_id,endpoint,result,requested_vehicle_count,created_at +FROM vehicle_open_api_audit WHERE app_id=? ORDER BY id DESC LIMIT 100`, appID) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]PortalAuditItem, 0) + for rows.Next() { + var item PortalAuditItem + if err := rows.Scan(&item.TraceID, &item.Endpoint, &item.Result, &item.VehicleCount, &item.CreatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *PortalService) CanRotateKey(ctx context.Context, session PortalSession, appID uint64) error { + role, err := s.MembershipRole(ctx, session, appID) + if err != nil { + return err + } + if role != "owner" { + return ErrForbidden + } + return nil +} + +func (s *PortalService) user(ctx context.Context, id uint64) (PortalUser, error) { + row := s.db.QueryRowContext(ctx, ` +SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at +FROM vehicle_open_user WHERE id=?`, id) + user, err := scanPortalUser(row) + if errors.Is(err, sql.ErrNoRows) { + return PortalUser{}, ErrNotFound + } + return user, err +} + +func (s *PortalService) credential(ctx context.Context, username string) (portalCredential, error) { + var credential portalCredential + var validTo, lastLogin, locked sql.NullTime + err := s.db.QueryRowContext(ctx, ` +SELECT id,username,display_name,password_hash,status,valid_from,valid_to,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at +FROM vehicle_open_user WHERE username=?`, username).Scan( + &credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash, + &credential.Status, &credential.ValidFrom, &validTo, &credential.FailedLoginCount, + &locked, &lastLogin, &credential.CreatedBy, &credential.CreatedAt, &credential.UpdatedAt, + ) + if validTo.Valid { + credential.ValidTo = &validTo.Time + } + if lastLogin.Valid { + credential.LastLoginAt = &lastLogin.Time + } + credential.LockedUntil = locked + credential.UserType = "partner" + return credential, err +} + +func (s *PortalService) loginCredential(ctx context.Context, username string) (portalCredential, error) { + credential, err := s.credential(ctx, username) + if err == nil || !errors.Is(err, sql.ErrNoRows) { + return credential, err + } + var admin portalCredential + var lastLogin, locked sql.NullTime + err = s.db.QueryRowContext(ctx, ` +SELECT id,username,display_name,password_hash,status,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at +FROM platform_user +WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan( + &admin.ID, &admin.Username, &admin.DisplayName, &admin.PasswordHash, + &admin.Status, &admin.FailedLoginCount, &locked, &lastLogin, + &admin.CreatedBy, &admin.CreatedAt, &admin.UpdatedAt, + ) + if lastLogin.Valid { + admin.LastLoginAt = &lastLogin.Time + } + admin.LockedUntil = locked + admin.UserType = "admin" + return admin, err +} + +func (s *PortalService) platformAdminUsername(ctx context.Context, username string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, ` +SELECT COUNT(*) FROM platform_user +WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(&count) + return count > 0, err +} + +func (s *PortalService) portalApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT a.id,a.name,a.app_key_prefix,a.status,ua.role,a.valid_from,a.valid_to +FROM vehicle_open_user_app ua +JOIN vehicle_open_app a ON a.id=ua.app_id +WHERE ua.user_id=? ORDER BY a.id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + apps := make([]PortalUserApp, 0) + for rows.Next() { + var app PortalUserApp + var validTo sql.NullTime + if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil { + return nil, err + } + if validTo.Valid { + app.ValidTo = &validTo.Time + } + apps = append(apps, app) + } + return apps, rows.Err() +} + +func (s *PortalService) adminApps(ctx context.Context) ([]PortalUserApp, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT id,name,app_key_prefix,status,'owner',valid_from,valid_to +FROM vehicle_open_app ORDER BY id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + apps := make([]PortalUserApp, 0) + for rows.Next() { + var app PortalUserApp + var validTo sql.NullTime + if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil { + return nil, err + } + if validTo.Valid { + app.ValidTo = &validTo.Time + } + apps = append(apps, app) + } + return apps, rows.Err() +} + +func (s *PortalService) membershipRole(ctx context.Context, userID, appID uint64) (string, error) { + var role string + err := s.db.QueryRowContext(ctx, `SELECT role FROM vehicle_open_user_app WHERE user_id=? AND app_id=?`, userID, appID).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrForbidden + } + return role, err +} + +func (s *PortalService) MembershipRole(ctx context.Context, session PortalSession, appID uint64) (string, error) { + if session.UserType == "admin" { + var exists int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, appID).Scan(&exists); err != nil { + return "", err + } + if exists == 0 { + return "", ErrNotFound + } + return "owner", nil + } + return s.membershipRole(ctx, session.UserID, appID) +} + +func (s *PortalService) userAudit(ctx context.Context, userID uint64, actor, action, result string, detail any, remoteAddr string) error { + var nullableUser any + if userID > 0 { + nullableUser = userID + } + var encoded any + if detail != nil { + value, _ := json.Marshal(detail) + encoded = string(value) + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO vehicle_open_user_audit(user_id,actor,action,result,detail_json,remote_addr) +VALUES(?,?,?,?,?,?)`, nullableUser, truncate(actor, 96), action, result, encoded, truncate(remoteAddr, 96)) + return err +} + +func scanPortalUser(row scanner) (PortalUser, error) { + var user PortalUser + var validTo, lastLogin sql.NullTime + err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Status, &user.ValidFrom, &validTo, &lastLogin, &user.CreatedBy, &user.CreatedAt, &user.UpdatedAt) + if validTo.Valid { + user.ValidTo = &validTo.Time + } + if lastLogin.Valid { + user.LastLoginAt = &lastLogin.Time + } + return user, err +} + +func validatePortalUserInput(input *PortalUserInput, passwordRequired bool) (time.Time, *time.Time, error) { + input.Username = strings.TrimSpace(input.Username) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Status = strings.ToLower(strings.TrimSpace(input.Status)) + if input.Status == "" { + input.Status = "enabled" + } + if !portalUsernamePattern.MatchString(input.Username) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 96 { + return time.Time{}, nil, fmt.Errorf("%w: invalid portal user", ErrInvalidRequest) + } + if input.Status != "enabled" && input.Status != "disabled" { + return time.Time{}, nil, fmt.Errorf("%w: invalid user status", ErrInvalidRequest) + } + if passwordRequired || input.Password != "" { + if err := validatePortalPassword(input.Password); err != nil { + return time.Time{}, nil, err + } + } + return parseInterval(input.ValidFrom, input.ValidTo) +} + +func validatePortalPassword(password string) error { + if len(password) < 12 || len(password) > 128 { + return fmt.Errorf("%w: password must be 12-128 characters", ErrInvalidRequest) + } + var lower, upper, digit bool + for _, value := range password { + switch { + case value >= 'a' && value <= 'z': + lower = true + case value >= 'A' && value <= 'Z': + upper = true + case value >= '0' && value <= '9': + digit = true + } + } + if !lower || !upper || !digit { + return fmt.Errorf("%w: password requires upper, lower and digit", ErrInvalidRequest) + } + return nil +} + +func validPortalRole(role string) bool { + return role == "owner" || role == "developer" || role == "viewer" +} + +func credentialTable(userType string) string { + if userType == "admin" { + return "platform_user" + } + return "vehicle_open_user" +} + +func auditPortalUserID(credential portalCredential) uint64 { + if credential.UserType == "admin" { + return 0 + } + return credential.ID +} + +func newPortalSessionToken() (string, [sha256.Size]byte, error) { + raw, err := randomHexBytes(32) + if err != nil { + return "", [sha256.Size]byte{}, err + } + return raw, sha256.Sum256([]byte(raw)), nil +} + +func randomHexBytes(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/portal_admin_test.go b/vehicle-data-platform/apps/api/internal/openplatform/portal_admin_test.go new file mode 100644 index 00000000..7a178a22 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/portal_admin_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go new file mode 100644 index 00000000..0890b6d3 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -0,0 +1,732 @@ +package openplatform + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol" +) + +var appKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`) + +var ( + ErrUnauthorized = errors.New("open platform appKey unauthorized") + ErrForbidden = errors.New("open platform vehicle forbidden") + ErrInvalidRequest = errors.New("open platform invalid request") + ErrNotFound = errors.New("open platform resource not found") +) + +type Repository interface { + Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) + AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error) + DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error) + DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error) + DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error) + LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error) + CreateMileageSnapshot(context.Context, MileageSnapshot) error + LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error) + AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) + TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) + Audit(context.Context, uint64, string, string, string, int, string) error + + CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error) + ListApps(context.Context) ([]App, error) + UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) + RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) + ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) + ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) +} + +type Service struct { + repository Repository + now func() time.Time + location *time.Location +} + +type parsedGrant struct { + VIN string + ValidFrom time.Time + ValidTo *time.Time +} + +func NewService(repository Repository) *Service { + return &Service{ + repository: repository, + now: time.Now, + location: time.FixedZone("Asia/Shanghai", 8*60*60), + } +} + +func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) { + plates, date, start, end, err := s.validateQuery(request) + if err != nil { + return nil, err + } + app, vehicles, err := s.authorize(ctx, appKey, plates, start, end) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "denied", traceID, len(plates), err.Error()) + return nil, err + } + if len(plates) == 0 { + plates = vehiclePlates(vehicles) + } + vins := vehicleVINs(vehicles) + values, err := s.repository.DailyHydrogen(ctx, vins, date) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error()) + return nil, err + } + results := make([]HydrogenResult, 0, len(plates)) + for _, plate := range plates { + vehicle := vehicles[plate] + item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData} + // Sampling sufficiency is decided by the producer and persisted in + // quality_status. Imported refuelling-ledger rows can be authoritative + // with one transaction, while pressure-derived rows require two samples. + if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") { + consumption := round3(value.ConsumptionKg) + item.HydrogenConsumptionKg = &consumption + item.Status = StatusNormal + } + results = append(results, item) + } + _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "") + return results, nil +} + +func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) { + plates, date, start, end, err := s.validateQuery(request) + if err != nil { + return nil, err + } + protocols, err := normalizeProtocolPriority(request.ProtocolPriority) + if err != nil { + return nil, err + } + app, vehicles, err := s.authorize(ctx, appKey, plates, start, end) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "denied", traceID, len(plates), err.Error()) + return nil, err + } + if len(plates) == 0 { + plates = vehiclePlates(vehicles) + } + vins := vehicleVINs(vehicles) + values, err := s.repository.DailyMileage(ctx, vins, date, protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error()) + return nil, err + } + missingVINs := missingMileageVINs(vins, values) + carried := map[string]DailyMileage{} + if len(missingVINs) > 0 { + carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error()) + return nil, err + } + } + results := make([]MileageResult, 0, len(plates)) + for _, plate := range plates { + vehicle := vehicles[plate] + item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData} + if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) { + fillMileageResult(&item, value, value.MileageKm) + } else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) { + fillMileageResult(&item, value, 0) + } + results = append(results, item) + } + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "") + return results, nil +} + +func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) { + request.Cursor = strings.TrimSpace(request.Cursor) + plates, startDate, endDate, start, end, pageSize, protocols, err := s.validateMileageRange(request) + if err != nil { + return MileageRangeResponse{}, err + } + hash := mileageRangeRequestHash(startDate, endDate, plates, pageSize, protocols) + var ( + app AppCredential + snapshot MileageSnapshot + offset int + ) + if request.Cursor == "" { + vehicles := map[string]AuthorizedVehicle{} + app, vehicles, err = s.authorize(ctx, appKey, plates, start, end) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error()) + return MileageRangeResponse{}, err + } + snapshotID, idErr := newSnapshotID() + if idErr != nil { + return MileageRangeResponse{}, idErr + } + snapshot = MileageSnapshot{ + ID: snapshotID, + AppID: app.ID, + RequestHash: hash[:], + StartDate: startDate, + EndDate: endDate, + ExpiresAt: s.now().Add(2 * time.Hour), + Vehicles: orderedVehicles(vehicles), + } + snapshot.VehicleCount = len(snapshot.Vehicles) + if err = s.repository.CreateMileageSnapshot(ctx, snapshot); err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, len(snapshot.Vehicles), err.Error()) + return MileageRangeResponse{}, err + } + } else { + snapshotID, parsedOffset, cursorErr := parseMileageCursor(request.Cursor) + if cursorErr != nil { + return MileageRangeResponse{}, cursorErr + } + offset = parsedOffset + if !appKeyPattern.MatchString(appKey) { + return MileageRangeResponse{}, ErrUnauthorized + } + app, err = s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), start, end) + if err != nil { + _ = s.repository.Audit(ctx, 0, "mileage_range_query", "denied", traceID, len(plates), ErrUnauthorized.Error()) + return MileageRangeResponse{}, ErrUnauthorized + } + snapshot, err = s.repository.LoadMileageSnapshot(ctx, snapshotID, app.ID, s.now()) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error()) + return MileageRangeResponse{}, err + } + if !bytes.Equal(snapshot.RequestHash, hash[:]) || snapshot.StartDate != startDate || snapshot.EndDate != endDate { + return MileageRangeResponse{}, fmt.Errorf("%w: cursor does not match request", ErrInvalidRequest) + } + } + + dayCount := int(end.Sub(start).Hours() / 24) + total := dayCount * snapshot.VehicleCount + if offset < 0 || offset > total { + return MileageRangeResponse{}, fmt.Errorf("%w: cursor offset out of range", ErrInvalidRequest) + } + pageEnd := offset + pageSize + if pageEnd > total { + pageEnd = total + } + positions := make([]mileageRangePosition, 0, pageEnd-offset) + vinSet := make(map[string]struct{}) + var queryStart, queryEnd time.Time + for index := offset; index < pageEnd; index++ { + dayOffset := index / snapshot.VehicleCount + vehicle := snapshot.Vehicles[index%snapshot.VehicleCount] + date := start.AddDate(0, 0, dayOffset) + if len(positions) == 0 { + queryStart = date + } + queryEnd = date + positions = append(positions, mileageRangePosition{vehicle: vehicle, date: date.Format("2006-01-02")}) + vinSet[vehicle.VIN] = struct{}{} + } + values := map[string]DailyMileage{} + carried := map[string]DailyMileage{} + if len(positions) > 0 { + vins := make([]string, 0, len(vinSet)) + for vin := range vinSet { + vins = append(vins, vin) + } + sort.Strings(vins) + values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error()) + return MileageRangeResponse{}, err + } + missingVINs := missingMileageRangeInitialVINs(positions, values) + if len(missingVINs) > 0 { + carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error()) + return MileageRangeResponse{}, err + } + if carried == nil { + carried = map[string]DailyMileage{} + } + } + } + results := make([]MileageRangeResult, 0, len(positions)) + for _, position := range positions { + item := MileageRangeResult{ + VIN: position.vehicle.VIN, + PlateNumber: position.vehicle.Plate, + Date: position.date, + Status: StatusNoData, + } + if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) { + fillMileageRangeResult(&item, value, value.MileageKm) + carried[position.vehicle.VIN] = value + } else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) { + fillMileageRangeResult(&item, value, 0) + } + results = append(results, item) + } + response := MileageRangeResponse{ + Code: "SUCCESS", + Message: "success", + Data: results, + SnapshotID: snapshot.ID, + TraceID: traceID, + } + if pageEnd < total { + cursor := mileageCursor(snapshot.ID, pageEnd) + response.NextCursor = &cursor + } + _ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "success", traceID, snapshot.VehicleCount, "") + return response, nil +} + +type mileageRangePosition struct { + vehicle AuthorizedVehicle + date string +} + +func (s *Service) QueryTotalMileage(ctx context.Context, appKey, traceID string, request TotalMileageQueryRequest) (TotalMileageResult, error) { + vin, queryTime, protocols, protocolInput, err := s.validateTotalMileageQuery(request) + if err != nil { + return TotalMileageResult{}, err + } + result := TotalMileageResult{VIN: vin, QueryTime: queryTime.Format("2006-01-02 15:04:05"), ProtocolInput: protocolInput, SelectionPolicy: strings.Join(vehicleprotocol.MileagePriority(), " > "), Status: StatusNoData} + if !appKeyPattern.MatchString(appKey) { + return TotalMileageResult{}, ErrUnauthorized + } + app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), queryTime, queryTime) + if err != nil { + _ = s.repository.Audit(ctx, 0, "total_mileage_query", "denied", traceID, 1, ErrUnauthorized.Error()) + return TotalMileageResult{}, ErrUnauthorized + } + authorized, err := s.repository.AuthorizedVIN(ctx, app.ID, vin, queryTime) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error()) + return TotalMileageResult{}, err + } + if !authorized { + _ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "denied", traceID, 1, ErrForbidden.Error()) + return TotalMileageResult{}, ErrForbidden + } + point, err := s.repository.TotalMileage(ctx, vin, queryTime, protocols) + if err != nil { + _ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error()) + return TotalMileageResult{}, err + } + if point != nil { + value := round3(point.TotalMileageKm) + result.TotalMileageKm = &value + result.Protocol = point.Protocol + result.MileageMeaning = totalMileageMeaning(point.Protocol) + result.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05") + difference := int64(queryTime.Sub(point.ObservedAt.In(s.location)).Seconds()) + result.TimeDifferenceSeconds = &difference + result.Status = StatusNormal + } + _ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "success", traceID, 1, "") + return result, nil +} + +func (s *Service) validateTotalMileageQuery(request TotalMileageQueryRequest) (string, time.Time, []string, string, error) { + vin := strings.ToUpper(strings.TrimSpace(request.VIN)) + if !regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`).MatchString(vin) { + return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid vin", ErrInvalidRequest) + } + queryTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.Time), s.location) + if err != nil { + return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid datetime", ErrInvalidRequest) + } + input := strings.TrimSpace(request.Protocol) + if input == "" { + return vin, queryTime, vehicleprotocol.MileagePriority(), "", nil + } + canonical, ok := vehicleprotocol.Canonical(input) + if !ok { + return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid protocol", ErrInvalidRequest) + } + return vin, queryTime, []string{canonical}, input, nil +} + +func totalMileageMeaning(protocol string) string { + switch protocol { + case vehicleprotocol.GB32960: + return "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)" + case vehicleprotocol.YutongMQTT: + return "车辆仪表盘或车端控制器累计总里程(MQTT平台上报)" + case vehicleprotocol.JT808: + return "定位终端累计里程(GPS/终端侧计算,非车辆仪表盘里程)" + default: + return "车辆累计总里程" + } +} + +func (s *Service) authorize(ctx context.Context, rawKey string, plates []string, start, end time.Time) (AppCredential, map[string]AuthorizedVehicle, error) { + if !appKeyPattern.MatchString(rawKey) { + return AppCredential{}, nil, ErrUnauthorized + } + app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(rawKey))), s.now(), start, end) + if err != nil { + return AppCredential{}, nil, ErrUnauthorized + } + vehicles, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end) + if err != nil { + return app, nil, err + } + if len(plates) > 0 && len(vehicles) != len(plates) { + return app, nil, ErrForbidden + } + return app, vehicles, nil +} + +func (s *Service) validateQuery(request QueryRequest) ([]string, string, time.Time, time.Time, error) { + plates, err := normalizePlates(request.PlateNumbers, 200) + if err != nil { + return nil, "", time.Time{}, time.Time{}, err + } + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.Date), s.location) + if err != nil { + return nil, "", time.Time{}, time.Time{}, fmt.Errorf("%w: invalid date", ErrInvalidRequest) + } + return plates, start.Format("2006-01-02"), start, start.AddDate(0, 0, 1), nil +} + +func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, string, string, time.Time, time.Time, int, []string, error) { + plates, err := normalizePlates(request.PlateNumbers, 5000) + if err != nil { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, err + } + protocols, err := normalizeProtocolPriority(request.ProtocolPriority) + if err != nil { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, err + } + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.StartDate), s.location) + if err != nil { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid startDate", ErrInvalidRequest) + } + endInclusive, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.EndDate), s.location) + if err != nil { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid endDate", ErrInvalidRequest) + } + if endInclusive.Before(start) { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest) + } + end := endInclusive.AddDate(0, 0, 1) + if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest) + } + pageSize := request.PageSize + if pageSize == 0 { + pageSize = 5000 + } + if pageSize < 1 || pageSize > 5000 { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: pageSize must be between 1 and 5000", ErrInvalidRequest) + } + return plates, start.Format("2006-01-02"), endInclusive.Format("2006-01-02"), start, end, pageSize, protocols, nil +} + +func normalizeProtocolPriority(input ProtocolPriority) ([]string, error) { + if !input.Present && input.Values == nil { + return nil, nil + } + if len(input.Values) == 0 { + return nil, fmt.Errorf("%w: protocolPriority must not be empty", ErrInvalidRequest) + } + protocols := make([]string, 0, len(input.Values)) + seen := make(map[string]bool, len(input.Values)) + for _, protocol := range input.Values { + if seen[protocol] { + return nil, fmt.Errorf("%w: protocolPriority contains duplicate protocol", ErrInvalidRequest) + } + seen[protocol] = true + switch protocol { + case "GB32960", "JT808": + protocols = append(protocols, protocol) + case "MQTT": + protocols = append(protocols, "YUTONG_MQTT") + default: + return nil, fmt.Errorf("%w: protocolPriority contains unsupported protocol", ErrInvalidRequest) + } + } + return protocols, nil +} + +func externalMileageProtocol(protocol string) string { + if protocol == "YUTONG_MQTT" { + return "MQTT" + } + return protocol +} + +func normalizePlates(input []string, maximum int) ([]string, error) { + if len(input) > maximum { + return nil, fmt.Errorf("%w: plate numbers exceed %d", ErrInvalidRequest, maximum) + } + plates := make([]string, 0, len(input)) + seen := map[string]bool{} + for _, raw := range input { + plate := strings.ToUpper(strings.TrimSpace(raw)) + if plate == "" || len([]rune(plate)) > 32 { + return nil, fmt.Errorf("%w: invalid plate", ErrInvalidRequest) + } + if seen[plate] { + return nil, fmt.Errorf("%w: duplicate plate", ErrInvalidRequest) + } + seen[plate] = true + plates = append(plates, plate) + } + return plates, nil +} + +func (s *Service) CreateApp(ctx context.Context, input AppInput, actor string) (AppCreated, error) { + from, to, err := s.validateAppInput(&input) + if err != nil { + return AppCreated{}, err + } + key, hash, prefix, err := newAppKey() + if err != nil { + return AppCreated{}, err + } + app, err := s.repository.CreateApp(ctx, input, hash, prefix, from, to, actor) + if err != nil { + return AppCreated{}, err + } + return AppCreated{App: app, AppKey: key}, nil +} + +func (s *Service) ListApps(ctx context.Context) ([]App, error) { + return s.repository.ListApps(ctx) +} + +func (s *Service) UpdateApp(ctx context.Context, id uint64, input AppInput, actor string) (App, error) { + from, to, err := s.validateAppInput(&input) + if err != nil { + return App{}, err + } + return s.repository.UpdateApp(ctx, id, input, from, to, actor) +} + +func (s *Service) RotateKey(ctx context.Context, id uint64, actor string) (AppCreated, error) { + key, hash, prefix, err := newAppKey() + if err != nil { + return AppCreated{}, err + } + app, err := s.repository.RotateKey(ctx, id, hash, prefix, actor) + if err != nil { + return AppCreated{}, err + } + return AppCreated{App: app, AppKey: key}, nil +} + +func (s *Service) ReplaceVehicleGrants(ctx context.Context, id uint64, request VehicleGrantRequest, actor string) ([]VehicleGrant, error) { + if len(request.Vehicles) > 5000 { + return nil, fmt.Errorf("%w: grants exceed 5000", ErrInvalidRequest) + } + grants := make([]parsedGrant, 0, len(request.Vehicles)) + seen := map[string]bool{} + for _, input := range request.Vehicles { + vin := strings.ToUpper(strings.TrimSpace(input.VIN)) + if len(vin) != 17 || seen[vin] { + return nil, fmt.Errorf("%w: invalid or duplicate VIN", ErrInvalidRequest) + } + seen[vin] = true + from, to, err := parseInterval(input.ValidFrom, input.ValidTo) + if err != nil { + return nil, err + } + grants = append(grants, parsedGrant{VIN: vin, ValidFrom: from, ValidTo: to}) + } + return s.repository.ReplaceVehicleGrants(ctx, id, grants, actor) +} + +func (s *Service) ListVehicleGrants(ctx context.Context, id uint64) ([]VehicleGrant, error) { + return s.repository.ListVehicleGrants(ctx, id) +} + +func (s *Service) validateAppInput(input *AppInput) (time.Time, *time.Time, error) { + input.Name = strings.TrimSpace(input.Name) + input.Status = strings.ToLower(strings.TrimSpace(input.Status)) + if input.Status == "" { + input.Status = "enabled" + } + if input.Name == "" || len([]rune(input.Name)) > 96 || (input.Status != "enabled" && input.Status != "disabled") { + return time.Time{}, nil, fmt.Errorf("%w: invalid app", ErrInvalidRequest) + } + return parseInterval(input.ValidFrom, input.ValidTo) +} + +func parseInterval(rawFrom, rawTo string) (time.Time, *time.Time, error) { + from, err := time.Parse(time.RFC3339, strings.TrimSpace(rawFrom)) + if err != nil { + return time.Time{}, nil, fmt.Errorf("%w: validFrom must be RFC3339", ErrInvalidRequest) + } + var to *time.Time + if strings.TrimSpace(rawTo) != "" { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTo)) + if err != nil || !parsed.After(from) { + return time.Time{}, nil, fmt.Errorf("%w: invalid validTo", ErrInvalidRequest) + } + to = &parsed + } + return from, to, nil +} + +func newAppKey() (string, [sha256.Size]byte, string, error) { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", [sha256.Size]byte{}, "", err + } + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + key := hex.EncodeToString(bytes[:]) + return key, sha256.Sum256([]byte(key)), key[:8], nil +} + +func vehicleVINs(vehicles map[string]AuthorizedVehicle) []string { + seen := map[string]bool{} + vins := make([]string, 0, len(vehicles)) + for _, vehicle := range vehicles { + if !seen[vehicle.VIN] { + seen[vehicle.VIN] = true + vins = append(vins, vehicle.VIN) + } + } + sort.Strings(vins) + return vins +} + +func vehiclePlates(vehicles map[string]AuthorizedVehicle) []string { + plates := make([]string, 0, len(vehicles)) + for plate := range vehicles { + plates = append(plates, plate) + } + sort.Strings(plates) + return plates +} + +func orderedVehicles(vehicles map[string]AuthorizedVehicle) []AuthorizedVehicle { + plates := vehiclePlates(vehicles) + out := make([]AuthorizedVehicle, 0, len(plates)) + for _, plate := range plates { + out = append(out, vehicles[plate]) + } + return out +} + +func validDailyMileage(value DailyMileage) bool { + return value.MileageKm >= 0 && + value.TotalMileageKm >= 0 && + (value.Protocol == "GB32960" || value.Protocol == "YUTONG_MQTT" || value.Protocol == "JT808") && + value.DataTime != "" && + value.UpdatedAt != "" +} + +func missingMileageVINs(vins []string, values map[string]DailyMileage) []string { + missing := make([]string, 0) + for _, vin := range vins { + if value, ok := values[vin]; !ok || !validDailyMileage(value) { + missing = append(missing, vin) + } + } + return missing +} + +func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string { + seen := make(map[string]struct{}, len(positions)) + missing := make([]string, 0) + for _, position := range positions { + vin := position.vehicle.VIN + if _, ok := seen[vin]; ok { + continue + } + seen[vin] = struct{}{} + value, ok := values[dailyMileageKey(vin, position.date)] + if !ok || !validDailyMileage(value) { + missing = append(missing, vin) + } + } + sort.Strings(missing) + return missing +} + +func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) { + item.DailyMileageKm = &dailyMileage + totalMileage := value.TotalMileageKm + item.TotalMileageKm = &totalMileage + dataTime := value.DataTime + updatedAt := value.UpdatedAt + item.DataTime = &dataTime + item.UpdatedAt = &updatedAt + sourceProtocol := externalMileageProtocol(value.Protocol) + item.SourceProtocol = &sourceProtocol + item.Status = StatusNormal +} + +func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) { + item.DailyMileageKm = &dailyMileage + totalMileage := value.TotalMileageKm + item.TotalMileageKm = &totalMileage + dataTime := value.DataTime + updatedAt := value.UpdatedAt + item.DataTime = &dataTime + item.UpdatedAt = &updatedAt + sourceProtocol := externalMileageProtocol(value.Protocol) + item.SourceProtocol = &sourceProtocol + item.Status = StatusNormal +} + +func dailyMileageKey(vin, date string) string { + return vin + "\x00" + date +} + +func mileageRangeRequestHash(startDate, endDate string, plates []string, pageSize int, protocols []string) [sha256.Size]byte { + normalized := append([]string(nil), plates...) + sort.Strings(normalized) + return sha256.Sum256([]byte(startDate + "\x00" + endDate + "\x00" + strconv.Itoa(pageSize) + "\x00" + strings.Join(normalized, "\x00") + "\x01" + strings.Join(protocols, "\x00"))) +} + +func newSnapshotID() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func mileageCursor(snapshotID string, offset int) string { + return base64.RawURLEncoding.EncodeToString([]byte(snapshotID + ":" + strconv.Itoa(offset))) +} + +func parseMileageCursor(cursor string) (string, int, error) { + decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cursor)) + if err != nil { + return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest) + } + parts := strings.Split(string(decoded), ":") + if len(parts) != 2 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(parts[0]) { + return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest) + } + offset, err := strconv.Atoi(parts[1]) + if err != nil || offset < 0 { + return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest) + } + return parts[0], offset, nil +} + +func round3(value float64) float64 { + if value >= 0 { + return float64(int64(value*1000+0.5)) / 1000 + } + return float64(int64(value*1000-0.5)) / 1000 +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go new file mode 100644 index 00000000..accfadb5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -0,0 +1,546 @@ +package openplatform + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type fakeRepository struct { + app AppCredential + authErr error + vehicles map[string]AuthorizedVehicle + hydrogen map[string]DailyHydrogen + mileage map[string]DailyMileage + priorMileage map[string]DailyMileage + authorizedVIN bool + totalMileage *TotalMileagePoint + audits []string + createdHash [sha256.Size]byte + createdPrefix string + requestedPlates []string + dailyVINs []string + dailyProtocols []string + rangeVINs []string + priorVINs []string + priorCalls int + snapshot MileageSnapshot +} + +func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) { + return f.app, f.authErr +} +func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, _ time.Time, _ time.Time) (map[string]AuthorizedVehicle, error) { + f.requestedPlates = append([]string(nil), plates...) + return f.vehicles, nil +} +func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) { + f.dailyVINs = append([]string(nil), vins...) + return f.hydrogen, nil +} +func (f *fakeRepository) DailyMileage(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) { + f.dailyVINs = append([]string(nil), vins...) + f.dailyProtocols = append([]string(nil), protocols...) + return f.mileage, nil +} +func (f *fakeRepository) DailyMileageRange(_ context.Context, vins []string, _, _ string, protocols []string) (map[string]DailyMileage, error) { + f.rangeVINs = append([]string(nil), vins...) + f.dailyVINs = append([]string(nil), vins...) + f.dailyProtocols = append([]string(nil), protocols...) + return f.mileage, nil +} +func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) { + f.priorCalls++ + f.priorVINs = append([]string(nil), vins...) + f.dailyVINs = append([]string(nil), vins...) + f.dailyProtocols = append([]string(nil), protocols...) + return f.priorMileage, nil +} +func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error { + f.snapshot = snapshot + return nil +} +func (f *fakeRepository) LoadMileageSnapshot(_ context.Context, id string, appID uint64, _ time.Time) (MileageSnapshot, error) { + if f.snapshot.ID != id || f.snapshot.AppID != appID { + return MileageSnapshot{}, ErrInvalidRequest + } + return f.snapshot, nil +} +func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) { + return f.authorizedVIN, nil +} +func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) { + return f.totalMileage, nil +} +func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error { + f.audits = append(f.audits, endpoint+":"+result) + return nil +} +func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) { + f.createdHash, f.createdPrefix = hash, prefix + return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil +} +func (f *fakeRepository) ListApps(context.Context) ([]App, error) { return nil, nil } +func (f *fakeRepository) UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) { + return App{}, nil +} +func (f *fakeRepository) RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) { + return App{}, nil +} +func (f *fakeRepository) ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) { + return nil, nil +} +func (f *fakeRepository) ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) { + return nil, nil +} + +func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7, Name: "partner"}, + vehicles: map[string]AuthorizedVehicle{ + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + "粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}, + }, + hydrogen: map[string]DailyHydrogen{ + "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"}, + }, + mileage: map[string]DailyMileage{ + "LTEST32960VIN0001": { + VIN: "LTEST32960VIN0001", Protocol: "GB32960", MileageKm: 101.235, TotalMileageKm: 12345.679, + DataTime: "2026-07-01T23:59:00+08:00", UpdatedAt: "2026-07-02T00:01:00+08:00", + }, + }, + } + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) } + request := QueryRequest{PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01"} + const key = "0123456789abcdef0123456789abcdef" + + hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h", request) + if err != nil { + t.Fatal(err) + } + if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil { + t.Fatalf("hydrogen = %#v", hydrogen) + } + mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request) + if err != nil { + t.Fatal(err) + } + if len(mileage) != 2 || mileage[0].VIN != "LTEST32960VIN0001" || mileage[0].DailyMileageKm == nil || *mileage[0].DailyMileageKm != 101.235 || mileage[0].TotalMileageKm == nil || *mileage[0].TotalMileageKm != 12345.679 || mileage[0].DataTime == nil || mileage[0].UpdatedAt == nil || mileage[1].Status != StatusNoData || mileage[1].TotalMileageKm != nil || mileage[1].DataTime != nil { + t.Fatalf("mileage = %#v", mileage) + } +} + +func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7, Name: "partner"}, + vehicles: map[string]AuthorizedVehicle{ + "粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}, + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + }, + hydrogen: map[string]DailyHydrogen{ + "LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 8.5, QualityStatus: "OK"}, + }, + mileage: map[string]DailyMileage{ + "LTEST32960VIN0002": { + VIN: "LTEST32960VIN0002", Protocol: "YUTONG_MQTT", MileageKm: 88.8, TotalMileageKm: 10088.8, + DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00", + }, + }, + } + service := NewService(repository) + request := QueryRequest{Date: "2026-07-01"} + const key = "0123456789abcdef0123456789abcdef" + + hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h-all", request) + if err != nil { + t.Fatal(err) + } + if len(repository.requestedPlates) != 0 || len(hydrogen) != 2 || hydrogen[0].PlateNumber != "粤A12345" || hydrogen[1].PlateNumber != "粤B67890" { + t.Fatalf("requested=%v hydrogen=%#v", repository.requestedPlates, hydrogen) + } + if len(repository.dailyVINs) != 2 || repository.dailyVINs[0] != "LTEST32960VIN0001" || repository.dailyVINs[1] != "LTEST32960VIN0002" { + t.Fatalf("daily VINs=%v", repository.dailyVINs) + } + + mileage, err := service.QueryMileage(context.Background(), key, "trace-m-all", QueryRequest{PlateNumbers: []string{}, Date: "2026-07-01"}) + if err != nil { + t.Fatal(err) + } + if len(mileage) != 2 || mileage[0].PlateNumber != "粤A12345" || mileage[1].PlateNumber != "粤B67890" || mileage[1].DailyMileageKm == nil { + t.Fatalf("mileage=%#v", mileage) + } +} + +func TestMileageProtocolPriorityValidationAndExternalSourceName(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{ + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + }, + mileage: map[string]DailyMileage{ + "LTEST32960VIN0001": { + VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", MileageKm: 0, TotalMileageKm: 12345, + DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00", + }, + }, + } + service := NewService(repository) + priority := ProtocolPriority{Values: []string{"JT808", "GB32960", "MQTT"}} + result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-priority", QueryRequest{ + PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01", ProtocolPriority: priority, + }) + if err != nil { + t.Fatal(err) + } + if strings.Join(repository.dailyProtocols, ",") != "JT808,GB32960,YUTONG_MQTT" { + t.Fatalf("internal protocols=%v", repository.dailyProtocols) + } + if len(result) != 1 || result[0].DailyMileageKm == nil || *result[0].DailyMileageKm != 0 || result[0].SourceProtocol == nil || *result[0].SourceProtocol != "MQTT" { + t.Fatalf("result=%#v", result) + } + + for _, values := range [][]string{{}, {"YUTONG_MQTT"}, {"GB32960", "GB32960"}, {"mqtt"}} { + invalid := ProtocolPriority{Values: values, Present: true} + _, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-invalid", QueryRequest{ + Date: "2026-07-01", ProtocolPriority: invalid, + }) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("priority=%v error=%v", values, err) + } + } +} + +func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing.T) { + previous := DailyMileage{ + VIN: "LTEST32960VIN0001", Date: "2026-07-20", Protocol: "YUTONG_MQTT", + MileageKm: 35.5, TotalMileageKm: 8888.8, + DataTime: "2026-07-20T23:58:00+08:00", UpdatedAt: "2026-07-21T01:15:00+08:00", + } + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{ + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + }, + mileage: map[string]DailyMileage{}, + priorMileage: map[string]DailyMileage{"LTEST32960VIN0001": previous}, + } + service := NewService(repository) + result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry", QueryRequest{ + PlateNumbers: []string{"粤A12345"}, Date: "2026-07-21", + }) + if err != nil { + t.Fatal(err) + } + item := result[0] + if item.Status != StatusNormal || item.DailyMileageKm == nil || *item.DailyMileageKm != 0 || + item.TotalMileageKm == nil || *item.TotalMileageKm != 8888.8 || + item.SourceProtocol == nil || *item.SourceProtocol != "MQTT" || + item.DataTime == nil || *item.DataTime != previous.DataTime || + item.UpdatedAt == nil || *item.UpdatedAt != previous.UpdatedAt { + t.Fatalf("carried result=%#v", item) + } + + rangeResult, err := service.QueryMileageRange(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry-range", MileageRangeRequest{ + StartDate: "2026-07-21", EndDate: "2026-07-22", PageSize: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(rangeResult.Data) != 2 { + t.Fatalf("range=%#v", rangeResult) + } + for _, row := range rangeResult.Data { + if row.DailyMileageKm == nil || *row.DailyMileageKm != 0 || + row.TotalMileageKm == nil || *row.TotalMileageKm != previous.TotalMileageKm || + row.UpdatedAt == nil || *row.UpdatedAt != previous.UpdatedAt { + t.Fatalf("carried range row=%#v", row) + } + } +} + +func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7, Name: "partner"}, + vehicles: map[string]AuthorizedVehicle{ + "沪A00002": {VIN: "LTEST32960VIN0002", Plate: "沪A00002"}, + "沪A00001": {VIN: "LTEST32960VIN0001", Plate: "沪A00001"}, + }, + mileage: map[string]DailyMileage{ + dailyMileageKey("LTEST32960VIN0001", "2026-07-01"): { + VIN: "LTEST32960VIN0001", Date: "2026-07-01", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 12000, + DataTime: "2026-07-01T23:58:45+08:00", UpdatedAt: "2026-07-02T05:10:00+08:00", + }, + dailyMileageKey("LTEST32960VIN0001", "2026-07-02"): { + VIN: "LTEST32960VIN0001", Date: "2026-07-02", Protocol: "GB32960", MileageKm: 10.25, TotalMileageKm: 12010.25, + DataTime: "2026-07-02T20:00:00+08:00", UpdatedAt: "2026-07-02T20:00:01+08:00", + }, + dailyMileageKey("LTEST32960VIN0002", "2026-07-02"): { + VIN: "LTEST32960VIN0002", Date: "2026-07-02", Protocol: "JT808", MileageKm: 8.5, TotalMileageKm: 9008.5, + DataTime: "2026-07-02T21:00:00+08:00", UpdatedAt: "2026-07-02T21:00:01+08:00", + }, + }, + } + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) } + request := MileageRangeRequest{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 3} + const key = "0123456789abcdef0123456789abcdef" + + first, err := service.QueryMileageRange(context.Background(), key, "trace-range-1", request) + if err != nil { + t.Fatal(err) + } + if len(first.Data) != 3 || first.SnapshotID == "" || first.NextCursor == nil { + t.Fatalf("first=%#v", first) + } + if first.Data[0].PlateNumber != "沪A00001" || first.Data[0].DailyMileageKm == nil || *first.Data[0].DailyMileageKm != 0 || first.Data[0].Status != StatusNormal { + t.Fatalf("zero mileage row=%#v", first.Data[0]) + } + if first.Data[1].PlateNumber != "沪A00002" || first.Data[1].Status != StatusNoData || first.Data[1].DailyMileageKm != nil || first.Data[1].DataTime != nil { + t.Fatalf("no-data row=%#v", first.Data[1]) + } + if first.Data[2].Date != "2026-07-02" || first.Data[2].PlateNumber != "沪A00001" { + t.Fatalf("third row=%#v", first.Data[2]) + } + + repository.vehicles = map[string]AuthorizedVehicle{} + request.Cursor = *first.NextCursor + changedPriority := ProtocolPriority{Values: []string{"JT808"}} + changedRequest := request + changedRequest.ProtocolPriority = changedPriority + if _, err := service.QueryMileageRange(context.Background(), key, "trace-range-changed", changedRequest); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("changed protocol priority must invalidate cursor: %v", err) + } + second, err := service.QueryMileageRange(context.Background(), key, "trace-range-2", request) + if err != nil { + t.Fatal(err) + } + if second.SnapshotID != first.SnapshotID || second.NextCursor != nil || len(second.Data) != 1 || second.Data[0].PlateNumber != "沪A00002" || second.Data[0].Date != "2026-07-02" { + t.Fatalf("second=%#v", second) + } +} + +func TestMileageRangeOnlyLoadsPriorMileageForVINsMissingAtTheirFirstPageDate(t *testing.T) { + const ( + firstVIN = "LTEST32960VIN0001" + secondVIN = "LTEST32960VIN0002" + ) + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{ + "沪A00001": {VIN: firstVIN, Plate: "沪A00001"}, + "沪A00002": {VIN: secondVIN, Plate: "沪A00002"}, + }, + mileage: map[string]DailyMileage{ + dailyMileageKey(firstVIN, "2026-07-27"): { + VIN: firstVIN, Date: "2026-07-27", Protocol: "GB32960", + MileageKm: 12.5, TotalMileageKm: 12012.5, + DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00", + }, + }, + priorMileage: map[string]DailyMileage{ + secondVIN: { + VIN: secondVIN, Date: "2026-07-26", Protocol: "JT808", + MileageKm: 8, TotalMileageKm: 9008, + DataTime: "2026-07-26T23:00:00+08:00", UpdatedAt: "2026-07-27T01:00:00+08:00", + }, + }, + } + + result, err := NewService(repository).QueryMileageRange( + context.Background(), + "0123456789abcdef0123456789abcdef", + "trace-range-missing-only", + MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10}, + ) + if err != nil { + t.Fatal(err) + } + if repository.priorCalls != 1 || len(repository.priorVINs) != 1 || repository.priorVINs[0] != secondVIN { + t.Fatalf("prior calls=%d vins=%v, want only %s", repository.priorCalls, repository.priorVINs, secondVIN) + } + if len(result.Data) != 2 || result.Data[0].DailyMileageKm == nil || *result.Data[0].DailyMileageKm != 12.5 || + result.Data[1].DailyMileageKm == nil || *result.Data[1].DailyMileageKm != 0 { + t.Fatalf("result=%#v", result) + } +} + +func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testing.T) { + const vin = "LTEST32960VIN0001" + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{ + "沪A00001": {VIN: vin, Plate: "沪A00001"}, + }, + mileage: map[string]DailyMileage{ + dailyMileageKey(vin, "2026-07-27"): { + VIN: vin, Date: "2026-07-27", Protocol: "GB32960", + MileageKm: 12.5, TotalMileageKm: 12012.5, + DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00", + }, + }, + } + + if _, err := NewService(repository).QueryMileageRange( + context.Background(), + "0123456789abcdef0123456789abcdef", + "trace-range-no-prior", + MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10}, + ); err != nil { + t.Fatal(err) + } + if repository.priorCalls != 0 || len(repository.priorVINs) != 0 { + t.Fatalf("prior calls=%d vins=%v, want no lookup", repository.priorCalls, repository.priorVINs) + } +} + +func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) { + service := NewService(&fakeRepository{}) + for _, request := range []MileageRangeRequest{ + {StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100}, + {StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100}, + {StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001}, + } { + if _, _, _, _, _, _, _, err := service.validateMileageRange(request); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("request=%#v error=%v", request, err) + } + } +} + +func TestTotalMileageReturnsRecordTimeDifferenceAndProtocolMeaning(t *testing.T) { + location := time.FixedZone("CST", 8*3600) + repository := &fakeRepository{ + app: AppCredential{ID: 7}, authorizedVIN: true, + totalMileage: &TotalMileagePoint{VIN: "LA9GG68L2PBAF4790", Protocol: "GB32960", ObservedAt: time.Date(2026, 7, 21, 9, 29, 45, 0, location), TotalMileageKm: 12345.6784}, + } + service := NewService(repository) + service.now = func() time.Time { return time.Date(2026, 7, 21, 12, 0, 0, 0, location) } + result, err := service.QueryTotalMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-total", TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00"}) + if err != nil { + t.Fatal(err) + } + if result.TotalMileageKm == nil || *result.TotalMileageKm != 12345.678 || result.Protocol != "GB32960" || result.RecordTime != "2026-07-21 09:29:45" || result.TimeDifferenceSeconds == nil || *result.TimeDifferenceSeconds != 15 || result.Status != StatusNormal || !strings.Contains(result.MileageMeaning, "仪表盘") { + t.Fatalf("result = %#v", result) + } +} + +func TestTotalMileageCanonicalProtocolsAndStrictTime(t *testing.T) { + service := NewService(&fakeRepository{}) + for _, input := range []string{"GB32960", "YUTONG_MQTT", "JT808"} { + _, _, protocols, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: input}) + if err != nil || len(protocols) != 1 || protocols[0] != input { + t.Fatalf("%s: protocols=%v err=%v", input, protocols, err) + } + } + for _, alias := range []string{"32960", "mqtt", "808"} { + if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: alias}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("alias %s should be rejected: %v", alias, err) + } + } + if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026/07/21 09:30"}); !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("error=%v", err) + } +} + +func TestQueryFailsClosedWhenAnyVehicleIsNotGranted(t *testing.T) { + repository := &fakeRepository{ + app: AppCredential{ID: 7}, + vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}}, + } + _, err := NewService(repository).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{ + PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01", + }) + if !errors.Is(err, ErrForbidden) { + t.Fatalf("error = %v", err) + } +} + +func TestCreateAppReturns32CharacterKeyOnlyOnce(t *testing.T) { + repository := &fakeRepository{} + service := NewService(repository) + created, err := service.CreateApp(context.Background(), AppInput{ + Name: "partner", ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00", + }, "admin") + if err != nil { + t.Fatal(err) + } + if !appKeyPattern.MatchString(created.AppKey) || len(created.AppKey) != 32 || created.AppKeyPrefix != created.AppKey[:8] { + t.Fatalf("created = %#v", created) + } + if created.AppKey[12] != '4' || !strings.ContainsRune("89ab", rune(created.AppKey[16])) { + t.Fatalf("appKey is not UUID v4: %s", created.AppKey) + } + if repository.createdHash != sha256.Sum256([]byte(created.AppKey)) || repository.createdPrefix != created.AppKey[:8] { + t.Fatal("repository did not receive the appKey hash and prefix") + } +} + +func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { + repository := &fakeRepository{app: AppCredential{ID: 1}, vehicles: map[string]AuthorizedVehicle{ + "粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}, + }, hydrogen: map[string]DailyHydrogen{}} + handler := NewHandler(NewService(repository)) + request := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026-07-01"}`)) + request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) + } + var body ExternalResponse + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Code != "SUCCESS" || body.Message != "success" || len(body.TraceID) != 32 { + t.Fatalf("body = %#v", body) + } + + allRequest := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01"}`)) + allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + allResponse := httptest.NewRecorder() + handler.ServeHTTP(allResponse, allRequest) + if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) { + t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String()) + } + + rangeRequest := httptest.NewRequest(http.MethodPost, MileageRangeQueryPath, strings.NewReader(`{"startDate":"2026-07-01","endDate":"2026-07-01","pageSize":10}`)) + rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + rangeResponse := httptest.NewRecorder() + handler.ServeHTTP(rangeResponse, rangeRequest) + if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) { + t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String()) + } + + badDate := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026/07/01"}`)) + badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + badResponse := httptest.NewRecorder() + handler.ServeHTTP(badResponse, badDate) + if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) { + t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String()) + } + + badProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":["GB32960","GB32960"]}`)) + badProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + badProtocolResponse := httptest.NewRecorder() + handler.ServeHTTP(badProtocolResponse, badProtocol) + if badProtocolResponse.Code != http.StatusBadRequest || + !strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) || + !strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) { + t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String()) + } + + nullProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":null}`)) + nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + nullProtocolResponse := httptest.NewRecorder() + handler.ServeHTTP(nullProtocolResponse, nullProtocol) + if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) { + t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String()) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/standalone.go b/vehicle-data-platform/apps/api/internal/openplatform/standalone.go new file mode 100644 index 00000000..9a1cae46 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/standalone.go @@ -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) +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/standalone_test.go b/vehicle-data-platform/apps/api/internal/openplatform/standalone_test.go new file mode 100644 index 00000000..81044570 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/standalone_test.go @@ -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(`
`), 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 + }{ + {"/", `
`, "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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go new file mode 100644 index 00000000..014f7038 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go @@ -0,0 +1,338 @@ +package openplatform + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +var hydrogenMassFields = []string{ + "gb32960.gd_fc_vehicle_info.hydrogen_mass_kg", + "gb32960.gd_fc_vehicle.hydrogen_mass_kg", + "gb32960.gd_fc_vehicle_info.gd_fc_vehicle_hydrogen_mass_kg", + "gd_fc_vehicle_hydrogen_mass_kg", +} + +func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat { + if noiseKg <= 0 { + noiseKg = 0.05 + } + if maxDropKg <= noiseKg { + maxDropKg = 20 + } + grouped := map[string]map[string][]HydrogenObservation{} + for _, observation := range observations { + observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN)) + observation.Source = strings.TrimSpace(observation.Source) + if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 { + continue + } + if grouped[observation.VIN] == nil { + grouped[observation.VIN] = map[string][]HydrogenObservation{} + } + grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation) + } + vins := make([]string, 0, len(grouped)) + for vin := range grouped { + vins = append(vins, vin) + } + sort.Strings(vins) + stats := make([]HydrogenDailyStat, 0, len(vins)) + for _, vin := range vins { + var selected *HydrogenDailyStat + for source, values := range grouped[vin] { + candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg) + if selected == nil || betterHydrogenStat(candidate, *selected) { + copy := candidate + selected = © + } + } + if selected != nil { + stats = append(stats, *selected) + } + } + return stats +} + +func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat { + sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) + stat := HydrogenDailyStat{ + VIN: vin, Source: source, Date: date, + FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg, + SampleCount: len(values), QualityStatus: "OK", + } + abnormalDrops := 0 + cycleMinimum := values[0].MassKg + for index := 1; index < len(values); index++ { + value := values[index] + sampleNoise := noiseKg + if value.NoiseKg > sampleNoise { + sampleNoise = value.NoiseKg + } + refuelThreshold := math.Max(1, cycleMinimum*0.05) + if value.RefuelThresholdKg > 0 { + refuelThreshold = value.RefuelThresholdKg + } + delta := cycleMinimum - value.MassKg + switch { + case value.MassKg-cycleMinimum > refuelThreshold: + stat.RefuelCount++ + cycleMinimum = value.MassKg + case delta > sampleNoise && delta <= maxDropKg: + stat.ConsumptionKg += delta + cycleMinimum = value.MassKg + case delta > maxDropKg: + abnormalDrops++ + } + } + stat.ConsumptionKg = round3(stat.ConsumptionKg) + if stat.SampleCount < 2 { + stat.QualityStatus = "NO_DATA" + stat.QualityReason = "有效车载氢量样本不足2条" + } else if abnormalDrops > 0 { + stat.QualityStatus = "SUSPECT" + stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg) + } + return stat +} + +func betterHydrogenStat(candidate, current HydrogenDailyStat) bool { + rank := func(status string) int { + switch status { + case "OK": + return 0 + case "SUSPECT": + return 1 + default: + return 2 + } + } + if rank(candidate.QualityStatus) != rank(current.QualityStatus) { + return rank(candidate.QualityStatus) < rank(current.QualityStatus) + } + if candidate.SampleCount != current.SampleCount { + return candidate.SampleCount > current.SampleCount + } + return candidate.Source < current.Source +} + +func ExtractHydrogenMass(parsedJSON string) (float64, bool) { + var fields map[string]any + if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { + return 0, false + } + for _, key := range hydrogenMassFields { + if value, ok := numericValue(fields[key]); ok && value >= 0 && value <= 200 { + return value, true + } + } + return 0, false +} + +func ExtractHydrogenRateAndMileage(parsedJSON string) (float64, float64, bool) { + var fields map[string]any + if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { + return 0, 0, false + } + rate, rateOK := numericValue(fields["gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"]) + mileage, mileageOK := numericValue(fields["gb32960.vehicle.total_mileage_km"]) + return rate, mileage, rateOK && mileageOK && rate >= 0 && rate <= 50 && mileage > 0 +} + +func BuildHydrogenRateDailyStats(observations []HydrogenRateObservation, date string, maxDeltaKm float64) []HydrogenRateDailyStat { + if maxDeltaKm <= 0 { + maxDeltaKm = 10 + } + grouped := map[string]map[string][]HydrogenRateObservation{} + for _, value := range observations { + value.VIN = strings.ToUpper(strings.TrimSpace(value.VIN)) + if len(value.VIN) != 17 || value.Rate < 0 || value.Rate > 50 || value.MileageKm <= 0 { + continue + } + if grouped[value.VIN] == nil { + grouped[value.VIN] = map[string][]HydrogenRateObservation{} + } + grouped[value.VIN][strings.TrimSpace(value.Source)] = append(grouped[value.VIN][strings.TrimSpace(value.Source)], value) + } + result := make([]HydrogenRateDailyStat, 0, len(grouped)) + for vin, sources := range grouped { + var best *HydrogenRateDailyStat + for source, values := range sources { + sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) }) + stat := HydrogenRateDailyStat{VIN: vin, Source: source, Date: date, SampleCount: len(values), QualityStatus: "NO_DATA", QualityReason: "尚无有效行驶里程区间"} + movement, abnormal := 0, 0 + for i := 1; i < len(values); i++ { + delta := values[i].MileageKm - values[i-1].MileageKm + if delta > 0 && delta <= maxDeltaKm { + stat.ConsumptionKg += delta * (values[i-1].Rate + values[i].Rate) / 200 + movement++ + } else if delta < 0 || delta > maxDeltaKm { + abnormal++ + } + } + stat.ConsumptionKg = round3(stat.ConsumptionKg) + if abnormal > 0 { + stat.QualityStatus, stat.QualityReason = "SUSPECT", "存在异常里程跳变" + } else if movement > 0 { + stat.QualityStatus, stat.QualityReason = "OK", "按里程区间积分百公里氢耗" + } + if best == nil || (stat.QualityStatus == "OK" && best.QualityStatus != "OK") || (stat.QualityStatus == best.QualityStatus && stat.SampleCount > best.SampleCount) { + copy := stat + best = © + } + } + if best != nil { + result = append(result, *best) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN }) + return result +} + +func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64, error) { + rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`) + if err != nil { + return nil, err + } + defer rows.Close() + capacities := map[string]float64{} + for rows.Next() { + var vin string + var capacity float64 + if err := rows.Scan(&vin, &capacity); err != nil { + return nil, err + } + if len(vin) == 17 && capacity > 0 && capacity <= 10000 { + capacities[vin] = capacity + } + } + return capacities, rows.Err() +} + +func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) { + database = strings.TrimSpace(database) + if database == "" { + database = "lingniu_vehicle_ts" + } + query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json +FROM ` + database + `.raw_frames +WHERE protocol='GB32960' + AND ts>='` + quoteTDTime(start) + `' + AND ts<'` + quoteTDTime(end) + `' + AND parse_status='OK' +ORDER BY vin,source_endpoint,ts` + rows, err := tdengine.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + observations := make([]HydrogenObservation, 0) + for rows.Next() { + var vin, parsed string + var source sql.NullString + var unixMS int64 + if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil { + return nil, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + capacity, capacityOK := capacities[vin] + pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed) + if !capacityOK || !ok { + continue + } + mass, ok := PressureHydrogenMassKg(pressure, temperature, capacity) + if !ok { + continue + } + stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity) + noise := math.Min(1, math.Max(0.05, mass-stepMass)) + observations = append(observations, HydrogenObservation{ + VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass, + TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature, + NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05), + }) + } + return observations, rows.Err() +} + +func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) { + var fields map[string]any + if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil { + return 0, 0, false + } + pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"]) + temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"]) + return pressure, temperature, pressureOK && temperatureOK +} + +func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) { + temperatureK := temperatureC + 273.15 + if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 { + return 0, false + } + a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403} + b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0} + c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5} + z := 1.0 + for index := range a { + z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index]) + } + if z <= 0 { + return 0, false + } + density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000 + mass := density * capacityLiter / 1000 + return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500 +} + +func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + // A pressure-based rebuild is authoritative for the whole day. Delete every + // previous hydrogen row first so legacy rate/direct-mass results cannot remain + // for vehicles without valid pressure observations in this run. + if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil { + return err + } + for _, stat := range stats { + if _, err := tx.ExecContext(ctx, ` +INSERT INTO vehicle_open_daily_energy( + vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg, + sample_count,refuel_count,quality_status,quality_reason,calculated_at +) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`, + stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg, + stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func numericValue(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func quoteTDTime(value time.Time) string { + return strings.ReplaceAll(value.Format(time.RFC3339Nano), "'", "''") +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go new file mode 100644 index 00000000..8c3ae226 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/access.go b/vehicle-data-platform/apps/api/internal/platform/access.go index 9f243138..27c8ca75 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access.go +++ b/vehicle-data-platform/apps/api/internal/platform/access.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/platform/access_store.go b/vehicle-data-platform/apps/api/internal/platform/access_store.go index 8470203e..f4a902c4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_store.go @@ -61,6 +61,102 @@ GREATEST(0,TIMESTAMPDIFF(SECOND,r.latest_seen_at,NOW())), return Page[AccessUnresolvedIdentity]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err() } +func (s *ProductionStore) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) { + if err := s.ensureAccessSchema(ctx); err != nil { + return AccessIdentityClaimResult{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return AccessIdentityClaimResult{}, err + } + defer tx.Rollback() + + var phone, sourcePlate, manufacturer string + err = tx.QueryRowContext(ctx, `SELECT r.phone,COALESCE(r.plate,''),COALESCE(r.manufacturer,'') +FROM jt808_registration r +LEFT JOIN vehicle_identity_binding b ON b.phone=r.phone +WHERE SHA2(CONCAT('JT808:',r.phone),256)=? + AND COALESCE(NULLIF(TRIM(b.vin),''),'')='' + AND (r.vin IS NULL OR TRIM(r.vin)='' OR LOWER(TRIM(r.vin))='unknown') +LIMIT 1 FOR UPDATE`, identityID).Scan(&phone, &sourcePlate, &manufacturer) + if err == sql.ErrNoRows { + return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"} + } + if err != nil { + return AccessIdentityClaimResult{}, err + } + + var targetPlate, currentPhone string + err = tx.QueryRowContext(ctx, `SELECT COALESCE(plate,''),COALESCE(phone,'') FROM vehicle_identity_binding WHERE BINARY vin=BINARY ? FOR UPDATE`, input.VIN).Scan(&targetPlate, ¤tPhone) + if err == sql.ErrNoRows { + return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"} + } + if err != nil { + return AccessIdentityClaimResult{}, err + } + if currentPhone != "" && currentPhone != phone { + return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_TARGET_CONFLICT", Message: "所选车辆已绑定其他 JT808 终端,请先核对换绑关系"} + } + + var conflictingVIN string + err = tx.QueryRowContext(ctx, `SELECT vin FROM vehicle_identity_binding WHERE phone=? AND BINARY vin<>BINARY ? LIMIT 1 FOR UPDATE`, phone, input.VIN).Scan(&conflictingVIN) + if err == nil { + return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_SOURCE_CONFLICT", Message: "该来源已绑定其他车辆,请刷新待办后核对"} + } + if err != sql.ErrNoRows { + return AccessIdentityClaimResult{}, err + } + if _, err = tx.ExecContext(ctx, `UPDATE vehicle_identity_binding SET phone=?,updated_at=CURRENT_TIMESTAMP WHERE BINARY vin=BINARY ?`, phone, input.VIN); err != nil { + return AccessIdentityClaimResult{}, err + } + if _, err = tx.ExecContext(ctx, `UPDATE jt808_registration SET vin=? WHERE phone=?`, input.VIN, phone); err != nil { + return AccessIdentityClaimResult{}, err + } + + profileValues := make([]string, 7) + err = tx.QueryRowContext(ctx, `SELECT COALESCE(brand_name,''),COALESCE(model_name,''),COALESCE(vehicle_type,''),COALESCE(company_name,''),COALESCE(operation_status,''),COALESCE(access_provider,''),COALESCE(DATE_FORMAT(first_access_at,'%Y-%m-%d %H:%i:%s'),'') FROM vehicle_profile WHERE BINARY vin=BINARY ?`, input.VIN).Scan( + &profileValues[0], &profileValues[1], &profileValues[2], &profileValues[3], &profileValues[4], &profileValues[5], &profileValues[6], + ) + if err != nil && err != sql.ErrNoRows { + return AccessIdentityClaimResult{}, err + } + missingLabels := []string{"车辆品牌", "车型", "车辆类型", "所属企业", "运营状态", "接入服务商", "首次接入"} + missing := make([]string, 0, len(missingLabels)) + for index, label := range missingLabels { + if strings.TrimSpace(profileValues[index]) == "" || (index == 4 && strings.EqualFold(profileValues[index], "unknown")) { + missing = append(missing, label) + } + } + note := input.Note + if note == "" { + note = "核对来源标识、车牌与厂家后绑定权威 VIN" + } + claimedAt := time.Now().Format(time.RFC3339) + audit, err := tx.ExecContext(ctx, `INSERT INTO vehicle_access_identity_audit +(identity_id,protocol,identifier_hash,identifier_masked,source_plate,manufacturer,vin,actor,note,claimed_at) +VALUES (?,'JT808',?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`, identityID, identityID, maskAccessIdentifier(phone), sourcePlate, manufacturer, input.VIN, input.Actor, note) + if err != nil { + return AccessIdentityClaimResult{}, err + } + auditID, _ := audit.LastInsertId() + if err = tx.Commit(); err != nil { + return AccessIdentityClaimResult{}, err + } + return AccessIdentityClaimResult{ + IdentityID: identityID, Protocol: "JT808", IdentifierMasked: maskAccessIdentifier(phone), VIN: input.VIN, + Plate: targetPlate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing, + ClaimedBy: input.Actor, ClaimedAt: claimedAt, AuditID: auditID, + }, nil +} + +func maskAccessIdentifier(value string) string { + value = strings.TrimSpace(value) + if len(value) < 7 { + return "***" + } + return value[:3] + "****" + value[len(value)-4:] +} + func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT v.vin, @@ -166,6 +262,22 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error { changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_access_threshold_audit_version (version), KEY idx_access_threshold_audit_changed (changed_at) +)`, + `CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + identity_id VARCHAR(128) NOT NULL, + protocol VARCHAR(32) NOT NULL, + identifier_hash VARCHAR(128) NOT NULL, + identifier_masked VARCHAR(64) NOT NULL, + source_plate VARCHAR(64) NOT NULL, + manufacturer VARCHAR(128) NOT NULL, + vin VARCHAR(32) NOT NULL, + actor VARCHAR(128) NOT NULL, + note TEXT NOT NULL, + claimed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_access_identity_vin (vin), + KEY idx_access_identity_claimed (claimed_at), + UNIQUE KEY uk_access_identity_claim (identity_id) )`, } for _, statement := range statements { diff --git a/vehicle-data-platform/apps/api/internal/platform/access_test.go b/vehicle-data-platform/apps/api/internal/platform/access_test.go index 8fc1961c..d073a313 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_test.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/platform/alert.go b/vehicle-data-platform/apps/api/internal/platform/alert.go index 8056811d..a01111ac 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert.go @@ -4,8 +4,10 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "strings" + "time" ) type alertStore interface { @@ -13,17 +15,26 @@ type alertStore interface { AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error) AlertEvent(context.Context, string) (AlertEvent, error) AlertRules(context.Context) ([]AlertRule, error) + AlertRulePage(context.Context, AlertRuleQuery) (AlertRulePage, error) + AlertRuleRevisions(context.Context, string) ([]AlertRuleRevision, error) SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error) SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error) + SetAlertRuleArchived(context.Context, string, bool, AlertRuleLifecycleRequest) (AlertRule, error) ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error) AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error) MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error) + RetryAlertNotification(context.Context, int64, AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) + AlertNotificationRetryAudits(context.Context, int64) ([]AlertNotificationRetryAudit, error) } type alertEvaluatorStore interface { EvaluateAlerts(context.Context) (AlertEvaluationResult, error) } +type alertNotificationHealthStore interface { + AlertNotificationDeliveryHealth(context.Context) (AlertNotificationDeliveryHealth, error) +} + func (s *Service) alertStore() (alertStore, error) { store, ok := s.store.(alertStore) if !ok { @@ -58,7 +69,14 @@ func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[Alert if err != nil { return Page[AlertEvent]{}, err } - return store.AlertEvents(ctx, normalizeAlertQuery(query)) + page, err := store.AlertEvents(ctx, normalizeAlertQuery(query)) + if err != nil { + return Page[AlertEvent]{}, err + } + for index := range page.Items { + page.Items[index] = normalizeVehicleEvent(page.Items[index]) + } + return page, nil } func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) { @@ -69,7 +87,11 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) if err != nil { return AlertEvent{}, err } - return store.AlertEvent(ctx, strings.TrimSpace(id)) + event, err := store.AlertEvent(ctx, strings.TrimSpace(id)) + if err != nil { + return AlertEvent{}, err + } + return normalizeVehicleEvent(event), nil } func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) { @@ -80,7 +102,176 @@ func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) { return store.AlertRules(ctx) } +func normalizeAlertRuleQuery(query AlertRuleQuery) AlertRuleQuery { + query.Keyword = strings.TrimSpace(query.Keyword) + if len([]rune(query.Keyword)) > 160 { + query.Keyword = string([]rune(query.Keyword)[:160]) + } + query.Protocol = strings.TrimSpace(query.Protocol) + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + if query.Status != "enabled" && query.Status != "disabled" { + query.Status = "all" + } + query.Lifecycle = strings.ToLower(strings.TrimSpace(query.Lifecycle)) + if query.Lifecycle != "archived" { + query.Lifecycle = "current" + } + if query.Limit != 20 && query.Limit != 50 { + query.Limit = 10 + } + if query.Offset < 0 { + query.Offset = 0 + } + query.Offset = query.Offset / query.Limit * query.Limit + return query +} + +func (s *Service) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) { + if err := authorizeInternalOperations(ctx, true); err != nil { + return AlertRulePage{}, err + } + store, err := s.alertStore() + if err != nil { + return AlertRulePage{}, err + } + return store.AlertRulePage(ctx, normalizeAlertRuleQuery(query)) +} + +func (s *Service) AlertNotificationConfig(ctx context.Context) (AlertNotificationConfig, error) { + if err := authorizeInternalOperations(ctx, false); err != nil { + return AlertNotificationConfig{}, err + } + return NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig), nil +} + +func (s *Service) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) { + if err := authorizeInternalOperations(ctx, false); err != nil { + return AlertNotificationDeliveryHealth{}, err + } + store, ok := s.store.(alertNotificationHealthStore) + if !ok { + return AlertNotificationDeliveryHealth{}, fmt.Errorf("store does not provide notification delivery health") + } + health, err := store.AlertNotificationDeliveryHealth(ctx) + if err != nil { + return AlertNotificationDeliveryHealth{}, err + } + config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig) + configured := map[string]AlertNotificationChannelCapability{} + for _, channel := range config.Channels { + configured[channel.Channel] = channel + } + byChannel := map[string]AlertNotificationChannelHealth{} + for _, channel := range health.Channels { + byChannel[channel.Channel] = channel + } + health.Channels = make([]AlertNotificationChannelHealth, 0, len(config.Channels)) + for _, capability := range config.Channels { + channel := byChannel[capability.Channel] + channel.Channel = capability.Channel + channel.Label = capability.Label + channel.Configured = capability.Configured + health.Channels = append(health.Channels, channel) + } + if health.AsOf == "" { + health.AsOf = time.Now().Format(time.RFC3339) + } + return health, nil +} + +func (s *Service) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"} + } + store, err := s.alertStore() + if err != nil { + return nil, err + } + return store.AlertRuleRevisions(ctx, id) +} + +func alertRuleInputFromRule(rule AlertRule) AlertRuleInput { + return AlertRuleInput{ + ID: rule.ID, Name: rule.Name, Description: rule.Description, TriggerType: rule.TriggerType, + FenceName: rule.FenceName, FenceLongitude: rule.FenceLongitude, FenceLatitude: rule.FenceLatitude, FenceRadiusM: rule.FenceRadiusM, + Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator, + Threshold: rule.Threshold, ThresholdHigh: rule.ThresholdHigh, BooleanThreshold: rule.BooleanThreshold, + DurationSec: rule.DurationSec, RecoveryOperator: rule.RecoveryOperator, RecoveryThreshold: rule.RecoveryThreshold, + RepeatIntervalSec: rule.RepeatIntervalSec, ScopeProtocols: append([]string(nil), rule.ScopeProtocols...), ScopeVINs: append([]string(nil), rule.ScopeVINs...), + ScopeOEMs: append([]string(nil), rule.ScopeOEMs...), ScopeModels: append([]string(nil), rule.ScopeModels...), ScopeCompanies: append([]string(nil), rule.ScopeCompanies...), + NotificationChannels: append([]string(nil), rule.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), rule.NotificationTargets...), Enabled: rule.Enabled, Version: rule.Version, + } +} + +func alertRuleConfigurationSignature(input AlertRuleInput) string { + input.Version = 0 + input.Actor = "" + input.AuditAction = "" + value, _ := json.Marshal(input) + return string(value) +} + +func (s *Service) RollbackAlertRule(ctx context.Context, id string, request AlertRuleRollbackRequest) (AlertRule, error) { + id = strings.TrimSpace(id) + if id == "" || request.TargetVersion <= 0 || request.CurrentVersion <= 0 { + return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_VERSION_REQUIRED", Message: "自动化 ID、当前版本和目标版本不能为空"} + } + if request.TargetVersion == request.CurrentVersion { + return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_TARGET_INVALID", Message: "目标版本必须与当前版本不同"} + } + store, err := s.alertStore() + if err != nil { + return AlertRule{}, err + } + rules, err := store.AlertRules(ctx) + if err != nil { + return AlertRule{}, err + } + var current *AlertRule + for index := range rules { + if rules[index].ID == id { + current = &rules[index] + break + } + } + if current == nil { + return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"} + } + if current.Version != request.CurrentVersion { + return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"} + } + revisions, err := store.AlertRuleRevisions(ctx, id) + if err != nil { + return AlertRule{}, err + } + var target *AlertRule + for index := range revisions { + if revisions[index].Version == request.TargetVersion { + target = &revisions[index].Snapshot + break + } + } + if target == nil { + return AlertRule{}, clientError{Code: "ALERT_RULE_REVISION_NOT_FOUND", Message: "目标版本不存在或已被清理"} + } + next := alertRuleInputFromRule(*target) + next.ID = id + next.Version = current.Version + next.Enabled = current.Enabled + next.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") + next.AuditAction = "rollback" + if alertRuleConfigurationSignature(next) == alertRuleConfigurationSignature(alertRuleInputFromRule(*current)) { + return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_NO_CHANGES", Message: "目标版本与当前配置相同,无需恢复"} + } + return s.SaveAlertRule(ctx, next) +} + func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) { + if err := authorizeInternalOperations(ctx, true); err != nil { + return AlertRule{}, err + } + normalizeAlertRuleTrigger(&input) definitions, err := s.metricDefinitions(ctx) if err != nil { return AlertRule{}, err @@ -105,7 +296,12 @@ func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (Aler if err := validateAlertRuleScopes(input); err != nil { return AlertRule{}, err } - input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels) + if err := normalizeAlertNotificationTargets(&input); err != nil { + return AlertRule{}, err + } + if err := s.validateAlertNotificationTargets(input.NotificationTargets); err != nil { + return AlertRule{}, err + } if strings.EqualFold(input.Operator, "changed") { input.DurationSec = 0 } @@ -144,6 +340,9 @@ func validateAlertRuleScopes(input AlertRuleInput) error { } func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) { + if err := authorizeInternalOperations(ctx, true); err != nil { + return AlertRule{}, err + } if strings.TrimSpace(id) == "" || update.Version <= 0 { return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"} } @@ -155,6 +354,40 @@ func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update Ale return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update) } +func normalizeAlertRuleLifecycleRequest(request AlertRuleLifecycleRequest) (AlertRuleLifecycleRequest, error) { + request.Reason = strings.TrimSpace(request.Reason) + if request.Version <= 0 { + return request, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则版本不能为空"} + } + if len([]rune(request.Reason)) < 4 { + return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"} + } + if len([]rune(request.Reason)) > 200 { + return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 200 字"} + } + request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") + return request, nil +} + +func (s *Service) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) { + if err := authorizeInternalOperations(ctx, true); err != nil { + return AlertRule{}, err + } + id = strings.TrimSpace(id) + if id == "" { + return AlertRule{}, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"} + } + normalized, err := normalizeAlertRuleLifecycleRequest(request) + if err != nil { + return AlertRule{}, err + } + store, err := s.alertStore() + if err != nil { + return AlertRule{}, err + } + return store.SetAlertRuleArchived(ctx, id, archived, normalized) +} + func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) { if strings.TrimSpace(id) == "" || request.Version <= 0 { return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"} @@ -171,7 +404,73 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction if err != nil { return AlertEvent{}, err } - return store.ActOnAlert(ctx, strings.TrimSpace(id), request) + event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request) + if err != nil { + return AlertEvent{}, err + } + return normalizeVehicleEvent(event), nil +} + +func normalizeVehicleEvent(event AlertEvent) AlertEvent { + event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator) + switch strings.ToLower(strings.TrimSpace(event.Status)) { + case "processing": + event.ExecutionState = "processing" + case "recovered": + event.ExecutionState = "recovered" + case "closed": + event.ExecutionState = "completed" + case "ignored": + event.ExecutionState = "ignored" + default: + event.ExecutionState = "pending" + } + return event +} + +func canonicalVehicleEvent(triggerType, metric, operator string) (string, string) { + triggerType = strings.ToLower(strings.TrimSpace(triggerType)) + metric = strings.ToLower(strings.TrimSpace(metric)) + operator = strings.ToLower(strings.TrimSpace(operator)) + if triggerType == "geofence" { + action := map[string]string{"enter": "entered", "exit": "exited", "inside": "inside", "outside": "outside"}[operator] + if action == "" { + action = "changed" + } + return "geofence", "vehicle.geofence." + action + } + if triggerType == "offline" || metric == "freshness_sec" { + return "connectivity", "vehicle.connectivity.offline" + } + if triggerType == "stationary" { + return "telemetry", "vehicle.motion.stationary" + } + switch metric { + case "soc_percent": + if operator == "lt" || operator == "lte" { + return "telemetry", "vehicle.telemetry.soc_low" + } + case "speed_kmh": + if operator == "gt" || operator == "gte" { + return "telemetry", "vehicle.motion.speed_high" + } + case "alarm_active": + return "safety", "vehicle.safety.alarm_activated" + case "hydrogen_concentration_percent": + return "safety", "vehicle.safety.hydrogen_concentration_high" + case "daily_mileage_km": + return "business", "vehicle.mileage.daily_completed" + } + if strings.Contains(metric, "hydrogen") { + return "safety", "vehicle.safety." + metric + } + if strings.Contains(metric, "mileage") || strings.HasPrefix(metric, "daily_") { + return "business", "vehicle.business." + metric + } + if metric == "" { + metric = "changed" + } + return "telemetry", "vehicle.telemetry." + metric } func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) { @@ -184,6 +483,23 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio if query.Offset < 0 { query.Offset = 0 } + query.Search = strings.TrimSpace(query.Search) + searchRunes := []rune(query.Search) + if len(searchRunes) > 160 { + query.Search = string(searchRunes[:160]) + } + query.DeliveryStatus = strings.ToLower(strings.TrimSpace(query.DeliveryStatus)) + switch query.DeliveryStatus { + case "", "failed", "queued", "delivered": + default: + return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"} + } + if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + query.AllowedVINs = alertNotificationAllowedVINs(principal) + if len(query.AllowedVINs) == 0 { + return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil + } + } store, err := s.alertStore() if err != nil { return Page[AlertNotification]{}, err @@ -196,6 +512,12 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"} } request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") + if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + request.AllowedVINs = alertNotificationAllowedVINs(principal) + if len(request.AllowedVINs) == 0 { + return 0, nil + } + } store, err := s.alertStore() if err != nil { return 0, err @@ -203,6 +525,106 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN return store.MarkAlertNotificationsRead(ctx, request) } +func alertNotificationAllowedVINs(principal Principal) []string { + seen := map[string]struct{}{} + allowed := make([]string, 0, len(principal.VehicleVINs)+len(principal.VehicleGrants)) + for _, value := range principal.VehicleVINs { + vin := strings.ToUpper(strings.TrimSpace(value)) + if vin == "" { + continue + } + if _, exists := seen[vin]; exists { + continue + } + seen[vin] = struct{}{} + allowed = append(allowed, vin) + } + for _, grant := range principal.VehicleGrants { + vin := strings.ToUpper(strings.TrimSpace(grant.VIN)) + if vin == "" { + continue + } + if _, exists := seen[vin]; exists { + continue + } + seen[vin] = struct{}{} + allowed = append(allowed, vin) + } + return allowed +} + +func alertNotificationRetryActor(ctx context.Context) (string, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_AUTH_REQUIRED", Message: "通知重试需要已认证的操作账号"} + } + role := strings.ToLower(strings.TrimSpace(principal.Role)) + if role != "admin" && role != "operator" { + return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_FORBIDDEN", Message: "当前账号无权重新投递通知"} + } + return firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "platform-operator"), nil +} + +func validAlertNotificationRetryKey(value string) bool { + if len(value) < 16 || len(value) > 96 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { + continue + } + switch char { + case '-', '_', '.', ':': + continue + default: + return false + } + } + return true +} + +func (s *Service) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) { + actor, err := alertNotificationRetryActor(ctx) + if err != nil { + return AlertNotificationRetryResult{}, err + } + if id <= 0 { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"} + } + request.Reason = strings.TrimSpace(request.Reason) + reasonRunes := []rune(request.Reason) + if len(reasonRunes) < 4 || len(reasonRunes) > 200 { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_REASON_INVALID", Message: "请填写 4 到 200 个字符的重试原因"} + } + request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey) + if !validAlertNotificationRetryKey(request.IdempotencyKey) { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_INVALID", Message: "重试请求键无效,请重新打开确认窗口"} + } + if request.ExpectedAttemptCount <= 0 || request.ExpectedAttemptCount >= alertNotificationMaxAttempts { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_INVALID", Message: "通知尝试次数无效或已经达到上限"} + } + request.Actor = actor + store, err := s.alertStore() + if err != nil { + return AlertNotificationRetryResult{}, err + } + return store.RetryAlertNotification(ctx, id, request) +} + +func (s *Service) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) { + if err := authorizeInternalOperations(ctx, false); err != nil { + return nil, err + } + if id <= 0 { + return nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"} + } + store, err := s.alertStore() + if err != nil { + return nil, err + } + return store.AlertNotificationRetryAudits(ctx, id) +} + func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) { store, ok := s.store.(alertEvaluatorStore) if !ok { @@ -237,6 +659,10 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error if severity != "critical" && severity != "major" && severity != "minor" { return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"} } + triggerType := normalizedAlertTriggerType(input.TriggerType, input.Metric) + if triggerType != "metric" && triggerType != "geofence" && triggerType != "stationary" && triggerType != "offline" { + return clientError{Code: "ALERT_RULE_TRIGGER_TYPE_INVALID", Message: "自动化触发类型无效"} + } valueType := strings.ToLower(strings.TrimSpace(input.ValueType)) if valueType != "numeric" && valueType != "boolean" { return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"} @@ -244,6 +670,37 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error if strings.TrimSpace(input.Metric) == "" { return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"} } + operator := strings.ToLower(strings.TrimSpace(input.Operator)) + if triggerType == "geofence" { + if len(input.ScopeProtocols) != 1 { + return clientError{Code: "ALERT_RULE_GEOFENCE_PROTOCOL_REQUIRED", Message: "电子围栏必须指定唯一定位协议,避免多数据源坐标漂移或重复触发"} + } + if valueType != "numeric" || !strings.EqualFold(input.Metric, "geofence_distance_m") { + return clientError{Code: "ALERT_RULE_GEOFENCE_TYPE_INVALID", Message: "电子围栏规则配置不完整"} + } + if operator != "enter" && operator != "exit" && operator != "inside" && operator != "outside" { + return clientError{Code: "ALERT_RULE_GEOFENCE_MODE_INVALID", Message: "电子围栏仅支持进入、离开、围栏内或围栏外"} + } + if strings.TrimSpace(input.FenceName) == "" || len([]rune(strings.TrimSpace(input.FenceName))) > 80 { + return clientError{Code: "ALERT_RULE_GEOFENCE_NAME_INVALID", Message: "围栏名称不能为空且不能超过 80 字"} + } + if input.FenceLongitude < -180 || input.FenceLongitude > 180 || input.FenceLatitude < -90 || input.FenceLatitude > 90 || (input.FenceLongitude == 0 && input.FenceLatitude == 0) { + return clientError{Code: "ALERT_RULE_GEOFENCE_CENTER_INVALID", Message: "围栏中心坐标无效"} + } + if input.FenceRadiusM < 50 || input.FenceRadiusM > 100000 { + return clientError{Code: "ALERT_RULE_GEOFENCE_RADIUS_INVALID", Message: "围栏半径须在 50 米到 100 公里之间"} + } + if (operator == "enter" || operator == "exit") && input.DurationSec != 0 { + return clientError{Code: "ALERT_RULE_GEOFENCE_DURATION_INVALID", Message: "进入或离开围栏按状态变化立即触发,不能配置持续时间"} + } + if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 { + return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "重复间隔超出允许范围"} + } + if input.Version < 0 { + return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"} + } + return nil + } var metric *MetricDefinition definitions := catalog if len(definitions) == 0 { @@ -259,13 +716,12 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error if metric == nil || !metric.Alertable { return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"} } - if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported { + if !alertMetricDefinitionSupported(*metric) { return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"} } if metric.ValueType != valueType { return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"} } - operator := strings.ToLower(strings.TrimSpace(input.Operator)) allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true} if !allowed[operator] { return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"} @@ -279,15 +735,81 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil { return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"} } - if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 { + if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 { return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"} } + if triggerType == "stationary" && (!strings.EqualFold(input.Metric, "speed_kmh") || (operator != "lt" && operator != "lte") || input.Threshold < 0 || input.Threshold > 10 || input.DurationSec < 300) { + return clientError{Code: "ALERT_RULE_STATIONARY_INVALID", Message: "长时间静止须使用 0–10 km/h 的速度上限且持续至少 5 分钟"} + } + if triggerType == "offline" && (!strings.EqualFold(input.Metric, "freshness_sec") || (operator != "gt" && operator != "gte") || input.Threshold < 60) { + return clientError{Code: "ALERT_RULE_OFFLINE_INVALID", Message: "长时间离线须配置至少 60 秒的离线阈值"} + } if input.Version < 0 { return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"} } return nil } +func normalizedAlertTriggerType(triggerType, metric string) string { + triggerType = strings.ToLower(strings.TrimSpace(triggerType)) + if triggerType != "" { + return triggerType + } + if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") { + return "offline" + } + return "metric" +} + +func normalizeAlertRuleTrigger(input *AlertRuleInput) { + input.TriggerType = normalizedAlertTriggerType(input.TriggerType, input.Metric) + switch input.TriggerType { + case "geofence": + input.ValueType = "numeric" + input.Metric = "geofence_distance_m" + input.Threshold = input.FenceRadiusM + input.ThresholdHigh = 0 + input.BooleanThreshold = nil + input.RecoveryOperator = "" + input.RecoveryThreshold = 0 + if strings.EqualFold(input.Operator, "enter") || strings.EqualFold(input.Operator, "exit") { + input.DurationSec = 0 + } + case "stationary": + input.ValueType = "numeric" + input.Metric = "speed_kmh" + if input.Operator != "lt" && input.Operator != "lte" { + input.Operator = "lte" + } + input.RecoveryOperator = "gt" + input.RecoveryThreshold = input.Threshold + case "offline": + input.ValueType = "numeric" + input.Metric = "freshness_sec" + if input.Operator != "gt" && input.Operator != "gte" { + input.Operator = "gt" + } + input.DurationSec = 0 + input.RecoveryOperator = "lte" + input.RecoveryThreshold = input.Threshold + } +} + +func alertMetricDefinitionSupported(metric MetricDefinition) bool { + if _, supported := alertMetricValue(metric.Key, alertEvaluationEvidence{}); supported { + return true + } + for protocol, source := range metric.SourceFields { + switch strings.ToUpper(strings.TrimSpace(protocol)) { + case "GB32960", "JT808", "YUTONG_MQTT": + if strings.TrimSpace(source) != "" { + return true + } + } + } + return false +} + func normalizeAlertChannels(values []string) []string { allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true} out := make([]string, 0, len(values)+1) @@ -299,12 +821,163 @@ func normalizeAlertChannels(values []string) []string { out = append(out, value) } } + if len(out) == 0 { + return []string{} + } if !seen["in_app"] { out = append([]string{"in_app"}, out...) } return out } +func alertNotificationTargetIDValid(value string) bool { + if len(value) < 2 || len(value) > 96 { + return false + } + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { + continue + } + switch char { + case '-', '_', '.', ':': + continue + default: + return false + } + } + return true +} + +func normalizeAlertNotificationTargets(input *AlertRuleInput) error { + if len(input.NotificationTargets) > 20 { + return clientError{Code: "ALERT_NOTIFICATION_TARGET_LIMIT", Message: "单条自动化最多配置 20 个通知目标"} + } + if len(input.NotificationTargets) == 0 { + channels := normalizeAlertChannels(input.NotificationChannels) + if len(channels) == 0 { + input.NotificationChannels = []string{} + input.NotificationTargets = []AlertNotificationTarget{} + return nil + } + for _, channel := range channels { + if channel != "in_app" { + return clientError{Code: "ALERT_NOTIFICATION_TARGET_REQUIRED", Message: "短信、邮件和企业通讯必须选择明确的通知目标"} + } + } + input.NotificationChannels = []string{"in_app"} + input.NotificationTargets = []AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}} + return nil + } + allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true} + targets := make([]AlertNotificationTarget, 0, len(input.NotificationTargets)+1) + seen := map[string]bool{} + hasInApp := false + for _, target := range input.NotificationTargets { + target.Channel = strings.ToLower(strings.TrimSpace(target.Channel)) + target.RecipientID = strings.TrimSpace(target.RecipientID) + target.Label = strings.TrimSpace(target.Label) + if !allowed[target.Channel] { + return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_INVALID", Message: "通知渠道无效"} + } + if !alertNotificationTargetIDValid(target.RecipientID) { + return clientError{Code: "ALERT_NOTIFICATION_TARGET_INVALID", Message: "通知目标标识无效"} + } + if target.Label == "" || len([]rune(target.Label)) > 80 { + return clientError{Code: "ALERT_NOTIFICATION_TARGET_LABEL_INVALID", Message: "通知目标名称不能为空且不能超过 80 字"} + } + key := target.Channel + "\x00" + target.RecipientID + if seen[key] { + continue + } + seen[key] = true + hasInApp = hasInApp || target.Channel == "in_app" + targets = append(targets, target) + } + if len(targets) > 0 && !hasInApp { + targets = append([]AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}, targets...) + } + channels := make([]string, 0, len(targets)) + channelSeen := map[string]bool{} + for _, target := range targets { + if !channelSeen[target.Channel] { + channelSeen[target.Channel] = true + channels = append(channels, target.Channel) + } + } + input.NotificationTargets = targets + input.NotificationChannels = channels + return nil +} + +func NormalizeAlertNotificationConfig(config AlertNotificationConfig) AlertNotificationConfig { + allowed := map[string]string{"in_app": "站内信", "sms": "短信", "email": "邮件", "wecom": "企业通讯"} + channelConfigured := map[string]bool{"in_app": true} + for _, channel := range config.Channels { + key := strings.ToLower(strings.TrimSpace(channel.Channel)) + if _, ok := allowed[key]; ok { + channelConfigured[key] = channel.Configured || key == "in_app" + } + } + config.Channels = make([]AlertNotificationChannelCapability, 0, len(allowed)) + for _, key := range []string{"in_app", "sms", "email", "wecom"} { + config.Channels = append(config.Channels, AlertNotificationChannelCapability{Channel: key, Label: allowed[key], Configured: channelConfigured[key]}) + } + targets := make([]AlertNotificationTargetOption, 0, len(config.Targets)+1) + seen := map[string]bool{} + for _, target := range config.Targets { + target.ID = strings.TrimSpace(target.ID) + target.Label = strings.TrimSpace(target.Label) + if !alertNotificationTargetIDValid(target.ID) || target.Label == "" || len([]rune(target.Label)) > 80 || seen[target.ID] { + continue + } + channels := make([]string, 0, len(target.Channels)) + for _, raw := range target.Channels { + channel := strings.ToLower(strings.TrimSpace(raw)) + if _, ok := allowed[channel]; ok && !containsString(channels, channel) { + channels = append(channels, channel) + } + } + if len(channels) == 0 { + continue + } + seen[target.ID] = true + target.Channels = channels + targets = append(targets, target) + } + if !seen["platform-operators"] { + targets = append([]AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}, targets...) + } + config.Targets = targets + return config +} + +func (s *Service) validateAlertNotificationTargets(targets []AlertNotificationTarget) error { + config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig) + capabilities := map[string]bool{} + for _, channel := range config.Channels { + capabilities[channel.Channel] = channel.Configured + } + options := map[string]map[string]bool{} + for _, target := range config.Targets { + options[target.ID] = map[string]bool{} + for _, channel := range target.Channels { + options[target.ID][channel] = true + } + } + for _, target := range targets { + if target.Channel == "in_app" { + continue + } + if !capabilities[target.Channel] { + return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_UNAVAILABLE", Message: "所选外部通知渠道尚未配置发送网关"} + } + if len(options) > 0 && !options[target.RecipientID][target.Channel] { + return clientError{Code: "ALERT_NOTIFICATION_TARGET_UNAVAILABLE", Message: "所选通知目标不支持当前渠道"} + } + } + return nil +} + func cleanUniqueStrings(values []string) []string { out := make([]string, 0, len(values)) seen := map[string]bool{} diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go b/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go index 602bba0f..42dcee2d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_evaluator.go @@ -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 "" } diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_mock.go b/vehicle-data-platform/apps/api/internal/platform/alert_mock.go index 9e2c07f9..3e277be3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_mock.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_mock.go @@ -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 } diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch.go b/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch.go new file mode 100644 index 00000000..7bc75af4 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch.go @@ -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) diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch_test.go b/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch_test.go new file mode 100644 index 00000000..0f613eb0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/alert_notification_dispatch_test.go @@ -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") + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_store.go b/vehicle-data-platform/apps/api/internal/platform/alert_store.go index bd36cb81..6ce57823 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" @@ -106,16 +107,20 @@ func buildAlertWhere(query AlertQuery) (string, []any) { return strings.Join(where, " AND "), args } -const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.status,e.vin,e.plate,e.protocol, +const alertSQLTimestampFormat = `%Y-%m-%dT%H:%i:%s.%f+08:00` + +const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.trigger_type,e.status,e.vin,e.plate,e.protocol, e.metric,e.operator,e.trigger_value,e.threshold_value,e.threshold_high,e.unit,e.duration_sec,e.location_text,e.longitude,e.latitude, -e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),COALESCE(DATE_FORMAT(e.received_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''), -DATE_FORMAT(e.triggered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(e.recovered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),e.handler,e.version +e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(e.received_at,'` + alertSQLTimestampFormat + `'),''), +DATE_FORMAT(e.triggered_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(e.recovered_at,'` + alertSQLTimestampFormat + `'),''),e.handler,e.version FROM vehicle_alert_event e ` +const alertActionSelect = `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id` + func scanAlertEvent(scanner interface{ Scan(...any) error }) (AlertEvent, error) { var event AlertEvent var longitude, latitude sql.NullFloat64 - err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.Status, &event.VIN, &event.Plate, &event.Protocol, + err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.TriggerType, &event.Status, &event.VIN, &event.Plate, &event.Protocol, &event.Metric, &event.Operator, &event.TriggerValue, &event.Threshold, &event.ThresholdHigh, &event.Unit, &event.DurationSec, &event.Location, &longitude, &latitude, &event.SourceEventID, &event.EventAt, &event.ReceivedAt, &event.TriggeredAt, &event.RecoveredAt, &event.Handler, &event.Version) if longitude.Valid { @@ -164,7 +169,7 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent if err != nil { return AlertEvent{}, err } - rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`, id) + rows, err := s.db.QueryContext(ctx, alertActionSelect, id) if err != nil { return AlertEvent{}, err } @@ -179,15 +184,73 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent return event, rows.Err() } -const alertRuleSelect = `SELECT id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec, -recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,enabled,version, -created_by,updated_by,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_rule ` +const alertRuleSelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec, +recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version, +created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_rule ` + +const alertRuleLibrarySelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec, +recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version, +created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `'),COALESCE(archived_by,''),COALESCE(DATE_FORMAT(archived_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(archive_reason,'') FROM vehicle_alert_rule ` + +const alertNotificationSelect = `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,COALESCE(n.recipient,''),COALESCE(n.recipient_ref,''),n.delivery_status,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(n.provider_message_id,''),n.is_read,DATE_FORMAT(n.created_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(n.delivered_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(n.read_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.last_error,''),COALESCE(DATE_FORMAT(n.last_attempt_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.retry_requested_by,''),COALESCE(DATE_FORMAT(n.retry_requested_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,'') FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE ` + +const alertNotificationRetryAuditSelect = `SELECT id,notification_id,actor,reason,previous_status,next_status,attempt_count,DATE_FORMAT(requested_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_notification_retry_audit WHERE ` + +const alertNotificationMaxAttempts = 3 + +type alertRowScanner interface { + Scan(...any) error +} + +func scanAlertNotification(scanner alertRowScanner) (AlertNotification, error) { + var item AlertNotification + var deliveryStatus string + err := scanner.Scan( + &item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient, + &item.RecipientID, &deliveryStatus, &item.AttemptCount, &item.ProviderMessageID, &item.Read, &item.CreatedAt, &item.DeliveredAt, &item.ReadAt, + &item.LastError, &item.LastAttemptAt, &item.RetryRequestedBy, &item.RetryRequestedAt, + &item.VehiclePlate, &item.VehicleVIN, &item.Protocol, + ) + if err != nil { + return AlertNotification{}, err + } + switch deliveryStatus { + case "failed": + item.DeliveryStatus = "failed" + case "created", "reserved": + item.DeliveryStatus = "queued" + default: + item.DeliveryStatus = "delivered" + } + if item.Recipient == "" { + if item.Channel == "in_app" { + item.Recipient = "运营值班组" + } else { + item.Recipient = "规则通知目标" + } + } + if item.ProviderMessageID == "" && item.Channel == "in_app" { + item.ProviderMessageID = fmt.Sprintf("in_app:%d", item.ID) + } + if item.DeliveryStatus == "delivered" && item.DeliveredAt == "" { + item.DeliveredAt = item.CreatedAt + } + item.MaxAttempts = alertNotificationMaxAttempts + item.RetryAvailable = item.DeliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts + return item, nil +} + +func scanAlertNotificationRetryAudit(scanner alertRowScanner) (AlertNotificationRetryAudit, error) { + var item AlertNotificationRetryAudit + err := scanner.Scan(&item.ID, &item.NotificationID, &item.Actor, &item.Reason, &item.PreviousStatus, &item.NextStatus, &item.AttemptCount, &item.RequestedAt) + return item, err +} func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) { var rule AlertRule var boolean sql.NullBool - var protocols, vins, oems, models, companies, channels string - err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt) + var protocols, vins, oems, models, companies, channels, targets string + err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt) if boolean.Valid { rule.BooleanThreshold = &boolean.Bool } @@ -210,15 +273,58 @@ func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) { if e := json.Unmarshal([]byte(channels), &rule.NotificationChannels); e != nil { return AlertRule{}, e } + if e := json.Unmarshal([]byte(targets), &rule.NotificationTargets); e != nil { + return AlertRule{}, e + } + input := alertRuleInputFromRule(rule) + if e := normalizeAlertNotificationTargets(&input); e != nil { + return AlertRule{}, e + } + rule.NotificationChannels = input.NotificationChannels + rule.NotificationTargets = input.NotificationTargets } return rule, err } +func scanAlertRuleLibrary(scanner interface{ Scan(...any) error }) (AlertRule, error) { + var rule AlertRule + var boolean sql.NullBool + var protocols, vins, oems, models, companies, channels, targets string + err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt, &rule.ArchivedBy, &rule.ArchivedAt, &rule.ArchiveReason) + if boolean.Valid { + rule.BooleanThreshold = &boolean.Bool + } + if err != nil { + return rule, err + } + for _, item := range []struct { + raw string + target *[]string + }{ + {protocols, &rule.ScopeProtocols}, {vins, &rule.ScopeVINs}, {oems, &rule.ScopeOEMs}, + {models, &rule.ScopeModels}, {companies, &rule.ScopeCompanies}, {channels, &rule.NotificationChannels}, + } { + if err := json.Unmarshal([]byte(item.raw), item.target); err != nil { + return AlertRule{}, err + } + } + if err := json.Unmarshal([]byte(targets), &rule.NotificationTargets); err != nil { + return AlertRule{}, err + } + input := alertRuleInputFromRule(rule) + if err := normalizeAlertNotificationTargets(&input); err != nil { + return AlertRule{}, err + } + rule.NotificationChannels = input.NotificationChannels + rule.NotificationTargets = input.NotificationTargets + return rule, nil +} + func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) { if err := s.ensureAlertSchema(ctx); err != nil { return nil, err } - rows, err := s.db.QueryContext(ctx, alertRuleSelect+`ORDER BY enabled DESC,severity,name`) + rows, err := s.db.QueryContext(ctx, alertRuleSelect+`WHERE archived_at IS NULL ORDER BY enabled DESC,severity,name`) if err != nil { return nil, err } @@ -234,39 +340,138 @@ func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) { return items, rows.Err() } -func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, error) { +func buildAlertRuleWhere(query AlertRuleQuery) (string, []any) { + where := []string{"1=1"} + args := []any{} + if query.Lifecycle == "archived" { + where = append(where, "archived_at IS NOT NULL") + } else { + where = append(where, "archived_at IS NULL") + } + if query.Status == "enabled" { + where = append(where, "enabled=1") + } else if query.Status == "disabled" { + where = append(where, "enabled=0") + } + if query.Protocol != "" { + where = append(where, "(JSON_LENGTH(scope_protocols_json)=0 OR JSON_CONTAINS(scope_protocols_json,?))") + encoded, _ := json.Marshal(query.Protocol) + args = append(args, string(encoded)) + } + if query.Keyword != "" { + like := "%" + query.Keyword + "%" + where = append(where, "(name LIKE ? OR description LIKE ? OR metric LIKE ? OR scope_vins_json LIKE ? OR scope_oems_json LIKE ? OR scope_models_json LIKE ? OR scope_companies_json LIKE ? OR archive_reason LIKE ?)") + for range 8 { + args = append(args, like) + } + } + return strings.Join(where, " AND "), args +} + +func (s *ProductionStore) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return AlertRulePage{}, err + } + var summary AlertRuleLibrarySummary + if err := s.db.QueryRowContext(ctx, `SELECT + COALESCE(SUM(CASE WHEN archived_at IS NULL THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=1 THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=0 THEN 1 ELSE 0 END),0), + COALESCE(SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END),0) + FROM vehicle_alert_rule`).Scan(&summary.Current, &summary.Enabled, &summary.Disabled, &summary.Archived); err != nil { + return AlertRulePage{}, err + } + where, args := buildAlertRuleWhere(query) + var total int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule WHERE `+where, args...).Scan(&total); err != nil { + return AlertRulePage{}, err + } + order := `enabled DESC,severity,updated_at DESC,id` + if query.Lifecycle == "archived" { + order = `archived_at DESC,id` + } + listArgs := append(append([]any(nil), args...), query.Limit, query.Offset) + rows, err := s.db.QueryContext(ctx, alertRuleLibrarySelect+`WHERE `+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, listArgs...) + if err != nil { + return AlertRulePage{}, err + } + defer rows.Close() + items := make([]AlertRule, 0, query.Limit) + for rows.Next() { + item, err := scanAlertRuleLibrary(rows) + if err != nil { + return AlertRulePage{}, err + } + items = append(items, item) + } + return AlertRulePage{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, rows.Err() +} + +func (s *ProductionStore) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT rule_id,rule_version,actor,action,COALESCE(reason,''),snapshot_json,DATE_FORMAT(created_at,'`+alertSQLTimestampFormat+`') FROM vehicle_alert_rule_audit WHERE rule_id=? ORDER BY rule_version DESC,id DESC`, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AlertRuleRevision{} + for rows.Next() { + var item AlertRuleRevision + var snapshot string + if err := rows.Scan(&item.RuleID, &item.Version, &item.Actor, &item.Action, &item.Reason, &snapshot, &item.CreatedAt); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(snapshot), &item.Snapshot); err != nil { + return nil, fmt.Errorf("parse alert rule revision %d: %w", item.Version, err) + } + item.Snapshot.ID = item.RuleID + item.Snapshot.Version = item.Version + item.Snapshot.UpdatedBy = item.Actor + item.Snapshot.UpdatedAt = item.CreatedAt + items = append(items, item) + } + return items, rows.Err() +} + +func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, string, error) { p, e := json.Marshal(input.ScopeProtocols) if e != nil { - return "", "", "", "", "", "", e + return "", "", "", "", "", "", "", e } v, e := json.Marshal(input.ScopeVINs) if e != nil { - return "", "", "", "", "", "", e + return "", "", "", "", "", "", "", e } o, e := json.Marshal(input.ScopeOEMs) if e != nil { - return "", "", "", "", "", "", e + return "", "", "", "", "", "", "", e } m, e := json.Marshal(input.ScopeModels) if e != nil { - return "", "", "", "", "", "", e + return "", "", "", "", "", "", "", e } co, e := json.Marshal(input.ScopeCompanies) if e != nil { - return "", "", "", "", "", "", e + return "", "", "", "", "", "", "", e } c, e := json.Marshal(input.NotificationChannels) - return string(p), string(v), string(o), string(m), string(co), string(c), e + if e != nil { + return "", "", "", "", "", "", "", e + } + t, e := json.Marshal(input.NotificationTargets) + return string(p), string(v), string(o), string(m), string(co), string(c), string(t), e } -const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)` -const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?` +const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,notification_targets_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)` +const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,trigger_type=?,fence_name=?,fence_longitude=?,fence_latitude=?,fence_radius_m=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,notification_targets_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL` func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) { if err := s.ensureAlertSchema(ctx); err != nil { return AlertRule{}, err } - p, v, o, m, co, c, err := marshalAlertRuleLists(input) + p, v, o, m, co, c, t, err := marshalAlertRuleLists(input) if err != nil { return AlertRule{}, err } @@ -276,20 +481,24 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu } defer tx.Rollback() var current int - err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t) + var archivedAt sql.NullTime + err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t, &archivedAt) if err == sql.ErrNoRows { if input.Version != 0 { return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"} } - _, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.Actor) + _, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.Actor) current = 0 } else if err != nil { return AlertRule{}, err } else { + if archivedAt.Valid { + return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"} + } if current != input.Version { return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"} } - res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.ID, current) + res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.ID, current) err = e if err == nil { n, _ := res.RowsAffected() @@ -301,8 +510,11 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu if err != nil { return AlertRule{}, err } - snapshot, _ := json.Marshal(input) - if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, input.ID, current+1, input.Actor, firstNonEmpty(map[bool]string{true: "create", false: "update"}[current == 0], "update"), string(snapshot)); err != nil { + auditSnapshot := input + auditSnapshot.Version = current + 1 + snapshot, _ := json.Marshal(auditSnapshot) + action := firstNonEmpty(strings.TrimSpace(input.AuditAction), map[bool]string{true: "create", false: "update"}[current == 0], "update") + if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, input.ID, current+1, input.Actor, action, "", string(snapshot)); err != nil { return AlertRule{}, err } if err = tx.Commit(); err != nil { @@ -320,7 +532,7 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up return AlertRule{}, err } defer tx.Rollback() - res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`, update.Enabled, update.Actor, id, update.Version) + res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, update.Enabled, update.Actor, id, update.Version) if err != nil { return AlertRule{}, err } @@ -341,7 +553,70 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up } } snapshot, _ := json.Marshal(rule) - if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], string(snapshot)); err != nil { + if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], "", string(snapshot)); err != nil { + return AlertRule{}, err + } + if err = tx.Commit(); err != nil { + return AlertRule{}, err + } + return rule, nil +} + +func (s *ProductionStore) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return AlertRule{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return AlertRule{}, err + } + defer tx.Rollback() + var enabled bool + var version int + var archivedAt sql.NullTime + if err = tx.QueryRowContext(ctx, `SELECT enabled,version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, id).Scan(&enabled, &version, &archivedAt); err == sql.ErrNoRows { + return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"} + } else if err != nil { + return AlertRule{}, err + } + if version != request.Version { + return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"} + } + if archived { + if archivedAt.Valid { + return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"} + } + if enabled { + return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"} + } + _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, request.Actor, request.Reason, request.Actor, id, version) + } else { + if !archivedAt.Valid { + return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"} + } + _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NULL,archived_by='',archive_reason='',enabled=0,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NOT NULL`, request.Actor, id, version) + } + if err != nil { + return AlertRule{}, err + } + if archived { + if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=?`, id); err != nil { + return AlertRule{}, err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`, id); err != nil { + return AlertRule{}, err + } + } + rule, err := scanAlertRuleLibrary(tx.QueryRowContext(ctx, alertRuleLibrarySelect+`WHERE id=?`, id)) + if err != nil { + return AlertRule{}, err + } + snapshot, _ := json.Marshal(rule) + action := "restore" + if archived { + action = "archive" + } + if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, request.Actor, action, request.Reason, string(snapshot)); err != nil { return AlertRule{}, err } if err = tx.Commit(); err != nil { @@ -412,23 +687,46 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot if err := s.ensureAlertSchema(ctx); err != nil { return Page[AlertNotification]{}, err } - where := "channel='in_app'" + conditions := []string{"1=1"} + args := []any{} if query.UnreadOnly { - where += " AND is_read=0" + conditions = append(conditions, "n.channel='in_app'", "n.is_read=0") } + if len(query.AllowedVINs) > 0 { + placeholders := make([]string, len(query.AllowedVINs)) + for index, vin := range query.AllowedVINs { + placeholders[index] = "?" + args = append(args, vin) + } + conditions = append(conditions, "e.vin IN ("+strings.Join(placeholders, ",")+")") + } + if query.DeliveryStatus == "failed" { + conditions = append(conditions, "n.delivery_status='failed'") + } else if query.DeliveryStatus == "queued" { + conditions = append(conditions, "n.delivery_status IN ('created','reserved')") + } else if query.DeliveryStatus == "delivered" { + conditions = append(conditions, "n.delivery_status NOT IN ('created','reserved','failed')") + } + if query.Search != "" { + like := "%" + query.Search + "%" + conditions = append(conditions, "(n.title LIKE ? OR n.content LIKE ? OR n.event_id LIKE ? OR e.plate LIKE ? OR e.vin LIKE ? OR e.protocol LIKE ?)") + args = append(args, like, like, like, like, like, like) + } + where := strings.Join(conditions, " AND ") var total int - if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE `+where).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `+where, args...).Scan(&total); err != nil { return Page[AlertNotification]{}, err } - rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,title,content,severity,channel,is_read,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(read_at,'%Y-%m-%dT%H:%i:%s.%fZ'),'') FROM vehicle_alert_notification WHERE `+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, query.Limit, query.Offset) + queryArgs := append(append([]any{}, args...), query.Limit, query.Offset) + rows, err := s.db.QueryContext(ctx, alertNotificationSelect+where+` ORDER BY n.created_at DESC,n.id DESC LIMIT ? OFFSET ?`, queryArgs...) if err != nil { return Page[AlertNotification]{}, err } defer rows.Close() items := []AlertNotification{} for rows.Next() { - var item AlertNotification - if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Read, &item.CreatedAt, &item.ReadAt); err != nil { + item, err := scanAlertNotification(rows) + if err != nil { return Page[AlertNotification]{}, err } items = append(items, item) @@ -436,6 +734,42 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot return Page[AlertNotification]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err() } +func (s *ProductionStore) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return AlertNotificationDeliveryHealth{}, err + } + rows, err := s.db.QueryContext(ctx, `SELECT channel, +COALESCE(SUM(CASE WHEN delivery_status IN ('created','reserved') THEN 1 ELSE 0 END),0), +COALESCE(SUM(CASE WHEN delivery_status='failed' THEN 1 ELSE 0 END),0), +COALESCE(SUM(CASE WHEN delivery_status='failed' AND GREATEST(COALESCE(attempt_count,1),1)>=? THEN 1 ELSE 0 END),0), +COALESCE(SUM(CASE WHEN lease_token<>'' AND lease_expires_at>NOW(3) THEN 1 ELSE 0 END),0), +COALESCE(DATE_FORMAT(MIN(CASE WHEN delivery_status IN ('created','reserved') THEN created_at END),'`+alertSQLTimestampFormat+`'),'') +FROM vehicle_alert_notification +WHERE channel<>'in_app' +GROUP BY channel +ORDER BY FIELD(channel,'sms','email','wecom'),channel`, alertNotificationMaxAttempts) + if err != nil { + return AlertNotificationDeliveryHealth{}, err + } + defer rows.Close() + health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)} + for rows.Next() { + var channel AlertNotificationChannelHealth + if err := rows.Scan(&channel.Channel, &channel.Queued, &channel.Failed, &channel.DeadLetter, &channel.ActiveLeases, &channel.OldestQueuedAt); err != nil { + return AlertNotificationDeliveryHealth{}, err + } + health.Queued += channel.Queued + health.Failed += channel.Failed + health.DeadLetter += channel.DeadLetter + health.ActiveLeases += channel.ActiveLeases + if channel.OldestQueuedAt != "" && (health.OldestQueuedAt == "" || channel.OldestQueuedAt < health.OldestQueuedAt) { + health.OldestQueuedAt = channel.OldestQueuedAt + } + health.Channels = append(health.Channels, channel) + } + return health, rows.Err() +} + func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) { if err := s.ensureAlertSchema(ctx); err != nil { return 0, err @@ -447,10 +781,137 @@ func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, reques placeholders[i] = "?" args = append(args, id) } - result, err := s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET is_read=1,read_by=?,read_at=CURRENT_TIMESTAMP(3) WHERE channel='in_app' AND is_read=0 AND id IN (`+strings.Join(placeholders, ",")+`)`, args...) + query := `UPDATE vehicle_alert_notification n` + if len(request.AllowedVINs) > 0 { + query += ` JOIN vehicle_alert_event e ON e.id=n.event_id` + } + query += ` SET n.is_read=1,n.read_by=?,n.read_at=CURRENT_TIMESTAMP(3) WHERE n.channel='in_app' AND n.is_read=0 AND n.id IN (` + strings.Join(placeholders, ",") + `)` + if len(request.AllowedVINs) > 0 { + vinPlaceholders := make([]string, len(request.AllowedVINs)) + for index, vin := range request.AllowedVINs { + vinPlaceholders[index] = "?" + args = append(args, vin) + } + query += ` AND e.vin IN (` + strings.Join(vinPlaceholders, ",") + `)` + } + result, err := s.db.ExecContext(ctx, query, args...) if err != nil { return 0, err } n, err := result.RowsAffected() return int(n), err } + +func (s *ProductionStore) alertNotificationByID(ctx context.Context, id int64) (AlertNotification, error) { + item, err := scanAlertNotification(s.db.QueryRowContext(ctx, alertNotificationSelect+`n.id=?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return AlertNotification{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"} + } + return item, err +} + +func (s *ProductionStore) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, alertNotificationRetryAuditSelect+`notification_id=? ORDER BY requested_at DESC,id DESC LIMIT 20`, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]AlertNotificationRetryAudit, 0) + for rows.Next() { + item, scanErr := scanAlertNotificationRetryAudit(rows) + if scanErr != nil { + return nil, scanErr + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *ProductionStore) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) { + if err := s.ensureAlertSchema(ctx); err != nil { + return AlertNotificationRetryResult{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return AlertNotificationRetryResult{}, err + } + defer tx.Rollback() + + var channel, status string + var attemptCount int + err = tx.QueryRowContext(ctx, `SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`, id).Scan(&channel, &status, &attemptCount) + if errors.Is(err, sql.ErrNoRows) { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"} + } + if err != nil { + return AlertNotificationRetryResult{}, err + } + + existing, existingErr := scanAlertNotificationRetryAudit(tx.QueryRowContext(ctx, alertNotificationRetryAuditSelect+`idempotency_key=?`, request.IdempotencyKey)) + if existingErr == nil { + if existing.NotificationID != id { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"} + } + if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { + return AlertNotificationRetryResult{}, err + } + notification, loadErr := s.alertNotificationByID(ctx, id) + if loadErr != nil { + return AlertNotificationRetryResult{}, loadErr + } + return AlertNotificationRetryResult{Notification: notification, Receipt: existing, Idempotent: true}, nil + } + if !errors.Is(existingErr, sql.ErrNoRows) { + return AlertNotificationRetryResult{}, existingErr + } + if status != "failed" { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"} + } + if attemptCount != request.ExpectedAttemptCount { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"} + } + if attemptCount >= alertNotificationMaxAttempts { + return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"} + } + + nextStatus := "reserved" + if channel == "in_app" { + nextStatus = "sent" + } + nextAttempt := attemptCount + 1 + result, err := tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`, + id, request.IdempotencyKey, request.Actor, request.Reason, status, nextStatus, nextAttempt) + if err != nil { + return AlertNotificationRetryResult{}, err + } + auditID, err := result.LastInsertId() + if err != nil { + return AlertNotificationRetryResult{}, err + } + if channel == "in_app" { + _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',attempt_count=?,provider_message_id=CONCAT('in_app:',id,':retry:',?),delivered_at=CURRENT_TIMESTAMP(3),last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`, + nextAttempt, nextAttempt, request.Actor, id) + } else { + _, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`, + nextAttempt, request.Actor, id) + } + if err != nil { + return AlertNotificationRetryResult{}, err + } + if err := tx.Commit(); err != nil { + return AlertNotificationRetryResult{}, err + } + notification, err := s.alertNotificationByID(ctx, id) + if err != nil { + return AlertNotificationRetryResult{}, err + } + receipt := AlertNotificationRetryAudit{ + ID: auditID, NotificationID: id, Actor: request.Actor, Reason: request.Reason, + PreviousStatus: status, NextStatus: nextStatus, AttemptCount: nextAttempt, + RequestedAt: notification.RetryRequestedAt, + } + return AlertNotificationRetryResult{Notification: notification, Receipt: receipt}, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go b/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go index b28d9af4..5dbe9f92 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_stream_evaluator.go @@ -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]) diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_stream_test.go b/vehicle-data-platform/apps/api/internal/platform/alert_stream_test.go index d50aa985..4212a10d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_stream_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_stream_test.go @@ -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") diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_test.go b/vehicle-data-platform/apps/api/internal/platform/alert_test.go index 86be8b06..63e69a91 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_test.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/platform/business_scope_store.go b/vehicle-data-platform/apps/api/internal/platform/business_scope_store.go new file mode 100644 index 00000000..155a9bf2 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/business_scope_store.go @@ -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 +} diff --git a/vehicle-data-platform/apps/api/internal/platform/business_scope_store_test.go b/vehicle-data-platform/apps/api/internal/platform/business_scope_store_test.go new file mode 100644 index 00000000..16441a9f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/business_scope_store_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index fcac1e15..f85b23de 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -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) diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 8d74303a..bf5611bd 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -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() diff --git a/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation.go b/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation.go new file mode 100644 index 00000000..5a00268a --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation.go @@ -0,0 +1,562 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + historyExportCleanupAutomationRunLimit = 100 + historyExportCleanupAutomationAttempts = 3 +) + +var ( + allowedHistoryExportCleanupIntervals = map[int]bool{7: true, 14: true, 30: true} + allowedHistoryExportCleanupApprovalWindows = map[int]bool{24: true, 48: true, 72: true} +) + +type historyExportCleanupAutomationFile struct { + Policy HistoryExportCleanupAutomationPolicy `json:"policy"` + Runs []HistoryExportCleanupAutomationRun `json:"runs"` +} + +type HistoryExportCleanupAutomationTick struct { + LockAcquired bool `json:"lockAcquired"` + CreatedRunID string `json:"createdRunId,omitempty"` + ClaimedRunID string `json:"claimedRunId,omitempty"` + Status string `json:"status,omitempty"` +} + +func defaultHistoryExportCleanupAutomationPolicy() HistoryExportCleanupAutomationPolicy { + return HistoryExportCleanupAutomationPolicy{ + OlderThanDays: 180, + IntervalDays: 7, + ApprovalWindowHours: 48, + Revision: 1, + } +} + +func (s *Service) historyExportCleanupAutomationPath() string { + return filepath.Join(s.exportDir, "cleanup-automation.json") +} + +func (s *Service) historyExportCleanupAutomationLockPath() string { + return filepath.Join(s.exportDir, "cleanup-automation.lock") +} + +func (s *Service) acquireHistoryExportCleanupAutomationLock(nonBlocking bool) (*os.File, error) { + if err := os.MkdirAll(s.exportDir, 0o750); err != nil { + return nil, err + } + lockFile, err := os.OpenFile(s.historyExportCleanupAutomationLockPath(), os.O_CREATE|os.O_RDWR, 0o640) + if err != nil { + return nil, err + } + operation := syscall.LOCK_EX + if nonBlocking { + operation |= syscall.LOCK_NB + } + if err := syscall.Flock(int(lockFile.Fd()), operation); err != nil { + _ = lockFile.Close() + return nil, err + } + return lockFile, nil +} + +func releaseHistoryExportCleanupAutomationLock(lockFile *os.File) { + if lockFile == nil { + return + } + _ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) + _ = lockFile.Close() +} + +func (s *Service) loadHistoryExportCleanupAutomationLocked() (historyExportCleanupAutomationFile, error) { + state := historyExportCleanupAutomationFile{Policy: defaultHistoryExportCleanupAutomationPolicy(), Runs: []HistoryExportCleanupAutomationRun{}} + contents, err := os.ReadFile(s.historyExportCleanupAutomationPath()) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return state, err + } + if err := json.Unmarshal(contents, &state); err != nil { + return historyExportCleanupAutomationFile{}, fmt.Errorf("decode cleanup automation state: %w", err) + } + if state.Policy.Revision <= 0 { + state.Policy = defaultHistoryExportCleanupAutomationPolicy() + } + if state.Runs == nil { + state.Runs = []HistoryExportCleanupAutomationRun{} + } + return state, nil +} + +func (s *Service) persistHistoryExportCleanupAutomationLocked(state historyExportCleanupAutomationFile) error { + if len(state.Runs) > historyExportCleanupAutomationRunLimit { + state.Runs = state.Runs[:historyExportCleanupAutomationRunLimit] + } + contents, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + temporary := s.historyExportCleanupAutomationPath() + ".tmp" + if err := os.WriteFile(temporary, contents, 0o640); err != nil { + return err + } + return os.Rename(temporary, s.historyExportCleanupAutomationPath()) +} + +func historyExportCleanupAutomationState(state historyExportCleanupAutomationFile, now time.Time) HistoryExportCleanupAutomationState { + return HistoryExportCleanupAutomationState{ + Policy: state.Policy, + Runs: append([]HistoryExportCleanupAutomationRun{}, state.Runs...), + ServerTime: now.Format(time.RFC3339), + } +} + +func (s *Service) HistoryExportCleanupAutomation(ctx context.Context) (HistoryExportCleanupAutomationState, error) { + if _, err := historyExportCleanupPrincipal(ctx); err != nil { + return HistoryExportCleanupAutomationState{}, err + } + lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false) + if err != nil { + return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation state: %w", err) + } + defer releaseHistoryExportCleanupAutomationLock(lockFile) + state, err := s.loadHistoryExportCleanupAutomationLocked() + if err != nil { + return HistoryExportCleanupAutomationState{}, err + } + return historyExportCleanupAutomationState(state, time.Now().UTC()), nil +} + +func normalizedHistoryExportCleanupAutomationPolicy(request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationPolicyRequest, error) { + if !allowedHistoryExportCleanupDays[request.OlderThanDays] { + return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_WINDOW_INVALID", Message: "自动清理周期仅支持 30、90、180 或 365 天"} + } + if !allowedHistoryExportCleanupIntervals[request.IntervalDays] { + return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_INTERVAL_INVALID", Message: "自动复核频率仅支持每 7、14 或 30 天"} + } + if !allowedHistoryExportCleanupApprovalWindows[request.ApprovalWindowHours] { + return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_INVALID", Message: "审批窗口仅支持 24、48 或 72 小时"} + } + return request, nil +} + +func (s *Service) UpdateHistoryExportCleanupAutomation(ctx context.Context, request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationState, error) { + principal, err := historyExportCleanupPrincipal(ctx) + if err != nil { + return HistoryExportCleanupAutomationState{}, err + } + request, err = normalizedHistoryExportCleanupAutomationPolicy(request) + if err != nil { + return HistoryExportCleanupAutomationState{}, err + } + lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false) + if err != nil { + return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation policy: %w", err) + } + state, err := s.loadHistoryExportCleanupAutomationLocked() + if err != nil { + releaseHistoryExportCleanupAutomationLock(lockFile) + return HistoryExportCleanupAutomationState{}, err + } + if request.ExpectedRevision != state.Policy.Revision { + releaseHistoryExportCleanupAutomationLock(lockFile) + return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REVISION_CONFLICT", Message: "自动清理策略已被其他管理员修改,请刷新后重试"} + } + now := time.Now().UTC() + changed := state.Policy.Enabled != request.Enabled || + state.Policy.OlderThanDays != request.OlderThanDays || + state.Policy.IntervalDays != request.IntervalDays || + state.Policy.ApprovalWindowHours != request.ApprovalWindowHours + state.Policy.Enabled = request.Enabled + state.Policy.OlderThanDays = request.OlderThanDays + state.Policy.IntervalDays = request.IntervalDays + state.Policy.ApprovalWindowHours = request.ApprovalWindowHours + state.Policy.Revision++ + state.Policy.UpdatedAt = now.Format(time.RFC3339) + state.Policy.UpdatedBy = firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin") + if request.Enabled && changed { + state.Policy.NextReviewAt = now.Format(time.RFC3339) + } + if !request.Enabled { + state.Policy.NextReviewAt = "" + } + if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil { + releaseHistoryExportCleanupAutomationLock(lockFile) + return HistoryExportCleanupAutomationState{}, fmt.Errorf("persist cleanup automation policy: %w", err) + } + releaseHistoryExportCleanupAutomationLock(lockFile) + if request.Enabled { + _, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, "policy-update", 5*time.Minute) + } + return s.HistoryExportCleanupAutomation(ctx) +} + +func historyExportCleanupAutomationRunID(now time.Time) string { + if identifier, err := randomExportID(); err == nil { + return strings.Replace(identifier, "exp_", "cleanup_run_", 1) + } + return "cleanup_run_" + strconv.FormatInt(now.UnixNano(), 10) +} + +func historyExportCleanupAutomationRunFromPreview(policy HistoryExportCleanupAutomationPolicy, preview HistoryExportCleanupPreview, now time.Time) HistoryExportCleanupAutomationRun { + status := "awaiting_approval" + if preview.PlannedCount == 0 { + status = "no_candidates" + } + run := HistoryExportCleanupAutomationRun{ + ID: historyExportCleanupAutomationRunID(now), Status: status, Revision: 1, PolicyRevision: policy.Revision, + OlderThanDays: preview.OlderThanDays, Cutoff: preview.Cutoff, CandidateCount: preview.CandidateCount, + PlannedCount: preview.PlannedCount, ProtectedCount: preview.ProtectedCount, FileCount: preview.FileCount, + FileSizeBytes: preview.FileSizeBytes, PreviewToken: preview.PreviewToken, CreatedAt: now.Format(time.RFC3339), + ApprovalDeadline: now.Add(time.Duration(policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339), + MaxAttempts: historyExportCleanupAutomationAttempts, LastActionAt: now.Format(time.RFC3339), LastActionBy: "scheduler", + } + if status == "no_candidates" { + run.CompletedAt = now.Format(time.RFC3339) + run.LastActionReason = "本轮没有符合策略且未受保护的归档任务" + } + return run +} + +func historyExportCleanupAutomationNextReview(policy HistoryExportCleanupAutomationPolicy, now time.Time) string { + next := now.Add(time.Duration(policy.IntervalDays) * 24 * time.Hour) + return next.Format(time.RFC3339) +} + +func historyExportCleanupAutomationDue(value string, now time.Time) bool { + parsed, err := time.Parse(time.RFC3339, value) + return err != nil || !parsed.After(now) +} + +func historyExportCleanupAutomationRefreshExpired(state *historyExportCleanupAutomationFile, now time.Time) { + for index := range state.Runs { + run := &state.Runs[index] + if run.Status == "awaiting_approval" || run.Status == "needs_review" { + if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) { + run.Status = "expired" + run.Revision++ + run.CompletedAt = now.Format(time.RFC3339) + run.LastActionAt = now.Format(time.RFC3339) + run.LastActionBy = "scheduler" + run.LastActionReason = "审批窗口已结束,未执行任何清理" + } + } + if run.Status == "running" && historyExportCleanupAutomationDue(run.LeaseExpiresAt, now) { + run.Status = "failed" + run.Revision++ + run.Error = "执行实例租约已过期,本轮等待安全恢复" + run.LeaseOwner = "" + run.LeaseExpiresAt = "" + if run.AttemptCount < run.MaxAttempts { + run.NextRetryAt = now.Format(time.RFC3339) + } + } + } +} + +func (s *Service) automationPreview(now time.Time, olderThanDays int) HistoryExportCleanupPreview { + principal := Principal{SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler", Role: "admin", UserType: "admin", AuthProvider: "system"} + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + if s.refreshHistoryExportAvailabilityLocked(now) { + _ = s.persistHistoryExportsLocked() + } + preview, _ := s.historyExportCleanupPreviewLocked(principal, HistoryExportCleanupQuery{OlderThanDays: olderThanDays, OwnerScope: "all"}, now) + return preview +} + +func (s *Service) claimHistoryExportCleanupAutomationRun(state *historyExportCleanupAutomationFile, workerID string, lease time.Duration, now time.Time) *HistoryExportCleanupAutomationRun { + for index := len(state.Runs) - 1; index >= 0; index-- { + run := &state.Runs[index] + runnable := run.Status == "approved" || + run.Status == "failed" && run.AttemptCount < run.MaxAttempts && historyExportCleanupAutomationDue(run.NextRetryAt, now) + if !runnable { + continue + } + run.Status = "running" + run.Revision++ + run.AttemptCount++ + run.StartedAt = now.Format(time.RFC3339) + run.LeaseOwner = workerID + ":" + strconv.FormatInt(now.UnixNano(), 10) + run.LeaseExpiresAt = now.Add(lease).Format(time.RFC3339) + run.NextRetryAt = "" + run.Error = "" + copy := *run + return © + } + return nil +} + +func (s *Service) RunHistoryExportCleanupAutomationOnce(ctx context.Context, workerID string, lease time.Duration) (HistoryExportCleanupAutomationTick, error) { + if strings.TrimSpace(workerID) == "" { + workerID = "cleanup-scheduler" + } + if lease < time.Minute { + lease = 5 * time.Minute + } + now := time.Now().UTC() + lockFile, err := s.acquireHistoryExportCleanupAutomationLock(true) + if err != nil { + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return HistoryExportCleanupAutomationTick{LockAcquired: false}, nil + } + return HistoryExportCleanupAutomationTick{}, fmt.Errorf("lock cleanup automation tick: %w", err) + } + state, err := s.loadHistoryExportCleanupAutomationLocked() + if err != nil { + releaseHistoryExportCleanupAutomationLock(lockFile) + return HistoryExportCleanupAutomationTick{}, err + } + historyExportCleanupAutomationRefreshExpired(&state, now) + tick := HistoryExportCleanupAutomationTick{LockAcquired: true} + if state.Policy.Enabled && historyExportCleanupAutomationDue(state.Policy.NextReviewAt, now) { + preview := s.automationPreview(now, state.Policy.OlderThanDays) + run := historyExportCleanupAutomationRunFromPreview(state.Policy, preview, now) + state.Runs = append([]HistoryExportCleanupAutomationRun{run}, state.Runs...) + state.Policy.NextReviewAt = historyExportCleanupAutomationNextReview(state.Policy, now) + tick.CreatedRunID = run.ID + tick.Status = run.Status + } + claim := s.claimHistoryExportCleanupAutomationRun(&state, workerID, lease, now) + if claim != nil { + tick.ClaimedRunID = claim.ID + tick.Status = claim.Status + } + if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil { + releaseHistoryExportCleanupAutomationLock(lockFile) + return HistoryExportCleanupAutomationTick{}, fmt.Errorf("persist cleanup automation tick: %w", err) + } + releaseHistoryExportCleanupAutomationLock(lockFile) + if claim == nil { + return tick, nil + } + + schedulerContext := WithPrincipal(ctx, Principal{ + SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler", + Role: "admin", UserType: "admin", AuthProvider: "system", + }) + result, executionErr := s.CleanupHistoryExports(schedulerContext, HistoryExportCleanupRequest{ + OlderThanDays: claim.OlderThanDays, OwnerScope: "all", PreviewToken: claim.PreviewToken, + }) + if err := s.finishHistoryExportCleanupAutomationRun(claim, result, executionErr); err != nil { + return tick, err + } + return tick, executionErr +} + +func (s *Service) finishHistoryExportCleanupAutomationRun(claim *HistoryExportCleanupAutomationRun, result HistoryExportCleanupResult, executionErr error) error { + lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false) + if err != nil { + return fmt.Errorf("lock cleanup automation completion: %w", err) + } + defer releaseHistoryExportCleanupAutomationLock(lockFile) + state, err := s.loadHistoryExportCleanupAutomationLocked() + if err != nil { + return err + } + now := time.Now().UTC() + for index := range state.Runs { + run := &state.Runs[index] + if run.ID != claim.ID || run.Status != "running" || run.LeaseOwner != claim.LeaseOwner { + continue + } + run.Revision++ + run.LeaseOwner = "" + run.LeaseExpiresAt = "" + run.LastActionAt = now.Format(time.RFC3339) + run.LastActionBy = "scheduler" + if executionErr == nil { + run.Status = "completed" + run.CompletedAt = now.Format(time.RFC3339) + run.CleanupRecordID = result.Record.ID + run.CleanedCount = result.Record.Cleaned + run.LastActionReason = "审批后的清理批次已完成" + } else if clientErr, ok := asClientError(executionErr); ok && clientErr.Code == "EXPORT_CLEANUP_PREVIEW_STALE" { + preview := s.automationPreview(now, run.OlderThanDays) + run.Status = "needs_review" + run.Cutoff = preview.Cutoff + run.CandidateCount = preview.CandidateCount + run.PlannedCount = preview.PlannedCount + run.ProtectedCount = preview.ProtectedCount + run.FileCount = preview.FileCount + run.FileSizeBytes = preview.FileSizeBytes + run.PreviewToken = preview.PreviewToken + run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339) + run.ApprovedAt = "" + run.ApprovedBy = "" + run.ApprovalReason = "" + run.AttemptCount = 0 + run.Error = "候选范围已变化,需要按最新影响重新审批" + run.LastActionReason = run.Error + } else { + run.Status = "failed" + run.Error = executionErr.Error() + run.LastActionReason = "执行失败,保留审批与候选证据" + if run.AttemptCount < run.MaxAttempts { + delay := time.Duration(1< 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: + } + } + }() +} diff --git a/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation_test.go b/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation_test.go new file mode 100644 index 00000000..33de53da --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/history_export_cleanup_automation_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index c60a8f92..5a4257ce 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -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])) }) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index a67325bc..2ee1d144 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -1,5 +1,7 @@ package platform +import "time" + type Page[T any] struct { Items []T `json:"items"` Total int `json:"total"` @@ -244,21 +246,34 @@ type HistoryMetricDefinition struct { } type HistoryDataResponse struct { - Category string `json:"category"` - Columns []HistoryMetricDefinition `json:"columns"` - Rows []HistoryDataRow `json:"rows"` - Summary HistoryDataSummary `json:"summary"` - Total int `json:"total"` - Limit int `json:"limit"` - Offset int `json:"offset"` - AsOf string `json:"asOf"` + Category string `json:"category"` + Columns []HistoryMetricDefinition `json:"columns"` + Segments []LatestTelemetryCategory `json:"segments,omitempty"` + SegmentCountsExact bool `json:"segmentCountsExact,omitempty"` + AllTotal int `json:"allTotal,omitempty"` + Rows []HistoryDataRow `json:"rows"` + Summary HistoryDataSummary `json:"summary"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + AsOf string `json:"asOf"` } type HistoryDataSummary struct { - ResultRows int `json:"resultRows"` - VehicleCount int `json:"vehicleCount"` - Sources []string `json:"sources"` - QueryDuration int64 `json:"queryDurationMs"` + ResultRows int `json:"resultRows"` + VehicleCount int `json:"vehicleCount"` + RequestedVehicleCount int `json:"requestedVehicleCount"` + Vehicles []HistoryQueryVehicleResult `json:"vehicles"` + Sources []string `json:"sources"` + QueryDuration int64 `json:"queryDurationMs"` +} + +type HistoryQueryVehicleResult struct { + Keyword string `json:"keyword"` + VIN string `json:"vin,omitempty"` + Plate string `json:"plate,omitempty"` + RowCount int `json:"rowCount"` + Status string `json:"status"` } type HistoryDataRow struct { @@ -339,13 +354,14 @@ type HistoryLocationSeriesBucket struct { } type HistoryExportRequest struct { - Keywords []string `json:"keywords"` - Category string `json:"category"` - Protocol string `json:"protocol"` - DateFrom string `json:"dateFrom"` - DateTo string `json:"dateTo"` - Metrics []string `json:"metrics"` - Format string `json:"format"` + Keywords []string `json:"keywords"` + Category string `json:"category"` + Protocol string `json:"protocol"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + Metrics []string `json:"metrics"` + Format string `json:"format"` + RetentionDays int `json:"retentionDays,omitempty"` } type HistoryExportVehicleScope struct { @@ -355,39 +371,242 @@ type HistoryExportVehicleScope struct { } type HistoryExportJob struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Progress int `json:"progress"` - Format string `json:"format"` - Category string `json:"category"` - Protocol string `json:"protocol,omitempty"` - Keywords []string `json:"keywords"` - Metrics []string `json:"metrics,omitempty"` - VehicleVINs []string `json:"vehicleVins"` - VehicleScopes []HistoryExportVehicleScope `json:"vehicleScopes"` - DateFrom string `json:"dateFrom"` - DateTo string `json:"dateTo"` - OwnerID string `json:"ownerId"` - OwnerSubjectID string `json:"ownerSubjectId,omitempty"` - OwnerName string `json:"ownerName"` - OwnerUsername string `json:"ownerUsername"` - OwnerRole string `json:"ownerRole"` - OwnerUserType string `json:"ownerUserType"` - AuthProvider string `json:"authProvider"` - CustomerRef string `json:"customerRef,omitempty"` - TenantRef string `json:"tenantRef,omitempty"` - RowCount int `json:"rowCount"` - TotalRows int64 `json:"totalRows"` - ProcessedRows int64 `json:"processedRows"` - FileSizeBytes int64 `json:"fileSizeBytes"` - Error string `json:"error,omitempty"` - DownloadURL string `json:"downloadUrl,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CompletedAt string `json:"completedAt,omitempty"` - Evidence string `json:"evidence"` - filePath string + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Progress int `json:"progress"` + Format string `json:"format"` + Category string `json:"category"` + Protocol string `json:"protocol,omitempty"` + Keywords []string `json:"keywords"` + Metrics []string `json:"metrics,omitempty"` + VehicleVINs []string `json:"vehicleVins"` + VehicleScopes []HistoryExportVehicleScope `json:"vehicleScopes"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + OwnerID string `json:"ownerId"` + OwnerSubjectID string `json:"ownerSubjectId,omitempty"` + OwnerName string `json:"ownerName"` + OwnerUsername string `json:"ownerUsername"` + OwnerRole string `json:"ownerRole"` + OwnerUserType string `json:"ownerUserType"` + AuthProvider string `json:"authProvider"` + CustomerRef string `json:"customerRef,omitempty"` + TenantRef string `json:"tenantRef,omitempty"` + RowCount int `json:"rowCount"` + TotalRows int64 `json:"totalRows"` + ProcessedRows int64 `json:"processedRows"` + FileSizeBytes int64 `json:"fileSizeBytes"` + Error string `json:"error,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + ExpiredAt string `json:"expiredAt,omitempty"` + RebuiltFrom string `json:"rebuiltFrom,omitempty"` + CancelledAt string `json:"cancelledAt,omitempty"` + CancelledBy string `json:"cancelledBy,omitempty"` + ArchivedAt string `json:"archivedAt,omitempty"` + ArchivedBy string `json:"archivedBy,omitempty"` + CleanupProtectedAt string `json:"cleanupProtectedAt,omitempty"` + CleanupProtectedBy string `json:"cleanupProtectedBy,omitempty"` + CleanupProtectionReason string `json:"cleanupProtectionReason,omitempty"` + RetentionDays int `json:"retentionDays"` + Evidence string `json:"evidence"` + filePath string +} + +type HistoryExportQuery struct { + Search string + Status string + Scope string + OwnerScope string + Sort string + Limit int + Offset int +} + +type HistoryExportSummary struct { + Total int `json:"total"` + Active int `json:"active"` + Completed int `json:"completed"` + Expiring int `json:"expiring"` + Recoverable int `json:"recoverable"` + Cancelled int `json:"cancelled"` + Current int `json:"current"` + Archived int `json:"archived"` +} + +type HistoryExportPage struct { + Items []HistoryExportJob `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Summary HistoryExportSummary `json:"summary"` +} + +type HistoryFieldView struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Keys []string `json:"keys"` +} + +type HistoryPreferences struct { + Revision int64 `json:"revision"` + RetentionDays int `json:"retentionDays"` + FieldViews []HistoryFieldView `json:"fieldViews"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type HistoryExportBatchRequest struct { + IDs []string `json:"ids"` + Action string `json:"action"` +} + +type HistoryExportBatchSkipped struct { + ID string `json:"id"` + Code string `json:"code"` + Message string `json:"message"` +} + +type HistoryExportBatchResult struct { + Action string `json:"action"` + Succeeded []HistoryExportJob `json:"succeeded"` + Skipped []HistoryExportBatchSkipped `json:"skipped"` + Requested int `json:"requested"` +} + +type HistoryExportCleanupQuery struct { + OlderThanDays int + OwnerScope string +} + +type HistoryExportCleanupCandidate struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerUsername string `json:"ownerUsername"` + Status string `json:"status"` + ArchivedAt string `json:"archivedAt"` + FileSizeBytes int64 `json:"fileSizeBytes"` + ProtectedAt string `json:"protectedAt,omitempty"` + ProtectedBy string `json:"protectedBy,omitempty"` + ProtectionReason string `json:"protectionReason,omitempty"` +} + +type HistoryExportCleanupPreview struct { + OlderThanDays int `json:"olderThanDays"` + OwnerScope string `json:"ownerScope"` + Cutoff string `json:"cutoff"` + GeneratedAt string `json:"generatedAt"` + ArchivedCount int `json:"archivedCount"` + CandidateCount int `json:"candidateCount"` + PlannedCount int `json:"plannedCount"` + ProtectedCount int `json:"protectedCount"` + FileCount int `json:"fileCount"` + FileSizeBytes int64 `json:"fileSizeBytes"` + Candidates []HistoryExportCleanupCandidate `json:"candidates"` + Protected []HistoryExportCleanupCandidate `json:"protected"` + PreviewToken string `json:"previewToken"` +} + +type HistoryExportCleanupRequest struct { + OlderThanDays int `json:"olderThanDays"` + OwnerScope string `json:"ownerScope"` + PreviewToken string `json:"previewToken"` +} + +type HistoryExportCleanupRecord struct { + ID string `json:"id"` + Actor string `json:"actor"` + OwnerScope string `json:"ownerScope"` + OlderThanDays int `json:"olderThanDays"` + Cutoff string `json:"cutoff"` + Requested int `json:"requested"` + Cleaned int `json:"cleaned"` + Protected int `json:"protected"` + FileCount int `json:"fileCount"` + FileSizeBytes int64 `json:"fileSizeBytes"` + CandidateDigest string `json:"candidateDigest"` + ExecutedAt string `json:"executedAt"` +} + +type HistoryExportCleanupResult struct { + Record HistoryExportCleanupRecord `json:"record"` + Deleted []HistoryExportCleanupCandidate `json:"deleted"` +} + +type HistoryExportCleanupAutomationPolicy struct { + Enabled bool `json:"enabled"` + OlderThanDays int `json:"olderThanDays"` + IntervalDays int `json:"intervalDays"` + ApprovalWindowHours int `json:"approvalWindowHours"` + NextReviewAt string `json:"nextReviewAt,omitempty"` + Revision int64 `json:"revision"` + UpdatedAt string `json:"updatedAt,omitempty"` + UpdatedBy string `json:"updatedBy,omitempty"` +} + +type HistoryExportCleanupAutomationRun struct { + ID string `json:"id"` + Status string `json:"status"` + Revision int64 `json:"revision"` + PolicyRevision int64 `json:"policyRevision"` + OlderThanDays int `json:"olderThanDays"` + Cutoff string `json:"cutoff"` + CandidateCount int `json:"candidateCount"` + PlannedCount int `json:"plannedCount"` + ProtectedCount int `json:"protectedCount"` + FileCount int `json:"fileCount"` + FileSizeBytes int64 `json:"fileSizeBytes"` + PreviewToken string `json:"previewToken"` + CreatedAt string `json:"createdAt"` + ApprovalDeadline string `json:"approvalDeadline,omitempty"` + ApprovedAt string `json:"approvedAt,omitempty"` + ApprovedBy string `json:"approvedBy,omitempty"` + ApprovalReason string `json:"approvalReason,omitempty"` + RejectedAt string `json:"rejectedAt,omitempty"` + RejectedBy string `json:"rejectedBy,omitempty"` + RejectionReason string `json:"rejectionReason,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + CompletedAt string `json:"completedAt,omitempty"` + LeaseOwner string `json:"leaseOwner,omitempty"` + LeaseExpiresAt string `json:"leaseExpiresAt,omitempty"` + AttemptCount int `json:"attemptCount"` + MaxAttempts int `json:"maxAttempts"` + NextRetryAt string `json:"nextRetryAt,omitempty"` + Error string `json:"error,omitempty"` + CleanupRecordID string `json:"cleanupRecordId,omitempty"` + CleanedCount int `json:"cleanedCount,omitempty"` + ExecutionWarning string `json:"executionWarning,omitempty"` + LastActionAt string `json:"lastActionAt,omitempty"` + LastActionBy string `json:"lastActionBy,omitempty"` + LastActionReason string `json:"lastActionReason,omitempty"` +} + +type HistoryExportCleanupAutomationState struct { + Policy HistoryExportCleanupAutomationPolicy `json:"policy"` + Runs []HistoryExportCleanupAutomationRun `json:"runs"` + ServerTime string `json:"serverTime"` +} + +type HistoryExportCleanupAutomationPolicyRequest struct { + Enabled bool `json:"enabled"` + OlderThanDays int `json:"olderThanDays"` + IntervalDays int `json:"intervalDays"` + ApprovalWindowHours int `json:"approvalWindowHours"` + ExpectedRevision int64 `json:"expectedRevision"` +} + +type HistoryExportCleanupAutomationActionRequest struct { + ExpectedRevision int64 `json:"expectedRevision"` + Reason string `json:"reason"` +} + +type HistoryExportCleanupProtectionRequest struct { + Protected bool `json:"protected"` + Reason string `json:"reason"` } type HistoryExportStoreQuery struct { @@ -533,6 +752,25 @@ type AccessUnresolvedIdentity struct { RecommendedAction string `json:"recommendedAction"` } +type AccessIdentityClaimInput struct { + VIN string `json:"vin"` + Actor string `json:"actor"` + Note string `json:"note"` +} + +type AccessIdentityClaimResult struct { + IdentityID string `json:"identityId"` + Protocol string `json:"protocol"` + IdentifierMasked string `json:"identifierMasked"` + VIN string `json:"vin"` + Plate string `json:"plate"` + ProfileComplete bool `json:"profileComplete"` + ProfileMissingFields []string `json:"profileMissingFields"` + ClaimedBy string `json:"claimedBy"` + ClaimedAt string `json:"claimedAt"` + AuditID int64 `json:"auditId"` +} + type AccessDistribution struct { Name string `json:"name"` Total int `json:"total"` @@ -616,58 +854,137 @@ type AlertSummary struct { } type AlertRule struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Severity string `json:"severity"` - ValueType string `json:"valueType"` - Metric string `json:"metric"` - Operator string `json:"operator"` - Threshold float64 `json:"threshold"` - ThresholdHigh float64 `json:"thresholdHigh"` - BooleanThreshold *bool `json:"booleanThreshold,omitempty"` - DurationSec int `json:"durationSec"` - RecoveryOperator string `json:"recoveryOperator"` - RecoveryThreshold float64 `json:"recoveryThreshold"` - RepeatIntervalSec int `json:"repeatIntervalSec"` - ScopeProtocols []string `json:"scopeProtocols"` - ScopeVINs []string `json:"scopeVins"` - ScopeOEMs []string `json:"scopeOems"` - ScopeModels []string `json:"scopeModels"` - ScopeCompanies []string `json:"scopeCompanies"` - NotificationChannels []string `json:"notificationChannels"` - Enabled bool `json:"enabled"` - Version int `json:"version"` - CreatedBy string `json:"createdBy"` - UpdatedBy string `json:"updatedBy"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TriggerType string `json:"triggerType"` + FenceName string `json:"fenceName"` + FenceLongitude float64 `json:"fenceLongitude"` + FenceLatitude float64 `json:"fenceLatitude"` + FenceRadiusM float64 `json:"fenceRadiusM"` + Severity string `json:"severity"` + ValueType string `json:"valueType"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold float64 `json:"threshold"` + ThresholdHigh float64 `json:"thresholdHigh"` + BooleanThreshold *bool `json:"booleanThreshold,omitempty"` + DurationSec int `json:"durationSec"` + RecoveryOperator string `json:"recoveryOperator"` + RecoveryThreshold float64 `json:"recoveryThreshold"` + RepeatIntervalSec int `json:"repeatIntervalSec"` + ScopeProtocols []string `json:"scopeProtocols"` + ScopeVINs []string `json:"scopeVins"` + ScopeOEMs []string `json:"scopeOems"` + ScopeModels []string `json:"scopeModels"` + ScopeCompanies []string `json:"scopeCompanies"` + NotificationChannels []string `json:"notificationChannels"` + NotificationTargets []AlertNotificationTarget `json:"notificationTargets"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + CreatedBy string `json:"createdBy"` + UpdatedBy string `json:"updatedBy"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + ArchivedBy string `json:"archivedBy,omitempty"` + ArchivedAt string `json:"archivedAt,omitempty"` + ArchiveReason string `json:"archiveReason,omitempty"` +} + +type AlertRuleQuery struct { + Keyword string `json:"keyword"` + Status string `json:"status"` + Protocol string `json:"protocol"` + Lifecycle string `json:"lifecycle"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type AlertRuleLibrarySummary struct { + Current int `json:"current"` + Enabled int `json:"enabled"` + Disabled int `json:"disabled"` + Archived int `json:"archived"` +} + +type AlertRulePage struct { + Items []AlertRule `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Summary AlertRuleLibrarySummary `json:"summary"` } type AlertRuleInput struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Severity string `json:"severity"` - ValueType string `json:"valueType"` - Metric string `json:"metric"` - Operator string `json:"operator"` - Threshold float64 `json:"threshold"` - ThresholdHigh float64 `json:"thresholdHigh"` - BooleanThreshold *bool `json:"booleanThreshold,omitempty"` - DurationSec int `json:"durationSec"` - RecoveryOperator string `json:"recoveryOperator"` - RecoveryThreshold float64 `json:"recoveryThreshold"` - RepeatIntervalSec int `json:"repeatIntervalSec"` - ScopeProtocols []string `json:"scopeProtocols"` - ScopeVINs []string `json:"scopeVins"` - ScopeOEMs []string `json:"scopeOems"` - ScopeModels []string `json:"scopeModels"` - ScopeCompanies []string `json:"scopeCompanies"` - NotificationChannels []string `json:"notificationChannels"` - Enabled bool `json:"enabled"` - Version int `json:"version"` - Actor string `json:"actor"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TriggerType string `json:"triggerType"` + FenceName string `json:"fenceName"` + FenceLongitude float64 `json:"fenceLongitude"` + FenceLatitude float64 `json:"fenceLatitude"` + FenceRadiusM float64 `json:"fenceRadiusM"` + Severity string `json:"severity"` + ValueType string `json:"valueType"` + Metric string `json:"metric"` + Operator string `json:"operator"` + Threshold float64 `json:"threshold"` + ThresholdHigh float64 `json:"thresholdHigh"` + BooleanThreshold *bool `json:"booleanThreshold,omitempty"` + DurationSec int `json:"durationSec"` + RecoveryOperator string `json:"recoveryOperator"` + RecoveryThreshold float64 `json:"recoveryThreshold"` + RepeatIntervalSec int `json:"repeatIntervalSec"` + ScopeProtocols []string `json:"scopeProtocols"` + ScopeVINs []string `json:"scopeVins"` + ScopeOEMs []string `json:"scopeOems"` + ScopeModels []string `json:"scopeModels"` + ScopeCompanies []string `json:"scopeCompanies"` + NotificationChannels []string `json:"notificationChannels"` + NotificationTargets []AlertNotificationTarget `json:"notificationTargets"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + Actor string `json:"actor"` + AuditAction string `json:"-"` +} + +type AlertNotificationTarget struct { + Channel string `json:"channel"` + RecipientID string `json:"recipientId"` + Label string `json:"label"` +} + +type AlertNotificationTargetOption struct { + ID string `json:"id"` + Label string `json:"label"` + Channels []string `json:"channels"` +} + +type AlertNotificationChannelCapability struct { + Channel string `json:"channel"` + Label string `json:"label"` + Configured bool `json:"configured"` +} + +type AlertNotificationConfig struct { + Targets []AlertNotificationTargetOption `json:"targets"` + Channels []AlertNotificationChannelCapability `json:"channels"` +} + +type AlertRuleRevision struct { + RuleID string `json:"ruleId"` + Version int `json:"version"` + Actor string `json:"actor"` + Action string `json:"action"` + Reason string `json:"reason,omitempty"` + CreatedAt string `json:"createdAt"` + Snapshot AlertRule `json:"snapshot"` +} + +type AlertRuleRollbackRequest struct { + TargetVersion int `json:"targetVersion"` + CurrentVersion int `json:"currentVersion"` + Actor string `json:"actor"` } type AlertRuleEnabledUpdate struct { @@ -676,34 +993,44 @@ type AlertRuleEnabledUpdate struct { Actor string `json:"actor"` } +type AlertRuleLifecycleRequest struct { + Version int `json:"version"` + Reason string `json:"reason"` + Actor string `json:"actor"` +} + type AlertEvent struct { - ID string `json:"id"` - RuleID string `json:"ruleId"` - RuleName string `json:"ruleName"` - RuleVersion int `json:"ruleVersion"` - Severity string `json:"severity"` - Status string `json:"status"` - VIN string `json:"vin"` - Plate string `json:"plate"` - Protocol string `json:"protocol"` - Metric string `json:"metric"` - Operator string `json:"operator"` - TriggerValue float64 `json:"triggerValue"` - Threshold float64 `json:"threshold"` - ThresholdHigh float64 `json:"thresholdHigh"` - Unit string `json:"unit"` - DurationSec int `json:"durationSec"` - Location string `json:"location"` - Longitude *float64 `json:"longitude,omitempty"` - Latitude *float64 `json:"latitude,omitempty"` - SourceEventID string `json:"sourceEventId"` - EventAt string `json:"eventAt"` - ReceivedAt string `json:"receivedAt"` - TriggeredAt string `json:"triggeredAt"` - RecoveredAt string `json:"recoveredAt"` - Handler string `json:"handler"` - Version int `json:"version"` - Actions []AlertAction `json:"actions,omitempty"` + ID string `json:"id"` + EventType string `json:"eventType"` + EventCategory string `json:"eventCategory"` + ExecutionState string `json:"executionState"` + RuleID string `json:"ruleId"` + RuleName string `json:"ruleName"` + RuleVersion int `json:"ruleVersion"` + Severity string `json:"severity"` + TriggerType string `json:"triggerType"` + Status string `json:"status"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Protocol string `json:"protocol"` + Metric string `json:"metric"` + Operator string `json:"operator"` + TriggerValue float64 `json:"triggerValue"` + Threshold float64 `json:"threshold"` + ThresholdHigh float64 `json:"thresholdHigh"` + Unit string `json:"unit"` + DurationSec int `json:"durationSec"` + Location string `json:"location"` + Longitude *float64 `json:"longitude,omitempty"` + Latitude *float64 `json:"latitude,omitempty"` + SourceEventID string `json:"sourceEventId"` + EventAt string `json:"eventAt"` + ReceivedAt string `json:"receivedAt"` + TriggeredAt string `json:"triggeredAt"` + RecoveredAt string `json:"recoveredAt"` + Handler string `json:"handler"` + Version int `json:"version"` + Actions []AlertAction `json:"actions,omitempty"` } type AlertAction struct { @@ -724,38 +1051,115 @@ type AlertActionRequest struct { } type AlertNotification struct { - ID int64 `json:"id"` - EventID string `json:"eventId"` - Title string `json:"title"` - Content string `json:"content"` - Severity string `json:"severity"` - Channel string `json:"channel"` - Read bool `json:"read"` - CreatedAt string `json:"createdAt"` - ReadAt string `json:"readAt"` + ID int64 `json:"id"` + EventID string `json:"eventId"` + Title string `json:"title"` + Content string `json:"content"` + Severity string `json:"severity"` + Channel string `json:"channel"` + Recipient string `json:"recipient"` + RecipientID string `json:"recipientId"` + DeliveryStatus string `json:"deliveryStatus"` + AttemptCount int `json:"attemptCount"` + ProviderMessageID string `json:"providerMessageId"` + VehiclePlate string `json:"vehiclePlate"` + VehicleVIN string `json:"vehicleVin"` + Protocol string `json:"protocol"` + Read bool `json:"read"` + CreatedAt string `json:"createdAt"` + DeliveredAt string `json:"deliveredAt"` + ReadAt string `json:"readAt"` + LastError string `json:"lastError"` + LastAttemptAt string `json:"lastAttemptAt"` + RetryRequestedBy string `json:"retryRequestedBy"` + RetryRequestedAt string `json:"retryRequestedAt"` + RetryAvailable bool `json:"retryAvailable"` + MaxAttempts int `json:"maxAttempts"` +} + +type AlertNotificationChannelHealth struct { + Channel string `json:"channel"` + Label string `json:"label"` + Configured bool `json:"configured"` + Queued int `json:"queued"` + Failed int `json:"failed"` + DeadLetter int `json:"deadLetter"` + ActiveLeases int `json:"activeLeases"` + OldestQueuedAt string `json:"oldestQueuedAt"` +} + +type AlertNotificationDeliveryHealth struct { + Queued int `json:"queued"` + Failed int `json:"failed"` + DeadLetter int `json:"deadLetter"` + ActiveLeases int `json:"activeLeases"` + OldestQueuedAt string `json:"oldestQueuedAt"` + Channels []AlertNotificationChannelHealth `json:"channels"` + AsOf string `json:"asOf"` } type AlertNotificationQuery struct { - UnreadOnly bool `json:"unreadOnly"` - Limit int `json:"limit"` - Offset int `json:"offset"` + UnreadOnly bool `json:"unreadOnly"` + Search string `json:"search"` + DeliveryStatus string `json:"deliveryStatus"` + Limit int `json:"limit"` + Offset int `json:"offset"` + AllowedVINs []string `json:"-"` } type AlertNotificationReadRequest struct { - IDs []int64 `json:"ids"` - Actor string `json:"actor"` + IDs []int64 `json:"ids"` + Actor string `json:"actor"` + AllowedVINs []string `json:"-"` +} + +type AlertNotificationRetryRequest struct { + ExpectedAttemptCount int `json:"expectedAttemptCount"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotencyKey"` + Actor string `json:"actor"` +} + +type AlertNotificationRetryAudit struct { + ID int64 `json:"id"` + NotificationID int64 `json:"notificationId"` + IdempotencyKey string `json:"-"` + Actor string `json:"actor"` + Reason string `json:"reason"` + PreviousStatus string `json:"previousStatus"` + NextStatus string `json:"nextStatus"` + AttemptCount int `json:"attemptCount"` + RequestedAt string `json:"requestedAt"` +} + +type AlertNotificationRetryResult struct { + Notification AlertNotification `json:"notification"` + Receipt AlertNotificationRetryAudit `json:"receipt"` + Idempotent bool `json:"idempotent"` } type ReconciliationQuery struct { Keyword string `json:"keyword"` + Scope string `json:"scope"` RuleCode string `json:"ruleCode"` Category string `json:"category"` Severity string `json:"severity"` Status string `json:"status"` + Owner string `json:"owner"` + SLA string `json:"sla"` Limit int `json:"limit"` Offset int `json:"offset"` } +type ReconciliationAssignee struct { + Name string `json:"name"` + Username string `json:"username,omitempty"` + Source string `json:"source"` + ActiveCount int `json:"activeCount"` + LastAssignedAt string `json:"lastAssignedAt,omitempty"` + Current bool `json:"current,omitempty"` +} + type ReconciliationIssue struct { ID string `json:"id"` RuleCode string `json:"ruleCode"` @@ -775,10 +1179,30 @@ type ReconciliationIssue struct { RecoveredAt string `json:"recoveredAt"` ResolutionNote string `json:"resolutionNote"` ResolvedBy string `json:"resolvedBy"` + Assignee string `json:"assignee"` + AssignedBy string `json:"assignedBy"` + AssignedAt string `json:"assignedAt"` + DueAt string `json:"dueAt"` + ArchivedAt string `json:"archivedAt"` + ArchivedBy string `json:"archivedBy"` + ArchiveReason string `json:"archiveReason"` Version int `json:"version"` Actions []ReconciliationAction `json:"actions,omitempty"` } +type ReconciliationAssignmentRequest struct { + Version int `json:"version"` + Assignee string `json:"assignee"` + DueAt string `json:"dueAt"` + Actor string `json:"actor"` +} + +type ReconciliationBatchAssignmentRequest struct { + Items []ReconciliationBatchActionItem `json:"items"` + Assignee string `json:"assignee"` + DueAt string `json:"dueAt"` +} + type ReconciliationAction struct { ID int64 `json:"id"` Action string `json:"action"` @@ -796,6 +1220,40 @@ type ReconciliationActionRequest struct { Actor string `json:"actor"` } +type ReconciliationBatchActionItem struct { + ID string `json:"id"` + Version int `json:"version"` +} + +type ReconciliationBatchActionRequest struct { + Items []ReconciliationBatchActionItem `json:"items"` + Status string `json:"status"` + Note string `json:"note"` +} + +type ReconciliationLifecycleRequest struct { + Version int `json:"version"` + Reason string `json:"reason"` + Actor string `json:"actor"` +} + +type ReconciliationBatchLifecycleRequest struct { + Items []ReconciliationBatchActionItem `json:"items"` + Reason string `json:"reason"` +} + +type ReconciliationBatchActionFailure struct { + ID string `json:"id"` + Code string `json:"code"` + Message string `json:"message"` +} + +type ReconciliationBatchActionResult struct { + Requested int `json:"requested"` + Succeeded []ReconciliationIssue `json:"succeeded"` + Skipped []ReconciliationBatchActionFailure `json:"skipped"` +} + type ReconciliationBucket struct { Name string `json:"name"` Count int `json:"count"` @@ -810,6 +1268,8 @@ type ReconciliationTrendPoint struct { } type ReconciliationSummary struct { + Current int `json:"current"` + Archived int `json:"archived"` Active int `json:"active"` Pending int `json:"pending"` Confirmed int `json:"confirmed"` @@ -915,6 +1375,19 @@ type VehicleCoverageSummary struct { ArchiveMissingFields []ArchiveMissingFieldStat `json:"archiveMissingFields"` } +type VehicleBusinessFilterOption struct { + Value string `json:"value"` + Label string `json:"label"` + Count int `json:"count"` +} + +type VehicleBusinessFilters struct { + Departments []VehicleBusinessFilterOption `json:"departments"` + ResponsibleUsers []VehicleBusinessFilterOption `json:"responsibleUsers"` + Customers []VehicleBusinessFilterOption `json:"customers"` + Statuses []VehicleBusinessFilterOption `json:"statuses"` +} + type VehicleIdentityResolution struct { LookupKey string `json:"lookupKey"` Resolved bool `json:"resolved"` @@ -935,6 +1408,7 @@ type VehicleDetail struct { Resolution *VehicleIdentityResolution `json:"resolution,omitempty"` Identity *VehicleRow `json:"identity,omitempty"` Profile *VehicleProfile `json:"profile,omitempty"` + BusinessRelation *VehicleBusinessRelation `json:"businessRelation,omitempty"` RealtimeSummary *VehicleRealtimeRow `json:"realtimeSummary,omitempty"` ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` ServiceOverview *VehicleServiceOverview `json:"serviceOverview,omitempty"` @@ -948,6 +1422,30 @@ type VehicleDetail struct { Quality Page[QualityIssueRow] `json:"quality"` } +// VehicleBusinessRelation is the active, read-only OneOS business projection +// for a vehicle. IDs are serialized as strings because OneOS uses BIGINT keys +// that may exceed JavaScript's safe integer range. +type VehicleBusinessRelation struct { + SourceSystem string `json:"sourceSystem"` + SourceVersion string `json:"sourceVersion"` + VehicleID string `json:"vehicleId"` + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + CustomerID string `json:"customerId"` + CustomerName string `json:"customerName"` + ContractID string `json:"contractId"` + ContractCode string `json:"contractCode"` + ProjectName string `json:"projectName"` + DepartmentID string `json:"departmentId"` + DepartmentName string `json:"departmentName"` + ResponsibleUserID string `json:"responsibleUserId"` + ResponsibleUserName string `json:"responsibleUserName"` + OperationStatus string `json:"operationStatus"` + ScopeStartAt string `json:"scopeStartAt"` + SourceUpdatedAt string `json:"sourceUpdatedAt"` + PublishedAt string `json:"publishedAt"` +} + type VehicleProfile struct { VIN string `json:"vin"` BrandName string `json:"brandName"` @@ -1374,28 +1872,53 @@ type LatestTelemetryResponse struct { } type DailyMileageRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Date string `json:"date"` - StartMileageKm float64 `json:"startMileageKm"` - EndMileageKm float64 `json:"endMileageKm"` - DailyMileageKm float64 `json:"dailyMileageKm"` - Source string `json:"source"` - AnomalySeverity string `json:"anomalySeverity,omitempty"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Date string `json:"date"` + StartMileageKm float64 `json:"startMileageKm"` + EndMileageKm float64 `json:"endMileageKm"` + DailyMileageKm float64 `json:"dailyMileageKm"` + PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"` + HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"` + HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` + Source string `json:"source"` + AnomalySeverity string `json:"anomalySeverity,omitempty"` +} + +// MileageQuery is the POST contract used by mileage statistics and daily +// mileage queries. Array fields keep large vehicle selections out of the URL +// and avoid ambiguity when clients submit thousands of VINs. +type MileageQuery struct { + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + Keyword string `json:"keyword"` + VINs []string `json:"vins"` + VehicleScope string `json:"vehicleScope"` + Protocol string `json:"protocol"` + Protocols []string `json:"protocols"` + Deduplicate bool `json:"deduplicate"` + Limit int `json:"limit"` + Offset int `json:"offset"` } type MileageSummary struct { - VehicleCount int `json:"vehicleCount"` - RecordCount int `json:"recordCount"` - SourceCount int `json:"sourceCount"` - TotalMileageKm float64 `json:"totalMileageKm"` - AverageMileagePerVIN float64 `json:"averageMileagePerVin"` + VehicleCount int `json:"vehicleCount"` + RecordCount int `json:"recordCount"` + SourceCount int `json:"sourceCount"` + TotalMileageKm float64 `json:"totalMileageKm"` + TotalPureHydrogenMileageKm float64 `json:"totalPureHydrogenMileageKm,omitempty"` + AverageMileagePerVIN float64 `json:"averageMileagePerVin"` } type MileageTrendPoint struct { - Date string `json:"date"` - MileageKm float64 `json:"mileageKm"` - Vehicles int `json:"vehicles"` + Date string `json:"date"` + MileageKm float64 `json:"mileageKm"` + PureHydrogenMileageKm float64 `json:"pureHydrogenMileageKm,omitempty"` + HydrogenMatchedMileageKm float64 `json:"hydrogenMatchedMileageKm,omitempty"` + HydrogenDataDays int `json:"hydrogenDataDays,omitempty"` + HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg,omitempty"` + HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` + Vehicles int `json:"vehicles"` } type MileageVehicleRank struct { @@ -1407,19 +1930,24 @@ type MileageVehicleRank struct { } type MileageStatistics struct { - DateFrom string `json:"dateFrom"` - DateTo string `json:"dateTo"` - VehicleCount int `json:"vehicleCount"` - RecordCount int `json:"recordCount"` - SourceCount int `json:"sourceCount"` - PeriodMileageKm float64 `json:"periodMileageKm"` - FleetLatestMileageKm float64 `json:"fleetLatestMileageKm"` - AverageMileagePerVIN float64 `json:"averageMileagePerVin"` - AverageDailyMileageKm float64 `json:"averageDailyMileageKm"` - Trend []MileageTrendPoint `json:"trend"` - Ranking []MileageVehicleRank `json:"ranking"` - AsOf string `json:"asOf"` - Evidence string `json:"evidence"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + VehicleCount int `json:"vehicleCount"` + RecordCount int `json:"recordCount"` + SourceCount int `json:"sourceCount"` + PeriodMileageKm float64 `json:"periodMileageKm"` + PeriodPureHydrogenMileageKm float64 `json:"periodPureHydrogenMileageKm,omitempty"` + HydrogenMatchedMileageKm float64 `json:"hydrogenMatchedMileageKm,omitempty"` + HydrogenDataDays int `json:"hydrogenDataDays,omitempty"` + PeriodHydrogenConsumptionKg float64 `json:"periodHydrogenConsumptionKg,omitempty"` + HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` + FleetLatestMileageKm float64 `json:"fleetLatestMileageKm"` + AverageMileagePerVIN float64 `json:"averageMileagePerVin"` + AverageDailyMileageKm float64 `json:"averageDailyMileageKm"` + Trend []MileageTrendPoint `json:"trend"` + Ranking []MileageVehicleRank `json:"ranking"` + AsOf string `json:"asOf"` + Evidence string `json:"evidence"` } type OnlineStatisticsSummary struct { @@ -1557,15 +2085,20 @@ type CapacityMetrics struct { } type RuntimeInfo struct { - DataMode string `json:"dataMode"` - ExportDir string `json:"-"` - RequestTimeoutMs int `json:"requestTimeoutMs"` - AMapWebJSConfigured bool `json:"amapWebJsConfigured"` - AMapAPIConfigured bool `json:"amapApiConfigured"` - AMapSecurityProxyEnabled bool `json:"amapSecurityProxyEnabled"` - AMapSecurityCodeExposed bool `json:"amapSecurityCodeExposed"` - AMapSecurityServiceHost string `json:"amapSecurityServiceHost"` - PlatformRelease string `json:"platformRelease"` - AlertStreamMode string `json:"alertStreamMode"` - AlertStreamConsumerGroup string `json:"alertStreamConsumerGroup"` + DataMode string `json:"dataMode"` + ExportDir string `json:"-"` + RequestTimeoutMs int `json:"requestTimeoutMs"` + AMapWebJSConfigured bool `json:"amapWebJsConfigured"` + AMapAPIConfigured bool `json:"amapApiConfigured"` + AMapSecurityProxyEnabled bool `json:"amapSecurityProxyEnabled"` + AMapSecurityCodeExposed bool `json:"amapSecurityCodeExposed"` + AMapSecurityServiceHost string `json:"amapSecurityServiceHost"` + PlatformRelease string `json:"platformRelease"` + AlertStreamMode string `json:"alertStreamMode"` + AlertStreamConsumerGroup string `json:"alertStreamConsumerGroup"` + AlertNotificationConfig AlertNotificationConfig `json:"-"` + HistoryCleanupAutomation bool `json:"historyCleanupAutomation"` + HistoryCleanupPoll time.Duration `json:"-"` + HistoryCleanupLease time.Duration `json:"-"` + HistoryCleanupWorkerID string `json:"-"` } diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index f4cd978e..1e1d08c4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -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 } diff --git a/vehicle-data-platform/apps/api/internal/platform/principal.go b/vehicle-data-platform/apps/api/internal/platform/principal.go index 9cca9808..86da3f6f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/principal.go +++ b/vehicle-data-platform/apps/api/internal/platform/principal.go @@ -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 } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index e3c25136..90405509 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -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{} diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index 62764e04..d3613b0d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -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) { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index 7d3e19d5..29830e38 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -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", diff --git a/vehicle-data-platform/apps/api/internal/platform/reconciliation.go b/vehicle-data-platform/apps/api/internal/platform/reconciliation.go index 79ef975a..fb796cd3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/reconciliation.go +++ b/vehicle-data-platform/apps/api/internal/platform/reconciliation.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go index 648c62e6..d67fab3f 100644 --- a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go @@ -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.due_at<=DATE_ADD(NOW(),INTERVAL 8 HOUR) AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')") + } return strings.Join(where, " AND "), args } const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b, i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'), DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count, -COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version +COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by, +i.assignee,i.assigned_by,COALESCE(DATE_FORMAT(i.assigned_at,'%Y-%m-%d %H:%i:%s'),''),COALESCE(DATE_FORMAT(i.due_at,'%Y-%m-%d %H:%i:%s'),''), +COALESCE(DATE_FORMAT(i.archived_at,'%Y-%m-%d %H:%i:%s'),''),i.archived_by,i.archive_reason,i.version FROM vehicle_reconciliation_issue i ` func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) { @@ -415,7 +443,9 @@ func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (Reconcili var evidence string err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate, &item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt, - &item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version) + &item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, + &item.Assignee, &item.AssignedBy, &item.AssignedAt, &item.DueAt, + &item.ArchivedAt, &item.ArchivedBy, &item.ArchiveReason, &item.Version) if err == nil { item.Evidence = map[string]any{} err = json.Unmarshal([]byte(evidence), &item.Evidence) @@ -439,8 +469,12 @@ func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query Reconc return Page[ReconciliationIssue]{}, err } listArgs := append(append([]any(nil), args...), query.Limit, query.Offset) + orderBy := "FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC" + if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") { + orderBy = "i.archived_at DESC,i.id DESC" + } rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+` -ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...) +ORDER BY `+orderBy+` LIMIT ? OFFSET ?`, listArgs...) if err != nil { return Page[ReconciliationIssue]{}, err } @@ -456,6 +490,79 @@ ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id D return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err() } +func (s *ProductionStore) ReconciliationAssignees(ctx context.Context) ([]ReconciliationAssignee, error) { + if err := s.ensureReconciliationSchema(ctx); err != nil { + return nil, err + } + byName := map[string]ReconciliationAssignee{} + accountRows, err := s.db.QueryContext(ctx, `SELECT display_name,username FROM platform_user +WHERE user_type='admin' AND status='enabled' ORDER BY display_name,username LIMIT 100`) + if err != nil { + return nil, err + } + for accountRows.Next() { + var item ReconciliationAssignee + if err := accountRows.Scan(&item.Name, &item.Username); err != nil { + accountRows.Close() + return nil, err + } + item.Name = strings.TrimSpace(item.Name) + item.Username = strings.TrimSpace(item.Username) + item.Source = "account" + if item.Name == "" { + item.Name = item.Username + } + if item.Name != "" { + byName[strings.ToLower(item.Name)] = item + } + } + if err := accountRows.Close(); err != nil { + return nil, err + } + historyRows, err := s.db.QueryContext(ctx, `SELECT assignee, +SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')), +COALESCE(DATE_FORMAT(MAX(assigned_at),'%Y-%m-%d %H:%i:%s'),'') +FROM vehicle_reconciliation_issue WHERE assignee<>'' AND archived_at IS NULL GROUP BY assignee +ORDER BY MAX(assigned_at) DESC,assignee LIMIT 100`) + if err != nil { + return nil, err + } + for historyRows.Next() { + var name, lastAssignedAt string + var activeCount int + if err := historyRows.Scan(&name, &activeCount, &lastAssignedAt); err != nil { + historyRows.Close() + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := strings.ToLower(name) + item, exists := byName[key] + if !exists { + item = ReconciliationAssignee{Name: name, Source: "history"} + } + item.ActiveCount = activeCount + item.LastAssignedAt = lastAssignedAt + byName[key] = item + } + if err := historyRows.Close(); err != nil { + return nil, err + } + items := make([]ReconciliationAssignee, 0, len(byName)) + for _, item := range byName { + items = append(items, item) + } + sort.Slice(items, func(i, j int) bool { + if items[i].ActiveCount != items[j].ActiveCount { + return items[i].ActiveCount > items[j].ActiveCount + } + return items[i].Name < items[j].Name + }) + return items, nil +} + func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) { if err := s.ensureReconciliationSchema(ctx); err != nil { return ReconciliationIssue{}, err @@ -511,7 +618,8 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri defer tx.Rollback() var currentStatus string var currentVersion int - if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion); err != nil { + var archived bool + if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil { if err == sql.ErrNoRows { return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"} } @@ -520,6 +628,9 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri if currentVersion != request.Version { return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"} } + if archived { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"} + } result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET status=?,resolution_note=?,resolved_by=?, recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END, @@ -543,6 +654,115 @@ issue_id,action,from_status,to_status,actor,note return s.ReconciliationIssue(ctx, id) } +func (s *ProductionStore) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) { + if err := s.ensureReconciliationSchema(ctx); err != nil { + return ReconciliationIssue{}, err + } + request.Assignee = strings.TrimSpace(request.Assignee) + if request.Assignee == "" || len([]rune(request.Assignee)) > 128 { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNEE_INVALID", Message: "请输入有效负责人"} + } + dueAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.DueAt)) + if err != nil || !dueAt.After(time.Now()) { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_DUE_AT_INVALID", Message: "请选择有效的未来处理期限"} + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return ReconciliationIssue{}, err + } + defer tx.Rollback() + var currentStatus string + var currentVersion int + var archived bool + if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil { + if err == sql.ErrNoRows { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"} + } + return ReconciliationIssue{}, err + } + if currentVersion != request.Version { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"} + } + if archived { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"} + } + if currentStatus != "pending" && currentStatus != "confirmed_source_a" && currentStatus != "confirmed_source_b" { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"} + } + result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET assignee=?,assigned_by=?,assigned_at=NOW(3),due_at=?,version=version+1 WHERE id=? AND version=?`, request.Assignee, request.Actor, dueAt.UTC(), id, request.Version) + if err != nil { + return ReconciliationIssue{}, err + } + if affected, _ := result.RowsAffected(); affected != 1 { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"} + } + note := fmt.Sprintf("交接给 %s,处理期限 %s", request.Assignee, dueAt.In(time.Local).Format("2006-01-02 15:04")) + if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(issue_id,action,from_status,to_status,actor,note) VALUES(?,'assign','','',?,?)`, id, request.Actor, note); err != nil { + return ReconciliationIssue{}, err + } + if err := tx.Commit(); err != nil { + return ReconciliationIssue{}, err + } + return s.ReconciliationIssue(ctx, id) +} + +func (s *ProductionStore) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) { + if err := s.ensureReconciliationSchema(ctx); err != nil { + return ReconciliationIssue{}, err + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return ReconciliationIssue{}, err + } + defer tx.Rollback() + var currentStatus string + var currentVersion int + var currentlyArchived bool + if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL +FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, ¤tlyArchived); err != nil { + if err == sql.ErrNoRows { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"} + } + return ReconciliationIssue{}, err + } + if currentVersion != request.Version { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"} + } + if currentlyArchived == archived { + if archived { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"} + } + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"} + } + action := "restore" + if archived { + if currentStatus != "fixed" && currentStatus != "no_action" && currentStatus != "recovered" { + return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"} + } + action = "archive" + if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET +archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1 +WHERE id=? AND version=?`, request.Actor, request.Reason, id, request.Version); err != nil { + return ReconciliationIssue{}, err + } + } else { + if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET +archived_at=NULL,archived_by='',archive_reason='',version=version+1 +WHERE id=? AND version=?`, id, request.Version); err != nil { + return ReconciliationIssue{}, err + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action( +issue_id,action,from_status,to_status,actor,note +) VALUES(?,?,?,?,?,?)`, id, action, currentStatus, currentStatus, request.Actor, request.Reason); err != nil { + return ReconciliationIssue{}, err + } + if err := tx.Commit(); err != nil { + return ReconciliationIssue{}, err + } + return s.ReconciliationIssue(ctx, id) +} + const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'), CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED), SUM(new_count), @@ -561,12 +781,14 @@ func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) ( } var result ReconciliationSummary if err := s.db.QueryRowContext(ctx, `SELECT -COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0), -COALESCE(SUM(status='pending'),0), -COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0), -COALESCE(SUM(status='recovered'),0), -COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at 0 { statuses[index].HasHistory = true statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) } } if !statuses[index].HasRaw && scopeErr == nil { - page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1}) + page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), Limit: 1, SkipCount: true}) if err == nil && len(page.Items) > 0 { statuses[index].HasRaw = true statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime)) @@ -1614,7 +1676,7 @@ type trackSourceCandidate struct { Quality TrackQuality } -var trackCandidateProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"} +var trackCandidateProtocols = vehicleprotocol.All() func cloneURLValues(values url.Values) url.Values { next := make(url.Values, len(values)) @@ -1910,12 +1972,7 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin StaleAfterSeconds: int64(latestTelemetryStaleAfter / time.Second), ScannedFrames: len(frames), } - definitionBySource := make(map[string]MetricDefinition, len(definitions)*2) - for _, definition := range definitions { - for protocol, sourceField := range definition.SourceFields { - definitionBySource[telemetrySourceLookupKey(protocol, sourceField)] = definition - } - } + definitionBySource := telemetryDefinitionsBySource(definitions) candidates := make(map[string]latestTelemetryCandidate, 64) for _, frame := range frames { if response.VIN == vehicleKey && strings.TrimSpace(frame.VIN) != "" { @@ -1939,7 +1996,7 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin continue } label, unit := latestTelemetryRawMetadata(sourceField) - category := telemetrySourceCategory(sourceField) + category := telemetryCategoryForSource(normalizedProtocol, sourceField, definitionBySource) valueType := "dynamic" description := "" value := rawValue @@ -1951,7 +2008,6 @@ func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, defin if unified { label = definition.Label unit = definition.Unit - category = normalizeTelemetryCategory(definition.Category) valueType = definition.ValueType description = definition.Description value = normalizeTelemetryValue(rawValue, definition.ValueType) @@ -2164,6 +2220,23 @@ func telemetrySourceCategory(sourceField string) string { } } +func telemetryDefinitionsBySource(definitions []MetricDefinition) map[string]MetricDefinition { + definitionBySource := make(map[string]MetricDefinition, len(definitions)*2) + for _, definition := range definitions { + for protocol, sourceField := range definition.SourceFields { + definitionBySource[telemetrySourceLookupKey(protocol, sourceField)] = definition + } + } + return definitionBySource +} + +func telemetryCategoryForSource(protocol, sourceField string, definitions map[string]MetricDefinition) string { + if definition, ok := definitions[telemetrySourceLookupKey(protocol, sourceField)]; ok { + return normalizeTelemetryCategory(definition.Category) + } + return telemetrySourceCategory(sourceField) +} + func telemetryCategoryRank(category string) int { for index, key := range []string{"vehicle", "motor", "fuel-cell", "battery", "location", "alarm", "quality", "extension"} { if category == key { @@ -2225,6 +2298,13 @@ func metricDefinitions() []MetricDefinition { func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDataResponse, error) { started := time.Now() + dateFrom, dateTo, _, _, windowErr := historySeriesWindow(query.Get("dateFrom"), query.Get("dateTo"), time.Now()) + if windowErr != nil { + return HistoryDataResponse{}, windowErr + } + query = cloneURLValues(query) + query.Set("dateFrom", dateFrom) + query.Set("dateTo", dateTo) category := strings.ToLower(strings.TrimSpace(query.Get("category"))) if category == "" { category = "location" @@ -2244,19 +2324,47 @@ func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDat limit = 200 } offset := parsePositive(query.Get("offset"), 0) + if category == "raw" { + return s.historyRawData(ctx, query, keywords, limit, offset, started) + } fetchLimit := limit + offset if fetchLimit > 1000 { fetchLimit = 1000 } + type vehicleHistoryResult struct { + rows []HistoryDataRow + total int + err error + } + perVehicle := make([]vehicleHistoryResult, len(keywords)) + var vehicleQueries sync.WaitGroup + for index, keyword := range keywords { + vehicleQueries.Add(1) + go func(index int, keyword string) { + defer vehicleQueries.Done() + perVehicle[index].rows, perVehicle[index].total, perVehicle[index].err = s.historyDataForVehicle(ctx, category, keyword, query, fetchLimit) + }(index, keyword) + } + vehicleQueries.Wait() rows := make([]HistoryDataRow, 0, fetchLimit*len(keywords)) + vehicleResults := make([]HistoryQueryVehicleResult, 0, len(keywords)) total := 0 - for _, keyword := range keywords { - vehicleRows, vehicleTotal, err := s.historyDataForVehicle(ctx, category, keyword, query, fetchLimit) - if err != nil { - return HistoryDataResponse{}, err + for index, keyword := range keywords { + vehicleResultData := perVehicle[index] + if vehicleResultData.err != nil { + return HistoryDataResponse{}, vehicleResultData.err } - rows = append(rows, vehicleRows...) - total += vehicleTotal + rows = append(rows, vehicleResultData.rows...) + total += vehicleResultData.total + vehicleResult := HistoryQueryVehicleResult{Keyword: keyword, RowCount: vehicleResultData.total, Status: "no_data"} + if vehicleResultData.total > 0 { + vehicleResult.Status = "matched" + } + if len(vehicleResultData.rows) > 0 { + vehicleResult.VIN = vehicleResultData.rows[0].VIN + vehicleResult.Plate = vehicleResultData.rows[0].Plate + } + vehicleResults = append(vehicleResults, vehicleResult) } sort.SliceStable(rows, func(i, j int) bool { if rows[i].DeviceTime == rows[j].DeviceTime { @@ -2273,8 +2381,14 @@ func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDat } pageRows := append([]HistoryDataRow(nil), rows[offset:end]...) columns := historyMetricsForCategory(category) + segments := []LatestTelemetryCategory{} if category == "raw" { - columns = mergeDiscoveredRawMetrics(columns, rows) + definitions, err := s.metricDefinitions(ctx) + if err != nil { + return HistoryDataResponse{}, err + } + columns = mergeDiscoveredRawMetrics(columns, rows, definitions) + segments = historyRawSegments(columns) } vehicles := map[string]struct{}{} sources := map[string]struct{}{} @@ -2290,12 +2404,299 @@ func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDat } sort.Strings(sourceNames) return HistoryDataResponse{ - Category: category, Columns: columns, Rows: pageRows, Total: total, Limit: limit, Offset: offset, - Summary: HistoryDataSummary{ResultRows: total, VehicleCount: len(vehicles), Sources: sourceNames, QueryDuration: time.Since(started).Milliseconds()}, + Category: category, Columns: columns, Segments: segments, Rows: pageRows, Total: total, Limit: limit, Offset: offset, + Summary: HistoryDataSummary{ResultRows: total, VehicleCount: len(vehicles), RequestedVehicleCount: len(keywords), Vehicles: vehicleResults, Sources: sourceNames, QueryDuration: time.Since(started).Milliseconds()}, AsOf: time.Now().UTC().Format(time.RFC3339), }, nil } +type rawHistoryScan struct { + rows []HistoryDataRow + allTotal int + filteredTotal int + segmentCounts map[string]int + fields map[string]struct{} + sources map[string]struct{} + vin string + plate string +} + +type rawHistoryBatchStore interface { + HistoryExportBatch(context.Context, HistoryExportStoreQuery, HistoryExportCursor, int) ([]HistoryDataRow, HistoryExportCursor, error) +} + +type historyLatestRowHeap []HistoryDataRow + +func (items historyLatestRowHeap) Len() int { return len(items) } + +func (items historyLatestRowHeap) Less(left, right int) bool { + return historyRowBefore(items[right], items[left]) +} + +func (items historyLatestRowHeap) Swap(left, right int) { + items[left], items[right] = items[right], items[left] +} + +func (items *historyLatestRowHeap) Push(value any) { + *items = append(*items, value.(HistoryDataRow)) +} + +func (items *historyLatestRowHeap) Pop() any { + current := *items + last := len(current) - 1 + value := current[last] + *items = current[:last] + return value +} + +func historyRowBefore(left, right HistoryDataRow) bool { + if left.DeviceTime != right.DeviceTime { + return left.DeviceTime > right.DeviceTime + } + if left.VIN != right.VIN { + return left.VIN < right.VIN + } + if left.Protocol != right.Protocol { + return left.Protocol < right.Protocol + } + return left.ID < right.ID +} + +func retainLatestHistoryRow(rows *historyLatestRowHeap, row HistoryDataRow, limit int) { + if limit <= 0 { + return + } + if rows.Len() < limit { + heap.Push(rows, row) + return + } + if historyRowBefore(row, (*rows)[0]) { + (*rows)[0] = row + heap.Fix(rows, 0) + } +} + +func validRawSegment(segment string) bool { + if segment == "" { + return true + } + for _, candidate := range []string{"vehicle", "motor", "fuel-cell", "battery", "location", "alarm", "quality", "extension"} { + if segment == candidate { + return true + } + } + return false +} + +func historyRawRowCategories(row HistoryDataRow, definitions map[string]MetricDefinition) map[string]struct{} { + categories := map[string]struct{}{} + for key := range row.Values { + if key == "frameType" || key == "rawSizeBytes" { + continue + } + protocol := strings.ToUpper(strings.TrimSpace(row.Protocol)) + if parts := strings.SplitN(key, ".", 2); len(parts) == 2 && strings.TrimSpace(parts[0]) != "" { + protocol = strings.ToUpper(strings.TrimSpace(parts[0])) + } + categories[telemetryCategoryForSource(protocol, key, definitions)] = struct{}{} + } + return categories +} + +func (s *Service) historyRawData(ctx context.Context, query url.Values, keywords []string, limit, offset int, started time.Time) (HistoryDataResponse, error) { + segment := strings.ToLower(strings.TrimSpace(query.Get("rawSegment"))) + if !validRawSegment(segment) { + return HistoryDataResponse{}, clientError{Code: "HISTORY_RAW_SEGMENT_INVALID", Message: "原始报文分段类型无效"} + } + store, ok := s.store.(rawHistoryBatchStore) + if !ok { + return HistoryDataResponse{}, errors.New("当前数据存储不支持原始报文精确分段查询") + } + catalog, err := s.metricDefinitions(ctx) + if err != nil { + return HistoryDataResponse{}, err + } + definitions := telemetryDefinitionsBySource(catalog) + keep := limit + offset + scans := make([]rawHistoryScan, len(keywords)) + errs := make([]error, len(keywords)) + var group sync.WaitGroup + for index, keyword := range keywords { + group.Add(1) + go func(index int, keyword string) { + defer group.Done() + resolvedVIN, resolveErr := s.resolveVehicleVIN(ctx, keyword, query.Get("protocol")) + if resolveErr != nil { + errs[index] = resolveErr + return + } + if authorizeErr := authorizeVehicleVIN(ctx, resolvedVIN); authorizeErr != nil { + errs[index] = authorizeErr + return + } + scoped, scopeErr := applyPrincipalHistoryTimeScope(ctx, resolvedVIN, url.Values{ + "dateFrom": {query.Get("dateFrom")}, + "dateTo": {query.Get("dateTo")}, + }) + if scopeErr != nil { + errs[index] = scopeErr + return + } + scans[index], errs[index] = scanRawHistoryVehicle(ctx, store, HistoryExportStoreQuery{ + Category: "raw", + VIN: resolvedVIN, + Protocol: strings.TrimSpace(query.Get("protocol")), + DateFrom: scoped.Get("dateFrom"), + DateTo: scoped.Get("dateTo"), + }, segment, definitions, keep) + }(index, keyword) + } + group.Wait() + + latest := &historyLatestRowHeap{} + heap.Init(latest) + segmentCounts := map[string]int{} + fields := map[string]struct{}{} + sources := map[string]struct{}{} + vehicleResults := make([]HistoryQueryVehicleResult, 0, len(keywords)) + allTotal := 0 + total := 0 + for index, keyword := range keywords { + if errs[index] != nil { + return HistoryDataResponse{}, errs[index] + } + scan := scans[index] + allTotal += scan.allTotal + total += scan.filteredTotal + for key, count := range scan.segmentCounts { + segmentCounts[key] += count + } + for key := range scan.fields { + fields[key] = struct{}{} + } + for source := range scan.sources { + sources[source] = struct{}{} + } + for _, row := range scan.rows { + retainLatestHistoryRow(latest, row, keep) + } + status := "no_data" + if scan.filteredTotal > 0 { + status = "matched" + } + vehicleResults = append(vehicleResults, HistoryQueryVehicleResult{ + Keyword: keyword, + VIN: scan.vin, + Plate: scan.plate, + RowCount: scan.filteredTotal, + Status: status, + }) + } + rows := append([]HistoryDataRow(nil), (*latest)...) + sort.SliceStable(rows, func(left, right int) bool { return historyRowBefore(rows[left], rows[right]) }) + if offset > len(rows) { + offset = len(rows) + } + end := offset + limit + if end > len(rows) { + end = len(rows) + } + pageRows := append([]HistoryDataRow(nil), rows[offset:end]...) + sourceNames := make([]string, 0, len(sources)) + for source := range sources { + if source != "" { + sourceNames = append(sourceNames, source) + } + } + sort.Strings(sourceNames) + vehicleCount := 0 + for _, item := range vehicleResults { + if item.RowCount > 0 { + vehicleCount++ + } + } + columns := mergeDiscoveredRawMetricKeys(historyRawMetrics(), fields, catalog) + return HistoryDataResponse{ + Category: "raw", + Columns: columns, + Segments: historyRawSegmentsFromCounts(segmentCounts), + SegmentCountsExact: true, + AllTotal: allTotal, + Rows: pageRows, + Total: total, + Limit: limit, + Offset: offset, + Summary: HistoryDataSummary{ + ResultRows: total, + VehicleCount: vehicleCount, + RequestedVehicleCount: len(keywords), + Vehicles: vehicleResults, + Sources: sourceNames, + QueryDuration: time.Since(started).Milliseconds(), + }, + AsOf: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func scanRawHistoryVehicle(ctx context.Context, store rawHistoryBatchStore, query HistoryExportStoreQuery, segment string, definitions map[string]MetricDefinition, keep int) (rawHistoryScan, error) { + result := rawHistoryScan{ + segmentCounts: map[string]int{}, + fields: map[string]struct{}{}, + sources: map[string]struct{}{}, + vin: query.VIN, + } + latest := &historyLatestRowHeap{} + heap.Init(latest) + cursor := HistoryExportCursor{} + const batchSize = 5000 + for { + batch, next, err := store.HistoryExportBatch(ctx, query, cursor, batchSize) + if err != nil { + return rawHistoryScan{}, err + } + if len(batch) == 0 { + break + } + for _, row := range batch { + result.allTotal++ + if result.vin == "" { + result.vin = row.VIN + } + if result.plate == "" { + result.plate = row.Plate + } + categories := historyRawRowCategories(row, definitions) + for category := range categories { + result.segmentCounts[category]++ + } + for key := range row.Values { + if key != "frameType" && key != "rawSizeBytes" { + result.fields[key] = struct{}{} + } + } + if segment != "" { + if _, matches := categories[segment]; !matches { + continue + } + } + result.filteredTotal++ + if row.Protocol != "" { + result.sources[row.Protocol] = struct{}{} + } + retainLatestHistoryRow(latest, row, keep) + } + if len(batch) < batchSize { + break + } + if next == cursor { + return rawHistoryScan{}, errors.New("原始报文分段扫描游标未推进") + } + cursor = next + } + result.rows = append([]HistoryDataRow(nil), (*latest)...) + return result, nil +} + type historyLocationSeriesStore interface { HistoryLocationSeries(context.Context, HistoryLocationSeriesQuery) ([]HistoryLocationSeriesBucket, error) } @@ -2366,27 +2767,48 @@ func (s *Service) HistorySeries(ctx context.Context, query url.Values) (HistoryS vin, plate string buckets []HistoryLocationSeriesBucket } + type vehicleSeriesResult struct { + vehicle vehicleBuckets + err error + } + perVehicleSeries := make([]vehicleSeriesResult, len(keywords)) + var seriesQueries sync.WaitGroup + for index, keyword := range keywords { + seriesQueries.Add(1) + go func(index int, keyword string) { + defer seriesQueries.Done() + vin, resolveErr := s.resolveVehicleVIN(ctx, keyword, protocol) + if resolveErr != nil { + perVehicleSeries[index].err = resolveErr + return + } + if authErr := authorizeVehicleVIN(ctx, vin); authErr != nil { + perVehicleSeries[index].err = authErr + return + } + scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{ + "dateFrom": {dateFrom}, + "dateTo": {dateTo}, + }) + if scopeErr != nil { + perVehicleSeries[index].err = scopeErr + return + } + buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds}) + if aggregateErr != nil { + perVehicleSeries[index].err = aggregateErr + return + } + perVehicleSeries[index].vehicle = vehicleBuckets{vin: vin, plate: s.historySeriesPlate(ctx, vin), buckets: buckets} + }(index, keyword) + } + seriesQueries.Wait() vehicles := make([]vehicleBuckets, 0, len(keywords)) - for _, keyword := range keywords { - vin, resolveErr := s.resolveVehicleVIN(ctx, keyword, protocol) - if resolveErr != nil { - return HistorySeriesResponse{}, resolveErr + for _, result := range perVehicleSeries { + if result.err != nil { + return HistorySeriesResponse{}, result.err } - if err := authorizeVehicleVIN(ctx, vin); err != nil { - return HistorySeriesResponse{}, err - } - scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{ - "dateFrom": {dateFrom}, - "dateTo": {dateTo}, - }) - if scopeErr != nil { - return HistorySeriesResponse{}, scopeErr - } - buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds}) - if aggregateErr != nil { - return HistorySeriesResponse{}, aggregateErr - } - vehicles = append(vehicles, vehicleBuckets{vin: vin, plate: s.historySeriesPlate(ctx, vin), buckets: buckets}) + vehicles = append(vehicles, result.vehicle) } seriesByKey := map[string]*HistorySeries{} seriesOrder := make([]string, 0) @@ -2464,20 +2886,29 @@ func historySeriesWindow(rawFrom, rawTo string, now time.Time) (string, string, if strings.TrimSpace(rawTo) == "" { rawTo = localNow.Format("2006-01-02T15:04") } - start, startOK := parseTrackRequestTime(rawFrom) - end, endOK := parseTrackRequestTime(rawTo) + start, startOK := parseHistoryRequestTime(rawFrom) + end, endOK := parseHistoryRequestTime(rawTo) if !startOK || !endOK { - return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_INVALID", Message: "趋势时间格式无效"} + return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_INVALID", Message: "历史时间格式无效"} } if !end.After(start) { - return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_ORDER_INVALID", Message: "趋势结束时间必须晚于开始时间"} + return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_ORDER_INVALID", Message: "历史结束时间必须晚于开始时间"} } if end.Sub(start) > 31*24*time.Hour { - return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_RANGE_TOO_LARGE", Message: "单次趋势分析最长支持 31 天"} + return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_RANGE_TOO_LARGE", Message: "单次历史查询最长支持 31 天,请缩小时间范围"} } return rawFrom, rawTo, start, end, nil } +func parseHistoryRequestTime(value string) (time.Time, bool) { + if parsed, ok := parseTrackRequestTime(value); ok { + return parsed, true + } + shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) + parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), shanghai) + return parsed, err == nil +} + func historySeriesGrain(duration time.Duration, targetPoints int) int { required := int(math.Ceil(duration.Seconds() / float64(targetPoints))) for _, grain := range []int{1, 5, 10, 30, 60, 300, 900, 1800, 3600, 21600, 86400} { @@ -2706,25 +3137,39 @@ func historyMetricsForCategory(category string) []HistoryMetricDefinition { } } -func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDataRow) []HistoryMetricDefinition { +func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDataRow, catalog ...[]MetricDefinition) []HistoryMetricDefinition { + keys := map[string]struct{}{} + for _, row := range rows { + for key := range row.Values { + keys[key] = struct{}{} + } + } + definitions := []MetricDefinition{} + if len(catalog) > 0 { + definitions = catalog[0] + } + return mergeDiscoveredRawMetricKeys(base, keys, definitions) +} + +func mergeDiscoveredRawMetricKeys(base []HistoryMetricDefinition, discovered map[string]struct{}, catalog []MetricDefinition) []HistoryMetricDefinition { result := append([]HistoryMetricDefinition(nil), base...) + definitions := telemetryDefinitionsBySource(catalog) seen := map[string]struct{}{} for _, metric := range result { seen[metric.Key] = struct{}{} } - keys := make([]string, 0) - for _, row := range rows { - for key := range row.Values { - if _, ok := seen[key]; !ok { - seen[key] = struct{}{} - keys = append(keys, key) - } + keys := make([]string, 0, len(discovered)) + for key := range discovered { + if _, ok := seen[key]; ok { + continue } + keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { label, unit := rawMetricMetadata(key) - metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7} + protocol := strings.ToUpper(strings.TrimSpace(strings.SplitN(key, ".", 2)[0])) + metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: telemetryCategoryForSource(protocol, key, definitions), ValueType: "dynamic", DefaultVisible: len(result) < 7} if reference, ok := gb32960ReferenceForSource(key); ok { metric.Label = reference.Label + " · GB32960" metric.Description = reference.Description @@ -2736,6 +3181,35 @@ func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDat return result } +func historyRawSegments(columns []HistoryMetricDefinition) []LatestTelemetryCategory { + counts := map[string]int{} + for _, column := range columns { + if column.Category == "raw" || column.Category == "" { + continue + } + counts[column.Category]++ + } + return historyRawSegmentsFromCounts(counts) +} + +func historyRawSegmentsFromCounts(counts map[string]int) []LatestTelemetryCategory { + keys := make([]string, 0, len(counts)) + for key := range counts { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if left, right := telemetryCategoryRank(keys[i]), telemetryCategoryRank(keys[j]); left != right { + return left < right + } + return keys[i] < keys[j] + }) + segments := make([]LatestTelemetryCategory, 0, len(keys)) + for _, key := range keys { + segments = append(segments, LatestTelemetryCategory{Key: key, Label: telemetryCategoryLabel(key), Count: counts[key]}) + } + return segments +} + func rawMetricMetadata(key string) (string, string) { if reference, ok := gb32960ReferenceForSource(key); ok { return reference.Label + " · GB32960", reference.Unit @@ -2847,6 +3321,10 @@ func isRawMetricUnitToken(token string) bool { } func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) { + return s.createHistoryExport(ctx, request, "") +} + +func (s *Service) createHistoryExport(ctx context.Context, request HistoryExportRequest, rebuiltFrom string) (HistoryExportJob, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "导出任务需要已认证账号"} @@ -2882,6 +3360,12 @@ func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExport if len(request.Metrics) > 32 { return HistoryExportJob{}, clientError{Code: "EXPORT_METRIC_LIMIT_EXCEEDED", Message: "单次导出最多支持 32 个指标"} } + if request.RetentionDays == 0 { + request.RetentionDays = s.historyRetentionDaysForPrincipal(principal) + } + if !allowedHistoryExportRetentionDays[request.RetentionDays] { + return HistoryExportJob{}, clientError{Code: "EXPORT_RETENTION_INVALID", Message: "文件保留期仅支持 1、3、7、14 或 30 天"} + } scopes, err := s.resolveHistoryExportScopes(ctx, request) if err != nil { return HistoryExportJob{}, err @@ -2905,8 +3389,13 @@ func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExport OwnerID: ownerID, OwnerSubjectID: principal.SubjectID, OwnerName: principal.Name, OwnerUsername: principal.Username, OwnerRole: principal.Role, OwnerUserType: principal.UserType, AuthProvider: principal.AuthProvider, CustomerRef: principal.CustomerRef, TenantRef: principal.TenantRef, - CreatedAt: now, UpdatedAt: now, - Evidence: "账号归属与车辆/时间 Scope 已固化;单并发流式任务;最多 1,000,000 行;完成文件原子发布", + RebuiltFrom: rebuiltFrom, + RetentionDays: request.RetentionDays, + CreatedAt: now, UpdatedAt: now, + Evidence: fmt.Sprintf("账号归属与车辆/时间 Scope 已固化;单并发流式任务;最多 1,000,000 行;完成文件原子发布;文件保留 %d 天", request.RetentionDays), + } + if rebuiltFrom != "" { + job.Evidence += ";由任务 " + rebuiltFrom + " 按原范围重建,原记录保留" } s.exportsMu.Lock() s.exports[job.ID] = job @@ -2921,33 +3410,617 @@ func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExport } func (s *Service) ListHistoryExports(ctx context.Context) []HistoryExportJob { + return s.ListHistoryExportsPage(ctx, HistoryExportQuery{Limit: 20}).Items +} + +func (s *Service) ListHistoryExportsPage(ctx context.Context, query HistoryExportQuery) HistoryExportPage { principal, _ := PrincipalFromContext(ctx) - s.exportsMu.RLock() - result := make([]HistoryExportJob, 0, len(s.exports)) + query.Search = strings.ToLower(strings.TrimSpace(query.Search)) + query.Status = strings.ToLower(strings.TrimSpace(query.Status)) + query.Scope = strings.ToLower(strings.TrimSpace(query.Scope)) + query.OwnerScope = strings.ToLower(strings.TrimSpace(query.OwnerScope)) + query.Sort = strings.ToLower(strings.TrimSpace(query.Sort)) + if query.Scope != "archived" { + query.Scope = "current" + } + if query.OwnerScope != "mine" { + query.OwnerScope = "all" + } + if query.Sort != "expiry" { + query.Sort = "recent" + } + if query.Limit != 10 && query.Limit != 20 && query.Limit != 50 { + query.Limit = 10 + } + if query.Offset < 0 { + query.Offset = 0 + } + now := time.Now().UTC() + s.exportsMu.Lock() + if s.refreshHistoryExportAvailabilityLocked(now) { + _ = s.persistHistoryExportsLocked() + } + all := make([]HistoryExportJob, 0, len(s.exports)) + ownerID := historyExportOwnerID(principal) for _, job := range s.exports { - if canAccessHistoryExport(principal, job) { - result = append(result, copyHistoryExportJob(job)) + if canAccessHistoryExport(principal, job) && (query.OwnerScope != "mine" || ownerID != "" && job.OwnerID == ownerID) { + all = append(all, copyHistoryExportJob(job)) } } - s.exportsMu.RUnlock() - sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt > result[j].CreatedAt }) - if len(result) > 20 { - result = result[:20] + s.exportsMu.Unlock() + summary := HistoryExportSummary{Total: len(all)} + filtered := make([]HistoryExportJob, 0, len(all)) + for _, job := range all { + if job.ArchivedAt != "" { + summary.Archived++ + } else { + summary.Current++ + } + if historyExportMatchesScope(job, query.Scope) { + switch job.Status { + case "queued", "running": + summary.Active++ + case "completed": + summary.Completed++ + if historyExportExpiresSoon(job, now) { + summary.Expiring++ + } + case "failed", "expired": + summary.Recoverable++ + case "cancelled": + summary.Cancelled++ + } + } + if historyExportMatchesQuery(job, query, now) { + filtered = append(filtered, job) + } } - return result + sortHistoryExports(filtered, query, now) + start := query.Offset + if start > len(filtered) { + start = len(filtered) + } + end := start + query.Limit + if end > len(filtered) { + end = len(filtered) + } + return HistoryExportPage{Items: append([]HistoryExportJob{}, filtered[start:end]...), Total: len(filtered), Limit: query.Limit, Offset: query.Offset, Summary: summary} +} + +func historyExportTimestamp(value string) int64 { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return 0 + } + return parsed.UnixNano() +} + +func historyExportScopeActivity(job HistoryExportJob, scope string) int64 { + if scope == "archived" { + if archivedAt := historyExportTimestamp(job.ArchivedAt); archivedAt > 0 { + return archivedAt + } + } + return historyExportTimestamp(job.CreatedAt) +} + +func historyExportFutureExpiry(job HistoryExportJob, now time.Time) (int64, bool) { + if job.Status != "completed" { + return 0, false + } + expiresAt := historyExportTimestamp(job.ExpiresAt) + return expiresAt, expiresAt > now.UnixNano() +} + +func sortHistoryExports(jobs []HistoryExportJob, query HistoryExportQuery, now time.Time) { + sort.SliceStable(jobs, func(i, j int) bool { + left, right := jobs[i], jobs[j] + if query.Sort == "expiry" { + leftExpiry, leftExpires := historyExportFutureExpiry(left, now) + rightExpiry, rightExpires := historyExportFutureExpiry(right, now) + if leftExpires != rightExpires { + return leftExpires + } + if leftExpires && leftExpiry != rightExpiry { + return leftExpiry < rightExpiry + } + } + leftActivity := historyExportScopeActivity(left, query.Scope) + rightActivity := historyExportScopeActivity(right, query.Scope) + if leftActivity != rightActivity { + return leftActivity > rightActivity + } + leftCreated := historyExportTimestamp(left.CreatedAt) + rightCreated := historyExportTimestamp(right.CreatedAt) + if leftCreated != rightCreated { + return leftCreated > rightCreated + } + return left.ID > right.ID + }) +} + +func historyExportMatchesScope(job HistoryExportJob, scope string) bool { + return scope == "archived" && job.ArchivedAt != "" || scope != "archived" && job.ArchivedAt == "" +} + +func historyExportExpiresSoon(job HistoryExportJob, now time.Time) bool { + if job.Status != "completed" || job.ExpiresAt == "" { + return false + } + expiresAt, err := time.Parse(time.RFC3339, job.ExpiresAt) + if err != nil { + return false + } + return expiresAt.After(now) && !expiresAt.After(now.Add(24*time.Hour)) +} + +func historyExportMatchesQuery(job HistoryExportJob, query HistoryExportQuery, now time.Time) bool { + if !historyExportMatchesScope(job, query.Scope) { + return false + } + switch query.Status { + case "active": + if job.Status != "queued" && job.Status != "running" { + return false + } + case "failed": + if job.Status != "failed" && job.Status != "expired" { + return false + } + case "expiring": + if !historyExportExpiresSoon(job, now) { + return false + } + case "completed", "cancelled": + if job.Status != query.Status { + return false + } + } + if query.Search == "" { + return true + } + values := []string{job.ID, job.Name, job.Protocol, job.Category, job.Status, job.OwnerName, job.OwnerUsername, job.DateFrom, job.DateTo, job.CreatedAt, job.CompletedAt, job.RebuiltFrom, job.ArchivedBy, job.Evidence} + values = append(values, job.Keywords...) + values = append(values, job.VehicleVINs...) + for _, value := range values { + if strings.Contains(strings.ToLower(value), query.Search) { + return true + } + } + return false +} + +func (s *Service) RebuildHistoryExport(ctx context.Context, id string) (HistoryExportJob, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "重建导出任务需要已认证账号"} + } + id = strings.TrimSpace(id) + s.exportsMu.Lock() + if s.refreshHistoryExportAvailabilityLocked(time.Now().UTC()) { + _ = s.persistHistoryExportsLocked() + } + job := s.exports[id] + if job == nil || !canAccessHistoryExport(principal, job) { + s.exportsMu.Unlock() + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} + } + copy := copyHistoryExportJob(job) + for _, candidate := range s.exports { + if candidate != nil && candidate.ID != copy.ID && canAccessHistoryExport(principal, candidate) && (candidate.Status == "queued" || candidate.Status == "running") && sameHistoryExportRequest(candidate, ©) { + s.exportsMu.Unlock() + return HistoryExportJob{}, clientError{Code: "EXPORT_REBUILD_ALREADY_ACTIVE", Message: "相同车辆、时间和字段范围已在生成,无需重复重建"} + } + } + s.exportsMu.Unlock() + if copy.Status != "failed" && copy.Status != "expired" { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_REBUILDABLE", Message: "仅失败或文件已过期的任务可以按原范围重建"} + } + return s.createHistoryExport(ctx, historyExportRequestFromJob(copy), copy.ID) +} + +func sameHistoryExportRequest(left, right *HistoryExportJob) bool { + if left == nil || right == nil || left.Category != right.Category || left.Protocol != right.Protocol || left.DateFrom != right.DateFrom || left.DateTo != right.DateTo { + return false + } + return equalStrings(left.Keywords, right.Keywords) && equalStrings(left.Metrics, right.Metrics) +} + +func equalStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func historyExportRequestFromJob(job HistoryExportJob) HistoryExportRequest { + return HistoryExportRequest{ + Keywords: append([]string(nil), job.Keywords...), Category: job.Category, Protocol: job.Protocol, + DateFrom: job.DateFrom, DateTo: job.DateTo, Metrics: append([]string(nil), job.Metrics...), Format: "csv", RetentionDays: normalizedHistoryRetentionDays(job.RetentionDays), + } +} + +func (s *Service) BatchHistoryExports(ctx context.Context, request HistoryExportBatchRequest) (HistoryExportBatchResult, error) { + action := strings.ToLower(strings.TrimSpace(request.Action)) + if action != "cancel" && action != "rebuild" && action != "archive" && action != "restore" { + return HistoryExportBatchResult{}, clientError{Code: "EXPORT_BATCH_ACTION_INVALID", Message: "批量操作仅支持 cancel、rebuild、archive 或 restore"} + } + ids := normalizedKeywords(request.IDs) + if len(ids) == 0 || len(ids) > 20 { + return HistoryExportBatchResult{}, clientError{Code: "EXPORT_BATCH_SCOPE_INVALID", Message: "批量操作需要选择 1 至 20 个任务"} + } + result := HistoryExportBatchResult{Action: action, Requested: len(ids), Succeeded: []HistoryExportJob{}, Skipped: []HistoryExportBatchSkipped{}} + for _, id := range ids { + var job HistoryExportJob + var err error + if action == "cancel" { + job, err = s.CancelHistoryExport(ctx, id) + } else if action == "rebuild" { + job, err = s.RebuildHistoryExport(ctx, id) + } else { + job, err = s.SetHistoryExportArchived(ctx, id, action == "archive") + } + if err == nil { + result.Succeeded = append(result.Succeeded, job) + continue + } + code := "EXPORT_BATCH_ITEM_FAILED" + if clientErr, ok := asClientError(err); ok { + code = clientErr.Code + } + result.Skipped = append(result.Skipped, HistoryExportBatchSkipped{ID: id, Code: code, Message: err.Error()}) + } + return result, nil +} + +func (s *Service) SetHistoryExportArchived(ctx context.Context, id string, archived bool) (HistoryExportJob, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "整理导出任务需要已认证账号"} + } + id = strings.TrimSpace(id) + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + job := s.exports[id] + if job == nil || !canAccessHistoryExport(principal, job) { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} + } + if archived && (job.Status == "queued" || job.Status == "running") { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_ARCHIVABLE", Message: "生成中的任务不能归档,请等待完成或先取消"} + } + if archived == (job.ArchivedAt != "") { + return copyHistoryExportJob(job), nil + } + now := time.Now().UTC().Format(time.RFC3339) + actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") + if archived { + job.ArchivedAt = now + job.ArchivedBy = actor + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 归档,原范围、结果与文件策略保持不变" + } else { + job.ArchivedAt = "" + job.ArchivedBy = "" + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 移回当前任务" + } + job.UpdatedAt = now + if err := s.persistHistoryExportsLocked(); err != nil { + return HistoryExportJob{}, fmt.Errorf("persist archived export job: %w", err) + } + return copyHistoryExportJob(job), nil +} + +func historyExportCleanupPrincipal(ctx context.Context) (Principal, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return Principal{}, clientError{Code: "EXPORT_CLEANUP_ADMIN_REQUIRED", Message: "归档清理需要已认证管理员"} + } + if !strings.EqualFold(strings.TrimSpace(principal.Role), "admin") { + return Principal{}, clientError{Code: "EXPORT_CLEANUP_ADMIN_REQUIRED", Message: "只有管理员可以预演或执行归档清理"} + } + return principal, nil +} + +func normalizedHistoryExportCleanupQuery(query HistoryExportCleanupQuery) (HistoryExportCleanupQuery, error) { + if !allowedHistoryExportCleanupDays[query.OlderThanDays] { + return HistoryExportCleanupQuery{}, clientError{Code: "EXPORT_CLEANUP_WINDOW_INVALID", Message: "归档清理周期仅支持 30、90、180 或 365 天"} + } + query.OwnerScope = strings.ToLower(strings.TrimSpace(query.OwnerScope)) + if query.OwnerScope != "mine" { + query.OwnerScope = "all" + } + return query, nil +} + +func historyExportCleanupCandidate(job *HistoryExportJob) HistoryExportCleanupCandidate { + return HistoryExportCleanupCandidate{ + ID: job.ID, Name: job.Name, OwnerUsername: firstNonEmpty(job.OwnerUsername, job.OwnerName, "未知账号"), + Status: job.Status, ArchivedAt: job.ArchivedAt, FileSizeBytes: job.FileSizeBytes, + ProtectedAt: job.CleanupProtectedAt, ProtectedBy: job.CleanupProtectedBy, ProtectionReason: job.CleanupProtectionReason, + } +} + +func historyExportCleanupToken(query HistoryExportCleanupQuery, candidates []*HistoryExportJob) string { + hash := sha256.New() + _, _ = hash.Write([]byte(strconv.Itoa(query.OlderThanDays) + "\x00" + query.OwnerScope + "\x00")) + for _, job := range candidates { + _, _ = hash.Write([]byte(job.ID + "\x00" + job.UpdatedAt + "\x00")) + } + return "cleanup_" + hex.EncodeToString(hash.Sum(nil)[:12]) +} + +func (s *Service) historyExportCleanupPreviewLocked(principal Principal, query HistoryExportCleanupQuery, now time.Time) (HistoryExportCleanupPreview, []*HistoryExportJob) { + cutoff := now.Add(-time.Duration(query.OlderThanDays) * 24 * time.Hour) + ownerID := historyExportOwnerID(principal) + eligible := make([]*HistoryExportJob, 0) + protected := make([]*HistoryExportJob, 0) + archivedCount := 0 + for _, job := range s.exports { + if job == nil || !canAccessHistoryExport(principal, job) || job.ArchivedAt == "" { + continue + } + if query.OwnerScope == "mine" && (ownerID == "" || job.OwnerID != ownerID) { + continue + } + archivedCount++ + archivedAt, err := time.Parse(time.RFC3339, job.ArchivedAt) + if err != nil || archivedAt.After(cutoff) || job.Status == "queued" || job.Status == "running" { + continue + } + if job.CleanupProtectedAt != "" { + protected = append(protected, job) + } else { + eligible = append(eligible, job) + } + } + sort.SliceStable(eligible, func(i, j int) bool { + left, right := historyExportTimestamp(eligible[i].ArchivedAt), historyExportTimestamp(eligible[j].ArchivedAt) + if left != right { + return left < right + } + return eligible[i].ID < eligible[j].ID + }) + sort.SliceStable(protected, func(i, j int) bool { + left, right := historyExportTimestamp(protected[i].ArchivedAt), historyExportTimestamp(protected[j].ArchivedAt) + if left != right { + return left < right + } + return protected[i].ID < protected[j].ID + }) + planned := eligible + if len(planned) > historyExportCleanupBatchLimit { + planned = planned[:historyExportCleanupBatchLimit] + } + candidatePreview := planned + if len(candidatePreview) > historyExportCleanupCandidatePreviewLimit { + candidatePreview = candidatePreview[:historyExportCleanupCandidatePreviewLimit] + } + protectedPreview := protected + if len(protectedPreview) > historyExportCleanupCandidatePreviewLimit { + protectedPreview = protectedPreview[:historyExportCleanupCandidatePreviewLimit] + } + preview := HistoryExportCleanupPreview{ + OlderThanDays: query.OlderThanDays, OwnerScope: query.OwnerScope, Cutoff: cutoff.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), + ArchivedCount: archivedCount, CandidateCount: len(eligible), PlannedCount: len(planned), ProtectedCount: len(protected), + Candidates: make([]HistoryExportCleanupCandidate, 0, len(candidatePreview)), Protected: make([]HistoryExportCleanupCandidate, 0, len(protectedPreview)), + PreviewToken: historyExportCleanupToken(query, planned), + } + for _, job := range planned { + if job.filePath != "" { + preview.FileCount++ + preview.FileSizeBytes += job.FileSizeBytes + } + } + for _, job := range candidatePreview { + preview.Candidates = append(preview.Candidates, historyExportCleanupCandidate(job)) + } + for _, job := range protectedPreview { + preview.Protected = append(preview.Protected, historyExportCleanupCandidate(job)) + } + return preview, planned +} + +func (s *Service) PreviewHistoryExportCleanup(ctx context.Context, query HistoryExportCleanupQuery) (HistoryExportCleanupPreview, error) { + principal, err := historyExportCleanupPrincipal(ctx) + if err != nil { + return HistoryExportCleanupPreview{}, err + } + query, err = normalizedHistoryExportCleanupQuery(query) + if err != nil { + return HistoryExportCleanupPreview{}, err + } + now := time.Now().UTC() + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + if s.refreshHistoryExportAvailabilityLocked(now) { + _ = s.persistHistoryExportsLocked() + } + preview, _ := s.historyExportCleanupPreviewLocked(principal, query, now) + return preview, nil +} + +func (s *Service) CleanupHistoryExports(ctx context.Context, request HistoryExportCleanupRequest) (HistoryExportCleanupResult, error) { + principal, err := historyExportCleanupPrincipal(ctx) + if err != nil { + return HistoryExportCleanupResult{}, err + } + query, err := normalizedHistoryExportCleanupQuery(HistoryExportCleanupQuery{OlderThanDays: request.OlderThanDays, OwnerScope: request.OwnerScope}) + if err != nil { + return HistoryExportCleanupResult{}, err + } + if strings.TrimSpace(request.PreviewToken) == "" { + return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_PREVIEW_REQUIRED", Message: "执行清理前必须先完成预演"} + } + now := time.Now().UTC() + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + if s.refreshHistoryExportAvailabilityLocked(now) { + _ = s.persistHistoryExportsLocked() + } + preview, planned := s.historyExportCleanupPreviewLocked(principal, query, now) + if request.PreviewToken != preview.PreviewToken { + return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_PREVIEW_STALE", Message: "归档范围已经变化,请重新预演后再执行"} + } + if len(planned) == 0 { + return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_EMPTY", Message: "当前没有符合条件且未受保护的归档任务"} + } + deleted := make([]HistoryExportCleanupCandidate, 0, len(planned)) + for _, job := range planned { + deleted = append(deleted, historyExportCleanupCandidate(job)) + } + actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") + recordID, randomErr := randomExportID() + if randomErr != nil { + recordID = "cleanup_" + strconv.FormatInt(now.UnixNano(), 10) + } else { + recordID = strings.Replace(recordID, "exp_", "cleanup_", 1) + } + record := HistoryExportCleanupRecord{ + ID: recordID, Actor: actor, OwnerScope: query.OwnerScope, OlderThanDays: query.OlderThanDays, Cutoff: preview.Cutoff, + Requested: preview.PlannedCount, Cleaned: len(deleted), Protected: preview.ProtectedCount, + FileCount: preview.FileCount, FileSizeBytes: preview.FileSizeBytes, CandidateDigest: preview.PreviewToken, ExecutedAt: now.Format(time.RFC3339), + } + previousAudit := append([]HistoryExportCleanupRecord(nil), s.exportCleanupAudit...) + s.exportCleanupAudit = append([]HistoryExportCleanupRecord{record}, s.exportCleanupAudit...) + if len(s.exportCleanupAudit) > historyExportCleanupAuditLimit { + s.exportCleanupAudit = s.exportCleanupAudit[:historyExportCleanupAuditLimit] + } + if err := s.persistHistoryExportCleanupAuditLocked(); err != nil { + s.exportCleanupAudit = previousAudit + return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup audit: %w", err) + } + for _, job := range planned { + delete(s.exports, job.ID) + } + if err := s.persistHistoryExportsLocked(); err != nil { + for _, job := range planned { + s.exports[job.ID] = job + } + s.exportCleanupAudit = previousAudit + if rollbackErr := s.persistHistoryExportCleanupAuditLocked(); rollbackErr != nil { + return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup: %w; rollback cleanup audit: %v", err, rollbackErr) + } + return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup: %w", err) + } + for _, job := range planned { + if job.filePath == "" { + continue + } + if removeErr := os.Remove(job.filePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { + return HistoryExportCleanupResult{}, fmt.Errorf("export cleanup committed but archived file removal failed: %w", removeErr) + } + } + return HistoryExportCleanupResult{Record: record, Deleted: deleted}, nil +} + +func (s *Service) HistoryExportCleanupAudit(ctx context.Context, limit int) ([]HistoryExportCleanupRecord, error) { + if _, err := historyExportCleanupPrincipal(ctx); err != nil { + return nil, err + } + if limit <= 0 || limit > 50 { + limit = 20 + } + s.exportsMu.RLock() + defer s.exportsMu.RUnlock() + if limit > len(s.exportCleanupAudit) { + limit = len(s.exportCleanupAudit) + } + return append([]HistoryExportCleanupRecord(nil), s.exportCleanupAudit[:limit]...), nil +} + +func (s *Service) SetHistoryExportCleanupProtection(ctx context.Context, id string, request HistoryExportCleanupProtectionRequest) (HistoryExportJob, error) { + principal, err := historyExportCleanupPrincipal(ctx) + if err != nil { + return HistoryExportJob{}, err + } + reason := truncateHistoryPreferenceText(request.Reason, 120) + if request.Protected && reason == "" { + return HistoryExportJob{}, clientError{Code: "EXPORT_CLEANUP_PROTECTION_REASON_REQUIRED", Message: "长期保留需要填写审计原因"} + } + id = strings.TrimSpace(id) + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + job := s.exports[id] + if job == nil || !canAccessHistoryExport(principal, job) { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} + } + if job.ArchivedAt == "" || job.Status == "queued" || job.Status == "running" { + return HistoryExportJob{}, clientError{Code: "EXPORT_CLEANUP_PROTECTION_INVALID", Message: "只有已结束的审计归档任务可以设置长期保留"} + } + now := time.Now().UTC().Format(time.RFC3339) + actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") + if request.Protected { + job.CleanupProtectedAt = now + job.CleanupProtectedBy = actor + job.CleanupProtectionReason = reason + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";" + actor + " 设置长期保留:" + reason + } else { + job.CleanupProtectedAt = "" + job.CleanupProtectedBy = "" + job.CleanupProtectionReason = "" + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";" + actor + " 取消长期保留,任务重新进入归档清理范围" + } + job.UpdatedAt = now + if err := s.persistHistoryExportsLocked(); err != nil { + return HistoryExportJob{}, fmt.Errorf("persist export cleanup protection: %w", err) + } + return copyHistoryExportJob(job), nil +} + +func (s *Service) CancelHistoryExport(ctx context.Context, id string) (HistoryExportJob, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "取消导出任务需要已认证账号"} + } + id = strings.TrimSpace(id) + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + job := s.exports[id] + if job == nil || !canAccessHistoryExport(principal, job) { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} + } + if job.Status == "cancelled" { + return copyHistoryExportJob(job), nil + } + if job.Status != "queued" && job.Status != "running" { + return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_CANCELLABLE", Message: "仅排队中或执行中的导出任务可以取消"} + } + now := time.Now().UTC().Format(time.RFC3339) + actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") + job.Status = "cancelled" + job.Error = "" + job.DownloadURL = "" + job.CancelledAt = now + job.CancelledBy = actor + job.UpdatedAt = now + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 主动取消,已处理进度与原范围记录保留" + if err := s.persistHistoryExportsLocked(); err != nil { + return HistoryExportJob{}, fmt.Errorf("persist cancelled export job: %w", err) + } + return copyHistoryExportJob(job), nil } func (s *Service) HistoryExportFile(ctx context.Context, id string) (string, string, error) { principal, _ := PrincipalFromContext(ctx) - s.exportsMu.RLock() + s.exportsMu.Lock() + if s.refreshHistoryExportAvailabilityLocked(time.Now().UTC()) { + _ = s.persistHistoryExportsLocked() + } job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { - s.exportsMu.RUnlock() + s.exportsMu.Unlock() return "", "", clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } copy := copyHistoryExportJob(job) path := job.filePath - s.exportsMu.RUnlock() + s.exportsMu.Unlock() + if copy.Status == "expired" { + return "", "", clientError{Code: "EXPORT_EXPIRED", Message: "导出文件已过期,请按原范围重建"} + } if copy.Status != "completed" || path == "" { return "", "", clientError{Code: "EXPORT_NOT_READY", Message: "导出文件尚未生成"} } @@ -2957,6 +4030,50 @@ func (s *Service) HistoryExportFile(ctx context.Context, id string) (string, str return path, copy.Name + ".csv", nil } +func (s *Service) refreshHistoryExportAvailabilityLocked(now time.Time) bool { + changed := false + for _, job := range s.exports { + if job == nil || job.Status != "completed" { + continue + } + expiresAt, err := time.Parse(time.RFC3339, job.ExpiresAt) + if err != nil { + completedAt, completedErr := time.Parse(time.RFC3339, firstNonEmpty(job.CompletedAt, job.UpdatedAt, job.CreatedAt)) + if completedErr != nil { + completedAt = now + } + expiresAt = completedAt.Add(time.Duration(normalizedHistoryRetentionDays(job.RetentionDays)) * 24 * time.Hour) + job.RetentionDays = normalizedHistoryRetentionDays(job.RetentionDays) + job.ExpiresAt = expiresAt.Format(time.RFC3339) + changed = true + } + missing := job.filePath == "" + if !missing { + _, statErr := os.Stat(job.filePath) + missing = statErr != nil + } + if now.Before(expiresAt) && !missing { + continue + } + if job.filePath != "" { + _ = os.Remove(job.filePath) + } + job.Status = "expired" + job.DownloadURL = "" + job.filePath = "" + job.ExpiredAt = now.Format(time.RFC3339) + job.UpdatedAt = job.ExpiredAt + if missing { + job.Error = "导出文件已不存在,请按原范围重建" + } else { + job.Error = "文件保留期已结束,请按原范围重建" + } + job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";文件已失效,原车辆、时间与字段范围继续保留" + changed = true + } + return changed +} + func historyExportOwnerID(principal Principal) string { provider := strings.TrimSpace(principal.AuthProvider) if provider == "" { @@ -3088,7 +4205,17 @@ func (s *Service) historyExportJob(id string) (HistoryExportJob, bool) { func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scopes []HistoryExportVehicleScope) { s.exportSlots <- struct{}{} defer func() { <-s.exportSlots }() - s.updateHistoryExport(id, func(job *HistoryExportJob) { job.Status = "running"; job.Progress = 1 }) + started := s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { + if job.Status != "queued" { + return false + } + job.Status = "running" + job.Progress = 1 + return true + }) + if !started { + return + } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() job, ok := s.historyExportJob(id) @@ -3123,7 +4250,14 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scop } queries = append(queries, query) } - s.updateHistoryExport(id, func(job *HistoryExportJob) { job.TotalRows = totalRows; job.Progress = 3 }) + s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { + if job.Status == "cancelled" { + return false + } + job.TotalRows = totalRows + job.Progress = 3 + return true + }) columns := historyExportColumns(request.Category, request.Metrics) if err := os.MkdirAll(s.exportDir, 0o750); err != nil { s.failHistoryExport(id, err) @@ -3162,6 +4296,9 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scop if !exists { return } + if currentJob.Status == "cancelled" { + return + } if err := s.ensureHistoryExportScopeActive(ctx, currentJob); err != nil { s.failHistoryExport(id, err) return @@ -3202,12 +4339,16 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scop progress = 95 } } - s.updateHistoryExport(id, func(job *HistoryExportJob) { + s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { + if job.Status == "cancelled" { + return false + } job.ProcessedRows = processed job.Progress = progress if stat != nil { job.FileSizeBytes = stat.Size() } + return true }) if nextCursor == cursor { s.failHistoryExport(id, errors.New("导出游标未推进,任务已安全停止")) @@ -3237,6 +4378,9 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scop s.failHistoryExport(id, closeErr) return } + if currentJob, exists := s.historyExportJob(id); !exists || currentJob.Status == "cancelled" { + return + } if err := os.Rename(partPath, path); err != nil { s.failHistoryExport(id, err) return @@ -3244,19 +4388,28 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scop completed = true stat, _ := os.Stat(path) completedAt := time.Now().UTC().Format(time.RFC3339) - s.updateHistoryExport(id, func(job *HistoryExportJob) { + published := s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { + if job.Status == "cancelled" { + return false + } job.Status = "completed" job.Progress = 100 job.RowCount = int(processed) job.ProcessedRows = processed job.TotalRows = totalRows job.CompletedAt = completedAt + job.RetentionDays = normalizedHistoryRetentionDays(job.RetentionDays) + job.ExpiresAt = time.Now().UTC().Add(time.Duration(job.RetentionDays) * 24 * time.Hour).Format(time.RFC3339) if stat != nil { job.FileSizeBytes = stat.Size() } job.filePath = path job.DownloadURL = "/api/v2/exports/" + job.ID + "/download" + return true }) + if !published { + _ = os.Remove(path) + } } func (s *Service) updateHistoryExport(id string, update func(*HistoryExportJob)) { @@ -3269,14 +4422,179 @@ func (s *Service) updateHistoryExport(id string, update func(*HistoryExportJob)) } } +func (s *Service) updateHistoryExportIf(id string, update func(*HistoryExportJob) bool) bool { + s.exportsMu.Lock() + defer s.exportsMu.Unlock() + job := s.exports[id] + if job == nil || !update(job) { + return false + } + job.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + _ = s.persistHistoryExportsLocked() + return true +} + func (s *Service) failHistoryExport(id string, err error) { - s.updateHistoryExport(id, func(job *HistoryExportJob) { job.Status = "failed"; job.Error = err.Error(); job.Progress = 0 }) + s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { + if job.Status == "cancelled" { + return false + } + job.Status = "failed" + job.Error = err.Error() + job.Progress = 0 + return true + }) +} + +func (s *Service) HistoryPreferences(ctx context.Context) (HistoryPreferences, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "历史偏好需要已认证账号"} + } + ownerID := historyExportOwnerID(principal) + if ownerID == "" { + return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "当前账号缺少可持久化的偏好身份"} + } + s.exportsMu.RLock() + preference, exists := s.historyPreferences[ownerID] + s.exportsMu.RUnlock() + if !exists { + preference = HistoryPreferences{RetentionDays: defaultHistoryExportRetentionDays, FieldViews: []HistoryFieldView{}} + } + return copyHistoryPreferences(preference), nil +} + +func (s *Service) UpdateHistoryPreferences(ctx context.Context, input HistoryPreferences) (HistoryPreferences, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "保存历史偏好需要已认证账号"} + } + ownerID := historyExportOwnerID(principal) + if ownerID == "" { + return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "当前账号缺少可持久化的偏好身份"} + } + if input.RetentionDays != 0 && !allowedHistoryExportRetentionDays[input.RetentionDays] { + return HistoryPreferences{}, clientError{Code: "HISTORY_RETENTION_INVALID", Message: "导出文件保留时间仅支持 1、3、7、14 或 30 天"} + } + input.RetentionDays = normalizedHistoryRetentionDays(input.RetentionDays) + if len(input.FieldViews) > 8 { + return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_LIMIT_EXCEEDED", Message: "每个账号最多保存 8 个字段视图"} + } + views := make([]HistoryFieldView, 0, len(input.FieldViews)) + seen := map[string]bool{} + for _, candidate := range input.FieldViews { + candidate.Name = truncateHistoryPreferenceText(candidate.Name, 24) + candidate.Category = strings.ToLower(strings.TrimSpace(candidate.Category)) + if candidate.Category != "location" && candidate.Category != "raw" && candidate.Category != "mileage" { + return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_INVALID", Message: "字段视图类型仅支持 location、raw 或 mileage"} + } + candidate.Keys = normalizedKeywords(candidate.Keys) + if candidate.Name == "" || len(candidate.Keys) == 0 || len(candidate.Keys) > 32 { + return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_INVALID", Message: "字段视图需要名称和 1 至 32 个字段"} + } + candidate.ID = truncateHistoryPreferenceText(candidate.ID, 80) + if candidate.ID == "" { + candidate.ID = truncateHistoryPreferenceText(candidate.Category+":"+candidate.Name, 80) + } + key := candidate.Category + ":" + strings.ToLower(candidate.Name) + if seen[key] { + continue + } + seen[key] = true + views = append(views, candidate) + } + now := time.Now().UTC().Format(time.RFC3339) + s.exportsMu.Lock() + current := s.historyPreferences[ownerID] + updated := HistoryPreferences{Revision: current.Revision + 1, RetentionDays: input.RetentionDays, FieldViews: views, UpdatedAt: now} + s.historyPreferences[ownerID] = updated + if err := s.persistHistoryPreferencesLocked(); err != nil { + if current.Revision == 0 && len(current.FieldViews) == 0 && current.UpdatedAt == "" { + delete(s.historyPreferences, ownerID) + } else { + s.historyPreferences[ownerID] = current + } + s.exportsMu.Unlock() + return HistoryPreferences{}, fmt.Errorf("persist history preferences: %w", err) + } + s.exportsMu.Unlock() + return copyHistoryPreferences(updated), nil +} + +func (s *Service) historyRetentionDaysForPrincipal(principal Principal) int { + ownerID := historyExportOwnerID(principal) + if ownerID == "" { + return defaultHistoryExportRetentionDays + } + s.exportsMu.RLock() + days := s.historyPreferences[ownerID].RetentionDays + s.exportsMu.RUnlock() + return normalizedHistoryRetentionDays(days) +} + +func truncateHistoryPreferenceText(value string, limit int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) > limit { + runes = runes[:limit] + } + return string(runes) +} + +func copyHistoryPreferences(preference HistoryPreferences) HistoryPreferences { + copy := preference + copy.RetentionDays = normalizedHistoryRetentionDays(copy.RetentionDays) + copy.FieldViews = make([]HistoryFieldView, len(preference.FieldViews)) + for index, view := range preference.FieldViews { + copy.FieldViews[index] = view + copy.FieldViews[index].Keys = append([]string(nil), view.Keys...) + } + return copy } func (s *Service) exportIndexPath() string { return filepath.Join(s.exportDir, "jobs.json") } +func (s *Service) historyPreferencesPath() string { + return filepath.Join(s.exportDir, "history-preferences.json") +} + +func (s *Service) historyExportCleanupAuditPath() string { + return filepath.Join(s.exportDir, "cleanup-audit.json") +} + +func (s *Service) persistHistoryPreferencesLocked() error { + if err := os.MkdirAll(s.exportDir, 0o750); err != nil { + return err + } + contents, err := json.MarshalIndent(s.historyPreferences, "", " ") + if err != nil { + return err + } + temporary := s.historyPreferencesPath() + ".tmp" + if err := os.WriteFile(temporary, contents, 0o640); err != nil { + return err + } + return os.Rename(temporary, s.historyPreferencesPath()) +} + +func (s *Service) loadHistoryPreferences() { + contents, err := os.ReadFile(s.historyPreferencesPath()) + if err != nil { + return + } + preferences := map[string]HistoryPreferences{} + if json.Unmarshal(contents, &preferences) != nil { + return + } + for ownerID, preference := range preferences { + if strings.TrimSpace(ownerID) == "" { + continue + } + s.historyPreferences[ownerID] = copyHistoryPreferences(preference) + } +} + func (s *Service) persistHistoryExportsLocked() error { if err := os.MkdirAll(s.exportDir, 0o750); err != nil { return err @@ -3286,9 +4604,6 @@ func (s *Service) persistHistoryExportsLocked() error { jobs = append(jobs, copyHistoryExportJob(job)) } sort.Slice(jobs, func(i, j int) bool { return jobs[i].CreatedAt > jobs[j].CreatedAt }) - if len(jobs) > 100 { - jobs = jobs[:100] - } contents, err := json.MarshalIndent(jobs, "", " ") if err != nil { return err @@ -3300,6 +4615,36 @@ func (s *Service) persistHistoryExportsLocked() error { return os.Rename(temporary, s.exportIndexPath()) } +func (s *Service) persistHistoryExportCleanupAuditLocked() error { + if err := os.MkdirAll(s.exportDir, 0o750); err != nil { + return err + } + contents, err := json.MarshalIndent(s.exportCleanupAudit, "", " ") + if err != nil { + return err + } + temporary := s.historyExportCleanupAuditPath() + ".tmp" + if err := os.WriteFile(temporary, contents, 0o640); err != nil { + return err + } + return os.Rename(temporary, s.historyExportCleanupAuditPath()) +} + +func (s *Service) loadHistoryExportCleanupAudit() { + contents, err := os.ReadFile(s.historyExportCleanupAuditPath()) + if err != nil { + return + } + var records []HistoryExportCleanupRecord + if json.Unmarshal(contents, &records) != nil { + return + } + if len(records) > historyExportCleanupAuditLimit { + records = records[:historyExportCleanupAuditLimit] + } + s.exportCleanupAudit = append([]HistoryExportCleanupRecord(nil), records...) +} + func (s *Service) loadHistoryExports() { contents, err := os.ReadFile(s.exportIndexPath()) if err != nil { @@ -3321,11 +4666,12 @@ func (s *Service) loadHistoryExports() { if job.Status == "completed" { job.filePath = filepath.Join(s.exportDir, job.ID+".csv") if _, err := os.Stat(job.filePath); err != nil { - job.Status = "failed" + job.Status = "expired" job.Progress = 0 - job.Error = "导出文件已不存在,请重新创建导出" + job.Error = "导出文件已不存在,请按原范围重建" job.DownloadURL = "" job.filePath = "" + job.ExpiredAt = now job.UpdatedAt = now } } @@ -3349,6 +4695,7 @@ func copyHistoryExportJob(job *HistoryExportJob) HistoryExportJob { return HistoryExportJob{} } copy := *job + copy.RetentionDays = normalizedHistoryRetentionDays(copy.RetentionDays) copy.Keywords = append([]string(nil), job.Keywords...) copy.Metrics = append([]string(nil), job.Metrics...) copy.VehicleVINs = append([]string(nil), job.VehicleVINs...) @@ -3356,6 +4703,13 @@ func copyHistoryExportJob(job *HistoryExportJob) HistoryExportJob { return copy } +func normalizedHistoryRetentionDays(days int) int { + if allowedHistoryExportRetentionDays[days] { + return days + } + return defaultHistoryExportRetentionDays +} + func selectedExportMetrics(columns []HistoryMetricDefinition, requested []string) []HistoryMetricDefinition { if len(requested) == 0 { return append([]HistoryMetricDefinition(nil), columns...) @@ -3403,6 +4757,7 @@ func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest rows := [][]string{ {"导出元数据", "查询开始", request.DateFrom, "查询结束", request.DateTo, "车辆", strings.Join(request.Keywords, "、"), "数据类型", request.Category, "协议", firstNonEmpty(request.Protocol, "全部")}, {"导出审计", "创建账号", firstNonEmpty(job.OwnerUsername, job.OwnerName), "显示名称", job.OwnerName, "角色", firstNonEmpty(job.OwnerUserType, job.OwnerRole), "客户", job.CustomerRef, "创建时间", job.CreatedAt, "生成时间", time.Now().UTC().Format(time.RFC3339)}, + {"文件策略", "完成后保留天数", strconv.Itoa(normalizedHistoryRetentionDays(job.RetentionDays))}, {"车辆 Scope", strings.Join(job.VehicleVINs, "、"), "授权窗口", job.DateFrom + " 至 " + job.DateTo}, {"指标与单位", strings.Join(exportMetricLabels(columns), ";")}, append([]string{"设备时间", "服务时间", "车牌", "VIN", "协议", "数据质量", "质量原因", "证据ID"}, exportMetricLabels(columns)...), @@ -4014,7 +5369,14 @@ func (s *Service) MileageSummary(ctx context.Context, query url.Values) (Mileage if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil { return MileageSummary{}, err } - return s.store.MileageSummary(ctx, resolvedQuery) + result, err := s.store.MileageSummary(ctx, resolvedQuery) + if err != nil { + return MileageSummary{}, err + } + if !hydrogenConsumptionAllowed(ctx) { + result.TotalPureHydrogenMileageKm = 0 + } + return result, nil } func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { @@ -4033,7 +5395,18 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail if err != nil { return Page[DailyMileageRow]{}, err } - return s.store.DailyMileage(ctx, resolvedQuery) + result, err := s.store.DailyMileage(ctx, resolvedQuery) + if err != nil { + return Page[DailyMileageRow]{}, err + } + if !hydrogenConsumptionAllowed(ctx) { + for index := range result.Items { + result.Items[index].PureHydrogenMileageKm = 0 + result.Items[index].HydrogenConsumptionKg = nil + result.Items[index].HydrogenConsumptionKgPer100Km = nil + } + } + return result, nil } func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { @@ -4052,7 +5425,30 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile if err != nil { return MileageStatistics{}, err } - return s.store.MileageStatistics(ctx, resolvedQuery) + result, err := s.store.MileageStatistics(ctx, resolvedQuery) + if err != nil { + return MileageStatistics{}, err + } + if !hydrogenConsumptionAllowed(ctx) { + result.PeriodPureHydrogenMileageKm = 0 + result.HydrogenMatchedMileageKm = 0 + result.HydrogenDataDays = 0 + result.PeriodHydrogenConsumptionKg = 0 + result.HydrogenConsumptionKgPer100Km = nil + for index := range result.Trend { + result.Trend[index].PureHydrogenMileageKm = 0 + result.Trend[index].HydrogenMatchedMileageKm = 0 + result.Trend[index].HydrogenDataDays = 0 + result.Trend[index].HydrogenConsumptionKg = nil + result.Trend[index].HydrogenConsumptionKgPer100Km = nil + } + } + return result, nil +} + +func hydrogenConsumptionAllowed(ctx context.Context) bool { + principal, ok := PrincipalFromContext(ctx) + return !ok || (principal.Role != "customer" && principal.UserType != "customer") } func normalizeMileageVINSelection(query url.Values) (url.Values, error) { @@ -4067,8 +5463,8 @@ func normalizeMileageVINSelection(query url.Values) (url.Values, error) { seen[vin] = true vins = append(vins, vin) } - if len(vins) > 20 { - return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 20 辆车"} + if len(vins) > 50_000 { + return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 50,000 辆车"} } if len(vins) == 0 { next.Del("vins") @@ -4977,6 +6373,19 @@ func sourceNames(statuses []VehicleSourceStatus) []string { return out } +func sourceNamesForProtocol(statuses []VehicleSourceStatus, protocol string) []string { + protocol = strings.TrimSpace(protocol) + out := make([]string, 0, len(statuses)) + for _, status := range statuses { + name := strings.TrimSpace(status.Protocol) + if name == "" || (protocol != "" && name != protocol) { + continue + } + out = append(out, name) + } + return out +} + func appendIfMissing(values []string, value string) []string { value = strings.TrimSpace(value) if value == "" { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 3988d777..5e72c771 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/tdengine_queries.go b/vehicle-data-platform/apps/api/internal/platform/tdengine_queries.go index aba78e4e..82bf944e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/tdengine_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/tdengine_queries.go @@ -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} } diff --git a/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog.go b/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog.go new file mode 100644 index 00000000..2603e6de --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog.go @@ -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 + } +} diff --git a/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog_test.go b/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog_test.go new file mode 100644 index 00000000..3c0e384c --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/vehicleprotocol/catalog_test.go @@ -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) + } + } +} diff --git a/vehicle-data-platform/apps/open-portal/index.html b/vehicle-data-platform/apps/open-portal/index.html new file mode 100644 index 00000000..1984c956 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/index.html @@ -0,0 +1,13 @@ + + + + + + + 羚牛车辆数据开放平台 + + +
+ + + diff --git a/vehicle-data-platform/apps/open-portal/package.json b/vehicle-data-platform/apps/open-portal/package.json new file mode 100644 index 00000000..62bb1b76 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/package.json @@ -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" + } +} diff --git a/vehicle-data-platform/apps/open-portal/pnpm-lock.yaml b/vehicle-data-platform/apps/open-portal/pnpm-lock.yaml new file mode 100644 index 00000000..10e9eb7a --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/pnpm-lock.yaml @@ -0,0 +1,2366 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + vite: + specifier: ^6.0.0 + version: 6.4.3 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ^18.3.18 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.31) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(jsdom@25.0.1) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + baseline-browser-mapping@2.10.43: {} + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001806: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.393: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + indent-string@4.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.19 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.19 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/vehicle-data-platform/apps/open-portal/pnpm-workspace.yaml b/vehicle-data-platform/apps/open-portal/pnpm-workspace.yaml new file mode 100644 index 00000000..00f6fc47 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx b/vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx new file mode 100644 index 00000000..e2fe6cff --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx @@ -0,0 +1,198 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { api, type AdminApp, type AuditItem, type PortalApp, type PortalUser } from "./api"; +import { dateInput, EmptyState, errorMessage, formatDate, formatTime, Icon, PageHeader, rfcDate, Status } from "./ui"; +import { VehicleAuthorization } from "./VehicleAuthorization"; + +export type AdminSection = "admin-overview" | "apps" | "partners" | "grants" | "security"; + +type Props = { + section: AdminSection; + onNavigate: (section: AdminSection) => void; + onKey: (value: string) => void; +}; + +const today = () => new Date().toISOString().slice(0, 10); +const nextYear = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + return date.toISOString().slice(0, 10); +}; + +export function AdminWorkspace({ section, onNavigate, onKey }: Props) { + const [apps, setApps] = useState([]); + const [users, setUsers] = useState([]); + const [vehicleCount, setVehicleCount] = useState(0); + const [notice, setNotice] = useState(""); + const [loading, setLoading] = useState(true); + + async function refresh() { + setLoading(true); + try { + const [nextApps, nextUsers] = await Promise.all([api.adminApps(), api.adminUsers()]); + setApps(nextApps); setUsers(nextUsers); + const grants = await Promise.all(nextApps.map((item) => api.vehicles(item.id).catch(() => []))); + setVehicleCount(new Set(grants.flat().map((item) => item.vin)).size); + } catch (reason) { + setNotice(errorMessage(reason)); + } finally { + setLoading(false); + } + } + + useEffect(() => { void refresh(); }, []); + + const shared = { apps, users, refresh, setNotice, onKey }; + return
+ {notice &&
{notice}
} + {section === "admin-overview" && } + {section === "apps" && } + {section === "partners" && } + {section === "grants" && } + {section === "security" && } +
; +} + +function AdminOverview({ apps, users, vehicleCount, loading, onNavigate }: { + apps: AdminApp[]; users: PortalUser[]; vehicleCount: number; loading: boolean; onNavigate: (section: AdminSection) => void; +}) { + const enabledApps = apps.filter((item) => item.status === "enabled").length; + const enabledUsers = users.filter((item) => item.status === "enabled").length; + const now = Date.now(); + const expiring = users.filter((item) => item.validTo && new Date(item.validTo).getTime() - now < 30 * 86400000 && new Date(item.validTo).getTime() > now); + const expired = users.filter((item) => item.validTo && new Date(item.validTo).getTime() <= now); + const steps = [ + { label: "创建开放应用", detail: "生成应用凭证并设置有效期", done: apps.length > 0, target: "apps" as const }, + { label: "添加合作伙伴", detail: "创建独立的开发者登录账号", done: users.length > 0, target: "partners" as const }, + { label: "分配应用角色", detail: "为合作伙伴分配 Owner、Developer 或 Viewer", done: users.length > 0 && apps.length > 0, target: "partners" as const }, + { label: "授权车辆", detail: "配置应用可访问的车辆与期限", done: vehicleCount > 0, target: "grants" as const } + ]; + return <> + onNavigate("partners")}>添加合作伙伴} /> +

完成以下步骤,开启开放服务

每一步都对应一项明确的访问控制。

{steps.filter((item) => item.done).length} / 4 已完成
{steps.map((item, index) => )}
+
+
{loading ? "—" : enabledApps}启用的开放应用
+
{loading ? "—" : enabledUsers}启用的合作伙伴
+
{loading ? "—" : vehicleCount}已授权车辆
+
全量操作与调用审计
+
+
+

待处理事项

优先处理即将影响合作伙伴访问的问题。

+
+ {expiring.map((user) => )} + {expired.map((user) => )} + {!vehicleCount && apps.map((app) => )} + {!expiring.length && !expired.length && (vehicleCount > 0 || !apps.length) && } +
事项相关对象时间状态操作
账号即将到期{user.displayName}{user.username}{formatDate(user.validTo)}即将到期
账号授权已到期{user.displayName}{user.username}{formatDate(user.validTo)}
应用尚未授权车辆{app.name}{app.appKeyPrefix}••••••••待处理
当前没有需要处理的授权风险
+
+ ; +} + +type Shared = { + apps: AdminApp[]; users: PortalUser[]; refresh: () => Promise; + setNotice: (value: string) => void; onKey: (value: string) => void; +}; + +function AppsManager({ apps, refresh, setNotice, onKey }: Shared) { + const [editing, setEditing] = useState(null); + return <> + setEditing("new")}>创建开放应用} /> + {!apps.length ? setEditing("new")}>创建开放应用} /> : +
共 {apps.length} 个应用
{apps.map((app) => )}
应用名称AppKey 标识有效期状态操作
{app.name}应用 ID {app.id}{app.appKeyPrefix}••••••••{formatDate(app.validFrom)} — {formatDate(app.validTo)}
} + {editing && setEditing(null)} onSaved={async (message, key) => { setEditing(null); if (key) onKey(key); setNotice(message); await refresh(); }} />} + ; +} + +function AppDrawer({ app, onClose, onSaved }: { app: AdminApp | "new"; onClose: () => void; onSaved: (message: string, key?: string) => Promise }) { + const existing = app === "new" ? null : app; + const [name, setName] = useState(existing?.name || ""); + const [status, setStatus] = useState(existing?.status || "enabled"); + const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today()); + const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear()); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); setError(""); + if (!name.trim()) return setError("请输入应用名称。"); + if (!validFrom || !validTo || validTo <= validFrom) return setError("截止日期必须晚于生效日期。"); + setSaving(true); + try { + if (existing) { + await api.updateApp(existing.id, { name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) }); + await onSaved("应用设置已保存。"); + } else { + const created = await api.createApp({ name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) }); + await onSaved("应用已创建。完整 AppKey 仅展示一次,请立即保存。", created.appKey); + } + } catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); } + } + return
{ if (event.currentTarget === event.target) onClose(); }}>
; +} + +function PartnersManager({ apps, users, refresh, setNotice }: Shared) { + const [query, setQuery] = useState(""); + const [editing, setEditing] = useState(null); + const filtered = users.filter((user) => `${user.displayName} ${user.username}`.toLowerCase().includes(query.toLowerCase())); + return <> + setEditing("new")} disabled={!apps.length}>添加合作伙伴} /> + {!apps.length &&
请先创建开放应用添加合作伙伴前,至少需要一个可以分配的开放应用。
} +
setQuery(event.target.value)} aria-label="搜索合作伙伴" placeholder="搜索合作伙伴名称或登录账号" />
共 {filtered.length} 个合作伙伴
{filtered.map((user) => )}{!filtered.length && }
合作伙伴登录账号账号有效期最近登录状态操作
{user.displayName}合作伙伴 ID {user.id}{user.username}{formatDate(user.validFrom)} — {formatDate(user.validTo)}{formatTime(user.lastLoginAt)}
没有匹配的合作伙伴
+ {editing && setEditing(null)} onSaved={async (message) => { setEditing(null); setNotice(message); await refresh(); }} />} + ; +} + +type Membership = { appId: number; role: string; enabled: boolean }; + +function PartnerDrawer({ user, apps, onClose, onSaved }: { user: PortalUser | "new"; apps: AdminApp[]; onClose: () => void; onSaved: (message: string) => Promise }) { + const existing = user === "new" ? null : user; + const [displayName, setDisplayName] = useState(existing?.displayName || ""); + const [username, setUsername] = useState(existing?.username || ""); + const [password, setPassword] = useState(""); + const [status, setStatus] = useState(existing?.status || "enabled"); + const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today()); + const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear()); + const [memberships, setMemberships] = useState(apps.map((app) => ({ appId: app.id, role: "developer", enabled: false }))); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!existing) return; + api.userApps(existing.id).then((assigned) => setMemberships(apps.map((app) => { + const membership = assigned.find((item) => item.appId === app.id); + return { appId: app.id, role: membership?.role || "developer", enabled: Boolean(membership) }; + }))).catch((reason) => setError(errorMessage(reason))); + }, [existing?.id, apps]); + + function validate() { + if (!/^[A-Za-z0-9._-]{3,64}$/.test(username.trim())) return "登录账号需为 3–64 位,只能包含字母、数字、点、下划线或连字符。"; + if (!displayName.trim() || [...displayName.trim()].length > 96) return "显示名称不能为空且不能超过 96 个字符。"; + if (!existing && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "初始密码必须为 12–128 位,并包含大写字母、小写字母和数字。"; + if (password && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "新密码必须为 12–128 位,并包含大写字母、小写字母和数字。"; + if (!validFrom || !validTo || validTo <= validFrom) return "截止日期必须晚于生效日期。"; + return ""; + } + async function submit(event: FormEvent) { + event.preventDefault(); setError(""); + const validation = validate(); if (validation) return setError(validation); + setSaving(true); + try { + const input = { username: username.trim(), displayName: displayName.trim(), password, status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) }; + const saved = existing ? await api.updateUser(existing.id, input) : await api.createUser(input); + await api.replaceUserApps(saved.id, memberships.filter((item) => item.enabled).map(({ appId, role }) => ({ appId, role }))); + await onSaved(existing ? "合作伙伴账号和应用权限已保存。" : "合作伙伴账号已创建并完成应用授权。"); + } catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); } + } + return
{ if (event.currentTarget === event.target) onClose(); }}>
; +} + +function SecurityAudit({ apps }: { apps: AdminApp[] }) { + const [appId, setAppId] = useState(0); + const [audit, setAudit] = useState([]); + const [error, setError] = useState(""); + useEffect(() => { if (!appId && apps[0]) setAppId(apps[0].id); }, [apps, appId]); + useEffect(() => { if (appId) api.audit(appId).then(setAudit).catch((reason) => setError(errorMessage(reason))); }, [appId]); + return <> + setAppId(Number(event.target.value))}>{apps.map((app) => )} : undefined} /> + {error &&
{error}
} +

最近调用记录

最多展示最近 100 条记录。

{audit.length} 条
{audit.map((item) => )}{!audit.length && }
时间接口车辆数结果Trace ID
{formatTime(item.createdAt)}{item.endpoint}{item.vehicleCount}{item.traceId}
暂无调用记录
+ ; +} diff --git a/vehicle-data-platform/apps/open-portal/src/App.test.tsx b/vehicle-data-platform/apps/open-portal/src/App.test.tsx new file mode 100644 index 00000000..75d5e9f8 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/App.test.tsx @@ -0,0 +1,170 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "./App"; + +const app = { + id: 7, + name: "测试开放应用", + appKeyPrefix: "12345678", + status: "enabled", + validFrom: "2026-07-20T00:00:00+08:00", + validTo: "2027-07-20T00:00:00+08:00" +}; + +describe("open platform portal", () => { + beforeEach(() => { + window.location.hash = "/"; + sessionStorage.clear(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) })); + }); + + it("renders the factual public homepage and interactive API example", () => { + render(); + expect(screen.getAllByAltText("羚牛智能").length).toBeGreaterThan(0); + expect(screen.getByRole("heading", { name: "车辆数据,按需开放。" })).toBeInTheDocument(); + expect(screen.getByText("车辆日用氢量")).toBeInTheDocument(); + expect(screen.getByText("车辆日里程")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "进入控制台" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "阅读文档" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "日里程" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByLabelText("日里程响应示例")).toHaveTextContent("totalMileageKm"); + fireEvent.click(screen.getByRole("tab", { name: "日用氢量" })); + expect(screen.getByRole("tab", { name: "日用氢量" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByLabelText("日用氢量响应示例")).toHaveTextContent("hydrogenConsumptionKg"); + }); + + it("provides complete onboarding and API reference documentation", async () => { + window.location.hash = "/docs"; + render(); + expect(screen.getByRole("heading", { name: "五分钟完成首次调用" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "获取 AppKey" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "确认授权车辆" })).toBeInTheDocument(); + expect(screen.getAllByText("/api/v1/vehicles/mileage/query").length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: "车辆区间日里程" })); + expect(screen.getByRole("heading", { name: "车辆区间日里程" })).toBeInTheDocument(); + expect(screen.getByText("/api/v1/vehicles/mileage/range/query")).toBeInTheDocument(); + expect(screen.getAllByText("snapshotId", { exact: false }).length).toBeGreaterThan(0); + fireEvent.click(screen.getByRole("button", { name: "错误码与重试" })); + expect(screen.getByRole("heading", { name: "错误码与重试" })).toBeInTheDocument(); + expect(screen.getByText("INVALID_DATE_FORMAT")).toBeInTheDocument(); + expect(screen.getByText("指数退避", { exact: false })).toBeInTheDocument(); + }); + + it("gives administrators a dedicated partner management flow", async () => { + window.location.hash = "/console"; + sessionStorage.setItem("lingniu-open-platform-token", "a".repeat(64)); + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + let data: unknown = []; + if (url.includes("/portal-api/session")) { + data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" }; + } else if (url.endsWith("/portal-api/admin/apps")) { + data = [app]; + } + return { ok: true, json: async () => ({ data }) }; + })); + render(); + expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument(); + fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "合作伙伴" })); + expect(await screen.findByRole("heading", { name: "合作伙伴" })).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("button", { name: "添加合作伙伴" })[0]); + expect(await screen.findByRole("heading", { name: "添加合作伙伴" })).toBeInTheDocument(); + expect(screen.getByText("应用角色")).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText("显示名称"), { target: { value: "合作伙伴甲" } }); + fireEvent.change(screen.getByLabelText("登录账号"), { target: { value: "partner-a" } }); + fireEvent.change(screen.getByPlaceholderText("至少 12 位,含大小写字母和数字"), { target: { value: "weak-password" } }); + fireEvent.click(screen.getByRole("button", { name: "创建并授权" })); + expect(screen.getByRole("alert")).toHaveTextContent("初始密码必须为 12–128 位"); + }); + + it("gives partner developers a focused application workspace", async () => { + window.location.hash = "/console"; + sessionStorage.setItem("lingniu-open-platform-token", "b".repeat(64)); + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + let data: unknown = []; + if (url.includes("/portal-api/session")) { + data = { + userId: 2, + username: "partner-a", + displayName: "合作伙伴甲", + userType: "partner", + expiresAt: "2026-07-21T00:00:00+08:00" + }; + } else if (url.endsWith("/portal-api/apps")) { + data = [{ + appId: 7, + appName: "测试开放应用", + appKeyPrefix: "12345678", + appStatus: "enabled", + role: "owner", + validFrom: "2026-07-20T00:00:00+08:00", + validTo: "2027-07-20T00:00:00+08:00" + }]; + } else if (url.includes("/vehicles")) { + data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00" }]; + } else if (url.includes("/audit")) { + data = [{ traceId: "trace-1", endpoint: "mileage", result: "success", vehicleCount: 1, createdAt: "2026-07-20T12:00:00+08:00" }]; + } + return { ok: true, json: async () => ({ data }) }; + })); + + render(); + const navigation = await screen.findByRole("navigation", { name: "开发者控制台导航" }); + expect(await screen.findByText("12345678••••••••")).toBeInTheDocument(); + expect(within(navigation).getByRole("button", { name: "应用与密钥" })).toBeInTheDocument(); + expect(within(navigation).getByRole("button", { name: "授权车辆" })).toBeInTheDocument(); + fireEvent.click(within(navigation).getByRole("button", { name: "授权车辆" })); + expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0); + expect(screen.queryByRole("button", { name: "合作伙伴" })).not.toBeInTheDocument(); + fireEvent.click(within(navigation).getByRole("button", { name: "API 调试" })); + fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "mileage/range" } }); + expect(screen.getAllByText("/api/v1/vehicles/mileage/range/query", { exact: false }).length).toBeGreaterThan(0); + expect(screen.getByLabelText("开始日期")).toBeInTheDocument(); + fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "total-mileage" } }); + expect(screen.getAllByText("/api/v1/vehicles/total-mileage/query", { exact: false }).length).toBeGreaterThan(0); + expect(screen.getByLabelText("采集协议")).toHaveValue(""); + fireEvent.change(screen.getByLabelText("采集协议"), { target: { value: "YUTONG_MQTT" } }); + expect(screen.getByText(/"protocol": "YUTONG_MQTT"/)).toBeInTheDocument(); + }); + + it("lets administrators select, paste, and add all catalog vehicles", async () => { + window.location.hash = "/console"; + sessionStorage.setItem("lingniu-open-platform-token", "c".repeat(64)); + vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + let data: unknown = []; + if (url.includes("/portal-api/session")) { + data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" }; + } else if (url.endsWith("/portal-api/admin/apps")) { + data = [app]; + } else if (url.endsWith("/portal-api/admin/vehicles")) { + data = [ + { vin: "LNB00000000000001", plate: "辽A00001", oem: "羚牛", status: "available", source: "车辆主数据" }, + { vin: "LNB00000000000002", plate: "辽A00002", oem: "羚牛", status: "available", source: "车辆主数据" } + ]; + } else if (url.includes("/portal-api/apps/7/vehicles")) { + data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00", validTo: "2027-07-20T00:00:00+08:00" }]; + } + return { ok: true, json: async () => ({ data }) }; + })); + + render(); + expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument(); + fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "车辆授权" })); + expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0); + expect(screen.getByText("辽A00002")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "批量粘贴" })); + fireEvent.change(screen.getByLabelText("批量输入 VIN 或车牌"), { target: { value: "辽A00002\nUNKNOWN" } }); + expect(await screen.findByText("识别 1 辆 · 未匹配 1 条")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "应用识别结果" })); + expect(screen.getByRole("button", { name: "保存 1 辆授权" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "全部车辆" })); + expect(screen.getByRole("heading", { name: "已选择当前全部车辆" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "保存 2 辆授权" })).toBeInTheDocument(); + expect(screen.getByText("未来新增车辆不会自动获得权限", { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/vehicle-data-platform/apps/open-portal/src/App.tsx b/vehicle-data-platform/apps/open-portal/src/App.tsx new file mode 100644 index 00000000..ec313c67 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/App.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { api, type Session, tokenStore } from "./api"; +import { LoginPage } from "./Auth"; +import { Console } from "./Console"; +import { PublicSite } from "./PublicSite"; +import { Brand, currentRoute, type Route } from "./ui"; + +export function App() { + const [route, setRoute] = useState(currentRoute); + const [session, setSession] = useState(null); + const [checking, setChecking] = useState(Boolean(tokenStore.get())); + + useEffect(() => { + const onHash = () => setRoute(currentRoute()); + window.addEventListener("hashchange", onHash); + if (tokenStore.get()) { + api.session() + .then(setSession) + .catch(() => tokenStore.clear()) + .finally(() => setChecking(false)); + } + return () => window.removeEventListener("hashchange", onHash); + }, []); + + useEffect(() => { void api.catalog().catch(() => undefined); }, []); + + if (route === "console") { + if (checking) return
; + if (!session) return ; + return setSession(null)} />; + } + + return ; +} diff --git a/vehicle-data-platform/apps/open-portal/src/Auth.tsx b/vehicle-data-platform/apps/open-portal/src/Auth.tsx new file mode 100644 index 00000000..fb9d3d6f --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/Auth.tsx @@ -0,0 +1,39 @@ +import { FormEvent, useState } from "react"; +import { api, type Session, tokenStore } from "./api"; +import { Brand, Icon, navigate } from "./ui"; + +export function LoginPage({ onLogin }: { onLogin: (session: Session) => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); setLoading(true); + try { + const response = await api.login(username.trim(), password); + tokenStore.set(response.accessToken); + onLogin(response.session); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "登录失败"); + } finally { + setLoading(false); + } + } + + return
+ +
+ +

登录开放平台

使用平台管理员账号,或由管理员创建的合作伙伴账号。

+
+ + + {error &&
{error}
} + +
+

两类账号,同一入口管理员使用内部车辆平台身份;合作伙伴使用独立开放平台账号。

+
+
; +} diff --git a/vehicle-data-platform/apps/open-portal/src/Console.tsx b/vehicle-data-platform/apps/open-portal/src/Console.tsx new file mode 100644 index 00000000..b209ba3d --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/Console.tsx @@ -0,0 +1,177 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { AdminWorkspace, type AdminSection } from "./AdminWorkspace"; +import { api, type AuditItem, type PortalApp, type Session, tokenStore, type VehicleGrant } from "./api"; +import { Brand, EmptyState, errorMessage, formatDate, formatTime, Icon, navigate, PageHeader, Status } from "./ui"; + +type PartnerSection = "overview" | "credentials" | "vehicles" | "sandbox" | "audit" | "account"; +type Section = AdminSection | PartnerSection; + +const adminNavigation: Array<[AdminSection | "account", string, "home" | "apps" | "users" | "car" | "shield" | "settings"]> = [ + ["admin-overview", "总览", "home"], + ["apps", "开放应用", "apps"], + ["partners", "合作伙伴", "users"], + ["grants", "车辆授权", "car"], + ["security", "调用与安全", "shield"], + ["account", "账号设置", "settings"] +]; + +const partnerNavigation: Array<[PartnerSection, string, "home" | "key" | "car" | "terminal" | "activity" | "settings"]> = [ + ["overview", "总览", "home"], + ["credentials", "应用与密钥", "key"], + ["vehicles", "授权车辆", "car"], + ["sandbox", "API 调试", "terminal"], + ["audit", "调用记录", "activity"], + ["account", "账号设置", "settings"] +]; + +export function Console({ session, onLogout }: { session: Session; onLogout: () => void }) { + const isAdmin = session.userType === "admin"; + const [section, setSection] = useState
(isAdmin ? "admin-overview" : "overview"); + const [apps, setApps] = useState([]); + const [selectedId, setSelectedId] = useState(0); + const [vehicles, setVehicles] = useState([]); + const [audit, setAudit] = useState([]); + const [error, setError] = useState(""); + const [newKey, setNewKey] = useState(""); + const [menuOpen, setMenuOpen] = useState(false); + const selected = useMemo(() => apps.find((item) => item.appId === selectedId), [apps, selectedId]); + const navigation = isAdmin ? adminNavigation : partnerNavigation; + + async function loadApps() { + try { + const items = await api.apps(); + setApps(items); + if (!items.some((item) => item.appId === selectedId) && items[0]) setSelectedId(items[0].appId); + } catch (reason) { setError(errorMessage(reason)); } + } + useEffect(() => { void loadApps(); }, []); + useEffect(() => { + if (!selectedId) { setVehicles([]); setAudit([]); return; } + Promise.all([api.vehicles(selectedId), api.audit(selectedId)]) + .then(([nextVehicles, nextAudit]) => { setVehicles(nextVehicles); setAudit(nextAudit); }) + .catch((reason) => setError(errorMessage(reason))); + }, [selectedId]); + + async function logout() { + try { await api.logout(); } catch { /* local logout still applies */ } + tokenStore.clear(); onLogout(); + } + async function rotate() { + if (!selected || !window.confirm(`确认轮换“${selected.appName}”的 AppKey?旧密钥将立即失效。`)) return; + try { const response = await api.rotateKey(selected.appId); setNewKey(response.appKey); await loadApps(); } + catch (reason) { setError(errorMessage(reason)); } + } + function choose(next: Section) { setSection(next); setMenuOpen(false); } + + return
+ + {menuOpen && {session.displayName.slice(0, 1)}
+ {error &&
{error}
} + {isAdmin && section !== "account" && } + {!isAdmin && } + {section === "account" && } + + {newKey && setNewKey("")} />} + ; +} + +function PartnerWorkspace({ section, app, vehicles, audit, onRotate }: { section: PartnerSection; app?: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onRotate: () => void }) { + if (!app && section !== "account") return <>; + if (!app) return null; + if (section === "overview") return window.location.hash = "/console"} />; + if (section === "credentials") return ; + if (section === "vehicles") return ; + if (section === "sandbox") return ; + if (section === "audit") return ; + return null; +} + +function DeveloperOverview({ app, vehicles, audit, onOpenSandbox }: { app: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onOpenSandbox: () => void }) { + const success = audit.filter((item) => item.result === "success").length; + const successRate = audit.length ? `${Math.round(success / audit.length * 1000) / 10}%` : "—"; + return <> + +
AppKey{app.appKeyPrefix}••••••••仅显示安全标识
授权车辆{vehicles.length}当前应用可访问
授权有效期{formatDate(app.validFrom)} {formatDate(app.validTo)}
+

开始接入

沿着这条路径完成首次生产调用。

{[ + ["保存 AppKey", true], ["查看车辆范围", vehicles.length > 0], ["调用测试接口", audit.length > 0], ["接入生产", audit.length >= 10] + ].map(([label, done], index) =>
{done ? : index + 1}{label}{done ? "已完成" : index === 2 ? "进行中" : "待完成"}
)}
打开 API 调试
+

近期调用

最近 {audit.length} 条审计记录

请求数{audit.length}成功率{successRate}访问车辆{new Set(audit.map((item) => item.vehicleCount)).size || "—"}
{[32, 46, 38, 62, 58, 76, 68, 86, 72, 54, 64, 42].map((height, index) => )}

常用接口

当前已开放的数据产品

POST车辆日用氢量/api/v1/vehicles/hydrogen-consumption/queryPOST车辆日里程/api/v1/vehicles/mileage/queryPOST车辆区间日里程/api/v1/vehicles/mileage/range/queryPOST指定时刻总里程/api/v1/vehicles/total-mileage/query
+ ; +} + +function Credentials({ app, onRotate }: { app: PortalApp; onRotate: () => void }) { + return <> + +

{app.appName}

应用 ID {app.appId}

AppKey 安全标识
{app.appKeyPrefix}••••••••••••••••••••••••
当前角色
{app.role}
应用有效期
{formatDate(app.validFrom)} — {formatDate(app.validTo)}
完整 AppKey 不会再次展示

如怀疑密钥泄露,请立即轮换。旧密钥会即时失效,操作会写入安全审计。

{app.role === "owner" ? : 仅 Owner 可轮换}
+ ; +} + +function PartnerVehicles({ vehicles }: { vehicles: VehicleGrant[] }) { + const [query, setQuery] = useState(""); + const filtered = vehicles.filter((item) => `${item.plate} ${item.vin}`.toLowerCase().includes(query.toLowerCase())); + return <>{vehicles.length} 辆} />
setQuery(event.target.value)} placeholder="搜索车牌或 VIN" />
{filtered.map((item) => )}{!filtered.length && }
车牌号VIN授权起始授权截止状态
{item.plate || "—"}{item.vin}{formatDate(item.validFrom)}{formatDate(item.validTo)}
没有匹配的授权车辆
; +} + +function ApiSandbox({ app, vehicles }: { app: PortalApp; vehicles: VehicleGrant[] }) { + const [product, setProduct] = useState("hydrogen-consumption"); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [startDate, setStartDate] = useState(new Date(Date.now() - 6 * 86400000).toISOString().slice(0, 10)); + const [queryTime, setQueryTime] = useState(new Date().toISOString().slice(0, 16)); + const [protocol, setProtocol] = useState(""); + const [protocolPriority, setProtocolPriority] = useState("GB32960,MQTT,JT808"); + const plate = vehicles.find((item) => item.plate)?.plate || "授权车辆车牌"; + const vin = vehicles[0]?.vin || "授权车辆VIN"; + const isTotalMileage = product === "total-mileage"; + const isMileageRange = product === "mileage/range"; + const supportsProtocolPriority = product === "mileage" || isMileageRange; + const priorityJSON = protocolPriority.split(",").map((item) => `"${item}"`).join(", "); + const endpoint = `/api/v1/vehicles/${product}/query`; + const requestBody = isTotalMileage ? `{ + "vin": "${vin}", + "time": "${queryTime.replace("T", " ")}:00"${protocol ? `, + "protocol": "${protocol}"` : ""} + }` : isMileageRange ? `{ + "startDate": "${startDate}", + "endDate": "${date}", + "protocolPriority": [${priorityJSON}], + "pageSize": 5000 + }` : `{ + "plateNumbers": ["${plate}"], + "date": "${date}"${supportsProtocolPriority ? `, + "protocolPriority": [${priorityJSON}]` : ""} + }`; + const curl = `curl --request POST \\ + --url https://open.d.lnoneos.com${endpoint} \\ + --header 'Authorization: Bearer YOUR_APP_KEY' \\ + --header 'Content-Type: application/json' \\ + --data '${requestBody}'`; + return <>打开 Swagger ↗} />

请求配置

AppKey 请在 Swagger 的 Authorize 中安全输入。

{isTotalMileage ? <> : isMileageRange ? <> : <>}{supportsProtocolPriority && }
当前应用共有 {vehicles.length} 辆授权车辆

请求预览

POST {endpoint}

{curl}
; +} + +function PartnerAudit({ audit }: { audit: AuditItem[] }) { + return <>最近 {audit.length} 条} />
{audit.map((item) => )}{!audit.length && }
时间接口车辆数结果Trace ID
{formatTime(item.createdAt)}{item.endpoint}{item.vehicleCount}{item.traceId}
暂无调用记录。完成首次 API 调用后会显示在这里。
; +} + +function Account({ session, onChanged }: { session: Session; onChanged: () => void }) { + const [currentPassword, setCurrent] = useState(""); + const [newPassword, setNext] = useState(""); + const [message, setMessage] = useState(""); + async function submit(event: FormEvent) { + event.preventDefault(); setMessage(""); + try { await api.changePassword(currentPassword, newPassword); tokenStore.clear(); setMessage("密码已更新,请重新登录。"); window.setTimeout(onChanged, 900); } + catch (reason) { setMessage(errorMessage(reason)); } + } + return <>
{session.displayName.slice(0, 1).toUpperCase()}

{session.displayName}

{session.username}

身份类型
{session.userType === "admin" ? "平台管理员" : "合作伙伴开发者"}
会话有效至
{formatTime(session.expiresAt)}
账号 ID
{session.userId}
{session.userType === "admin" ?

统一管理员身份

此账号与内部车辆平台共用身份和密码。请在内部平台维护密码,两个系统保持一致。

前往内部车辆平台 ↗
:

修改登录密码

密码需 12–128 位,并同时包含大小写字母和数字。

{message &&
{message}
}
}
; +} + +function KeyModal({ value, onClose }: { value: string; onClose: () => void }) { + const [copied, setCopied] = useState(false); + return

AppKey 已生成

出于安全考虑,此密钥只展示一次。请立即复制并存放到安全的密钥管理系统。

{value}
; +} diff --git a/vehicle-data-platform/apps/open-portal/src/Documentation.tsx b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx new file mode 100644 index 00000000..d47ca8a1 --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/Documentation.tsx @@ -0,0 +1,313 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { products } from "./catalog"; +import { Icon, navigate } from "./ui"; + +type Topic = + | "overview" | "quickstart" | "auth" | "errors" | "limits" + | "hydrogen" | "mileage" | "mileage-range" | "total-mileage" + | "vehicle-auth" | "key-rotation" | "quality" | "checklist"; + +const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [ + { title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] }, + { title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] }, + { title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] } +]; + +const topicTitles = new Map(groups.flatMap((group) => group.items)); + +export function DocumentationPage() { + const [topic, setTopic] = useState("quickstart"); + const title = topicTitles.get(topic) || ""; + return
+ +
+
文档 / {groupFor(topic)} / {title}
+ + +
+ +
; +} + +function TopicContent({ topic }: { topic: Topic }) { + if (topic === "quickstart") return ; + if (topic === "hydrogen" || topic === "mileage") return ; + if (topic === "mileage-range") return ; + if (topic === "total-mileage") return ; + if (topic === "overview") return ; + if (topic === "auth") return ; + if (topic === "errors") return ; + if (topic === "limits") return ; + if (topic === "vehicle-auth") return ; + if (topic === "key-rotation") return ; + if (topic === "quality") return ; + return ; +} + +function Quickstart() { + const curl = `curl --request POST \\ + --url https://open.d.lnoneos.com/api/v1/vehicles/mileage/query \\ + --header 'Authorization: Bearer YOUR_APP_KEY' \\ + --header 'Content-Type: application/json' \\ + --data '{ + "plateNumbers": ["辽A00001"], + "date": "2026-07-19", + "protocolPriority": ["GB32960", "MQTT", "JT808"] + }'`; + return <> +

五分钟完成首次调用

+

取得 AppKey、确认车辆授权,然后发送第一条真实查询。数据访问同时受应用、车辆和日期有效期约束。

+
+

平台管理员创建开放应用后,AppKey 仅展示一次。请立即保存到密钥管理系统,不要写入代码仓库。

+

在控制台的“车辆授权”中,通过 VIN 或车牌选择车辆,并确认应用与车辆授权日期覆盖查询自然日。

+ +

HTTP 200 表示请求被接受。每辆车仍可能返回 NORMALNO_DATA,请按状态处理。

+
+ + ; +} + +function Overview() { + return <> +

车辆数据开放平台

羚牛开放平台以应用为边界,对外提供经过授权的车辆日用氢量、单日与区间日里程、指定时刻总里程。

+

核心模型

+
{[ + ["开放应用", "持有独立的 32 位 AppKey、状态和有效期。"], + ["合作伙伴", "使用独立账号登录开发者控制台,并获得 Owner、Developer 或 Viewer 角色。"], + ["车辆授权", "决定应用可以查询哪些 VIN,以及每辆车的授权起止日期。"], + ["调用审计", "保存接口、结果、车辆数量和 Trace ID,便于双方排查。"] + ].map(([term, body]) =>
{term}

{body}

)}
+

开放能力

+

安全边界

外部 AppKey 与门户登录会话完全独立。AppKey 明文只在创建或轮换时返回一次,服务端仅保存不可逆摘要和展示前缀。

+ ; +} + +function AuthGuide() { + return <> +

鉴权方式

所有数据接口都使用 HTTP Bearer 鉴权。不要把 AppKey 放进查询参数、浏览器前端代码或日志。

+

请求头

+

有效性判断

  1. 应用必须处于启用状态。
  2. 当前时间必须落在应用有效期内。
  3. 查询自然日必须被应用有效期完整覆盖。
  4. 每辆车辆授权必须完整覆盖查询自然日。
+

安全建议

使用专用密钥管理系统保存 AppKey;按环境拆分应用;怀疑泄漏时立即轮换;轮换前准备双配置并缩短切换窗口。

+ ; +} + +function ErrorGuide() { + return <> +

错误码与重试

业务错误会返回稳定的 code,并在响应中附带 Trace ID。提交工单时请同时提供 Trace ID 和请求时间。

+

错误码

{[ + ["400", "INVALID_REQUEST", "请求结构、车牌数量或字段不正确", "修正请求后再试"], + ["400", "INVALID_DATE_FORMAT", "date 不是 YYYY-MM-DD", "否"], + ["400", "INVALID_DATETIME_FORMAT", "time 不是 YYYY-MM-DD HH:mm:ss", "否"], + ["401", "UNAUTHORIZED", "AppKey 不存在、停用或过期", "否"], + ["403", "FORBIDDEN", "应用或车辆授权未覆盖查询日期", "否"], + ["500", "INTERNAL_ERROR", "服务内部异常", "可退避重试"] + ].map((row) => {row.map((cell) => )})}
HTTPcode含义是否重试
{cell}
+

重试策略

仅对网络错误和 5xx 使用指数退避,建议间隔 1 秒、2 秒、4 秒,最多重试 3 次。400、401、403 需要先修正请求或授权,不应自动重试。

+ ; +} + +function LimitsGuide() { + return <> +

请求边界

在批量和调度逻辑中遵循以下硬限制,可减少拒绝请求和无效重试。

+

参数限制

项目限制
单日接口车牌数量可省略;指定时最多 200 个,不能包含空值或重复
区间接口最长 366 天;pageSize 1–5000;指定时最多 5000 个车牌
查询日期YYYY-MM-DD,Asia/Shanghai
请求体JSON,Content-Type 为 application/json
车辆授权配置每个开放应用最多 5,000 辆
+

调度建议

大范围历史同步优先使用区间接口并顺序消费 nextCursor;为每页记录 Trace ID 和 snapshotId;不要并发复用同一个游标。

+ ; +} + +function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) { + const product = products[kind === "hydrogen" ? 0 : 1]; + const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`; + return <> +

{product.name}

{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。

+ +

请求参数

+

请求示例

省略 plateNumbers 时查询该应用在当日有效授权的全部车辆:

如只查询部分车辆,可传入最多 200 个车牌:

+ {kind === "mileage" && <>

协议优先级

protocolPriority 只允许 GB32960MQTTJT808。数组必须非空且不能重复;逐车按顺序选择第一个有效协议,未列出的协议完全禁用。不传时保持平台默认行为。

缺日补齐

查询日没有有效里程但此前存在有效累计里程时,dailyMileageKm 返回 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 保持为上一个统计周期的计算时间。此前也无有效累计里程时才返回 NO_DATA。

} +

响应字段

{kind === "mileage" && }{kind === "mileage" && <>}
字段类型说明
vinstring车辆 VIN
plateNumberstring请求中的车牌号
datestring统计自然日
{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}number | null{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}
totalMileageKmnumber | null同一协议在当日最后有效时刻的累计总里程
dataTimedate-time | null统计实际采用的最后一条车辆源数据时间
updatedAtdate-time | null日统计投影最后更新时间
sourceProtocolstring | null实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null
statusstringNORMAL 或 NO_DATA
+

响应示例

+ ; +} + +function MileageRangeReference() { + return <> +

车辆区间日里程

按最长 366 天区间分页返回逐车逐日里程。授权车辆清单在首次请求时固化,翻页期间不会因授权变化产生重复或漏行。

+ +

请求参数

字段必填说明
startDate开始自然日,YYYY-MM-DD
endDate结束自然日,包含当日;最长 366 天
plateNumbers省略时查询整个区间均有效授权的全部车辆;指定时最多 5000 个
protocolPriority非空且不重复的 GB32960、MQTT、JT808 数组;未列出的协议禁用
cursor首次省略或传 null;翻页时传上一页 nextCursor
pageSize1–5000,默认 5000
+

首次请求

+

游标翻页

保持开始日期、结束日期、车牌列表、protocolPriority 和 pageSize 不变,只把上一页 nextCursor 放入下一次请求。同一次查询的 snapshotId 保持不变,最后一页 nextCursor 为 null。

+

响应示例

+

性能口径

接口只固化最多 5000 辆授权车辆,不预生成“车辆数 × 天数”的快照明细。每页直接读取已建立复合索引的日统计投影,不扫描原始时序明细。

+ ; +} + +function TotalMileageReference() { + return <> +

指定时刻总里程

按已授权 VIN 查询不晚于指定北京时间的最近一条有效总里程,并返回实际采集协议、记录时间和时间差秒数。

+ +

请求参数

字段必填说明
vin17 位已授权 VIN
time北京时间,YYYY-MM-DD HH:mm:ss
protocol唯一规范值:GB32960、YUTONG_MQTT 或 JT808;不传时自动选择
+

请求示例

+

协议口径

协议总里程含义
GB32960车辆仪表盘累计总里程
YUTONG_MQTT车辆仪表盘或车端控制器累计总里程
JT808GPS/定位终端侧计算的累计里程,不等同于仪表盘里程

不同协议不能拼接为同一条连续里程曲线。未指定协议时,优先级代表数据源选择顺序,不代表数值可相互替代。

+

响应示例

YUTONG_MQTT > JT808", + "status": "NORMAL" + } +}`} /> + ; +} + +function VehicleAuthGuide() { + return <> +

车辆授权

平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。

+

授权关系

车辆授权属于开放应用,而不是合作伙伴账号。获得该应用角色的合作伙伴只能查询应用已经授权的车辆。

+

配置步骤

  1. 选择目标开放应用。
  2. 设置车辆授权开始与截止日期。
  3. 从目录选择,或粘贴 VIN/车牌识别车辆。
  4. 核对右侧已选车辆并保存。
+
完整替换语义

保存时会用当前选中范围完整替换旧范围。未来新增主车辆不会自动获得权限,需要管理员再次确认。

+ ; +} + +function KeyRotationGuide() { + return <> +

密钥轮换

轮换会生成新的 32 位 AppKey,旧 Key 立即失效,完整新 Key 只展示一次。

+

推荐流程

  1. 在调用方准备可快速更新的密钥配置。
  2. 在低流量窗口执行轮换。
  3. 立即复制新 Key 并更新调用方。
  4. 发送一条授权车辆测试请求。
  5. 在调用记录中确认成功。
+

权限要求

平台管理员和应用 Owner 可以轮换密钥;Developer 与 Viewer 无权执行。

+ ; +} + +function QualityGuide() { + return <> +

数据质量

HTTP 成功不等于每辆车都有有效统计值。请始终读取每个结果对象的 status。

+

状态定义

状态含义建议
NORMAL存在满足口径的完整单日统计正常使用数值
NO_DATA授权有效,但当天没有足够有效样本保留空值,后续补查
+

统计口径

日里程根据有效累计里程差计算;多协议数据按平台质量策略选择。日用氢量按同一数据源内的有效氢质量变化统计,加氢上升不计为消耗。

+ ; +} + +function Checklist() { + return <>

上线检查清单

在生产调度启用前,逐项完成以下检查。

; +} + +function ProductionChecklist({ compact = false }: { compact?: boolean }) { + const items = ["AppKey 已安全保存且应用未过期", "目标车辆已授权且日期范围有效", "请求日期使用 YYYY-MM-DD", "按 NORMAL / NO_DATA 处理结果", "记录 Trace ID 并实现 5xx 退避重试", "生产日志不会输出完整 AppKey"]; + return

生产前检查

完成后再启用定时任务。

{items.map((item) => )}
; +} + +function DocStep({ number, title, children }: { number: string; title: string; children: ReactNode }) { + return
{number}

{title}

{children}
; +} + +function Endpoint({ method, path }: { method: string; path: string }) { + return
{method}{path}
; +} + +function CodeBlock({ value, language }: { value: string; language: string }) { + return
{language}{value}
; +} + +function ParameterTable({ mileage = false }: { mileage?: boolean }) { + return {mileage && }
字段类型必填说明
plateNumbersstring[]省略或空数组时查询全部有效授权车辆;指定时最多 200 个且不允许重复
datestring自然日,格式 YYYY-MM-DD
protocolPrioritystring[]协议选源顺序;只允许 GB32960、MQTT、JT808,未列出的协议禁用
; +} + +function ProductTable() { + return {products.map((item) => )}
数据产品方法路径单位
{item.name}{item.method}{item.path}{item.unit}
; +} + +function DocsPagination({ topic, onSelect }: { topic: Topic; onSelect: (topic: Topic) => void }) { + const topics = useMemo(() => groups.flatMap((group) => group.items), []); + const index = topics.findIndex(([id]) => id === topic); + const previous = topics[index - 1]; + const next = topics[index + 1]; + return
{previous ? : }{next && }
; +} + +function groupFor(topic: Topic) { + return groups.find((group) => group.items.some(([id]) => id === topic))?.title || ""; +} + +function tocFor(topic: Topic) { + const map: Partial> = { + quickstart: ["获取 AppKey", "确认授权车辆", "发送请求", "验证响应", "生产前检查"], + overview: ["核心模型", "开放能力", "安全边界"], + auth: ["请求头", "有效性判断", "安全建议"], + errors: ["错误码", "重试策略"], + limits: ["参数限制", "调度建议"], + hydrogen: ["请求参数", "请求示例", "响应字段", "响应示例"], + mileage: ["请求参数", "请求示例", "响应字段", "响应示例"], + "mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"], + "total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"], + "vehicle-auth": ["授权关系", "配置步骤"], + "key-rotation": ["推荐流程", "权限要求"], + quality: ["状态定义", "统计口径"], + checklist: ["生产前检查"] + }; + return map[topic] || []; +} diff --git a/vehicle-data-platform/apps/open-portal/src/PublicSite.tsx b/vehicle-data-platform/apps/open-portal/src/PublicSite.tsx new file mode 100644 index 00000000..d10f6ddc --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/PublicSite.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import { DocumentationPage } from "./Documentation"; +import { Brand, Icon, navigate, type Route } from "./ui"; + +export function PublicSite({ route }: { route: Exclude }) { + return ( +
+ + {route === "docs" ? : } + +
+ ); +} + +function scrollToSection(id: string) { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function PublicHeader({ route }: { route: Exclude }) { + return ( +
+ + + +
+ ); +} + +function HomePage() { + return ( +
+
+
+

车辆数据,按需开放。

+

用统一 API 访问车辆日用氢量与日里程。每次调用受应用、车辆和日期范围约束。

+
+ + +
+
+ +
+ +
+ 2 项数据能力 + VIN / 车牌授权 + 指定 1–200 辆 / 留空查全部 + Trace ID +
+ +
+
+ 01 +

可用接口

+
+ + +
+ +
+
+ 02 +

开始接入

+
+
+ {[ + ["01", "获取 AppKey", "创建开放应用并保存一次性展示的 AppKey。"], + ["02", "授权车辆", "按 VIN 或车牌配置车辆和有效期。"], + ["03", "调用 API", "按自然日查询数据,并记录 Trace ID。"] + ].map(([number, title, body]) => ( +
+ {number} +

{title}

{body}

+
+ ))} +
+
+ +
+
+ +

查看完整接口文档

请求参数、响应结构、错误码与重试策略。

+
+ +
+
+ ); +} + +type ApiKind = "mileage" | "hydrogen"; + +const apiExamples: Record = { + mileage: { + tab: "日里程", + path: "/api/v1/vehicles/mileage/query", + responseField: "dailyMileageKm", + value: "215.6", + extraResponse: `\n "totalMileageKm": 12345.6,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",` + }, + hydrogen: { + tab: "日用氢量", + path: "/api/v1/vehicles/hydrogen-consumption/query", + responseField: "hydrogenConsumptionKg", + value: "8.42" + } +}; + +function ApiWorkbench() { + const [kind, setKind] = useState("mileage"); + const current = apiExamples[kind]; + return ( +
+
+ {(Object.keys(apiExamples) as ApiKind[]).map((item) => ( + + ))} +
+
+
+
POST{current.path}
+
请求
+ + +
+
+
响应200 OK
+
{`{
+  "code": "SUCCESS",
+  "data": [{
+    "plateNumber": "辽A00001",
+    "date": "2026-07-19",
+    "${current.responseField}": ${current.value},
+${current.extraResponse || ""}
+    "status": "NORMAL"
+  }],
+  "traceId": "b7ff5582ab1a4e13"
+}`}
+
+
+
+ ); +} + +function ApiRow({ mark, name, path, unit }: { mark: string; name: string; path: string; unit: string }) { + return ( +
+ {mark} +
+

{name}

+

POST{path}

+
+
单位{unit}
+ +
+ ); +} + +function PublicFooter() { + return ( +
+ + + © 2026 Lingniu Technology +
+ ); +} diff --git a/vehicle-data-platform/apps/open-portal/src/VehicleAuthorization.tsx b/vehicle-data-platform/apps/open-portal/src/VehicleAuthorization.tsx new file mode 100644 index 00000000..337ccb6e --- /dev/null +++ b/vehicle-data-platform/apps/open-portal/src/VehicleAuthorization.tsx @@ -0,0 +1,215 @@ +import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { api, type AdminApp, type VehicleCatalogItem, type VehicleGrant } from "./api"; +import { dateInput, errorMessage, Icon, PageHeader, rfcDate, Status } from "./ui"; + +type Mode = "directory" | "paste" | "all"; + +const today = () => new Date().toISOString().slice(0, 10); +const nextYear = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 1); + return date.toISOString().slice(0, 10); +}; + +export function VehicleAuthorization({ + apps, + setNotice, + refresh +}: { + apps: AdminApp[]; + setNotice: (value: string) => void; + refresh: () => Promise; +}) { + const [appId, setAppId] = useState(0); + const [catalog, setCatalog] = useState([]); + const [grants, setGrants] = useState([]); + const [selected, setSelected] = useState>(() => new Set()); + const [mode, setMode] = useState("directory"); + const [query, setQuery] = useState(""); + const [batchText, setBatchText] = useState(""); + const [validFrom, setFrom] = useState(today()); + const [validTo, setTo] = useState(nextYear()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const deferredQuery = useDeferredValue(query.trim().toUpperCase()); + const deferredBatch = useDeferredValue(batchText); + + useEffect(() => { + api.adminVehicles() + .then(setCatalog) + .catch((reason) => setNotice(errorMessage(reason))) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (!appId && apps[0]) setAppId(apps[0].id); + }, [apps, appId]); + + useEffect(() => { + if (!appId) return; + api.vehicles(appId).then((items) => { + setGrants(items); + setSelected(new Set(items.map((item) => item.vin))); + setMode("directory"); + setBatchText(""); + if (items[0]) { + setFrom(dateInput(items[0].validFrom)); + setTo(dateInput(items[0].validTo) || nextYear()); + } + }).catch((reason) => setNotice(errorMessage(reason))); + }, [appId]); + + const catalogByVIN = useMemo(() => new Map(catalog.map((item) => [item.vin.toUpperCase(), item])), [catalog]); + const catalogByPlate = useMemo(() => { + const values = new Map(); + for (const item of catalog) { + const plate = item.plate.trim().toUpperCase(); + if (plate && !values.has(plate)) values.set(plate, item); + } + return values; + }, [catalog]); + const filtered = useMemo(() => { + if (!deferredQuery) return catalog; + return catalog.filter((item) => `${item.vin} ${item.plate} ${item.oem}`.toUpperCase().includes(deferredQuery)); + }, [catalog, deferredQuery]); + const batchResult = useMemo(() => { + const values = [...new Set(deferredBatch.split(/[\s,,;;]+/).map((item) => item.trim().toUpperCase()).filter(Boolean))]; + const matched: VehicleCatalogItem[] = []; + const unmatched: string[] = []; + for (const value of values) { + const item = catalogByVIN.get(value) || catalogByPlate.get(value); + if (item && !matched.some((vehicle) => vehicle.vin === item.vin)) matched.push(item); + else if (!item) unmatched.push(value); + } + return { matched, unmatched }; + }, [deferredBatch, catalogByVIN, catalogByPlate]); + const selectedVehicles = useMemo(() => { + const existingByVIN = new Map(grants.map((item) => [item.vin, item])); + return [...selected].map((vin) => { + const item = catalogByVIN.get(vin); + const existing = existingByVIN.get(vin); + return item || { + vin, + plate: existing?.plate || "", + oem: "", + source: "已有授权", + status: "available" + }; + }).sort((left, right) => (left.plate || left.vin).localeCompare(right.plate || right.vin, "zh-CN")); + }, [selected, grants, catalogByVIN]); + + function toggle(vin: string) { + setSelected((current) => { + const next = new Set(current); + if (next.has(vin)) next.delete(vin); + else next.add(vin); + return next; + }); + } + + function chooseMode(next: Mode) { + setMode(next); + if (next === "all") setSelected(new Set(catalog.map((item) => item.vin))); + } + + function applyBatch() { + setSelected(new Set(batchResult.matched.map((item) => item.vin))); + if (batchResult.unmatched.length) { + setNotice(`已识别 ${batchResult.matched.length} 辆,${batchResult.unmatched.length} 条未匹配。`); + } + } + + function reset() { + setSelected(new Set(grants.map((item) => item.vin))); + setBatchText(""); + setMode("directory"); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!validFrom || !validTo || validTo <= validFrom) { + setNotice("车辆授权截止日期必须晚于生效日期。"); + return; + } + setSaving(true); + try { + const result = await api.replaceVehicles(appId, selectedVehicles.map((item) => ({ + vin: item.vin, + validFrom: rfcDate(validFrom), + validTo: rfcDate(validTo) + }))); + setGrants(result); + setSelected(new Set(result.map((item) => item.vin))); + setNotice(`已保存 ${result.length} 辆车辆授权。`); + await refresh(); + } catch (reason) { + setNotice(errorMessage(reason)); + } finally { + setSaving(false); + } + } + + const allFilteredSelected = filtered.length > 0 && filtered.every((item) => selected.has(item.vin)); + + return <> + + {!apps.length ?

还没有可配置的应用

请先创建开放应用。

: +
+
+ +
+
+ +
+ + + +
+ + {mode === "paste" &&
+

批量添加 VIN 或车牌

每行一个 VIN 或车牌,也支持空格、逗号分隔。

+