feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
@@ -5,10 +5,12 @@ import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
@@ -27,12 +29,14 @@ type tokenPrincipal struct {
|
||||
}
|
||||
|
||||
type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
adapters []IdentityAdapter
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
profile *oneOSProfileAuth
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
localBackend bool
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
|
||||
@@ -62,14 +66,32 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(cfg)
|
||||
legacyConfig := cfg
|
||||
if cfg.OneOSAuthEnabled {
|
||||
legacyConfig.OneOSSSOEnabled = false
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(legacyConfig)
|
||||
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}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS, localBackend: cfg.OneOSLocalBackend}
|
||||
if cfg.OneOSAuthEnabled {
|
||||
localMock := (cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) && strings.EqualFold(cfg.DataMode, "mock")
|
||||
if mode != "enforce" || (local == nil && !localMock) {
|
||||
return nil, fmt.Errorf("OneOS auth requires AUTH_MODE=enforce and identity DB or explicit isolated local mock")
|
||||
}
|
||||
if !validOneOSOrigin(cfg.OneOSPublicOrigin, cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) {
|
||||
return nil, fmt.Errorf("ONEOS_PUBLIC_ORIGIN must be a trusted HTTPS origin or explicit local loopback origin")
|
||||
}
|
||||
client, e := newOneOSProfileClient(cfg)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
authenticator.profile = &oneOSProfileAuth{localBackend: cfg.OneOSLocalBackend, vehicleQueryScope: cfg.OneOSVehicleQueryScope, devIntegration: cfg.OneOSDevIntegration, devDataProxy: cfg.OneOSDevDataProxyURL != "", client: client, store: local, origin: strings.TrimRight(cfg.OneOSPublicOrigin, "/"), issuer: cfg.OneOSProfileIssuer, tenant: cfg.OneOSProfileAllowedTenant, passwordEnabled: cfg.OneOSPasswordEnabled, scopeEnabled: cfg.OneOSVehicleScopePath != "" || cfg.OneOSVehicleQueryScope, localOnly: localMock, ssoEnabled: localMock && cfg.OneOSLoginTicketExchangePath != ""}
|
||||
}
|
||||
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
|
||||
authenticator.demo = newDemoAuthDirectory()
|
||||
}
|
||||
@@ -101,7 +123,7 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)}
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal})
|
||||
}
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && authenticator.profile == nil && len(authenticator.adapters) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
|
||||
}
|
||||
return authenticator, nil
|
||||
@@ -109,6 +131,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/providers" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"oneos": map[string]bool{"enabled": a.profile != nil, "devIntegration": a.profile != nil && a.profile.devIntegration, "realDataEnabled": a.profile != nil && (a.profile.devDataProxy || a.profile.localBackend), "passwordEnabled": a.profile != nil && a.profile.passwordEnabled, "authorizationReady": a.profile != nil && a.profile.scopeEnabled, "ssoEnabled": a.profile != nil && a.profile.ssoEnabled}, "local": map[string]bool{"enabled": a.local != nil}})
|
||||
return
|
||||
}
|
||||
if a.profile != nil && a.profile.handle(w, r) {
|
||||
return
|
||||
}
|
||||
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))
|
||||
@@ -133,13 +167,79 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
a.local.login(w, r)
|
||||
return
|
||||
}
|
||||
// This route resolves the caller's own provider; request parameters never select it.
|
||||
if r.URL.Path == "/api/v2/auth/vehicle-scope" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
current, err := a.profile.client.CurrentUser(r.Context(), token, clientID)
|
||||
if err != nil || !a.profile.validProfile(current.Profile) {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
p, _, err := a.profile.resolveVehicleQueryScope(r.Context(), token, clientID, current)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "VEHICLE_SCOPE_UNAVAILABLE", "车辆权限暂时加载失败,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
p, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
limited := principal.AuthorizationStatus == "pending" || (a.profile.devIntegration && principal.AuthorizationStatus == "asset-catalog")
|
||||
identityRoute := (r.Method == http.MethodGet && (r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me")) || (r.Method == http.MethodPost && r.URL.Path == "/api/v2/auth/logout")
|
||||
catalogRoute := principal.AuthorizationStatus != "pending" && r.Method == http.MethodGet && r.URL.Path == "/api/v2/auth/oneos/vehicles" && (principal.AuthorizationStatus == "asset-catalog" || (a.profile.vehicleQueryScope || a.profile.devDataProxy && principal.Role == "admin"))
|
||||
action := oneOSReadAction(r)
|
||||
admin := oneOSHasAdminPermission(principal.PermissionKeys) && principal.Role == "admin"
|
||||
if limited && admin && !identityRoute && !catalogRoute {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_DATA_UNAVAILABLE", "管理员权限已开通,本地开发环境尚未连接中台业务数据源", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if (limited && !identityRoute && !catalogRoute) || (!limited && !admin && !identityRoute && !catalogRoute && (action == "" || !oneOSPermissions(principal.PermissionKeys)[action])) {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_PERMISSION_DENIED")
|
||||
return
|
||||
}
|
||||
if catalogRoute {
|
||||
page, err := a.profile.client.VehicleAssetPage(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID"), r.URL.Query())
|
||||
if err != nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if errors.Is(err, errOneOSAssetQuery) {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "ONEOS_CATALOG_QUERY_INVALID", "车辆目录查询参数无效,请检查筛选条件及分页范围", "", requestTraceID(r))
|
||||
} else {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 车辆目录暂不可用,请稍后重试", "", requestTraceID(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"records": page.Records, "total": page.Total, "current": page.Current, "size": page.Size, "telemetryAvailable": false, "historyAvailable": false, "source": "oneos-asset-catalog"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" && r.URL.Path != "/api/v2/auth/oneos/me" {
|
||||
menu := requiredMenu(r)
|
||||
allowed := principal.CanMenu(menu)
|
||||
if menu == "shared" {
|
||||
@@ -159,8 +259,45 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.localBackend {
|
||||
principal.DataSource = "production-local"
|
||||
}
|
||||
ctx := platform.WithPrincipal(r.Context(), principal)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/customer-mileage") || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance") {
|
||||
snapshot := platform.CustomerAssignmentSnapshot{Tenant: principal.TenantRef}
|
||||
if principal.AuthProvider != "oneos" && a.profile != nil {
|
||||
snapshot.Tenant = a.profile.tenant
|
||||
}
|
||||
if principal.AuthProvider == "oneos" && (r.URL.Path == "/api/v2/customer-mileage" || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance")) && r.Method == http.MethodGet && a.profile != nil && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
rows, err := a.profile.vehicleQuerySnapshot(ctx, token, clientID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 客户车辆归属暂不可用,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
snapshot.Live, snapshot.ObservedAt = true, time.Now()
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.profile.scopeMu.Lock()
|
||||
if entry := a.profile.queryScopes[key]; entry != nil && !entry.expires.IsZero() {
|
||||
snapshot.ObservedAt = entry.expires.Add(-oneOSQueryScopeTTL)
|
||||
}
|
||||
a.profile.scopeMu.Unlock()
|
||||
for _, row := range rows {
|
||||
snapshot.Rows = append(snapshot.Rows, platform.CustomerVehicleAssignment{VIN: row.VIN, Plate: row.PlateNumber, Customer: row.CustomerName, Department: row.BusinessDepartment, ResponsiblePerson: row.BusinessResponsiblePerson})
|
||||
}
|
||||
}
|
||||
ctx = platform.WithCustomerAssignments(ctx, snapshot)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if r.URL.Path == "/api/v2/auth/oneos/me" {
|
||||
if a.profile == nil || principal.AuthProvider != "oneos" || r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_REQUIRED")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"username": principal.Username, "displayName": principal.Name, "tenantId": principal.TenantRef, "menuKeys": principal.MenuKeys, "authorizationStatus": principal.AuthorizationStatus})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/session" {
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
@@ -174,7 +311,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" || principal.AuthProvider == "oneos") {
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos" || principal.AuthProvider == "oneos-profile") {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
@@ -206,8 +343,14 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
|
||||
if token == "" {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
return a.profile.authenticate(r, token)
|
||||
}
|
||||
if a.local != nil {
|
||||
if principal, ok := a.local.authenticate(r.Context(), token); ok {
|
||||
if principal.AuthProvider == "oneos-profile" || (a.profile != nil && principal.AuthProvider == "oneos") {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
@@ -245,13 +388,17 @@ func bearerToken(r *http.Request) string {
|
||||
func requiredMenu(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/operation-compliance"):
|
||||
return "operation-compliance"
|
||||
case strings.HasPrefix(path, "/api/v2/customer-mileage"):
|
||||
return "customer-mileage"
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/v2/statistics/vehicles", path == "/api/v2/statistics/vehicle-options", path == "/api/v2/statistics/vehicle-resolve":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
|
||||
return "shared"
|
||||
@@ -263,6 +410,9 @@ func requiredMenu(r *http.Request) string {
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if r.URL.Path == "/api/v2/customer-mileage/rules" && r.Method != http.MethodGet {
|
||||
return "admin"
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
|
||||
return "admin"
|
||||
}
|
||||
@@ -293,7 +443,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/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":
|
||||
case "/api/v2/statistics/vehicle-resolve", "/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"
|
||||
|
||||
@@ -35,14 +35,14 @@ const (
|
||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`)
|
||||
|
||||
var customerMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true,
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true, "customer-mileage": true, "operation-compliance": true,
|
||||
}
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "customer-mileage", "operation-compliance", "alerts", "access", "operations", "users"}
|
||||
|
||||
var adminMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true, "customer-mileage": true, "operation-compliance": true,
|
||||
}
|
||||
|
||||
type authUser struct {
|
||||
@@ -294,11 +294,9 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), credential.authUser)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
// Issue identity first; vehicle grants are loaded by the authenticated scope endpoint.
|
||||
principal := platform.Principal{SubjectID: strconv.FormatUint(credential.ID, 10), Name: credential.DisplayName, Username: credential.Username, AuthProvider: "local", Role: credential.UserType, UserType: credential.UserType, AuthorizationStatus: "loading", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
|
||||
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
@@ -432,7 +430,9 @@ func (s *authStore) issueSession(r *http.Request, userID uint64, principal platf
|
||||
return loginResponse{}, err
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
if principal.AuthorizationStatus != "loading" {
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
}
|
||||
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomerMileageOneOSPermissionMapping(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", "/api/v2/customer-mileage", nil)
|
||||
if requiredMenu(request) != "customer-mileage" || oneOSReadAction(request) != "vehiclePlatform:customerMileage:view" {
|
||||
t.Fatal("OneOS legal permission key not mapped to route")
|
||||
}
|
||||
if !oneOSPermissions([]string{"*:*:*"})["vehiclePlatform:customerMileage:view"] {
|
||||
t.Fatal("administrator lost customer mileage menu")
|
||||
}
|
||||
if oneOSPermissions([]string{"vehiclePlatform:statistics:read"})["vehiclePlatform:customerMileage:view"] {
|
||||
t.Fatal("ordinary mileage implicitly authorized hydrogen")
|
||||
}
|
||||
request = httptest.NewRequest("PUT", "/api/v2/customer-mileage/rules", nil)
|
||||
if requiredRole(request) != "admin" || oneOSReadAction(request) != "" {
|
||||
t.Fatal("read permission can mutate rules")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOneOSAdminPermissionRequiresAuthoritativeGrant(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
permissions []string
|
||||
admin bool
|
||||
}{
|
||||
{"super administrator", []string{"*:*:*"}, true},
|
||||
{"platform administrator", []string{"vehicle:platform:access", oneOSAdminPermission}, true},
|
||||
{"missing entry", []string{oneOSAdminPermission}, false},
|
||||
{"menu only", []string{"vehicle:platform:access", "vehiclePlatform:operations:view", "vehiclePlatform:users:view"}, false},
|
||||
{"no permissions", nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := oneOSHasAdminPermission(tc.permissions); got != tc.admin {
|
||||
t.Fatalf("admin=%v want %v", got, tc.admin)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSAdminFullAccessAndRevocation(t *testing.T) {
|
||||
var mode atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer admin-test-token" || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("upstream credentials changed")
|
||||
}
|
||||
if r.URL.Path != "/system/user/getInfo" {
|
||||
t.Errorf("admin should not require a separate scope endpoint: %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
permissions := []string{"vehicle:platform:access", oneOSAdminPermission}
|
||||
status := "0"
|
||||
switch mode.Load() {
|
||||
case 1:
|
||||
permissions = []string{"*:*:*"}
|
||||
case 2:
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
case 3:
|
||||
status = "1"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": status}, "permissions": permissions, "roles": []string{"superadmin"}}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := platform.PrincipalFromContext(r.Context())
|
||||
if !ok || p.Role != "admin" || p.UserType != "admin" || !p.CanVIN("ANY_VIN") {
|
||||
t.Error("admin principal missing")
|
||||
}
|
||||
for _, menu := range adminMenus {
|
||||
if !p.CanMenu(menu) {
|
||||
t.Errorf("missing menu %s", menu)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
request := func(method, path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("Authorization", "Bearer admin-test-token")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, modeValue := range []int32{0, 1} {
|
||||
mode.Store(modeValue)
|
||||
for _, route := range []struct{ method, path string }{
|
||||
{"GET", "/api/v2/operations/tasks"}, {"POST", "/api/v2/operations/tasks"},
|
||||
{"GET", "/api/v2/admin/users"}, {"POST", "/api/v2/admin/users"},
|
||||
{"PUT", "/api/v2/access/thresholds"}, {"POST", "/api/v2/alerts/rules"},
|
||||
{"POST", "/api/history/raw-frames/query"}, {"POST", "/api/v2/exports"},
|
||||
} {
|
||||
if w := request(route.method, route.path); w.Code != 204 {
|
||||
t.Fatalf("%s %s status=%d body=%s", route.method, route.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
// A dev administrator still cannot see mock business records as real data.
|
||||
auth.devIntegration = true
|
||||
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 503 || !strings.Contains(w.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
|
||||
t.Fatalf("dev storage leaked: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Revoking the full grant takes effect on the next request, even though the
|
||||
// username and upstream role display name still say admin/superadmin.
|
||||
mode.Store(2)
|
||||
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 403 {
|
||||
t.Fatalf("revoked admin retained operations: %d", w.Code)
|
||||
}
|
||||
p, _, err := auth.resolve(context.Background(), "admin-test-token", "web")
|
||||
if err != nil || p.Role != "customer" || p.CanMenu("users") || p.CanVIN("ANY_VIN") {
|
||||
t.Fatalf("username elevated revoked user: %+v %v", p, err)
|
||||
}
|
||||
mode.Store(3)
|
||||
if w := request("GET", "/api/v2/session"); w.Code != 401 {
|
||||
t.Fatalf("disabled admin accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// oneOSAssetVehicle contains only asset catalog fields from the OneOS vehicle
|
||||
// page. Authorization only consumes fully enumerated caller-scoped snapshots
|
||||
// when ONEOS_VEHICLE_QUERY_SCOPE is explicitly enabled.
|
||||
type oneOSAssetVehicle struct {
|
||||
ID string `json:"id"`
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
VehicleType string `json:"vehicleType"`
|
||||
Brand string `json:"brand"`
|
||||
BrandName string `json:"brandName"`
|
||||
Model string `json:"model"`
|
||||
ModelName string `json:"modelName"`
|
||||
ProvinceName string `json:"provinceName"`
|
||||
CityName string `json:"cityName"`
|
||||
CustomerName string `json:"customerName"`
|
||||
ProjectName string `json:"projectName"`
|
||||
BusinessDepartment string `json:"businessDepartment"`
|
||||
BusinessResponsiblePerson string `json:"businessResponsiblePerson"`
|
||||
OperationStatus string `json:"operationStatus"`
|
||||
VehicleSource string `json:"vehicleSource"`
|
||||
}
|
||||
|
||||
func (v *oneOSAssetVehicle) UnmarshalJSON(raw []byte) error {
|
||||
type fields oneOSAssetVehicle
|
||||
var payload struct {
|
||||
fields
|
||||
ID json.RawMessage `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
*v = oneOSAssetVehicle(payload.fields)
|
||||
id, err := oneOSIDString(payload.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.ID = id
|
||||
return nil
|
||||
}
|
||||
|
||||
type oneOSAssetPage struct {
|
||||
Records []oneOSAssetVehicle `json:"records"`
|
||||
Total int64 `json:"total"`
|
||||
Current int64 `json:"current"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
var errOneOSAssetQuery = errors.New("invalid OneOS asset query")
|
||||
|
||||
var oneOSAssetPageFilters = map[string]bool{
|
||||
"assetCategory": true, "vin": true, "plateNumber": true, "plateNumberList": true,
|
||||
"province": true, "city": true, "provinces": true, "vehicleType": true,
|
||||
"brand": true, "model": true, "customerName": true, "businessDept": true,
|
||||
"registeredOwnership": true, "contractCode": true, "operationOrgId": true,
|
||||
"vehicleSource": true, "rentalCompany": true, "operationStatus": true,
|
||||
"vehicleStatus": true, "onlineStatus": true, "licenseStatus": true,
|
||||
"packingLotId": true, "provinceName": true, "cityName": true,
|
||||
"commercialInsuranceStatus": true, "compulsoryInsuranceStatus": true,
|
||||
"projectName": true, "insuranceStatus": true,
|
||||
}
|
||||
|
||||
const oneOSVehicleAssetCategory = "allOperating"
|
||||
|
||||
// VehicleAssetPage faithfully reads one bounded page of the OneOS vehicle
|
||||
// catalog. It neither scans all pages nor turns records into permission grants.
|
||||
func (c *oneOSProfileClient) VehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values) (oneOSAssetPage, error) {
|
||||
return c.vehicleAssetPage(ctx, token, clientID, filters, 50)
|
||||
}
|
||||
|
||||
// Larger pages are reserved for the server-side permission snapshot.
|
||||
func (c *oneOSProfileClient) vehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values, maxPageSize int64) (oneOSAssetPage, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
query := url.Values{}
|
||||
pageNum, pageSize := int64(1), int64(10)
|
||||
if filters != nil {
|
||||
for key, values := range filters {
|
||||
if key == "pageNum" || key == "pageSize" {
|
||||
if len(values) != 1 || strings.TrimSpace(values[0]) == "" {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !oneOSAssetPageFilters[key] || len(values) == 0 || len(values) > 20 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" || len(value) > 128 || strings.ContainsAny(value, "\r\n") {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
query.Add(key, value)
|
||||
}
|
||||
}
|
||||
if raw := filters.Get("pageNum"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 1 || parsed > 1000000 || len(filters["pageNum"]) != 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
pageNum = parsed
|
||||
}
|
||||
if raw := filters.Get("pageSize"); raw != "" {
|
||||
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || parsed < 1 || parsed > maxPageSize || len(filters["pageSize"]) != 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
pageSize = parsed
|
||||
}
|
||||
}
|
||||
if len(query["assetCategory"]) > 1 {
|
||||
return oneOSAssetPage{}, errOneOSAssetQuery
|
||||
}
|
||||
// Fixed business boundary; caller filters cannot select a different category.
|
||||
query.Set("assetCategory", oneOSVehicleAssetCategory)
|
||||
query.Set("pageNum", strconv.FormatInt(pageNum, 10))
|
||||
query.Set("pageSize", strconv.FormatInt(pageSize, 10))
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/asset/vehicle-info/page?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("ClientID", clientID)
|
||||
request.Header.Set("Accept", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return oneOSAssetPage{}, errors.New("OneOS asset page unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return oneOSAssetPage{}, fmt.Errorf("OneOS asset page rejected: HTTP %d", response.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(raw) > 1<<20 {
|
||||
return oneOSAssetPage{}, errors.New("OneOS asset page too large")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[oneOSAssetPage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset page response")
|
||||
}
|
||||
page := envelope.Data
|
||||
if page.Total < 0 || page.Current != pageNum || page.Size != pageSize || int64(len(page.Records)) > pageSize {
|
||||
return oneOSAssetPage{}, errors.New("invalid OneOS asset page metadata")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSVehicleAssetPageForwardsBoundedOriginalQuery(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/asset/vehicle-info/page" {
|
||||
t.Error("wrong asset endpoint")
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
|
||||
t.Error("missing OneOS credential")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if q.Get("pageNum") != "2" || q.Get("pageSize") != "10" || q.Get("assetCategory") != "allOperating" || q.Get("vin") != "VIN123" || q.Get("businessDept") != "East" || len(q["provinces"]) != 3 || q["provinces"][0] != "410000" || q["provinces"][1] != "420000" || q["provinces"][2] != "430000" {
|
||||
t.Errorf("wrong query %v", q)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"records":[{"id":9123456789012345678,"vin":"VIN123","plateNumber":"A123","brand":"Brand","model":"Model","businessDepartment":"East"}],"total":11,"current":2,"size":10}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "client-a"}
|
||||
page, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"assetCategory": {"stock"}, "pageNum": {"2"}, "pageSize": {"10"}, "vin": {"VIN123"}, "businessDept": {"East"}, "provinces": {"410000", "420000", "430000"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 11 || len(page.Records) != 1 || page.Records[0].PlateNumber != "A123" || page.Records[0].ID != "9123456789012345678" {
|
||||
t.Fatalf("wrong asset page: %+v", page)
|
||||
}
|
||||
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"pageSize": {"9999"}}); err == nil {
|
||||
t.Fatal("unbounded page accepted")
|
||||
}
|
||||
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"arbitrary": {"1"}}); err == nil {
|
||||
t.Fatal("arbitrary filter accepted")
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("invalid filters reached OneOS: %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
// OneOS permissions are revalidated for each request. Administrator authority
|
||||
// comes from upstream permissions, never from a username or role display name.
|
||||
import (
|
||||
"errors"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errOneOSAuthority = errors.New("OneOS authorization unavailable")
|
||||
|
||||
const oneOSAdminPermission = "vehiclePlatform:admin:all"
|
||||
|
||||
func oneOSHasAdminPermission(values []string) bool {
|
||||
var access, admin bool
|
||||
for _, value := range values {
|
||||
if value == "*:*:*" {
|
||||
return true
|
||||
}
|
||||
access = access || value == "vehicle:platform:access"
|
||||
admin = admin || value == oneOSAdminPermission
|
||||
}
|
||||
return access && admin
|
||||
}
|
||||
|
||||
func oneOSAdminPrincipal(u authUser, p oneOSProfile, permissions []string) platform.Principal {
|
||||
return platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "admin", UserType: "admin", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: append([]string(nil), adminMenus...), AuthorizationStatus: "ready"}
|
||||
}
|
||||
|
||||
func oneOSPermissions(values []string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, v := range values {
|
||||
out[v] = true
|
||||
}
|
||||
if oneOSHasAdminPermission(values) {
|
||||
out[oneOSAdminPermission] = true
|
||||
for _, menu := range adminMenus {
|
||||
out[oneOSMenuPermission(menu)] = true
|
||||
}
|
||||
for _, key := range []string{"vehicle:platform:access", "vehiclePlatform:monitor:view", "vehiclePlatform:vehicles:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"} {
|
||||
out[key] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func oneOSReadAction(r *http.Request) string {
|
||||
if r.URL.Path == "/api/v2/alerts/events" {
|
||||
return ""
|
||||
}
|
||||
if r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me" {
|
||||
return "vehicle:platform:access"
|
||||
}
|
||||
if requiredRole(r) != "viewer" {
|
||||
return ""
|
||||
}
|
||||
switch requiredMenu(r) {
|
||||
case "operation-compliance":
|
||||
return "vehiclePlatform:operationCompliance:view"
|
||||
case "customer-mileage":
|
||||
return "vehiclePlatform:customerMileage:view"
|
||||
case "monitor", "vehicles", "shared":
|
||||
return "vehiclePlatform:vehicle:read"
|
||||
case "tracks":
|
||||
return "vehiclePlatform:track:read"
|
||||
case "statistics":
|
||||
return "vehiclePlatform:statistics:read"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func oneOSScopedPrincipal(u authUser, p oneOSProfile, permissions []string, scope oneOSVehicleScope, now time.Time) (platform.Principal, error) {
|
||||
if oneOSHasAdminPermission(permissions) {
|
||||
return oneOSAdminPrincipal(u, p, permissions), nil
|
||||
}
|
||||
grants := oneOSPermissions(permissions)
|
||||
if !grants["vehicle:platform:access"] || scope.TenantID != p.TenantID || scope.UserID != p.UserID || scope.PermissionVersion == "" || !scope.ExpiresAt.After(now) || scope.ExpiresAt.Sub(now) > 60*time.Second {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
if len(scope.Vehicles) > 100000 {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
for _, v := range scope.Vehicles {
|
||||
if len(v.VIN) == 0 || len(v.VIN) > 64 || v.VIN != strings.ToUpper(strings.TrimSpace(v.VIN)) || seen[v.VIN] || v.ValidFrom.IsZero() || (v.ValidTo != nil && !v.ValidTo.After(v.ValidFrom)) {
|
||||
return platform.Principal{}, errOneOSAuthority
|
||||
}
|
||||
seen[v.VIN] = true
|
||||
}
|
||||
principal := platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "customer", UserType: "customer", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: []string{}}
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
principal.MenuKeys = append(principal.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
for _, v := range scope.Vehicles {
|
||||
if !v.ValidFrom.After(now) && (v.ValidTo == nil || v.ValidTo.After(now)) {
|
||||
principal.VehicleVINs = append(principal.VehicleVINs, v.VIN)
|
||||
principal.VehicleGrants = append(principal.VehicleGrants, platform.VehicleGrant{VIN: v.VIN, ValidFrom: v.ValidFrom, ValidTo: v.ValidTo})
|
||||
}
|
||||
}
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
return principal, nil
|
||||
}
|
||||
|
||||
func oneOSMenuPermission(menu string) string {
|
||||
if menu == "operation-compliance" {
|
||||
menu = "operationCompliance"
|
||||
}
|
||||
if menu == "customer-mileage" {
|
||||
menu = "customerMileage"
|
||||
}
|
||||
return "vehiclePlatform:" + menu + ":view"
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
// This development bridge reads an existing platform through an SSH tunnel.
|
||||
// It never forwards the browser's OneOS credentials or permits business writes.
|
||||
type oneOSDevDataProxy struct {
|
||||
base string
|
||||
token string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func validateOneOSDevDataProxy(cfg config.Config) error {
|
||||
if cfg.OneOSDevDataProxyURL == "" && cfg.OneOSDevDataProxyToken == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(cfg.OneOSDevDataProxyURL)
|
||||
if err != nil || u.Host == "" || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a literal loopback HTTP origin")
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" || u.Host == cfg.HTTPAddr {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a separate loopback tunnel port")
|
||||
}
|
||||
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
|
||||
return fmt.Errorf("OneOS dev data proxy requires authenticated isolated dev mode")
|
||||
}
|
||||
if len(cfg.OneOSDevDataProxyToken) < 16 || strings.ContainsAny(cfg.OneOSDevDataProxyToken, " \t\r\n") {
|
||||
return fmt.Errorf("OneOS dev data proxy requires a server-side upstream credential")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newOneOSDevDataProxy(cfg config.Config) *oneOSDevDataProxy {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
return &oneOSDevDataProxy{base: cfg.OneOSDevDataProxyURL, token: cfg.OneOSDevDataProxyToken, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
|
||||
var oneOSDevVehicleRead = regexp.MustCompile(`^/api/v2/vehicles/[A-Za-z0-9_-]+/(profile|telemetry/latest|source-evidence|hydrogen-evidence)$`)
|
||||
var oneOSDevVehicleDiagnostic = regexp.MustCompile(`^/api/v2/operations/vehicles/[A-Za-z0-9_-]+/sources$`)
|
||||
|
||||
func oneOSDevReadAllowed(r *http.Request) bool {
|
||||
p := r.URL.Path
|
||||
if path.Clean(p) != p || strings.Contains(r.URL.EscapedPath(), "%") {
|
||||
return false
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
if oneOSDevVehicleRead.MatchString(p) || oneOSDevVehicleDiagnostic.MatchString(p) {
|
||||
return true
|
||||
}
|
||||
switch p {
|
||||
case "/api/dashboard/summary", "/api/vehicles", "/api/vehicles/resolve", "/api/vehicles/coverage", "/api/vehicles/coverage/summary", "/api/vehicles/business-filters", "/api/vehicles/detail",
|
||||
"/api/vehicle-service", "/api/vehicle-service/summary", "/api/vehicle-service/overview", "/api/realtime/vehicles", "/api/realtime/locations",
|
||||
"/api/history/locations", "/api/history/raw-frames", "/api/mileage/summary", "/api/mileage/daily", "/api/v2/statistics/mileage",
|
||||
"/api/statistics/online-summary", "/api/statistics/online-vehicles", "/api/quality/summary", "/api/quality/issues", "/api/quality/notification-plan",
|
||||
"/api/alert-events/summary", "/api/alert-events", "/api/alert-events/notification-plan", "/api/ops/health", "/api/ops/source-readiness",
|
||||
"/api/v2/monitor/summary", "/api/v2/monitor/map", "/api/v2/monitor/workspace", "/api/v2/tracks", "/api/v2/metrics",
|
||||
"/api/v2/history/metrics", "/api/v2/history/query", "/api/v2/history/series", "/api/v2/history/preferences",
|
||||
"/api/v2/access/thresholds", "/api/v2/alerts/rules", "/api/v2/alerts/rules/library", "/api/v2/alerts/notification-config",
|
||||
"/api/v2/reconciliation/summary", "/api/v2/reconciliation/assignees", "/api/map/reverse-geocode":
|
||||
return true
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
switch p {
|
||||
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/access/unresolved-identities",
|
||||
"/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/reconciliation/issues":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := platform.PrincipalFromContext(r.Context())
|
||||
if !ok || principal.AuthProvider != "oneos" || principal.Role != "admin" || !oneOSHasAdminPermission(principal.PermissionKeys) {
|
||||
oneOSAuthError(w, r, http.StatusForbidden, "ONEOS_DEV_ADMIN_REQUIRED")
|
||||
return
|
||||
}
|
||||
if !oneOSDevReadAllowed(r) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "DEV_READ_ONLY", "当前为真实数据只读联调,此操作未开放;写操作请在测试数据源验收", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
p.forward(w, r, true)
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) forward(w http.ResponseWriter, r *http.Request, authenticated bool) {
|
||||
body := http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, p.base+r.URL.RequestURI(), body)
|
||||
if err != nil {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
if authenticated {
|
||||
req.Header.Set("Authorization", "Bearer "+p.token)
|
||||
}
|
||||
for _, key := range []string{"Content-Type", "Accept"} {
|
||||
if value := r.Header.Get(key); value != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
response, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 300 && response.StatusCode < 400 || response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode >= 500 {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
payload, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1))
|
||||
if err != nil || len(payload) > 32<<20 {
|
||||
p.unavailable(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", response.Header.Get("Content-Type"))
|
||||
w.Header().Set("X-Development-Data-Source", "ecs-readonly")
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
|
||||
func (p *oneOSDevDataProxy) unavailable(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "DEV_DATA_UPSTREAM_UNAVAILABLE", "真实数据连接暂不可用,请检查本地 SSH 隧道和上游中台服务", "", requestTraceID(r))
|
||||
}
|
||||
|
||||
// AMap SDK requests cannot carry the browser's Authorization header. Only the
|
||||
// existing public map proxy's fixed, read-only paths are available here.
|
||||
func (p *oneOSDevDataProxy) withMapProxy(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/_AMapService/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
suffix := strings.TrimPrefix(r.URL.Path, "/_AMapService")
|
||||
if r.Method != http.MethodGet || path.Clean(r.URL.Path) != r.URL.Path || strings.Contains(r.URL.EscapedPath(), "%") || !(strings.HasPrefix(suffix, "/v3/") || strings.HasPrefix(suffix, "/v4/map/styles")) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p.forward(w, r, false)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOneOSDevDataProxyReadOnlyAndCredentialBoundary(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-service-token" {
|
||||
t.Error("wrong service credential")
|
||||
}
|
||||
for _, key := range []string{"Cookie", "X-OneOS-Client-ID", "Origin", "X-Forwarded-Host"} {
|
||||
if r.Header.Get(key) != "" {
|
||||
t.Errorf("forwarded untrusted header %s", key)
|
||||
}
|
||||
}
|
||||
if r.URL.Path == "/api/v2/tracks" && r.URL.Query().Get("vin") != "VIN123" {
|
||||
t.Error("query lost")
|
||||
}
|
||||
w.Header().Set("Set-Cookie", "upstream=secret")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"data":{"real":true}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "upstream-service-token"})
|
||||
admin := platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}
|
||||
request := func(method, path string, p platform.Principal) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(`{"dateFrom":"2026-09-29","dateTo":"2026-09-29"}`))
|
||||
r.Header.Set("Authorization", "Bearer browser-oneos-token")
|
||||
r.Header.Set("Cookie", "private=secret")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
r.Header.Set("Origin", "http://127.0.0.1:20400")
|
||||
r.Header.Set("X-Forwarded-Host", "evil.invalid")
|
||||
r = r.WithContext(platform.WithPrincipal(r.Context(), p))
|
||||
w := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, route := range []struct{ method, path string }{{"GET", "/api/v2/monitor/workspace"}, {"GET", "/api/v2/tracks?vin=VIN123"}, {"POST", "/api/v2/statistics/mileage"}} {
|
||||
w := request(route.method, route.path, admin)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"real":true`) || w.Header().Get("Set-Cookie") != "" {
|
||||
t.Fatalf("read failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
count := calls.Load()
|
||||
for _, route := range []struct{ method, path string }{{"POST", "/api/v2/alerts/rules"}, {"PUT", "/api/v2/access/thresholds"}, {"DELETE", "/api/v2/vehicles/VIN123"}, {"POST", "/api/v2/exports"}, {"POST", "/api/v2/vehicle-profiles/sync"}, {"GET", "/api/v2/admin/users"}, {"GET", "/api/v2/monitor/../admin/users"}, {"GET", "/api/v2/vehicles/VIN%2f123/profile"}, {"GET", "/unknown"}} {
|
||||
if w := request(route.method, route.path, admin); w.Code != 403 {
|
||||
t.Errorf("unexpected write/unknown access %s: %d", route.path, w.Code)
|
||||
}
|
||||
}
|
||||
for _, p := range []platform.Principal{{Role: "customer", AuthProvider: "oneos"}, {Role: "admin", AuthProvider: "local", PermissionKeys: []string{"*:*:*"}}, {Role: "admin", AuthProvider: "oneos"}} {
|
||||
if w := request("GET", "/api/v2/monitor/workspace", p); w.Code != 403 {
|
||||
t.Error("non-authoritative admin accepted")
|
||||
}
|
||||
}
|
||||
if calls.Load() != count {
|
||||
t.Fatal("denied request reached upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevDataProxyDoesNotFollowRedirectsOrFallBack(t *testing.T) {
|
||||
for _, status := range []int{302, 401, 403, 500} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "http://evil.invalid")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte("private upstream diagnostic"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "server-token-123456"})
|
||||
r := httptest.NewRequest("GET", "/api/v2/monitor/summary", nil)
|
||||
r = r.WithContext(platform.WithPrincipal(r.Context(), platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}))
|
||||
w := httptest.NewRecorder()
|
||||
proxy.ServeHTTP(w, r)
|
||||
if w.Code != 502 || strings.Contains(w.Body.String(), "private upstream") || w.Header().Get("Location") != "" {
|
||||
t.Fatalf("unsafe error %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevDataProxyIsolationConfiguration(t *testing.T) {
|
||||
base := config.Config{OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce", OneOSDevDataProxyURL: "http://127.0.0.1:20420", OneOSDevDataProxyToken: "server-token-123456", AMapWebJSKey: "public-key", AMapServiceHost: "/_AMapService"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*config.Config){func(c *config.Config) { c.OneOSDevIntegration = false }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://localhost:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://192.0.2.1:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20400" }, func(c *config.Config) { c.OneOSDevDataProxyURL += "/api" }, func(c *config.Config) { c.OneOSDevDataProxyToken = "" }, func(c *config.Config) { c.MySQLDSN = "external" }, func(c *config.Config) { c.HistoryCleanupAutomation = true }, func(c *config.Config) { c.AMapServiceHost = "https://evil.invalid" }} {
|
||||
c := base
|
||||
mutate(&c)
|
||||
if ValidateLocalIsolation(c) == nil {
|
||||
t.Fatal("unsafe dev proxy configuration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSRealDataAdminSessionKeepsOrdinaryUsersScoped(t *testing.T) {
|
||||
var admin atomic.Bool
|
||||
admin.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
perms := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
if admin.Load() {
|
||||
perms = []string{"*:*:*"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": perms}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true, devIntegration: true, devDataProxy: true}
|
||||
r := httptest.NewRequest("GET", "/api/v2/session", nil)
|
||||
p, _, err := a.resolve(r.Context(), "original-token-123456789", "web")
|
||||
if err != nil || p.Role != "admin" || p.AuthorizationStatus != "ready" || !p.ReadOnly || p.DataSource != "ecs-readonly" {
|
||||
t.Fatalf("real-data admin not ready: %+v %v", p, err)
|
||||
}
|
||||
admin.Store(false)
|
||||
p, _, err = a.resolve(r.Context(), "original-token-123456789", "web")
|
||||
if err != nil || p.Role != "customer" || p.AuthorizationStatus != "asset-catalog" || p.DataSource != "" {
|
||||
t.Fatalf("ordinary identity elevated: %+v %v", p, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOneOSLocalBackendRequiresExplicitLoopbackStores(t *testing.T) {
|
||||
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", RedisAddr: "127.0.0.1:20479", TDengineDSN: "test:test@ws(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mutate := range []func(*config.Config){
|
||||
func(c *config.Config) { c.MySQLDSN = "test:test@tcp(prod.example:3306)/vehicle" },
|
||||
func(c *config.Config) { c.RedisAddr = "prod.example:6379" },
|
||||
func(c *config.Config) { c.TDengineDSN = "test:test@ws(prod.example:6041)/" },
|
||||
func(c *config.Config) { c.OneOSVehicleQueryScope = false },
|
||||
func(c *config.Config) { c.AuthMode = "disabled" },
|
||||
func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
|
||||
func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
func(c *config.Config) { c.DailyGeographyEnabled = true },
|
||||
func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20420" },
|
||||
} {
|
||||
candidate := cfg
|
||||
mutate(&candidate)
|
||||
if ValidateLocalIsolation(candidate) == nil {
|
||||
t.Fatal("unsafe backend configuration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalOneOSProductionOriginAllowlist(t *testing.T) {
|
||||
for _, value := range []string{"https://lnoneos.com/prod-api", "https://lnoneos.com/prod-api/"} {
|
||||
if !validOneOSDevBaseURL(value) {
|
||||
t.Fatalf("production origin rejected: %s", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"http://lnoneos.com/prod-api", "https://lnoneos.com.evil.invalid/prod-api", "https://lnoneos.com/prod-api?x=1", "https://lnoneos.com:443/prod-api"} {
|
||||
if validOneOSDevBaseURL(value) {
|
||||
t.Fatalf("unsafe origin accepted: %s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLocalBackendRESTRequiresLiteralLoopbackTunnel(t *testing.T) {
|
||||
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20301", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", TDengineDriver: "taosRestful", TDengineDSN: "test:test@http(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dsn := range []string{
|
||||
"test:test@http(prod.example:6041)/",
|
||||
"test:test@http(localhost:20441)/",
|
||||
"test:test@http(0.0.0.0:20441)/",
|
||||
"test:test@http(127.0.0.1:0)/",
|
||||
"test:test@ws(127.0.0.1:20441)/",
|
||||
"invalid",
|
||||
} {
|
||||
candidate := cfg
|
||||
candidate.TDengineDSN = dsn
|
||||
if ValidateLocalIsolation(candidate) == nil {
|
||||
t.Fatalf("unsafe REST tunnel accepted: %s", dsn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/taosdata/driver-go/v3/taosRestful"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
// ValidateLocalIsolation runs before constructing any database, external client,
|
||||
// scheduler or listener. It is an opt-in development boundary, not an auth bypass.
|
||||
func validOneOSDevBaseURL(value string) bool {
|
||||
return value == "https://dev-oneos.lnh2e.com/prod-api" || value == "https://dev-oneos.lnh2e.com/prod-api/" || value == "https://lnoneos.com/prod-api" || value == "https://lnoneos.com/prod-api/"
|
||||
}
|
||||
|
||||
func ValidateLocalIsolation(cfg config.Config) error {
|
||||
if err := validateOneOSDevDataProxy(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
|
||||
return fmt.Errorf("ONEOS_LOCAL_ONLY and ONEOS_DEV_INTEGRATION are mutually exclusive")
|
||||
}
|
||||
if cfg.OneOSLocalBackend && !cfg.OneOSDevIntegration {
|
||||
return fmt.Errorf("local backend requires dev integration")
|
||||
}
|
||||
if !cfg.OneOSLocalOnly && !cfg.OneOSDevIntegration {
|
||||
return nil
|
||||
}
|
||||
if cfg.OneOSDevIntegration {
|
||||
if !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
|
||||
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires an explicitly allowed OneOS HTTPS origin")
|
||||
}
|
||||
if !validOneOSOrigin(cfg.OneOSPublicOrigin, true) {
|
||||
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires a literal loopback public origin")
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(cfg.HTTPAddr)
|
||||
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
|
||||
return fmt.Errorf("local OneOS testing requires an explicit loopback HTTP_ADDR")
|
||||
}
|
||||
if cfg.OneOSLocalBackend {
|
||||
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || !cfg.OneOSVehicleQueryScope || cfg.DataMode != "production" || cfg.AuthMode != "enforce" || cfg.OneOSDevDataProxyURL != "" {
|
||||
return fmt.Errorf("local backend requires dev OneOS, vehicle-query scope, production store and enforced authentication")
|
||||
}
|
||||
db, err := mysql.ParseDSN(cfg.MySQLDSN)
|
||||
if err != nil || db.Net != "tcp" || !loopbackAddress(db.Addr) {
|
||||
return fmt.Errorf("local backend requires a loopback MySQL tunnel")
|
||||
}
|
||||
if cfg.RedisAddr != "" && !loopbackAddress(cfg.RedisAddr) {
|
||||
return fmt.Errorf("local backend requires a loopback Redis tunnel")
|
||||
}
|
||||
if cfg.TDengineDSN != "" {
|
||||
if cfg.TDengineDriver == "taosRestful" {
|
||||
rest, err := taosRestful.ParseDSN(cfg.TDengineDSN)
|
||||
if err != nil || rest.Net != "http" || rest.Port < 1 || rest.Port > 65535 || !loopbackAddress(net.JoinHostPort(rest.Addr, fmt.Sprint(rest.Port))) {
|
||||
return fmt.Errorf("local backend requires a loopback TDengine REST tunnel")
|
||||
}
|
||||
} else {
|
||||
if cfg.TDengineDriver != "" && cfg.TDengineDriver != "taosWS" {
|
||||
return fmt.Errorf("local backend requires a supported TDengine driver")
|
||||
}
|
||||
marker := strings.LastIndex(cfg.TDengineDSN, "@ws(")
|
||||
if marker < 0 {
|
||||
return fmt.Errorf("local backend requires a TDengine websocket tunnel")
|
||||
}
|
||||
address := strings.SplitN(cfg.TDengineDSN[marker+4:], ")", 2)
|
||||
if len(address) != 2 || !loopbackAddress(address[0]) {
|
||||
return fmt.Errorf("local backend requires a loopback TDengine tunnel")
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled || cfg.CapacityCheckBin != "" || cfg.AlertStreamMode != "disabled" || len(cfg.AlertStreamKafkaBrokers) != 0 || cfg.AlertNotificationSMSURL != "" || cfg.AlertNotificationEmailURL != "" || cfg.AlertNotificationWeComURL != "" {
|
||||
return fmt.Errorf("local backend must not start production workers or notification delivery")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
|
||||
return fmt.Errorf("local OneOS testing requires DATA_MODE=mock and AUTH_MODE=enforce")
|
||||
}
|
||||
values := []string{
|
||||
cfg.MySQLDSN, cfg.RedisAddr, cfg.TDengineDSN, cfg.CapacityCheckBin,
|
||||
cfg.AMapAPIKey, cfg.AMapSecurityCode,
|
||||
cfg.AuthToken, cfg.AuthTokensJSON, cfg.BootstrapAdminPassword,
|
||||
cfg.AlertNotificationSMSURL, cfg.AlertNotificationEmailURL, cfg.AlertNotificationWeComURL,
|
||||
cfg.OneOSIntrospectionURL,
|
||||
}
|
||||
if cfg.OneOSDevDataProxyURL == "" && (cfg.AMapWebJSKey != "" || cfg.AMapServiceHost != "") {
|
||||
return fmt.Errorf("isolated OneOS testing rejects external map configuration")
|
||||
}
|
||||
if cfg.OneOSDevDataProxyURL != "" && cfg.AMapServiceHost != "" && cfg.AMapServiceHost != "/_AMapService" {
|
||||
return fmt.Errorf("real-data development requires the local map proxy path")
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return fmt.Errorf("local OneOS testing rejects external data, legacy auth and notification configuration")
|
||||
}
|
||||
}
|
||||
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled ||
|
||||
(cfg.AlertStreamMode != "" && cfg.AlertStreamMode != "disabled") || len(cfg.AlertStreamKafkaBrokers) != 0 {
|
||||
return fmt.Errorf("local OneOS testing rejects external workers and legacy SSO")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loopbackAddress(address string) bool {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
ip := net.ParseIP(host)
|
||||
return err == nil && ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func withOneOSLocalIsolationHeaders(next http.Handler, realData ...bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// The local UI must not load map SDKs, analytics, APIs or images remotely.
|
||||
// Existing index.html contains an inline boot/error indicator. Inline code
|
||||
// remains local; every external resource and connection is still excluded.
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
||||
if len(realData) > 0 && realData[0] {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://webapi.amap.com https://jsapi-service.amap.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.amap.com https://*.autonavi.com; font-src 'self' data:; connect-src 'self' https://*.amap.com https://*.autonavi.com; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
||||
}
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSLocalIsolationRejectsExternalConfiguration(t *testing.T) {
|
||||
base := config.Config{OneOSLocalOnly: true, HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]func(*config.Config){
|
||||
"wildcard listen": func(c *config.Config) { c.HTTPAddr = ":20400" },
|
||||
"hostname listen": func(c *config.Config) { c.HTTPAddr = "localhost:20400" },
|
||||
"production mode": func(c *config.Config) { c.DataMode = "production" },
|
||||
"auth bypass": func(c *config.Config) { c.AuthMode = "disabled" },
|
||||
"database": func(c *config.Config) { c.MySQLDSN = "must-not-be-opened" },
|
||||
"redis": func(c *config.Config) { c.RedisAddr = "192.0.2.1:6379" },
|
||||
"tdengine": func(c *config.Config) { c.TDengineDSN = "must-not-be-opened" },
|
||||
"notification": func(c *config.Config) { c.AlertNotificationSMSURL = "https://example.invalid" },
|
||||
"map": func(c *config.Config) { c.AMapWebJSKey = "local-must-not-load-sdk" },
|
||||
"legacy credential": func(c *config.Config) { c.AuthToken = "must-not-bypass-oneos" },
|
||||
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
"kafka": func(c *config.Config) { c.AlertStreamKafkaBrokers = []string{"192.0.2.1:9092"} },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := base
|
||||
mutate(&cfg)
|
||||
if ValidateLocalIsolation(cfg) == nil {
|
||||
t.Fatal("unsafe local configuration accepted")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
NewServer(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v2/session", nil))
|
||||
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "LOCAL_ISOLATION_REQUIRED") {
|
||||
t.Fatalf("unexpected response %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLocalBrowserCannotFetchRemoteResources(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
withOneOSLocalIsolationHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
csp := w.Header().Get("Content-Security-Policy")
|
||||
for _, requirement := range []string{"connect-src 'self'", "script-src 'self'", "frame-src 'none'", "form-action 'self'"} {
|
||||
if !strings.Contains(csp, requirement) {
|
||||
t.Fatalf("missing %s", requirement)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevIntegrationPreservesLocalIsolation(t *testing.T) {
|
||||
base := config.Config{OneOSDevIntegration: true, HTTPAddr: "127.0.0.1:20400", OneOSPublicOrigin: "http://127.0.0.1:20400", OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", DataMode: "mock", AuthMode: "enforce"}
|
||||
if err := ValidateLocalIsolation(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mutate := range map[string]func(*config.Config){
|
||||
"fixture conflict": func(c *config.Config) { c.OneOSLocalOnly = true },
|
||||
"other host": func(c *config.Config) { c.OneOSProfileBaseURL = "https://evil.invalid/prod-api" },
|
||||
"other prefix": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com/other" },
|
||||
"insecure": func(c *config.Config) { c.OneOSProfileBaseURL = "http://dev-oneos.lnh2e.com/prod-api" },
|
||||
"credentials": func(c *config.Config) { c.OneOSProfileBaseURL = "https://user@dev-oneos.lnh2e.com/prod-api" },
|
||||
"port": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com:443/prod-api" },
|
||||
"nonlocal listen": func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
|
||||
"nonlocal public": func(c *config.Config) { c.OneOSPublicOrigin = "https://dev-oneos.lnh2e.com" },
|
||||
"external db": func(c *config.Config) { c.MySQLDSN = "blocked" },
|
||||
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
|
||||
"real business": func(c *config.Config) { c.DataMode = "production" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := base
|
||||
mutate(&cfg)
|
||||
if ValidateLocalIsolation(cfg) == nil {
|
||||
t.Fatal("unsafe dev integration accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// oneOSCredential carries the real upstream login token. It is never derived
|
||||
// from, or interchangeable with, the restricted profile-jump token.
|
||||
type oneOSCredential struct {
|
||||
AccessToken string
|
||||
ClientID string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type oneOSCurrentUser struct {
|
||||
Profile oneOSProfile
|
||||
Permissions []string
|
||||
Roles []string
|
||||
Status string
|
||||
}
|
||||
|
||||
type oneOSVehicleGrant struct {
|
||||
VIN string `json:"vin"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
}
|
||||
|
||||
type oneOSVehicleScope struct {
|
||||
TenantID string `json:"tenantId"`
|
||||
UserID string `json:"userId"`
|
||||
PermissionVersion string `json:"permissionVersion"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Vehicles []oneOSVehicleGrant `json:"vehicles"`
|
||||
}
|
||||
|
||||
func validateOneOSConfiguredPath(path string) error {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "?#\\\r\n") {
|
||||
return errors.New("invalid OneOS path")
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
if err != nil || u.IsAbs() || u.Host != "" || u.RawPath != "" || u.Path != path || strings.Contains(path, "..") || strings.Contains(strings.ToLower(path), "%2f") || strings.Contains(strings.ToLower(path), "%2e") {
|
||||
return errors.New("invalid OneOS path")
|
||||
}
|
||||
if strings.Contains(path, "profile-jump") {
|
||||
return errors.New("profile-jump is not a login-token exchange")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) ExchangeLoginTicket(ctx context.Context, code string) (oneOSCredential, error) {
|
||||
if c == nil || c.loginTicketExchangePath == "" {
|
||||
return oneOSCredential{}, errors.New("OneOS login-ticket exchange is disabled")
|
||||
}
|
||||
if !validOneOSOpaqueToken(code) {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login ticket")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(struct {
|
||||
Code string `json:"code"`
|
||||
}{code})
|
||||
var result struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ClientID string `json:"clientId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
if err := c.doJSON(ctx, http.MethodPost, c.loginTicketExchangePath, body, c.loginTicketServiceToken, &result); err != nil {
|
||||
return oneOSCredential{}, err
|
||||
}
|
||||
if result.TokenType != "Bearer" || result.ClientID == "" || result.ClientID != c.passwordClientID || !validOneOSLoginToken(result.AccessToken) || !result.ExpiresAt.After(time.Now()) {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login-ticket response")
|
||||
}
|
||||
expiry := result.ExpiresAt
|
||||
if ceiling := time.Now().Add(30 * time.Minute); expiry.After(ceiling) {
|
||||
expiry = ceiling
|
||||
}
|
||||
return oneOSCredential{AccessToken: result.AccessToken, ClientID: result.ClientID, ExpiresAt: expiry}, nil
|
||||
}
|
||||
|
||||
func validOneOSLoginToken(token string) bool {
|
||||
return len(token) >= 16 && len(token) <= 4096 && !strings.ContainsAny(token, "\r\n\t ")
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) CurrentUser(ctx context.Context, token, clientID string) (oneOSCurrentUser, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var raw struct {
|
||||
User struct {
|
||||
UserID json.RawMessage `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"userName"`
|
||||
Nickname string `json:"nickName"`
|
||||
DeptID json.RawMessage `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
Status string `json:"status"`
|
||||
} `json:"user"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/getInfo", nil, token, clientID, &raw); err != nil {
|
||||
return oneOSCurrentUser{}, err
|
||||
}
|
||||
userID, err := oneOSIDString(raw.User.UserID)
|
||||
if err != nil {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS user identity")
|
||||
}
|
||||
deptID, err := oneOSIDString(raw.User.DeptID)
|
||||
if err != nil {
|
||||
return oneOSCurrentUser{}, errors.New("invalid OneOS department identity")
|
||||
}
|
||||
if userID == "" || raw.User.TenantID != c.allowedTenant || raw.User.Status != "0" {
|
||||
return oneOSCurrentUser{}, errors.New("OneOS user or tenant inactive")
|
||||
}
|
||||
return oneOSCurrentUser{Profile: oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, Permissions: raw.Permissions, Roles: raw.Roles, Status: raw.User.Status}, nil
|
||||
}
|
||||
|
||||
func oneOSIDString(raw json.RawMessage) (string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return "", nil
|
||||
}
|
||||
var s string
|
||||
if raw[0] == '"' {
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(raw, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := n.Int64(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return n.String(), nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) VehicleScope(ctx context.Context, token, clientID string) (oneOSVehicleScope, error) {
|
||||
if c == nil || c.vehicleScopePath == "" {
|
||||
return oneOSVehicleScope{}, errors.New("OneOS vehicle-scope endpoint is disabled")
|
||||
}
|
||||
if !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var scope oneOSVehicleScope
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, c.vehicleScopePath, nil, token, clientID, &scope); err != nil {
|
||||
return oneOSVehicleScope{}, err
|
||||
}
|
||||
if scope.TenantID != c.allowedTenant || strings.TrimSpace(scope.UserID) == "" || strings.TrimSpace(scope.PermissionVersion) == "" || !scope.ExpiresAt.After(time.Now()) || scope.ExpiresAt.After(time.Now().Add(65*time.Second)) {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle scope")
|
||||
}
|
||||
if len(scope.Vehicles) > 100000 {
|
||||
return oneOSVehicleScope{}, errors.New("OneOS vehicle scope too large")
|
||||
}
|
||||
for _, grant := range scope.Vehicles {
|
||||
if strings.TrimSpace(grant.VIN) == "" || grant.ValidFrom.IsZero() || grant.ValidFrom.After(time.Now()) || (grant.ValidTo != nil && !grant.ValidTo.After(grant.ValidFrom)) {
|
||||
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle grant")
|
||||
}
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) Logout(ctx context.Context, token, clientID string) error {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
return c.doAuthenticatedJSON(ctx, http.MethodPost, "/auth/logout", []byte("{}"), token, clientID, nil)
|
||||
}
|
||||
|
||||
// FetchOwnProfile demonstrates a fixed, trusted OneOS API call with the same
|
||||
// upstream token. It does not proxy arbitrary paths or return extra PII.
|
||||
func (c *oneOSProfileClient) FetchOwnProfile(ctx context.Context, token, clientID string) (oneOSProfile, error) {
|
||||
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var raw struct {
|
||||
User struct {
|
||||
UserID json.RawMessage `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"userName"`
|
||||
Nickname string `json:"nickName"`
|
||||
DeptID json.RawMessage `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/profile", nil, token, clientID, &raw); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
userID, err := oneOSIDString(raw.User.UserID)
|
||||
if err != nil {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
deptID, err := oneOSIDString(raw.User.DeptID)
|
||||
if err != nil {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
if userID == "" || raw.User.TenantID != c.allowedTenant {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile")
|
||||
}
|
||||
return oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) doAuthenticatedJSON(ctx context.Context, method, path string, body []byte, token, clientID string, target any) error {
|
||||
if !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
|
||||
return errors.New("invalid OneOS credential")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("invalid OneOS request")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("ClientID", clientID)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("OneOS API unavailable")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("OneOS API rejected request: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[json.RawMessage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
|
||||
return errors.New("OneOS API rejected request")
|
||||
}
|
||||
if target != nil {
|
||||
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, target); err != nil {
|
||||
return errors.New("invalid OneOS API response")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSLoginTransportLocalFixture(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
var paths []string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
if r.URL.Path != "/auth/inner/v1/sso/vehicle-platform/token-exchange" && (r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a") {
|
||||
t.Errorf("missing upstream credential on %s", r.URL.Path)
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/auth/inner/v1/sso/vehicle-platform/token-exchange":
|
||||
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
|
||||
t.Error("bad ticket exchange request")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": "upstream-token-123456", "tokenType": "Bearer", "clientId": "client-a", "expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}})
|
||||
case "/system/user/getInfo":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":"42","tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"],"roles":["operator"]}}`))
|
||||
case "/asset/vehicle-platform/scope":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"tenantId": "tenant-a", "userId": "42", "permissionVersion": "v1", "expiresAt": time.Now().Add(50 * time.Second).UTC().Format(time.RFC3339), "vehicles": []map[string]any{{"vin": "VIN123", "validFrom": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)}}}})
|
||||
case "/system/user/profile":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","deptId":7}}}`))
|
||||
case "/auth/logout":
|
||||
_, _ = w.Write([]byte(`{"code":200,"data":null}`))
|
||||
default:
|
||||
t.Errorf("unexpected endpoint %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordClientID: "client-a", OneOSLocalOnly: true, OneOSLoginTicketExchangePath: "/auth/inner/v1/sso/vehicle-platform/token-exchange", OneOSLoginTicketServiceToken: strings.Repeat("s", 32), OneOSVehicleScopePath: "/asset/vehicle-platform/scope"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credential, err := client.ExchangeLoginTicket(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" {
|
||||
t.Fatal("incorrect upstream credential")
|
||||
}
|
||||
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Profile.UserID != "42" || user.Profile.DeptID != "7" || len(user.Permissions) != 1 || user.Status != "0" {
|
||||
t.Fatalf("wrong user: %+v", user)
|
||||
}
|
||||
scope, err := client.VehicleScope(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scope.UserID != "42" || len(scope.Vehicles) != 1 || scope.Vehicles[0].VIN != "VIN123" {
|
||||
t.Fatalf("wrong scope: %+v", scope)
|
||||
}
|
||||
profile, err := client.FetchOwnProfile(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || profile.UserID != "42" {
|
||||
t.Fatalf("wrong own profile: %+v %v", profile, err)
|
||||
}
|
||||
if err := client.Logout(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 5 {
|
||||
t.Fatalf("wrong API call count: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSLoginTransportRejectsUnsafeOriginsAndPaths(t *testing.T) {
|
||||
base := config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant", OneOSLocalOnly: true, OneOSPasswordClientID: "client"}
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("remote HTTP accepted")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "https://oneos.example"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("remote HTTPS accepted in local-only mode")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "http://127.0.0.1:1234"
|
||||
base.OneOSLoginTicketExchangePath = "/auth/inner/v1/sso/vehicle-platform/token-exchange"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("missing service credential accepted")
|
||||
}
|
||||
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 31)
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("short service credential accepted")
|
||||
}
|
||||
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 32)
|
||||
base.OneOSLoginTicketExchangePath = "/auth/sso/profile-jump/exchange"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("profile-jump endpoint accepted as login exchange")
|
||||
}
|
||||
base.OneOSLoginTicketExchangePath = "//attacker.example/path"
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("network path accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSCurrentUserRejectsDisabledAndOtherTenant(t *testing.T) {
|
||||
for _, tc := range []struct{ tenant, status string }{{"other", "0"}, {"tenant-a", "1"}} {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": 42, "tenantId": tc.tenant, "status": tc.status}}})
|
||||
}))
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSLocalOnly: true, OneOSPasswordClientID: "client-a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := client.CurrentUser(context.Background(), "upstream-token-123456", "client-a"); err == nil {
|
||||
t.Fatalf("accepted tenant=%s status=%s", tc.tenant, tc.status)
|
||||
}
|
||||
server.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSStatisticsOnlyUserCanLoadMileageVehiclePickers(t *testing.T) {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:statistics:view", "vehiclePlatform:statistics:read"}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "18", "tenantId": "000000", "userName": "statistics-only", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LB9A32A24R0LS1426"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
|
||||
request := func(method, path, body string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Authorization", "Bearer statistics-user-token")
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, tc := range []struct{ method, path, body string }{
|
||||
{"GET", "/api/v2/statistics/vehicle-options?limit=20&bindingStatus=bound", ""},
|
||||
{"GET", "/api/v2/statistics/vehicles?limit=12", ""},
|
||||
{"POST", "/api/v2/statistics/vehicle-resolve", `{"keywords":["LB9A32A24R0LS1426"],"limit":200}`},
|
||||
} {
|
||||
w := request(tc.method, tc.path, tc.body)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s status=%d body=%s", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Data.Items) == 0 {
|
||||
t.Fatalf("missing authorized vehicle: %s", w.Body.String())
|
||||
}
|
||||
for _, row := range response.Data.Items {
|
||||
if row["vin"] != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("unauthorized VIN: %+v", row)
|
||||
}
|
||||
for key := range row {
|
||||
if key != "vin" && key != "plate" && key != "brandName" && key != "modelName" {
|
||||
t.Fatalf("excess field %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles/coverage", "/api/vehicles", "/api/v2/monitor/workspace"} {
|
||||
w := request("GET", path, "")
|
||||
if w.Code != 403 || !strings.Contains(w.Body.String(), "查询权限") || strings.Contains(w.Body.String(), "授权已失效") {
|
||||
t.Fatalf("permission error not preserved: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:view"}
|
||||
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
|
||||
t.Fatalf("revoked read access: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:read"}
|
||||
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
|
||||
t.Fatalf("revoked menu access: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type oneOSCaptcha struct {
|
||||
Enabled bool `json:"captchaEnabled"`
|
||||
UUID string `json:"uuid"`
|
||||
Image string `json:"img"`
|
||||
}
|
||||
|
||||
type oneOSPasswordLogin struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}
|
||||
|
||||
func parseOneOSRSAPublicKey(value string) (*rsa.PublicKey, error) {
|
||||
return parseOneOSRSAPublicKeyForMode(value, false)
|
||||
}
|
||||
|
||||
// The dev OneOS web bundle currently publishes a legacy 512-bit RSA key.
|
||||
// Accepting it requires the separate, fixed-origin dev integration guard in
|
||||
// the caller. Normal configurations retain the 2048-bit minimum.
|
||||
func parseOneOSRSAPublicKeyForMode(value string, devIntegration bool) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(value))
|
||||
var der []byte
|
||||
if block != nil {
|
||||
der = block.Bytes
|
||||
} else {
|
||||
var err error
|
||||
der, err = base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid RSA public key encoding")
|
||||
}
|
||||
}
|
||||
minBits := 2048
|
||||
if devIntegration {
|
||||
minBits = 512
|
||||
}
|
||||
if key, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||||
if public, ok := key.(*rsa.PublicKey); ok && public.N.BitLen() >= minBits {
|
||||
return public, nil
|
||||
}
|
||||
}
|
||||
if public, err := x509.ParsePKCS1PublicKey(der); err == nil && public.N.BitLen() >= minBits {
|
||||
return public, nil
|
||||
}
|
||||
return nil, errors.New("unsupported RSA public key")
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) FetchCaptcha(ctx context.Context) (oneOSCaptcha, error) {
|
||||
if c == nil || !c.passwordEnabled {
|
||||
return oneOSCaptcha{}, errors.New("OneOS password login is disabled")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
var captcha oneOSCaptcha
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/auth/code", nil, "", &captcha); err != nil {
|
||||
return oneOSCaptcha{}, err
|
||||
}
|
||||
if captcha.Enabled && (captcha.UUID == "" || captcha.Image == "" || len(captcha.Image) > 50<<10) {
|
||||
return oneOSCaptcha{}, errors.New("invalid OneOS captcha response")
|
||||
}
|
||||
return captcha, nil
|
||||
}
|
||||
|
||||
// AuthenticatePassword follows the OneOS browser's RSA/AES request protocol
|
||||
// and returns the actual OneOS login credential for online validation.
|
||||
func (c *oneOSProfileClient) AuthenticatePassword(ctx context.Context, login oneOSPasswordLogin) (oneOSCredential, error) {
|
||||
if c == nil || !c.passwordEnabled {
|
||||
return oneOSCredential{}, errors.New("OneOS password login is disabled")
|
||||
}
|
||||
if strings.TrimSpace(login.Username) == "" || login.Password == "" || len(login.Username) > 256 || len(login.Password) > 1024 {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS credentials")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
plain, _ := json.Marshal(struct {
|
||||
ClientID string `json:"clientId"`
|
||||
GrantType string `json:"grantType"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Code string `json:"code,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}{c.passwordClientID, "password", c.allowedTenant, login.Username, login.Password, login.Code, login.UUID})
|
||||
seed := make([]byte, 24)
|
||||
if _, err := rand.Read(seed); err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
key := []byte(base64.RawURLEncoding.EncodeToString(seed)) // 32 printable ASCII bytes, as in the OneOS web client.
|
||||
// JSEncrypt encrypts the base64 form of the ASCII AES key with RSA PKCS#1 v1.5.
|
||||
wrapped, err := rsa.EncryptPKCS1v15(rand.Reader, c.passwordPublicKey, []byte(base64.StdEncoding.EncodeToString(key)))
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
ciphertext, err := oneOSAesECBEncrypt(plain, key)
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS request encryption failed")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/auth/login", strings.NewReader(base64.StdEncoding.EncodeToString(ciphertext)))
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login request")
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("ClientID", c.passwordClientID)
|
||||
request.Header.Set("encrypt-key", base64.StdEncoding.EncodeToString(wrapped))
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return oneOSCredential{}, errors.New("OneOS password login unavailable")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return oneOSCredential{}, errors.New("OneOS password login rejected")
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return oneOSCredential{}, errors.New("invalid OneOS login response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpireIn int64 `json:"expire_in"`
|
||||
}]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || !validOneOSLoginToken(envelope.Data.AccessToken) || envelope.Data.ClientID != c.passwordClientID || envelope.Data.ExpireIn == 0 || envelope.Data.ExpireIn < -1 {
|
||||
return oneOSCredential{}, errors.New("OneOS password login rejected")
|
||||
}
|
||||
// OneOS can configure indefinite timeout (-1). This local credential horizon
|
||||
// stays bounded; online CurrentUser checks remain mandatory on use.
|
||||
ttl := envelope.Data.ExpireIn
|
||||
if ttl == -1 || ttl > 1800 {
|
||||
ttl = 1800
|
||||
}
|
||||
return oneOSCredential{AccessToken: envelope.Data.AccessToken, ClientID: envelope.Data.ClientID, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}, nil
|
||||
}
|
||||
|
||||
func oneOSAesECBEncrypt(plain, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
padding := aes.BlockSize - len(plain)%aes.BlockSize
|
||||
plain = append(bytes.Clone(plain), bytes.Repeat([]byte{byte(padding)}, padding)...)
|
||||
result := make([]byte, len(plain))
|
||||
for offset := 0; offset < len(plain); offset += aes.BlockSize {
|
||||
block.Encrypt(result[offset:offset+aes.BlockSize], plain[offset:offset+aes.BlockSize])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSRSAPublicKeyLegacyDevModeIsExplicit(t *testing.T) {
|
||||
legacy := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
|
||||
legacy.N.SetBit(legacy.N, 0, 1)
|
||||
der, err := x509.MarshalPKIXPublicKey(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, encoded := range []string{
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})),
|
||||
base64.StdEncoding.EncodeToString(der),
|
||||
} {
|
||||
if _, err := parseOneOSRSAPublicKey(encoded); err == nil {
|
||||
t.Fatal("normal parser accepted 512-bit key")
|
||||
}
|
||||
parsed, err := parseOneOSRSAPublicKeyForMode(encoded, true)
|
||||
if err != nil || parsed.N.BitLen() != 512 {
|
||||
t.Fatalf("dev parser rejected 512-bit key: %v", err)
|
||||
}
|
||||
}
|
||||
tooSmall := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 510), E: 65537}
|
||||
tooSmall.N.SetBit(tooSmall.N, 0, 1)
|
||||
smallDER, err := x509.MarshalPKIXPublicKey(tooSmall)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(smallDER), true); err == nil {
|
||||
t.Fatal("dev parser accepted sub-512-bit key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSPasswordLegacy512EncryptsOnlyWithGoDevOverride(t *testing.T) {
|
||||
t.Setenv("GODEBUG", "")
|
||||
legacyPublic := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
|
||||
legacyPublic.N.SetBit(legacyPublic.N, 0, 1)
|
||||
if _, err := rsa.EncryptPKCS1v15(rand.Reader, legacyPublic, []byte("probe")); err == nil {
|
||||
t.Fatal("Go unexpectedly accepted 512-bit RSA without override")
|
||||
}
|
||||
t.Setenv("GODEBUG", "rsa1024min=0")
|
||||
private, err := rsa.GenerateKey(rand.Reader, 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
der, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
public, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(der), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/auth/login" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request %s", r.URL.Path)
|
||||
return
|
||||
}
|
||||
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
base64Key, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(string(base64Key))
|
||||
if err != nil || len(key) != 32 {
|
||||
t.Error("invalid AES key")
|
||||
return
|
||||
}
|
||||
cipherBody, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(string(cipherBody))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
plain = plain[:len(plain)-int(plain[len(plain)-1])]
|
||||
var login map[string]string
|
||||
if err := json.Unmarshal(plain, &login); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if login["clientId"] != "dev-client" || login["password"] != "local-fixture-password" {
|
||||
t.Error("incorrect decrypted login fields")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"local-dev-token-123456","client_id":"dev-client","expire_in":120}}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, requestTimeout: time.Second, client: &http.Client{Timeout: time.Second}, passwordEnabled: true, passwordClientID: "dev-client", passwordPublicKey: public, allowedTenant: "tenant-a"}
|
||||
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "local-fixture-password"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "local-dev-token-123456" || credential.ClientID != "dev-client" {
|
||||
t.Fatal("incorrect legacy credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSPasswordUsesEncryptedLoginAndCurrentUser(t *testing.T) {
|
||||
private, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/auth/code":
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("captcha request has bearer")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"captchaEnabled":true,"uuid":"u","img":"image"}}`)
|
||||
case "/auth/login":
|
||||
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
keyBase64, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
key, err := base64.StdEncoding.DecodeString(string(keyBase64))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
encoded, _ := io.ReadAll(r.Body)
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(string(encoded))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
plain := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
plain = plain[:len(plain)-int(plain[len(plain)-1])]
|
||||
var login map[string]string
|
||||
if err := json.Unmarshal(plain, &login); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
if login["clientId"] != "client-a" || login["grantType"] != "password" || login["tenantId"] != "tenant-a" || login["password"] != "secret" || login["code"] != "1234" || login["uuid"] != "u" {
|
||||
t.Errorf("bad login request: %v", login)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"upstream-token-123456","client_id":"client-a","expire_in":3600}}`)
|
||||
case "/system/user/getInfo":
|
||||
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
|
||||
t.Error("wrong current-user token")
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"]}}`)
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordEnabled: true, OneOSPasswordClientID: "client-a", OneOSPasswordRSAPublicKey: publicPEM})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.client = server.Client()
|
||||
captcha, err := client.FetchCaptcha(context.Background())
|
||||
if err != nil || !captcha.Enabled || captcha.UUID != "u" {
|
||||
t.Fatalf("captcha=%+v err=%v", captcha, err)
|
||||
}
|
||||
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "secret", Code: "1234", UUID: "u"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" || credential.ExpiresAt.After(time.Now().Add(31*time.Minute)) {
|
||||
t.Fatal("bad credential")
|
||||
}
|
||||
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := user.Profile
|
||||
if !bytes.Equal([]byte(profile.UserID), []byte("42")) || profile.TenantID != "tenant-a" || profile.Issuer != "issuer" || len(user.Permissions) != 1 {
|
||||
t.Fatalf("bad current user: %+v", user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validOneOSOrigin(value string, local bool) bool {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return false
|
||||
}
|
||||
if local {
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
return ip != nil && ip.IsLoopback() && (u.Scheme == "http" || u.Scheme == "https")
|
||||
}
|
||||
return u.Scheme == "https"
|
||||
}
|
||||
|
||||
const oneOSHandoffCookie = "oneos_handoff"
|
||||
|
||||
type oneOSPendingLogin struct {
|
||||
credential oneOSCredential
|
||||
profile oneOSProfile
|
||||
csrf string
|
||||
expires time.Time
|
||||
}
|
||||
type oneOSProfileAuth struct {
|
||||
client *oneOSProfileClient
|
||||
store *authStore
|
||||
origin, issuer, tenant string
|
||||
passwordEnabled, ssoEnabled, scopeEnabled, localOnly bool
|
||||
devIntegration bool
|
||||
devDataProxy bool
|
||||
localBackend, vehicleQueryScope bool
|
||||
scopeMu sync.Mutex
|
||||
queryScopes map[[32]byte]*oneOSQueryScopeEntry
|
||||
mu sync.Mutex
|
||||
pendingLogins map[[32]byte]oneOSPendingLogin
|
||||
users map[string]authUser
|
||||
}
|
||||
type oneOSLoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ClientID string `json:"clientId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) cookie(w http.ResponseWriter, value string, maxAge int) {
|
||||
http.SetCookie(w, &http.Cookie{Name: oneOSHandoffCookie, Value: value, Path: "/api/v2/auth/oneos", HttpOnly: true, Secure: strings.HasPrefix(a.origin, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
|
||||
}
|
||||
func oneOSAuthError(w http.ResponseWriter, r *http.Request, status int, code string) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
message := "OneOS 登录暂不可用或授权已失效"
|
||||
if code == "ONEOS_PERMISSION_DENIED" {
|
||||
message = "当前 OneOS 账号缺少此功能的查询权限,请联系管理员开通后刷新校验"
|
||||
}
|
||||
httpx.WriteError(w, status, code, message, "", requestTraceID(r))
|
||||
}
|
||||
func (a *oneOSProfileAuth) handle(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.URL.Path == "/api/v2/auth/logout" && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
if err := a.client.Logout(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID")); err != nil {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_LOGOUT_FAILED")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
return true
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/oneos/me" || r.URL.Path == "/api/v2/auth/oneos/vehicles" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(r.URL.Path, "/api/v2/auth/oneos/") {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
switch r.URL.Path {
|
||||
case "/api/v2/auth/oneos/callback":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
// Never exchange profile-jump credentials here: they are not login tokens.
|
||||
var err error
|
||||
if !a.ssoEnabled {
|
||||
err = errOneOSAuthority
|
||||
} else {
|
||||
var credential oneOSCredential
|
||||
credential, err = a.client.ExchangeLoginTicket(r.Context(), r.URL.Query().Get("code"))
|
||||
if err == nil {
|
||||
var user oneOSCurrentUser
|
||||
user, err = a.client.CurrentUser(r.Context(), credential.AccessToken, credential.ClientID)
|
||||
if err == nil {
|
||||
err = a.stage(w, credential, user.Profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
target := "/auth/oneos/complete"
|
||||
if err != nil {
|
||||
a.cookie(w, "", -1)
|
||||
target += "?error=ONEOS_LOGIN_FAILED"
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
case "/api/v2/auth/oneos/pending":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
item, err := a.pending(r, false)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
|
||||
return true
|
||||
}
|
||||
p := item.profile
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"identity": map[string]string{"username": p.Username, "displayName": firstNonEmpty(p.Nickname, p.Username), "tenantId": p.TenantID, "departmentName": p.DeptName}, "csrfToken": item.csrf, "expiresAt": item.expires})
|
||||
case "/api/v2/auth/oneos/confirm", "/api/v2/auth/oneos/cancel":
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin || a.origin == "" {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
var input struct {
|
||||
CSRFToken string `json:"csrfToken"`
|
||||
}
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return true
|
||||
}
|
||||
item, err := a.pending(r, false)
|
||||
if err != nil || subtle.ConstantTimeCompare([]byte(item.csrf), []byte(input.CSRFToken)) != 1 {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_HANDOFF_REJECTED")
|
||||
return true
|
||||
}
|
||||
item, err = a.pending(r, true)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
|
||||
return true
|
||||
}
|
||||
a.cookie(w, "", -1)
|
||||
if strings.HasSuffix(r.URL.Path, "/cancel") {
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"cancelled": true})
|
||||
return true
|
||||
}
|
||||
response, err := a.login(r, item.credential)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
case "/api/v2/auth/oneos/captcha":
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if !a.passwordEnabled {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
captcha, err := a.client.FetchCaptcha(r.Context())
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), captcha)
|
||||
case "/api/v2/auth/oneos/login":
|
||||
if r.Method != http.MethodPost {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("Origin") != a.origin || a.origin == "" {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
|
||||
return true
|
||||
}
|
||||
if !a.passwordEnabled {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
if !a.scopeEnabled && !a.devIntegration {
|
||||
oneOSAuthError(w, r, 503, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
var input oneOSPasswordLogin
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return true
|
||||
}
|
||||
credential, err := a.client.AuthenticatePassword(r.Context(), input)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 401, "ONEOS_LOGIN_FAILED")
|
||||
return true
|
||||
}
|
||||
response, err := a.login(r, credential)
|
||||
if err != nil {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
|
||||
return true
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
default:
|
||||
oneOSAuthError(w, r, 404, "NOT_FOUND")
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (a *oneOSProfileAuth) validProfile(p oneOSProfile) bool {
|
||||
return p.Issuer == a.issuer && p.TenantID == a.tenant && p.UserID != "" && len(p.UserID) <= 96
|
||||
}
|
||||
func (a *oneOSProfileAuth) stage(w http.ResponseWriter, c oneOSCredential, p oneOSProfile) error {
|
||||
if !a.localOnly || !a.validProfile(p) || !c.ExpiresAt.After(time.Now()) {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
token, hash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
csrf, _, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expiry := time.Now().Add(2 * time.Minute)
|
||||
if c.ExpiresAt.Before(expiry) {
|
||||
expiry = c.ExpiresAt
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.pendingLogins == nil {
|
||||
a.pendingLogins = map[[32]byte]oneOSPendingLogin{}
|
||||
}
|
||||
for key, item := range a.pendingLogins {
|
||||
if !item.expires.After(time.Now()) {
|
||||
delete(a.pendingLogins, key)
|
||||
}
|
||||
}
|
||||
if len(a.pendingLogins) >= 1000 {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
a.pendingLogins[hash] = oneOSPendingLogin{credential: c, profile: p, csrf: csrf, expires: expiry}
|
||||
a.cookie(w, token, 120)
|
||||
return nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) pending(r *http.Request, consume bool) (oneOSPendingLogin, error) {
|
||||
c, err := r.Cookie(oneOSHandoffCookie)
|
||||
if err != nil || len(c.Value) != 43 {
|
||||
return oneOSPendingLogin{}, errOneOSAuthority
|
||||
}
|
||||
hash := sha256.Sum256([]byte(c.Value))
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
item, ok := a.pendingLogins[hash]
|
||||
if !ok {
|
||||
return item, errOneOSAuthority
|
||||
}
|
||||
if consume || !item.expires.After(time.Now()) {
|
||||
delete(a.pendingLogins, hash)
|
||||
}
|
||||
if !item.expires.After(time.Now()) {
|
||||
return item, errOneOSAuthority
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) bind(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
|
||||
u, id, err := a.bindAttempt(ctx, p)
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if err != nil && errors.As(err, &mysqlErr) && (mysqlErr.Number == 1062 || mysqlErr.Number == 1213 || mysqlErr.Number == 1205) {
|
||||
return a.bindAttempt(ctx, p)
|
||||
}
|
||||
return u, id, err
|
||||
}
|
||||
func (a *oneOSProfileAuth) bindAttempt(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
|
||||
if !a.validProfile(p) {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
if a.store == nil {
|
||||
if !a.localOnly {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
key := string(encoded)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.users == nil {
|
||||
a.users = map[string]authUser{}
|
||||
}
|
||||
if u, ok := a.users[key]; ok {
|
||||
if u.Status != "enabled" {
|
||||
return u, 0, errOneOSAuthority
|
||||
}
|
||||
return u, u.ID, nil
|
||||
}
|
||||
if len(a.users) >= 10000 {
|
||||
return authUser{}, 0, errOneOSAuthority
|
||||
}
|
||||
hash := sha256.Sum256(encoded)
|
||||
u := authUser{ID: uint64(len(a.users) + 1), Username: "oneos-" + hex.EncodeToString(hash[:24]), DisplayName: firstNonEmpty(p.Nickname, p.Username, p.UserID), Status: "enabled", AuthProvider: "oneos-profile"}
|
||||
a.users[key] = u
|
||||
return u, u.ID, nil
|
||||
}
|
||||
tx, err := a.store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return authUser{}, 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var u authUser
|
||||
var binding uint64
|
||||
err = tx.QueryRowContext(ctx, `SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider FROM platform_external_identity i JOIN platform_user u ON u.id=i.user_id WHERE i.issuer=? AND i.tenant_id=? AND i.external_user_id=? FOR UPDATE`, p.Issuer, p.TenantID, p.UserID).Scan(&binding, &u.ID, &u.Username, &u.DisplayName, &u.Status, &u.AuthProvider)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
hash := sha256.Sum256(encoded)
|
||||
u.Username = "oneos-" + hex.EncodeToString(hash[:24])
|
||||
u.DisplayName = truncateUTF8(firstNonEmpty(p.Nickname, p.Username, p.UserID), 96)
|
||||
u.Status = "enabled"
|
||||
u.AuthProvider = "oneos-profile"
|
||||
result, e := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,'','customer','enabled',?,'oneos-profile','oneos-profile','oneos-profile')`, u.Username, u.DisplayName, p.TenantID)
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
id, e := result.LastInsertId()
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
u.ID = uint64(id)
|
||||
result, e = tx.ExecContext(ctx, `INSERT INTO platform_external_identity(issuer,tenant_id,external_user_id,user_id) VALUES(?,?,?,?)`, p.Issuer, p.TenantID, p.UserID, u.ID)
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
id, e = result.LastInsertId()
|
||||
if e != nil {
|
||||
return u, 0, e
|
||||
}
|
||||
binding = uint64(id)
|
||||
} else if err != nil {
|
||||
return u, 0, err
|
||||
}
|
||||
if u.Status != "enabled" || u.AuthProvider != "oneos-profile" {
|
||||
return u, 0, errOneOSAuthority
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return u, 0, err
|
||||
}
|
||||
return u, binding, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) resolve(ctx context.Context, token, clientID string) (platform.Principal, oneOSCurrentUser, error) {
|
||||
current, err := a.client.CurrentUser(ctx, token, clientID)
|
||||
if err != nil || !a.validProfile(current.Profile) {
|
||||
return platform.Principal{}, current, errOneOSAuthority
|
||||
}
|
||||
if a.vehicleQueryScope {
|
||||
return a.resolveVehicleQueryScope(ctx, token, clientID, current)
|
||||
}
|
||||
if oneOSHasAdminPermission(current.Permissions) {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := oneOSAdminPrincipal(u, current.Profile, current.Permissions)
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
// Full authority does not make mock storage into real business data.
|
||||
if a.devIntegration && !a.devDataProxy {
|
||||
p.AuthorizationStatus = "asset-catalog"
|
||||
}
|
||||
if a.devDataProxy {
|
||||
p.ReadOnly = true
|
||||
p.DataSource = "ecs-readonly"
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
if a.devIntegration && !a.scopeEnabled {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, Role: "customer", UserType: "customer", AuthProvider: "oneos", AuthorizationStatus: "pending", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
grants := oneOSPermissions(current.Permissions)
|
||||
if grants["vehicle:platform:access"] && grants["vehiclePlatform:vehicles:view"] && grants["vehiclePlatform:vehicle:read"] {
|
||||
p.AuthorizationStatus = "asset-catalog"
|
||||
p.PermissionKeys = append([]string(nil), current.Permissions...)
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
p.MenuKeys = append(p.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
scope, err := a.client.VehicleScope(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
// Validate authorization before persisting an identity.
|
||||
if _, err = oneOSScopedPrincipal(authUser{}, current.Profile, current.Permissions, scope, time.Now()); err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p, err := oneOSScopedPrincipal(u, current.Profile, current.Permissions, scope, time.Now())
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
return p, current, err
|
||||
}
|
||||
func (a *oneOSProfileAuth) login(r *http.Request, c oneOSCredential) (oneOSLoginResponse, error) {
|
||||
if c.AccessToken == "" || c.ClientID == "" || !c.ExpiresAt.After(time.Now()) {
|
||||
return oneOSLoginResponse{}, errOneOSAuthority
|
||||
}
|
||||
if a.vehicleQueryScope {
|
||||
current, err := a.client.CurrentUser(r.Context(), c.AccessToken, c.ClientID)
|
||||
if err != nil || !a.validProfile(current.Profile) {
|
||||
return oneOSLoginResponse{}, errOneOSAuthority
|
||||
}
|
||||
u, _, err := a.bind(r.Context(), current.Profile)
|
||||
if err != nil {
|
||||
return oneOSLoginResponse{}, err
|
||||
}
|
||||
a.invalidateVehicleQueryScope(c.AccessToken, c.ClientID)
|
||||
principal := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "loading", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}
|
||||
if !oneOSPermissions(current.Permissions)["vehicle:platform:access"] {
|
||||
principal.AuthorizationStatus = "pending"
|
||||
}
|
||||
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
|
||||
}
|
||||
principal, _, err := a.resolve(r.Context(), c.AccessToken, c.ClientID)
|
||||
if err != nil {
|
||||
return oneOSLoginResponse{}, err
|
||||
}
|
||||
// Return the exact upstream token. No platform session or replacement token.
|
||||
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
|
||||
}
|
||||
func (a *oneOSProfileAuth) authenticate(r *http.Request, token string) (platform.Principal, bool) {
|
||||
p, _, err := a.resolve(r.Context(), token, r.Header.Get("X-OneOS-Client-ID"))
|
||||
return p, err == nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOneOSSharedTokenRevalidatesAndNeverFallsBack(t *testing.T) {
|
||||
const token = "exact-upstream-token-123456789"
|
||||
var revoked atomic.Bool
|
||||
var count atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
|
||||
t.Errorf("wrong credential headers")
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
count.Add(1)
|
||||
if revoked.Load() {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
var data any
|
||||
switch r.URL.Path {
|
||||
case "/system/user/getInfo":
|
||||
data = map[string]any{"user": map[string]any{"userId": "7", "tenantId": "t", "userName": "same-name", "nickName": "Seven", "status": "0"}, "permissions": []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}}
|
||||
case "/scope":
|
||||
data = oneOSVehicleScope{TenantID: "t", UserID: "7", PermissionVersion: "v1", ExpiresAt: time.Now().Add(30 * time.Second), Vehicles: []oneOSVehicleGrant{{VIN: "VIN7", ValidFrom: time.Now().Add(-time.Hour)}}}
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", vehicleScopePath: "/scope", client: server.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, scopeEnabled: true}
|
||||
req := httptest.NewRequest("GET", "/api/vehicles", nil)
|
||||
req.Header.Set("X-OneOS-Client-ID", "web")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := auth.login(req, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.AccessToken != token || response.ClientID != "web" || response.Session.Role != "customer" || !response.Session.CanVIN("VIN7") || response.Session.CanVIN("VIN8") {
|
||||
t.Fatalf("unsafe login: %#v", response)
|
||||
}
|
||||
if _, ok := auth.authenticate(req, token); !ok {
|
||||
t.Fatal("valid token denied")
|
||||
}
|
||||
if count.Load() != 4 {
|
||||
t.Fatal("did not revalidate identity and scope")
|
||||
}
|
||||
forbidden := httptest.NewRequest("GET", "/api/v2/alerts/events", nil)
|
||||
forbidden.Header = req.Header.Clone()
|
||||
forbiddenRec := httptest.NewRecorder()
|
||||
(&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.NotFoundHandler()).ServeHTTP(forbiddenRec, forbidden)
|
||||
if forbiddenRec.Code != http.StatusForbidden {
|
||||
t.Fatalf("valid token permission denial must be 403: %d", forbiddenRec.Code)
|
||||
}
|
||||
// An upstream-denied token must not fall through to a coincidentally equal legacy token.
|
||||
revoked.Store(true)
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
api := &apiAuthenticator{mode: "enforce", profile: auth, tokens: []tokenPrincipal{{hash: hash, principal: response.Session}}}
|
||||
if _, ok := api.authenticate(req); ok {
|
||||
t.Fatal("revoked token accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSStableBindingDoesNotMergeNamesOrReenable(t *testing.T) {
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", localOnly: true}
|
||||
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1", Username: "same"}
|
||||
u, _, err := a.bind(context.Background(), p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.UserID = "2"
|
||||
v, _, err := a.bind(context.Background(), p)
|
||||
if err != nil || u.ID == v.ID {
|
||||
t.Fatal("merged names")
|
||||
}
|
||||
p.UserID = "1"
|
||||
raw, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
|
||||
u.Status = "disabled"
|
||||
a.users[string(raw)] = u
|
||||
if _, _, err := a.bind(context.Background(), p); err == nil {
|
||||
t.Fatal("disabled identity reenabled")
|
||||
}
|
||||
}
|
||||
func TestOneOSPendingConsumeOriginAndExpiry(t *testing.T) {
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", origin: "http://127.0.0.1:20400", localOnly: true}
|
||||
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}
|
||||
w := httptest.NewRecorder()
|
||||
if err := a.stage(w, oneOSCredential{AccessToken: "real-upstream-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}, p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cookie := w.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatal("unsafe cookie")
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/api/v2/auth/oneos/pending", nil)
|
||||
req.AddCookie(cookie)
|
||||
item, err := a.pending(req, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forged := httptest.NewRequest("POST", "/api/v2/auth/oneos/confirm", strings.NewReader(`{"csrfToken":"`+item.csrf+`"}`))
|
||||
forged.AddCookie(cookie)
|
||||
forged.Header.Set("Origin", "https://attacker.invalid")
|
||||
out := httptest.NewRecorder()
|
||||
a.handle(out, forged)
|
||||
if out.Code != 403 {
|
||||
t.Fatal("cross-origin confirmation allowed")
|
||||
}
|
||||
if _, err := a.pending(req, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.pending(req, true); err == nil {
|
||||
t.Fatal("handoff replay")
|
||||
}
|
||||
if strings.Contains(out.Body.String(), "real-upstream-token") {
|
||||
t.Fatal("token leaked in error")
|
||||
}
|
||||
}
|
||||
func TestOneOSPermissionAndScopeFailClosed(t *testing.T) {
|
||||
now := time.Now()
|
||||
p := oneOSProfile{TenantID: "t", UserID: "1"}
|
||||
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
|
||||
wildcard, err := oneOSScopedPrincipal(authUser{}, p, []string{"*:*:*"}, scope, now)
|
||||
if err != nil || wildcard.Role != "admin" || !wildcard.CanVIN("ANY_VEHICLE") || !wildcard.CanMenu("users") {
|
||||
t.Fatal("OneOS super administrator did not receive platform administration")
|
||||
}
|
||||
for _, mutate := range []func(*oneOSVehicleScope){func(s *oneOSVehicleScope) { s.UserID = "2" }, func(s *oneOSVehicleScope) { s.TenantID = "other" }, func(s *oneOSVehicleScope) { s.ExpiresAt = now.Add(61 * time.Second) }, func(s *oneOSVehicleScope) { s.ExpiresAt = now }, func(s *oneOSVehicleScope) { s.PermissionVersion = "" }} {
|
||||
s := scope
|
||||
mutate(&s)
|
||||
if _, err := oneOSScopedPrincipal(authUser{}, p, []string{"vehicle:platform:access"}, s, now); err == nil {
|
||||
t.Fatal("invalid scope accepted")
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/api/v2/admin/users", "/api/v2/operations/tasks", "/api/v2/exports", "/api/v2/auth/password", "/api/v2/alerts/events"} {
|
||||
if oneOSReadAction(httptest.NewRequest("GET", path, nil)) != "" {
|
||||
t.Fatal("unsupported route authorized", path)
|
||||
}
|
||||
}
|
||||
if oneOSReadAction(httptest.NewRequest("DELETE", "/api/v2/vehicles/123", nil)) != "" {
|
||||
t.Fatal("write authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDatabaseBindingPreservesDisable(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WithArgs("i", "t", "1").WillReturnRows(sqlmock.NewRows([]string{"id", "uid", "username", "name", "status", "provider"}).AddRow(8, 9, "internal", "Name", "disabled", "oneos-profile"))
|
||||
mock.ExpectRollback()
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
|
||||
if _, _, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}); err == nil {
|
||||
t.Fatal("disabled persisted identity allowed")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestOneOSOriginPolicy(t *testing.T) {
|
||||
for _, origin := range []string{"http://example.com", "https://u:p@example.com", "https://example.com/path", "https://example.com?x=1", "https://example.com#x"} {
|
||||
if validOneOSOrigin(origin, false) {
|
||||
t.Fatal(origin)
|
||||
}
|
||||
}
|
||||
if !validOneOSOrigin("https://example.com", false) || !validOneOSOrigin("http://127.0.0.1:20400", true) || validOneOSOrigin("https://example.com", true) {
|
||||
t.Fatal("origin policy failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDatabaseBindingRetriesConcurrentWinnerOnce(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
cols := []string{"id", "uid", "username", "name", "status", "provider"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols))
|
||||
mock.ExpectExec("INSERT INTO platform_user").WillReturnError(&mysql.MySQLError{Number: 1062, Message: "duplicate"})
|
||||
mock.ExpectRollback()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols).AddRow(8, 9, "internal", "Name", "enabled", "oneos-profile"))
|
||||
mock.ExpectCommit()
|
||||
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
|
||||
u, id, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"})
|
||||
if err != nil || u.ID != 9 || id != 8 {
|
||||
t.Fatalf("did not reuse concurrent winner: %+v %d %v", u, id, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevPendingIdentityIsLiveAndHasNoBusinessAccess(t *testing.T) {
|
||||
const token = "real-oneos-token-for-test"
|
||||
var disabled atomic.Bool
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/system/user/getInfo" {
|
||||
t.Errorf("pending must not call scope %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
calls.Add(1)
|
||||
if r.Header.Get("Authorization") != "Bearer "+token {
|
||||
t.Error("upstream token changed")
|
||||
}
|
||||
status := "0"
|
||||
if disabled.Load() {
|
||||
status = "1"
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "nickName": "Administrator", "status": status}, "permissions": []string{}, "roles": []string{"superadmin"}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: server.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, devIntegration: true}
|
||||
request := httptest.NewRequest("GET", "/api/v2/session", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-OneOS-Client-ID", "web")
|
||||
login, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if login.AccessToken != token || login.Session.AuthorizationStatus != "pending" || login.Session.Role != "customer" || login.Session.UserType != "customer" || len(login.Session.MenuKeys) != 0 || len(login.Session.VehicleVINs) != 0 || len(login.Session.PermissionKeys) != 0 {
|
||||
t.Fatalf("unsafe pending identity: %+v", login.Session)
|
||||
}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending reached business handler") }))
|
||||
for _, item := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{{"GET", "/api/v2/session", 200}, {"GET", "/api/v2/auth/oneos/me", 200}, {"GET", "/api/vehicles", 403}, {"GET", "/api/v2/monitor/workspace", 403}, {"GET", "/api/v2/admin/users", 403}, {"POST", "/api/v2/session", 403}} {
|
||||
req := httptest.NewRequest(item.method, item.path, nil)
|
||||
req.Header = request.Header.Clone()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != item.status {
|
||||
t.Fatalf("%s %s status %d want %d", item.method, item.path, rec.Code, item.status)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 7 {
|
||||
t.Fatal("identity not revalidated on every request", calls.Load())
|
||||
}
|
||||
disabled.Store(true)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, request)
|
||||
if rec.Code != 401 {
|
||||
t.Fatal("disabled upstream user retained pending identity")
|
||||
}
|
||||
disabled.Store(false)
|
||||
auth.devIntegration = false
|
||||
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
|
||||
t.Fatal("fixture/normal mode accepted missing scope")
|
||||
}
|
||||
auth.devIntegration = true
|
||||
auth.scopeEnabled = true
|
||||
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
|
||||
t.Fatal("configured scope failure downgraded to pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevAssetCatalogDoesNotGrantTelemetryOrHistory(t *testing.T) {
|
||||
const token = "catalog-upstream-token"
|
||||
var permitted atomic.Bool
|
||||
permitted.Store(true)
|
||||
var assetCalls atomic.Int32
|
||||
var catalogOffline atomic.Bool
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("token changed")
|
||||
}
|
||||
var data any
|
||||
switch r.URL.Path {
|
||||
case "/system/user/getInfo":
|
||||
permissions := []string{}
|
||||
if permitted.Load() {
|
||||
permissions = []string{"*:*:*"}
|
||||
}
|
||||
data = map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}
|
||||
case "/asset/vehicle-info/page":
|
||||
assetCalls.Add(1)
|
||||
if catalogOffline.Load() {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("pageNum") != "2" || r.URL.Query().Get("pageSize") != "10" || r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Error("catalog query changed", r.URL.RawQuery)
|
||||
}
|
||||
data = map[string]any{"records": []map[string]any{{"vin": "LREALASSET00000001", "plateNumber": "沪A12345", "brand": "真实品牌"}}, "total": 11, "current": 2, "size": 10}
|
||||
default:
|
||||
t.Errorf("unexpected upstream route %s", r.URL.Path)
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
client := &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: upstream.Client()}
|
||||
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", devIntegration: true, localOnly: true}
|
||||
api := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("catalog user reached mock business") }))
|
||||
request := func(path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
api.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
session := request("/api/v2/session")
|
||||
if session.Code != 200 || !strings.Contains(session.Body.String(), `"authorizationStatus":"asset-catalog"`) || !strings.Contains(session.Body.String(), `"vehicleCount":0`) {
|
||||
t.Fatal("wrong catalog session", session.Body.String())
|
||||
}
|
||||
list := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10")
|
||||
if list.Code != 200 || !strings.Contains(list.Body.String(), "LREALASSET00000001") || !strings.Contains(list.Body.String(), `"telemetryAvailable":false`) {
|
||||
t.Fatal("catalog unavailable", list.Code, list.Body.String())
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles", "/api/v2/monitor/workspace", "/api/v2/tracks?vin=LREALASSET00000001", "/api/v2/vehicles/LREALASSET00000001/telemetry/latest"} {
|
||||
if rec := request(path); rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
|
||||
t.Fatal("catalog leaked platform data", path, rec.Code)
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"unknown=1", "pageSize=100", "pageNum=0", "pageSize=", "pageSize=10&pageSize=20"} {
|
||||
rec := request("/api/v2/auth/oneos/vehicles?" + query)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_QUERY_INVALID") || strings.Contains(rec.Body.String(), "登录") {
|
||||
t.Fatal("invalid catalog query misclassified", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
catalogOffline.Store(true)
|
||||
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 502 || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_UNAVAILABLE") || strings.Contains(rec.Body.String(), "登录") {
|
||||
t.Fatal("upstream outage misclassified", rec.Code, rec.Body.String())
|
||||
}
|
||||
permitted.Store(false)
|
||||
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 403 {
|
||||
t.Fatal("revoked catalog permission remained")
|
||||
}
|
||||
if assetCalls.Load() != 2 {
|
||||
t.Fatal("asset fetch happened without permission or for noncatalog routes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSChildPermissionsUseThreeSegmentMenuContract(t *testing.T) {
|
||||
now := time.Now()
|
||||
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
|
||||
profile := oneOSProfile{TenantID: "t", UserID: "1"}
|
||||
p, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}, scope, now)
|
||||
if err != nil || !p.CanMenu("vehicles") {
|
||||
t.Fatal("valid three-segment permission denied", err)
|
||||
}
|
||||
old, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehicle:platform:vehicles:view", "vehicle:platform:vehicle:read"}, scope, now)
|
||||
if err != nil || old.CanMenu("vehicles") {
|
||||
t.Fatal("legacy invalid child permission treated as configured menu")
|
||||
}
|
||||
for key := range oneOSPermissions([]string{"*:*:*"}) {
|
||||
if strings.Count(key, ":") != 2 {
|
||||
t.Fatal("permission incompatible with OneOS menu contract", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
// oneOSProfile is identity data only. The profile-jump API conveys no roles,
|
||||
// permissions, data scope, or evidence that the upstream session remains active.
|
||||
type oneOSProfile struct {
|
||||
Issuer string `json:"-"`
|
||||
UserID string `json:"userId"`
|
||||
TenantID string `json:"tenantId"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
DeptID string `json:"deptId"`
|
||||
DeptName string `json:"deptName"`
|
||||
}
|
||||
|
||||
type oneOSProfileClient struct {
|
||||
baseURL string
|
||||
serviceToken string
|
||||
issuer string
|
||||
allowedTenant string
|
||||
requestTimeout time.Duration
|
||||
client *http.Client
|
||||
passwordEnabled bool
|
||||
passwordClientID string
|
||||
passwordPublicKey *rsa.PublicKey
|
||||
loginTicketExchangePath string
|
||||
loginTicketServiceToken string
|
||||
vehicleScopePath string
|
||||
}
|
||||
|
||||
type oneOSProfileEnvelope[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type oneOSProfileToken struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
TokenType string `json:"tokenType"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
const oneOSProfileMaxResponse = 64 << 10
|
||||
|
||||
var errOneOSProfileRejected = errors.New("OneOS profile request rejected")
|
||||
|
||||
func newOneOSProfileClient(cfg config.Config) (*oneOSProfileClient, error) {
|
||||
if !cfg.OneOSProfileEnabled && !cfg.OneOSAuthEnabled && !cfg.OneOSPasswordEnabled && cfg.OneOSLoginTicketExchangePath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
|
||||
return nil, errors.New("OneOS local-only and dev-integration modes are mutually exclusive")
|
||||
}
|
||||
if cfg.OneOSDevIntegration && !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
|
||||
return nil, errors.New("local OneOS integration permits only the configured dev or production HTTPS origin")
|
||||
}
|
||||
productionOneOS := strings.TrimRight(cfg.OneOSProfileBaseURL, "/") == "https://lnoneos.com/prod-api"
|
||||
base, err := url.Parse(strings.TrimSpace(cfg.OneOSProfileBaseURL))
|
||||
if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (!cfg.OneOSDevIntegration && !productionOneOS && base.Path != "" && base.Path != "/") {
|
||||
return nil, errors.New("ONEOS_PROFILE_BASE_URL must be an HTTPS origin")
|
||||
}
|
||||
loopback := net.ParseIP(base.Hostname()) != nil && net.ParseIP(base.Hostname()).IsLoopback()
|
||||
if cfg.OneOSLocalOnly && !loopback {
|
||||
return nil, errors.New("ONEOS_LOCAL_ONLY requires a literal loopback OneOS origin")
|
||||
}
|
||||
if base.Scheme != "https" && !(cfg.OneOSLocalOnly && loopback && base.Scheme == "http") {
|
||||
return nil, errors.New("ONEOS_PROFILE_BASE_URL must use HTTPS outside explicit local-only mode")
|
||||
}
|
||||
if cfg.OneOSProfileServiceToken != "" && (len(cfg.OneOSProfileServiceToken) < 32 || strings.TrimSpace(cfg.OneOSProfileServiceToken) != cfg.OneOSProfileServiceToken || strings.ContainsAny(cfg.OneOSProfileServiceToken, "\r\n ")) {
|
||||
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
|
||||
}
|
||||
if cfg.OneOSProfileEnabled && len(cfg.OneOSProfileServiceToken) < 32 {
|
||||
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN is required for profile-jump")
|
||||
}
|
||||
if err := validateOneOSConfiguredPath(cfg.OneOSLoginTicketExchangePath); err != nil {
|
||||
return nil, errors.New("ONEOS_LOGIN_TICKET_EXCHANGE_PATH is invalid")
|
||||
}
|
||||
if cfg.OneOSLoginTicketExchangePath != "" && (len(cfg.OneOSLoginTicketServiceToken) < 32 || strings.TrimSpace(cfg.OneOSLoginTicketServiceToken) != cfg.OneOSLoginTicketServiceToken || strings.ContainsAny(cfg.OneOSLoginTicketServiceToken, " \t\r\n")) {
|
||||
return nil, errors.New("ONEOS_LOGIN_TICKET_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
|
||||
}
|
||||
if err := validateOneOSConfiguredPath(cfg.OneOSVehicleScopePath); err != nil {
|
||||
return nil, errors.New("ONEOS_VEHICLE_SCOPE_PATH is invalid")
|
||||
}
|
||||
issuer := strings.TrimSpace(cfg.OneOSProfileIssuer)
|
||||
tenant := strings.TrimSpace(cfg.OneOSProfileAllowedTenant)
|
||||
if issuer == "" || tenant == "" {
|
||||
return nil, errors.New("ONEOS_PROFILE_ISSUER and ONEOS_PROFILE_ALLOWED_TENANT are required")
|
||||
}
|
||||
timeout := cfg.OneOSProfileTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3500 * time.Millisecond
|
||||
}
|
||||
if timeout >= 5*time.Second {
|
||||
return nil, errors.New("ONEOS_PROFILE_TIMEOUT_MS must be less than 5000")
|
||||
}
|
||||
if cfg.OneOSDevIntegration || productionOneOS {
|
||||
base.Path = "/prod-api"
|
||||
} else {
|
||||
base.Path = ""
|
||||
}
|
||||
var passwordPublicKey *rsa.PublicKey
|
||||
if cfg.OneOSPasswordEnabled {
|
||||
if strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
|
||||
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required")
|
||||
}
|
||||
passwordPublicKey, err = parseOneOSRSAPublicKeyForMode(cfg.OneOSPasswordRSAPublicKey, cfg.OneOSDevIntegration || productionOneOS)
|
||||
if err != nil {
|
||||
return nil, errors.New("ONEOS_PASSWORD_RSA_PUBLIC_KEY is invalid")
|
||||
}
|
||||
}
|
||||
if cfg.OneOSLoginTicketExchangePath != "" && strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
|
||||
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required for login-ticket exchange")
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.ResponseHeaderTimeout = timeout
|
||||
return &oneOSProfileClient{
|
||||
baseURL: base.String(), serviceToken: cfg.OneOSProfileServiceToken,
|
||||
issuer: issuer, allowedTenant: tenant, requestTimeout: timeout,
|
||||
passwordEnabled: cfg.OneOSPasswordEnabled, passwordClientID: cfg.OneOSPasswordClientID, passwordPublicKey: passwordPublicKey,
|
||||
loginTicketExchangePath: cfg.OneOSLoginTicketExchangePath, loginTicketServiceToken: cfg.OneOSLoginTicketServiceToken, vehicleScopePath: cfg.OneOSVehicleScopePath,
|
||||
client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExchangeCode consumes a code exactly once, then reads its bound profile.
|
||||
// The whole exchange shares one deadline; a failed call is never retried.
|
||||
func (c *oneOSProfileClient) ExchangeCode(ctx context.Context, code string) (oneOSProfile, error) {
|
||||
if c == nil || c.serviceToken == "" {
|
||||
return oneOSProfile{}, errors.New("OneOS profile client is disabled")
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(code)
|
||||
if len(code) != 43 || err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != code {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile code")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
|
||||
defer cancel()
|
||||
body, _ := json.Marshal(struct {
|
||||
Code string `json:"code"`
|
||||
}{code})
|
||||
var token oneOSProfileToken
|
||||
if err := c.doJSON(ctx, http.MethodPost, "/auth/inner/v1/sso/profile-jump/exchange", body, c.serviceToken, &token); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
if !validOneOSOpaqueToken(token.AccessToken) || token.TokenType != "Bearer" || !token.ExpiresAt.After(time.Now().Add(time.Second)) {
|
||||
return oneOSProfile{}, errors.New("invalid OneOS profile token response")
|
||||
}
|
||||
var profile oneOSProfile
|
||||
if err := c.doJSON(ctx, http.MethodGet, "/auth/sso/profile-jump/me", nil, token.AccessToken, &profile); err != nil {
|
||||
return oneOSProfile{}, err
|
||||
}
|
||||
if strings.TrimSpace(profile.UserID) == "" || strings.TrimSpace(profile.TenantID) == "" || profile.TenantID != c.allowedTenant {
|
||||
return oneOSProfile{}, errors.New("OneOS profile identity or tenant rejected")
|
||||
}
|
||||
profile.Issuer = c.issuer
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func validOneOSOpaqueToken(value string) bool {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
return len(value) == 43 && err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) doJSON(ctx context.Context, method, path string, body []byte, bearer string, target any) error {
|
||||
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return errors.New("invalid OneOS request")
|
||||
}
|
||||
if bearer != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if method == http.MethodPost {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: transport failure", errOneOSProfileRejected)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%w: HTTP %d", errOneOSProfileRejected, response.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
|
||||
if err != nil || len(raw) > oneOSProfileMaxResponse {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
var envelope oneOSProfileEnvelope[json.RawMessage]
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
if err := json.Unmarshal(envelope.Data, target); err != nil {
|
||||
return errors.New("invalid OneOS profile response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func testOneOSProfileClient(t *testing.T, server *httptest.Server) *oneOSProfileClient {
|
||||
t.Helper()
|
||||
client, err := newOneOSProfileClient(config.Config{
|
||||
OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL,
|
||||
OneOSProfileServiceToken: strings.Repeat("s", 32),
|
||||
OneOSProfileIssuer: "oneos-dev", OneOSProfileAllowedTenant: "tenant-a",
|
||||
OneOSProfileTimeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.client = server.Client()
|
||||
client.client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
||||
return client
|
||||
}
|
||||
|
||||
func TestOneOSProfileExchangeUsesTwoTokensAndMinimalIdentity(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/auth/inner/v1/sso/profile-jump/exchange":
|
||||
calls.Add(1)
|
||||
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
|
||||
t.Errorf("bad exchange request")
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if string(body) != `{"code":"`+code+`"}` {
|
||||
t.Errorf("bad code body")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
|
||||
case "/auth/sso/profile-jump/me":
|
||||
calls.Add(1)
|
||||
if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+profileToken {
|
||||
t.Errorf("bad profile request")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "tenant-a", "username": "jane", "nickname": "Jane", "deptId": "7", "deptName": "East", "permissions": []string{"*"}}})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testOneOSProfileClient(t, server)
|
||||
profile, err := client.ExchangeCode(context.Background(), code)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls.Load() != 2 || profile.UserID != "42" || profile.Issuer != "oneos-dev" || profile.TenantID != "tenant-a" {
|
||||
t.Fatalf("unexpected profile: %+v calls=%d", profile, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSProfileExchangeRejectsTenantAndDoesNotRetry(t *testing.T) {
|
||||
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
|
||||
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Path == "/auth/inner/v1/sso/profile-jump/exchange" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "other"}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testOneOSProfileClient(t, server)
|
||||
if _, err := client.ExchangeCode(context.Background(), code); err == nil {
|
||||
t.Fatal("cross-tenant identity accepted")
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("unexpected retries: %d", calls.Load())
|
||||
}
|
||||
if _, err := client.ExchangeCode(context.Background(), "invalid"); err == nil || calls.Load() != 2 {
|
||||
t.Fatal("invalid code reached OneOS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSProfileClientRequiresHTTPSAndTenant(t *testing.T) {
|
||||
base := config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant"}
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("HTTP origin accepted")
|
||||
}
|
||||
base.OneOSProfileBaseURL = "https://oneos.example"
|
||||
base.OneOSProfileAllowedTenant = ""
|
||||
if _, err := newOneOSProfileClient(base); err == nil {
|
||||
t.Fatal("missing tenant accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSDevTransportFixedOriginPrefixWithoutNetwork(t *testing.T) {
|
||||
cfg := config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"}
|
||||
client, err := newOneOSProfileClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.baseURL != "https://dev-oneos.lnh2e.com/prod-api" {
|
||||
t.Fatal("API prefix lost", client.baseURL)
|
||||
}
|
||||
tr, ok := client.client.Transport.(*http.Transport)
|
||||
if !ok || tr.Proxy != nil {
|
||||
t.Fatal("environment proxy allowed")
|
||||
}
|
||||
if client.client.CheckRedirect(&http.Request{}, nil) != http.ErrUseLastResponse {
|
||||
t.Fatal("redirect allowed")
|
||||
}
|
||||
for _, value := range []string{"https://dev-oneos.lnh2e.com", "https://dev-oneos.lnh2e.com/prod-api/../evil", "https://dev-oneos.lnh2e.com/prod-api?x=1", "https://dev-oneos.lnh2e.com.evil.invalid/prod-api", "https://dev-oneos.lnh2e.com:444/prod-api", "http://127.0.0.1/prod-api"} {
|
||||
c := cfg
|
||||
c.OneOSProfileBaseURL = value
|
||||
if _, err := newOneOSProfileClient(c); err == nil {
|
||||
t.Fatal("invalid dev origin", value)
|
||||
}
|
||||
}
|
||||
cfg.OneOSLocalOnly = true
|
||||
if _, err := newOneOSProfileClient(cfg); err == nil {
|
||||
t.Fatal("fixture/dev modes conflict accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type oneOSDevURLCaptureTransport struct{ t *testing.T }
|
||||
|
||||
func (c oneOSDevURLCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
if got := r.URL.String(); got != "https://dev-oneos.lnh2e.com/prod-api/auth/code" {
|
||||
c.t.Fatal("wrong fixed request URL", got)
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"data":{"captchaEnabled":false}}`)), Request: r}, nil
|
||||
}
|
||||
func TestOneOSDevRequestKeepsVerifiedAPIPrefixWithoutNetwork(t *testing.T) {
|
||||
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api/", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.passwordEnabled = true
|
||||
client.client.Transport = oneOSDevURLCaptureTransport{t: t}
|
||||
if _, err := client.FetchCaptcha(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionOneOSGatewayPrefix(t *testing.T) {
|
||||
cfg := config.Config{OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://lnoneos.com/prod-api", OneOSProfileIssuer: "https://lnoneos.com", OneOSProfileAllowedTenant: "000000"}
|
||||
client, err := newOneOSProfileClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.baseURL != "https://lnoneos.com/prod-api" {
|
||||
t.Fatal(client.baseURL)
|
||||
}
|
||||
cfg.OneOSProfileBaseURL = "https://other.example/prod-api"
|
||||
if _, err := newOneOSProfileClient(cfg); err == nil {
|
||||
t.Fatal("unexpected gateway prefix accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const oneOSQueryScopeTTL = 2 * time.Minute
|
||||
const oneOSQueryScopePageSize int64 = 200
|
||||
|
||||
type oneOSQueryScopeEntry struct {
|
||||
ready chan struct{}
|
||||
vehicles []oneOSAssetVehicle
|
||||
expires time.Time
|
||||
err error
|
||||
}
|
||||
|
||||
// A new login must establish a fresh snapshot even if OneOS reuses its token.
|
||||
func (a *oneOSProfileAuth) invalidateVehicleQueryScope(token, clientID string) {
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.scopeMu.Lock()
|
||||
delete(a.queryScopes, key)
|
||||
a.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
// Each snapshot uses the caller's own upstream credentials. Browser filters,
|
||||
// service credentials and cached snapshots from other sessions cannot widen it.
|
||||
func (a *oneOSProfileAuth) vehicleQueryVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
rows, err := a.vehicleQuerySnapshot(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
seen[row.VIN] = true
|
||||
}
|
||||
vins := make([]string, 0, len(seen))
|
||||
for vin := range seen {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) vehicleQuerySnapshot(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.scopeMu.Lock()
|
||||
if a.queryScopes == nil {
|
||||
a.queryScopes = map[[32]byte]*oneOSQueryScopeEntry{}
|
||||
}
|
||||
for k, e := range a.queryScopes {
|
||||
select {
|
||||
case <-e.ready:
|
||||
if !e.expires.After(time.Now()) {
|
||||
delete(a.queryScopes, k)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
if e, ok := a.queryScopes[key]; ok {
|
||||
a.scopeMu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-e.ready:
|
||||
return append([]oneOSAssetVehicle{}, e.vehicles...), e.err
|
||||
}
|
||||
}
|
||||
if len(a.queryScopes) >= 1000 {
|
||||
a.scopeMu.Unlock()
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
e := &oneOSQueryScopeEntry{ready: make(chan struct{})}
|
||||
a.queryScopes[key] = e
|
||||
a.scopeMu.Unlock()
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
vehicles, err := a.client.queryOperatingVehicles(fetchCtx, token, clientID)
|
||||
a.scopeMu.Lock()
|
||||
e.vehicles, e.err, e.expires = vehicles, err, time.Now().Add(oneOSQueryScopeTTL)
|
||||
// An older in-flight request must not remove a newer login's snapshot.
|
||||
if err != nil && a.queryScopes[key] == e {
|
||||
delete(a.queryScopes, key)
|
||||
}
|
||||
close(e.ready)
|
||||
a.scopeMu.Unlock()
|
||||
return append([]oneOSAssetVehicle{}, vehicles...), err
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryVisibleVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
rows, err := c.queryOperatingVehicleVINs(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, vin := range rows {
|
||||
seen[vin] = true
|
||||
}
|
||||
vins := make([]string, 0, len(seen))
|
||||
for vin := range seen {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryOperatingVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
|
||||
rows, err := c.queryOperatingVehicles(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vins := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
vins = append(vins, row.VIN)
|
||||
}
|
||||
return vins, nil
|
||||
}
|
||||
|
||||
func (c *oneOSProfileClient) queryOperatingVehicles(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
fetch := func(pageNum int64) (oneOSAssetPage, error) {
|
||||
return c.vehicleAssetPage(ctx, token, clientID, url.Values{"assetCategory": {oneOSVehicleAssetCategory}, "pageNum": {strconv.FormatInt(pageNum, 10)}, "pageSize": {strconv.FormatInt(oneOSQueryScopePageSize, 10)}}, oneOSQueryScopePageSize)
|
||||
}
|
||||
first, err := fetch(1)
|
||||
if err != nil || first.Total > 100000 {
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
total := first.Total
|
||||
rowIDs := map[string]bool{}
|
||||
vehicles := []oneOSAssetVehicle{}
|
||||
consume := func(pageNum int64, page oneOSAssetPage) error {
|
||||
want := total - (pageNum-1)*oneOSQueryScopePageSize
|
||||
if want > oneOSQueryScopePageSize {
|
||||
want = oneOSQueryScopePageSize
|
||||
}
|
||||
if page.Total != total || want < 0 || int64(len(page.Records)) != want {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
for _, row := range page.Records {
|
||||
if row.ID == "" || rowIDs[row.ID] {
|
||||
return errOneOSAuthority
|
||||
}
|
||||
rowIDs[row.ID] = true
|
||||
vin := strings.ToUpper(strings.TrimSpace(row.VIN))
|
||||
if validScopeVIN(vin) {
|
||||
row.VIN = vin
|
||||
vehicles = append(vehicles, row)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = consume(1, first); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Fetch at most four pages concurrently; consume in page order so duplicate
|
||||
// and completeness checks remain identical to sequential enumeration.
|
||||
for start := int64(2); start <= (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize; start += 4 {
|
||||
count := (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize - start + 1
|
||||
if count > 4 {
|
||||
count = 4
|
||||
}
|
||||
pages := make([]oneOSAssetPage, int(count))
|
||||
errs := make([]error, int(count))
|
||||
var wg sync.WaitGroup
|
||||
for i := range pages {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
pages[i], errs[i] = fetch(start + int64(i))
|
||||
if errs[i] != nil {
|
||||
cancel()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, page := range pages {
|
||||
if errs[i] != nil {
|
||||
return nil, errOneOSAuthority
|
||||
}
|
||||
if err = consume(start+int64(i), page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vehicles, nil
|
||||
}
|
||||
|
||||
func validScopeVIN(vin string) bool {
|
||||
if len(vin) != 17 {
|
||||
return false
|
||||
}
|
||||
for _, r := range vin {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *oneOSProfileAuth) resolveVehicleQueryScope(ctx context.Context, token, clientID string, current oneOSCurrentUser) (platform.Principal, oneOSCurrentUser, error) {
|
||||
grants := oneOSPermissions(current.Permissions)
|
||||
if !grants["vehicle:platform:access"] {
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
return platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "pending", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}, current, nil
|
||||
}
|
||||
vins, err := a.vehicleQueryVINs(ctx, token, clientID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
u, _, err := a.bind(ctx, current.Profile)
|
||||
if err != nil {
|
||||
return platform.Principal{}, current, err
|
||||
}
|
||||
p := platform.Principal{PermissionKeys: append([]string(nil), current.Permissions...), SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", MenuKeys: []string{}}
|
||||
if oneOSHasAdminPermission(current.Permissions) {
|
||||
p = oneOSAdminPrincipal(u, current.Profile, current.Permissions)
|
||||
p.SubjectID = strconv.FormatUint(u.ID, 10)
|
||||
} else {
|
||||
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
|
||||
if grants[oneOSMenuPermission(menu)] {
|
||||
p.MenuKeys = append(p.MenuKeys, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.VehicleVINs, p.VehicleCount = vins, len(vins)
|
||||
p.VehicleScopeSource = "oneos-vehicle-query"
|
||||
p.AuthorizationStatus = "ready"
|
||||
if a.localBackend {
|
||||
p.DataSource = "production-local"
|
||||
}
|
||||
return p, current, nil
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVehicleQueryScopeFullPaginationAndCredentialIsolation(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Error("vehicle scope queried a non-operating category")
|
||||
}
|
||||
if r.URL.Path != "/asset/vehicle-info/page" || r.Header.Get("Clientid") != "web" {
|
||||
t.Error("wrong upstream request")
|
||||
}
|
||||
total := int64(0)
|
||||
records := []oneOSAssetVehicle{}
|
||||
if r.URL.Query().Get("assetCategory") == "allOperating" {
|
||||
total = 201
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("pageNum"))
|
||||
for i := (page - 1) * 200; i < page*200 && i < 201; i++ {
|
||||
records = append(records, oneOSAssetVehicle{ID: strconv.Itoa(i + 1), VIN: fmt.Sprintf("LNX%014d", i), CustomerName: "客户甲", BusinessDepartment: "业务一部", BusinessResponsiblePerson: "负责人甲"})
|
||||
}
|
||||
if r.Header.Get("Authorization") == "Bearer second-user-token" {
|
||||
total = 0
|
||||
records = []oneOSAssetVehicle{}
|
||||
}
|
||||
}
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
first, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || len(first) != 201 {
|
||||
t.Fatalf("incomplete scope: %d %v", len(first), err)
|
||||
}
|
||||
n := calls.Load()
|
||||
snapshot, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || len(snapshot) != 201 || snapshot[200].CustomerName != "客户甲" || snapshot[200].BusinessDepartment != "业务一部" || snapshot[200].BusinessResponsiblePerson != "负责人甲" || calls.Load() != n {
|
||||
t.Fatalf("metadata snapshot lost paginated assignment fields or repeated requests: %v", err)
|
||||
}
|
||||
snapshot[0].CustomerName = "tampered"
|
||||
again, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || again[0].CustomerName != "客户甲" {
|
||||
t.Fatal("metadata cache is mutable by caller")
|
||||
}
|
||||
first[0] = "tampered"
|
||||
cached, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
|
||||
if err != nil || cached[0] == "tampered" || calls.Load() != n {
|
||||
t.Fatal("unsafe cache")
|
||||
}
|
||||
second, err := a.vehicleQueryVINs(context.Background(), "second-user-token", "web")
|
||||
if err != nil || len(second) != 0 {
|
||||
t.Fatal("another user's scope leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeFailsClosedOnIncompleteOrFailedPage(t *testing.T) {
|
||||
for _, scenario := range []string{"truncated", "changed-total", "repeated-row", "upstream-failed"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if scenario == "upstream-failed" {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
records := []oneOSAssetVehicle{}
|
||||
count := 200
|
||||
total := int64(201)
|
||||
if page == 2 {
|
||||
count = 1
|
||||
}
|
||||
if scenario == "truncated" {
|
||||
count = 0
|
||||
}
|
||||
if page == 2 && scenario == "changed-total" {
|
||||
total = 202
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
id := fmt.Sprint((page-1)*200 + int64(i))
|
||||
if scenario == "repeated-row" {
|
||||
id = "same"
|
||||
}
|
||||
records = append(records, oneOSAssetVehicle{ID: id, VIN: fmt.Sprintf("LNX%014d", i)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
c := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}
|
||||
if _, err := c.queryVisibleVehicleVINs(context.Background(), "user-token-123456", "web"); err == nil {
|
||||
t.Fatal("partial scope accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeDoesNotReuseExpiredSnapshotOnFailure(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail.Load() {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer server.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range a.queryScopes {
|
||||
entry.expires = time.Now().Add(-time.Second)
|
||||
}
|
||||
fail.Store(true)
|
||||
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err == nil {
|
||||
t.Fatal("stale authorization reused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeRechecksRolesAndNeverGrantsUnlistedVINs(t *testing.T) {
|
||||
var admin atomic.Bool
|
||||
admin.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
if admin.Load() {
|
||||
permissions = []string{"*:*:*"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
records := []oneOSAssetVehicle{}
|
||||
if r.URL.Query().Get("assetCategory") == "allOperating" {
|
||||
records = []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: int64(len(records)), Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, scopeEnabled: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
p, _, err := a.resolve(context.Background(), "original-user-token", "web")
|
||||
if err != nil || p.Role != "admin" || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("L0000000000000000") {
|
||||
t.Fatalf("bad admin scope: %v %+v", err, p)
|
||||
}
|
||||
admin.Store(false)
|
||||
p, _, err = a.resolve(context.Background(), "original-user-token", "web")
|
||||
if err != nil || p.Role != "customer" || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
|
||||
t.Fatalf("stale administrator grant: %v %+v", err, p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeLoginRefreshAndFixedExpiry(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
var vin atomic.Value
|
||||
vin.Store("LNXNEGRR3SR321379")
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
|
||||
return
|
||||
}
|
||||
calls.Add(1)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: vin.Load().(string)}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
request := httptest.NewRequest("POST", "/login", nil)
|
||||
before := time.Now()
|
||||
login, err := a.login(request, credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" || login.Session.CanVIN("LNXNEGRR3SR321379") || calls.Load() != 0 {
|
||||
t.Fatalf("login must return identity without querying vehicles: %v", err)
|
||||
}
|
||||
if _, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := sha256.Sum256([]byte("web\x00" + credential.AccessToken))
|
||||
entry := a.queryScopes[key]
|
||||
if entry.expires.Before(before.Add(2*time.Minute)) || entry.expires.After(time.Now().Add(2*time.Minute)) {
|
||||
t.Fatal("scope TTL is not two minutes")
|
||||
}
|
||||
// A hit must retain the existing deadline instead of extending it.
|
||||
entry.expires = time.Now().Add(time.Minute)
|
||||
deadline := entry.expires
|
||||
vin.Store("LNXNEGRR3SR321380")
|
||||
p, _, err := a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 1 || !entry.expires.Equal(deadline) {
|
||||
t.Fatal("cache hit changed scope or extended deadline")
|
||||
}
|
||||
login, err = a.login(request, credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" || calls.Load() != 1 {
|
||||
t.Fatal("repeat login must defer vehicle lookup")
|
||||
}
|
||||
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || p.CanVIN("LNXNEGRR3SR321379") || !p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 2 {
|
||||
t.Fatal("repeat login reused old scope")
|
||||
}
|
||||
a.queryScopes[key].expires = time.Now().Add(-time.Second)
|
||||
vin.Store("LNXNEGRR3SR321381")
|
||||
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
|
||||
if err != nil || p.CanVIN("LNXNEGRR3SR321380") || !p.CanVIN("LNXNEGRR3SR321381") || calls.Load() != 3 {
|
||||
t.Fatal("expired scope was not refreshed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeOldFailureCannotEvictNewSnapshot(t *testing.T) {
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
close(started)
|
||||
<-release
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: 5 * time.Second, passwordClientID: "web"}}
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); done <- err }()
|
||||
<-started
|
||||
a.invalidateVehicleQueryScope("original-user-token", "web")
|
||||
_, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web")
|
||||
close(release)
|
||||
oldErr := <-done
|
||||
if err != nil || oldErr == nil {
|
||||
t.Fatalf("unexpected query results: new=%v old=%v", err, oldErr)
|
||||
}
|
||||
if _, err = a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); err != nil || calls.Load() != 2 {
|
||||
t.Fatal("old failure evicted new snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeBoundedParallelPagination(t *testing.T) {
|
||||
var active, peak, calls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
n := active.Add(1)
|
||||
defer active.Add(-1)
|
||||
for old := peak.Load(); n > old; old = peak.Load() {
|
||||
if peak.CompareAndSwap(old, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Model network latency across the 6 pages used by a 1,026-vehicle scope.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
|
||||
records := []oneOSAssetVehicle{}
|
||||
for i := (page - 1) * 200; i < page*200 && i < 1026; i++ {
|
||||
records = append(records, oneOSAssetVehicle{ID: strconv.FormatInt(i+1, 10), VIN: fmt.Sprintf("LNX%014d", i)})
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: 1026, Current: page, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
c := &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}
|
||||
started := time.Now()
|
||||
vins, err := c.queryVisibleVehicleVINs(context.Background(), "original-user-token", "web")
|
||||
elapsed := time.Since(started)
|
||||
if err != nil || len(vins) != 1026 || calls.Load() != 6 {
|
||||
t.Fatalf("incomplete scope: count=%d calls=%d err=%v", len(vins), calls.Load(), err)
|
||||
}
|
||||
if peak.Load() < 2 || peak.Load() > 4 {
|
||||
t.Fatalf("unexpected concurrency: %d", peak.Load())
|
||||
}
|
||||
t.Logf("6 pages with 50ms simulated latency: %s, sequential minimum 0.30s, peak concurrency %d", elapsed, peak.Load())
|
||||
}
|
||||
|
||||
func TestVehicleScopeEndpointSeparatesIdentityAndRetries(t *testing.T) {
|
||||
var fail atomic.Bool
|
||||
fail.Store(true)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
|
||||
return
|
||||
}
|
||||
if fail.Load() {
|
||||
http.Error(w, "unavailable", 503)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
login, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
|
||||
if err != nil || login.Session.AuthorizationStatus != "loading" {
|
||||
t.Fatal("unavailable vehicle service blocked identity login")
|
||||
}
|
||||
auth := &apiAuthenticator{mode: "enforce", profile: profile}
|
||||
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("scope request reached business handler") }))
|
||||
request := func(token, client string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", "/api/v2/auth/vehicle-scope", nil)
|
||||
if token != "" {
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if client != "" {
|
||||
r.Header.Set("X-OneOS-Client-ID", client)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
if w := request("", ""); w.Code != 401 {
|
||||
t.Fatalf("anonymous scope: %d", w.Code)
|
||||
}
|
||||
if w := request(credential.AccessToken, credential.ClientID); w.Code != 503 {
|
||||
t.Fatalf("scope outage should be retryable: %d", w.Code)
|
||||
}
|
||||
fail.Store(false)
|
||||
w := request(credential.AccessToken, credential.ClientID)
|
||||
var body struct {
|
||||
Data struct {
|
||||
AuthorizationStatus string `json:"authorizationStatus"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != 200 || body.Data.AuthorizationStatus != "ready" || body.Data.VehicleCount != 1 {
|
||||
t.Fatalf("scope retry failed: status=%d err=%v", w.Code, err)
|
||||
}
|
||||
if w := request(credential.AccessToken, ""); w.Code != 401 {
|
||||
t.Fatal("OneOS token crossed into local authentication")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryOneOSLoginWithoutEntryPermissionIsPending(t *testing.T) {
|
||||
var granted atomic.Bool
|
||||
var vehicleCalls atomic.Int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
permissions := []string{}
|
||||
if granted.Load() {
|
||||
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "2", "tenantId": "t", "userName": "ordinary", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
vehicleCalls.Add(1)
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
|
||||
credential := oneOSCredential{AccessToken: "ordinary-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
result, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
|
||||
if err != nil || result.Session.AuthorizationStatus != "pending" || result.Session.CanVIN("LNXNEGRR3SR321379") || result.Session.CanMenu("vehicles") || vehicleCalls.Load() != 0 {
|
||||
t.Fatal("ordinary identity should log in without receiving business access")
|
||||
}
|
||||
auth := &apiAuthenticator{mode: "enforce", profile: profile}
|
||||
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("pending user reached business handler") }))
|
||||
for _, path := range []string{"/api/v2/session", "/api/v2/auth/vehicle-scope", "/api/v2/auth/oneos/vehicles", "/api/v2/vehicles"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+credential.AccessToken)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
want := 403
|
||||
if path == "/api/v2/session" || path == "/api/v2/auth/vehicle-scope" {
|
||||
want = 200
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("%s status=%d want=%d", path, w.Code, want)
|
||||
}
|
||||
}
|
||||
if vehicleCalls.Load() != 0 {
|
||||
t.Fatal("pending user queried vehicle catalog")
|
||||
}
|
||||
granted.Store(true)
|
||||
p, _, err := profile.resolve(context.Background(), credential.AccessToken, "web")
|
||||
if err != nil || p.AuthorizationStatus != "ready" || p.Role != "customer" || !p.CanMenu("vehicles") || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
|
||||
t.Fatal("newly granted ordinary user could not refresh scoped access")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestOperationComplianceOnlyPermissionAndCallerScopedAssignments(t *testing.T) {
|
||||
permissions := []string{"vehicle:platform:access", "vehiclePlatform:operationCompliance:view"}
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
second := strings.Contains(r.Header.Get("Authorization"), "second")
|
||||
user, vin, customer := "21", "LB9A32A24R0LS1426", "客户甲"
|
||||
if second {
|
||||
user, vin, customer = "22", "LNXNEGRR7SR318212", "客户乙"
|
||||
}
|
||||
if r.URL.Path == "/system/user/getInfo" {
|
||||
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": user, "tenantId": "000000", "userName": "operation-only", "status": "0"}, "permissions": permissions}})
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/asset/vehicle-info/page" || r.URL.Query().Get("assetCategory") != "allOperating" {
|
||||
t.Errorf("unexpected upstream request: %s", r.URL.Path)
|
||||
}
|
||||
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: user, VIN: vin, CustomerName: customer, BusinessDepartment: "业务一部", BusinessResponsiblePerson: "张业务"}}, Total: 1, Current: 1, Size: 200}})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
|
||||
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
|
||||
request := func(token, path string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-OneOS-Client-ID", "web")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, tc := range []struct{ token, vin, customer, forbidden string }{
|
||||
{"first-operation-token", "LB9A32A24R0LS1426", "客户甲", "客户乙"},
|
||||
{"second-operation-token", "LNXNEGRR7SR318212", "客户乙", "客户甲"},
|
||||
} {
|
||||
w := request(tc.token, "/api/v2/operation-compliance/options")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.customer) || strings.Contains(w.Body.String(), tc.forbidden) {
|
||||
t.Fatalf("scoped options failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = request(tc.token, "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource=hydrogen&scopeVins=FORGED&vins=FORGED&protocol=GB32960")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || strings.Contains(w.Body.String(), tc.forbidden) || strings.Contains(w.Body.String(), "FORGED") {
|
||||
t.Fatalf("roster scope failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
for _, source := range []string{"gps", "odometer", "hydrogen"} {
|
||||
w = request(tc.token, "/api/v2/operation-compliance/detail?vin="+tc.vin+"&dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource="+source+"&scopeVins=FORGED")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || !strings.Contains(w.Body.String(), `"mileageSource":"`+source+`"`) || strings.Contains(w.Body.String(), tc.forbidden) {
|
||||
t.Fatalf("daily scope failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
if denied := request(tc.token, "/api/v2/operation-compliance/detail?vin=LNX00000000000002&dateFrom=2026-09-01&dateTo=2026-09-03"); denied.Code != 403 {
|
||||
t.Fatalf("daily VIN escape: %d", denied.Code)
|
||||
}
|
||||
for _, path := range []string{"/api/vehicles", "/api/v2/statistics/vehicle-options", "/api/v2/customer-mileage"} {
|
||||
if denied := request(tc.token, path); denied.Code != 403 {
|
||||
t.Fatalf("new menu escalated access to %s: %d", path, denied.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
permissions = []string{"vehicle:platform:access"}
|
||||
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
|
||||
if w := request("first-operation-token", path); w.Code != 403 {
|
||||
t.Fatalf("revocation not enforced: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceMenuMappingAndNativeConfiguration(t *testing.T) {
|
||||
if oneOSMenuPermission("operation-compliance") != "vehiclePlatform:operationCompliance:view" || !customerMenuSet["operation-compliance"] || !adminMenuSet["operation-compliance"] {
|
||||
t.Fatal("menu mapping missing")
|
||||
}
|
||||
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
|
||||
r := httptest.NewRequest("GET", path, nil)
|
||||
if requiredMenu(r) != "operation-compliance" || requiredRole(r) != "viewer" || oneOSReadAction(r) != "vehiclePlatform:operationCompliance:view" {
|
||||
t.Fatalf("permission dependency mismatch: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import (
|
||||
)
|
||||
|
||||
func NewServer(cfg config.Config) http.Handler {
|
||||
if err := ValidateLocalIsolation(cfg); err != nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_ISOLATION_REQUIRED", err.Error(), "", requestTraceID(r))
|
||||
})
|
||||
}
|
||||
dataMode := strings.ToLower(strings.TrimSpace(cfg.DataMode))
|
||||
if dataMode == "" {
|
||||
dataMode = "mock"
|
||||
@@ -124,10 +129,22 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
// 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)
|
||||
var devData *oneOSDevDataProxy
|
||||
if cfg.OneOSDevDataProxyURL != "" {
|
||||
devData = newOneOSDevDataProxy(cfg)
|
||||
api = devData
|
||||
}
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB))
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
if devData != nil {
|
||||
handler = devData.withMapProxy(handler)
|
||||
}
|
||||
handler = withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
if cfg.OneOSLocalOnly || cfg.OneOSDevIntegration {
|
||||
handler = withOneOSLocalIsolationHeaders(handler, devData != nil || cfg.OneOSLocalBackend)
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
|
||||
@@ -610,7 +627,9 @@ func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
|
||||
}
|
||||
buffered.writeTo(w)
|
||||
case <-ctx.Done():
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", requestTraceID(r))
|
||||
traceID := requestTraceID(r)
|
||||
log.Printf("request timed out trace_id=%q method=%s path=%s timeout_ms=%d", traceID, r.Method, r.URL.Path, timeout.Milliseconds())
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", traceID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,6 +34,26 @@ type Config struct {
|
||||
OneOSDefaultMenus []string
|
||||
OneOSRequestTimeout time.Duration
|
||||
OneOSSessionTTL time.Duration
|
||||
OneOSProfileEnabled bool
|
||||
OneOSAuthEnabled bool
|
||||
OneOSProfileBaseURL string
|
||||
OneOSProfileServiceToken string
|
||||
OneOSProfileIssuer string
|
||||
OneOSProfileAllowedTenant string
|
||||
OneOSProfileTimeout time.Duration
|
||||
OneOSPublicOrigin string
|
||||
OneOSPasswordEnabled bool
|
||||
OneOSPasswordClientID string
|
||||
OneOSPasswordRSAPublicKey string
|
||||
OneOSLoginTicketExchangePath string
|
||||
OneOSLoginTicketServiceToken string
|
||||
OneOSDevIntegration bool
|
||||
OneOSLocalBackend bool
|
||||
OneOSVehicleQueryScope bool
|
||||
OneOSDevDataProxyURL string
|
||||
OneOSDevDataProxyToken string
|
||||
OneOSLocalOnly bool
|
||||
OneOSVehicleScopePath string
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
@@ -93,6 +113,26 @@ func Load() Config {
|
||||
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,
|
||||
OneOSProfileEnabled: envBool("ONEOS_PROFILE_ENABLED", false),
|
||||
OneOSAuthEnabled: envBool("ONEOS_AUTH_ENABLED", false),
|
||||
OneOSProfileBaseURL: os.Getenv("ONEOS_PROFILE_BASE_URL"),
|
||||
OneOSProfileServiceToken: os.Getenv("ONEOS_PROFILE_SERVICE_TOKEN"),
|
||||
OneOSProfileIssuer: os.Getenv("ONEOS_PROFILE_ISSUER"),
|
||||
OneOSProfileAllowedTenant: os.Getenv("ONEOS_PROFILE_ALLOWED_TENANT"),
|
||||
OneOSProfileTimeout: time.Duration(envInt("ONEOS_PROFILE_TIMEOUT_MS", 3500)) * time.Millisecond,
|
||||
OneOSPublicOrigin: os.Getenv("ONEOS_PUBLIC_ORIGIN"),
|
||||
OneOSPasswordEnabled: envBool("ONEOS_PASSWORD_ENABLED", false),
|
||||
OneOSPasswordClientID: os.Getenv("ONEOS_PASSWORD_CLIENT_ID"),
|
||||
OneOSPasswordRSAPublicKey: os.Getenv("ONEOS_PASSWORD_RSA_PUBLIC_KEY"),
|
||||
OneOSLoginTicketExchangePath: os.Getenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH"),
|
||||
OneOSLoginTicketServiceToken: os.Getenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN"),
|
||||
OneOSDevIntegration: envBool("ONEOS_DEV_INTEGRATION", false),
|
||||
OneOSLocalBackend: envBool("ONEOS_LOCAL_BACKEND", false),
|
||||
OneOSVehicleQueryScope: envBool("ONEOS_VEHICLE_QUERY_SCOPE", false),
|
||||
OneOSDevDataProxyURL: os.Getenv("ONEOS_DEV_DATA_PROXY_URL"),
|
||||
OneOSDevDataProxyToken: os.Getenv("ONEOS_DEV_DATA_PROXY_TOKEN"),
|
||||
OneOSLocalOnly: envBool("ONEOS_LOCAL_ONLY", false),
|
||||
OneOSVehicleScopePath: os.Getenv("ONEOS_VEHICLE_SCOPE_PATH"),
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
|
||||
@@ -79,3 +79,12 @@ func TestLoadReadsHistoryCleanupAutomationSettings(t *testing.T) {
|
||||
t.Fatalf("history cleanup automation settings were not loaded: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsOneOSLoginTicketServiceToken(t *testing.T) {
|
||||
t.Setenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH", "/auth/inner/v1/sso/vehicle-platform/token-exchange")
|
||||
t.Setenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN", "local-fixture-service-credential")
|
||||
cfg := Load()
|
||||
if cfg.OneOSLoginTicketExchangePath != "/auth/inner/v1/sso/vehicle-platform/token-exchange" || cfg.OneOSLoginTicketServiceToken != "local-fixture-service-credential" {
|
||||
t.Fatal("OneOS login-ticket service configuration was not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// Package gzev collects Guangzhou platform daily mileage without browser automation.
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const Origin = "https://gzev.d.lnoneos.com"
|
||||
const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics"
|
||||
|
||||
var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login")
|
||||
var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`)
|
||||
|
||||
// Decimal preserves source decimal precision and distinguishes missing values from zero.
|
||||
type Decimal struct {
|
||||
Value string
|
||||
Valid bool
|
||||
}
|
||||
|
||||
func (d *Decimal) UnmarshalJSON(b []byte) error {
|
||||
*d = Decimal{}
|
||||
if string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
s := string(b)
|
||||
if len(s) > 0 && s[0] == '"' {
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !decimalPattern.MatchString(s) {
|
||||
return errors.New("invalid nonnegative mileage decimal")
|
||||
}
|
||||
d.Value = s
|
||||
d.Valid = true
|
||||
return nil
|
||||
}
|
||||
func (d Decimal) MarshalJSON() ([]byte, error) {
|
||||
if !d.Valid {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return json.Marshal(d.Value)
|
||||
}
|
||||
func (d Decimal) SQLValue() any {
|
||||
if !d.Valid {
|
||||
return nil
|
||||
}
|
||||
return d.Value
|
||||
}
|
||||
|
||||
type Row struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plateNumber"`
|
||||
VehicleSeq string `json:"vehicleSeq"`
|
||||
ProductionCompany string `json:"productionCompany"`
|
||||
OperationCompany string `json:"operationCompany"`
|
||||
Brand string `json:"brandName"`
|
||||
Model string `json:"brandModel"`
|
||||
DateRange string `json:"dateRange"`
|
||||
Total Decimal `json:"runMileage"`
|
||||
Hydrogen Decimal `json:"hyRunMileage"`
|
||||
Charge Decimal `json:"chargeRunMileage"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
type Day struct {
|
||||
Date string `json:"date"`
|
||||
Rows []Row `json:"rows"`
|
||||
Pages []json.RawMessage `json:"pages"`
|
||||
FetchedAt time.Time `json:"fetched_at"`
|
||||
}
|
||||
type SessionCookie struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type Session struct {
|
||||
Authorization string `json:"authorization"`
|
||||
Cookies []SessionCookie `json:"cookies"`
|
||||
}
|
||||
|
||||
func ReadSession(path string) (Session, error) {
|
||||
var s Session
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return s, errors.New("session file unavailable")
|
||||
}
|
||||
if info.Mode().Perm()&0077 != 0 {
|
||||
return s, errors.New("session file must have permissions 0600")
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s, errors.New("cannot read session file")
|
||||
}
|
||||
if json.Unmarshal(b, &s) != nil {
|
||||
return s, errors.New("invalid session JSON")
|
||||
}
|
||||
return s, ValidateSession(s)
|
||||
}
|
||||
|
||||
func ValidateSession(s Session) error {
|
||||
if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") {
|
||||
return errors.New("invalid session authorization")
|
||||
}
|
||||
seenCookies := map[string]bool{}
|
||||
for _, cookie := range s.Cookies {
|
||||
if seenCookies[cookie.Name] {
|
||||
return errors.New("duplicate session cookie")
|
||||
}
|
||||
seenCookies[cookie.Name] = true
|
||||
if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization {
|
||||
return errors.New("token cookie and bearer mismatch")
|
||||
}
|
||||
if cookie.Name != "token" && cookie.Name != "bigs" {
|
||||
return errors.New("session contains unsupported cookie")
|
||||
}
|
||||
if strings.ContainsAny(cookie.Value, "\r\n;") {
|
||||
return errors.New("invalid session cookie")
|
||||
}
|
||||
}
|
||||
if !seenCookies["token"] {
|
||||
return errors.New("token cookie is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func ShanghaiToday(now time.Time) time.Time {
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
n := now.In(loc)
|
||||
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
func ParseDate(s string) (time.Time, error) {
|
||||
return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
}
|
||||
func ValidateDate(s string, now time.Time) error {
|
||||
d, e := ParseDate(s)
|
||||
if e != nil || !d.Before(ShanghaiToday(now)) {
|
||||
return errors.New("date must be a completed Asia/Shanghai calendar day")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
HTTP *http.Client
|
||||
Session Session
|
||||
baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin.
|
||||
PageSize int
|
||||
Delay time.Duration
|
||||
}
|
||||
|
||||
func NewClient(s Session) *Client {
|
||||
return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond}
|
||||
}
|
||||
func wait(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) {
|
||||
q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}}
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if attempt > 0 {
|
||||
if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", c.Session.Authorization)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for _, cookie := range c.Session.Cookies {
|
||||
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if attempt < 2 {
|
||||
continue
|
||||
}
|
||||
return nil, errors.New("GZEV network request failed")
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
|
||||
if attempt < 2 {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode)
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, errors.New("GZEV response read failed")
|
||||
}
|
||||
var envelope struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(b, &envelope) != nil {
|
||||
return nil, errors.New("GZEV non-JSON response; login or upstream failure")
|
||||
}
|
||||
code := strings.Trim(string(envelope.Code), `"`)
|
||||
if code == "401" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if code != "0" {
|
||||
if code == "00000004" || code == "403" {
|
||||
return nil, errors.New("GZEV account lacks endpoint permission")
|
||||
}
|
||||
return nil, errors.New("GZEV non-success business response")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("GZEV retries exhausted")
|
||||
}
|
||||
func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) {
|
||||
day := Day{Date: date, FetchedAt: time.Now().UTC()}
|
||||
if err := wait(ctx, c.Delay); err != nil {
|
||||
return day, err
|
||||
}
|
||||
if err := ValidateDate(date, time.Now()); err != nil {
|
||||
return day, err
|
||||
}
|
||||
if c.PageSize < 1 || c.PageSize > 500 {
|
||||
return day, errors.New("page size must be 1..500")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
expected := -1
|
||||
for page := 1; page <= 10000; page++ {
|
||||
if page > 1 {
|
||||
if err := wait(ctx, c.Delay); err != nil {
|
||||
return day, err
|
||||
}
|
||||
}
|
||||
b, err := c.page(ctx, date, page)
|
||||
if err != nil {
|
||||
return day, err
|
||||
}
|
||||
var env struct {
|
||||
Data *struct {
|
||||
PageNum int `json:"pageNum"`
|
||||
Total *int `json:"total"`
|
||||
List []json.RawMessage `json:"list"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page {
|
||||
return day, errors.New("invalid pagination schema")
|
||||
}
|
||||
p := env.Data
|
||||
if expected < 0 {
|
||||
expected = *p.Total
|
||||
}
|
||||
if expected < 0 || expected > 1000000 || *p.Total != expected {
|
||||
return day, errors.New("source total changed or is invalid; retry day")
|
||||
}
|
||||
for _, raw := range p.List {
|
||||
var row Row
|
||||
if err := json.Unmarshal(raw, &row); err != nil {
|
||||
return day, fmt.Errorf("invalid mileage row: %w", err)
|
||||
}
|
||||
if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date {
|
||||
return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range")
|
||||
}
|
||||
row.Raw = raw
|
||||
seen[row.VIN] = true
|
||||
day.Rows = append(day.Rows, row)
|
||||
}
|
||||
day.Pages = append(day.Pages, b)
|
||||
if len(day.Rows) == expected {
|
||||
return day, nil
|
||||
}
|
||||
if len(day.Rows) > expected || len(p.List) == 0 {
|
||||
return day, errors.New("incomplete or inconsistent pagination")
|
||||
}
|
||||
}
|
||||
return day, errors.New("pagination safety limit exceeded")
|
||||
}
|
||||
|
||||
// VerifyAccount prevents an operator importing another account's valid session into this dataset.
|
||||
func (c *Client) VerifyAccount(ctx context.Context, account string) error {
|
||||
q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return errors.New("cannot create identity request")
|
||||
}
|
||||
req.Header.Set("Authorization", c.Session.Authorization)
|
||||
for _, cookie := range c.Session.Cookies {
|
||||
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("GZEV identity request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
return ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return errors.New("GZEV identity HTTP failure")
|
||||
}
|
||||
var body struct {
|
||||
Code json.RawMessage `json:"code"`
|
||||
Data struct {
|
||||
LoginName string `json:"loginname"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
|
||||
return errors.New("invalid identity response")
|
||||
}
|
||||
code := strings.Trim(string(body.Code), `"`)
|
||||
if code == "401" {
|
||||
return ErrAuth
|
||||
}
|
||||
if code != "0" || body.Data.LoginName != account {
|
||||
return errors.New("GZEV session account does not match configured account, or identity check denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecimalPreservesNullAndZero(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in string
|
||||
valid bool
|
||||
value string
|
||||
}{{"null", false, ""}, {`"0.0"`, true, "0.0"}, {"203.125", true, "203.125"}} {
|
||||
var d Decimal
|
||||
if err := json.Unmarshal([]byte(tc.in), &d); err != nil || d.Valid != tc.valid || d.Value != tc.value {
|
||||
t.Fatalf("%s: %+v %v", tc.in, d, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`""`, `"NaN"`, `-1`, `"1.2345"`, `"100000000000"`} {
|
||||
var d Decimal
|
||||
if json.Unmarshal([]byte(bad), &d) == nil {
|
||||
t.Fatalf("accepted %s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestDateShanghaiBoundary(t *testing.T) {
|
||||
now := time.Date(2026, 9, 27, 16, 0, 0, 0, time.UTC)
|
||||
if ValidateDate("2026-09-27", now) != nil || ValidateDate("2026-09-28", now) == nil {
|
||||
t.Fatal("Shanghai day cutoff incorrect")
|
||||
}
|
||||
}
|
||||
func TestFetchPaginationAndCookie(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
if r.Header.Get("Authorization") != "Bearer fake" {
|
||||
t.Error("missing bearer")
|
||||
}
|
||||
cookie, e := r.Cookie("token")
|
||||
if e != nil || cookie.Value != "fake" {
|
||||
t.Error("missing token cookie")
|
||||
}
|
||||
if r.URL.Query().Get("queryBeginDate") != "2024-01-01" || r.URL.Query().Get("queryEndDate") != "2024-01-01" || r.URL.Query().Get("queryType") != "2" {
|
||||
t.Error("wrong daily query")
|
||||
}
|
||||
fmt.Fprintf(w, `{"code":"0","data":{"pageNum":%d,"total":2,"list":[{"vin":"LNXNEGRR7SR31945%d","dateRange":"2024-01-01 - 2024-01-01","runMileage":"12.1","hyRunMileage":null,"chargeRunMileage":"0"}]}}`, calls, calls)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake", Cookies: []SessionCookie{{"token", "fake"}}})
|
||||
c.baseURL = srv.URL
|
||||
c.PageSize = 1
|
||||
c.Delay = 0
|
||||
day, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
if err != nil || len(day.Rows) != 2 || len(day.Pages) != 2 || calls != 2 {
|
||||
t.Fatalf("pagination: %+v %v", day, err)
|
||||
}
|
||||
if day.Rows[0].Hydrogen.Valid || !day.Rows[0].Charge.Valid {
|
||||
t.Fatal("null lost")
|
||||
}
|
||||
}
|
||||
func TestRejectsIncompleteSource(t *testing.T) {
|
||||
for name, body := range map[string]string{
|
||||
"missing code": `{"data":{}}`,
|
||||
"missing total": `{"code":"0","data":{"pageNum":1,"list":[]}}`,
|
||||
"empty page": `{"code":"0","data":{"pageNum":1,"total":2,"list":[]}}`,
|
||||
"wrong date": `{"code":"0","data":{"pageNum":1,"total":1,"list":[{"vin":"LNXNEGRR7SR319456","dateRange":"2024-01-01 - 2024-01-02"}]}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) }))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{})
|
||||
c.baseURL = srv.URL
|
||||
if _, err := c.FetchDay(context.Background(), "2024-01-01"); err == nil {
|
||||
t.Fatal("accepted incomplete day")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAuthAndPermissionAreDifferent(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
auth bool
|
||||
}{{"401", true}, {"00000004", false}, {"403", false}} {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; fmt.Fprintf(w, `{"code":%q}`, tc.code) }))
|
||||
c := NewClient(Session{})
|
||||
c.baseURL = srv.URL
|
||||
_, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
srv.Close()
|
||||
if err == nil || errors.Is(err, ErrAuth) != tc.auth || calls != 1 {
|
||||
t.Fatalf("code %s: %v calls=%d", tc.code, err, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestNoCredentialRedirect(t *testing.T) {
|
||||
hit := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true }))
|
||||
defer target.Close()
|
||||
source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) }))
|
||||
defer source.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake"})
|
||||
c.baseURL = source.URL
|
||||
_, err := c.FetchDay(context.Background(), "2024-01-01")
|
||||
if hit || !errors.Is(err, ErrAuth) {
|
||||
t.Fatalf("redirect leaked credentials: %v", err)
|
||||
}
|
||||
}
|
||||
func TestReadSessionPermissionsAndCookieAllowlist(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "session.json")
|
||||
body := `{"authorization":"Bearer fake","cookies":[{"name":"token","value":"fake"}]}`
|
||||
os.WriteFile(p, []byte(body), 0644)
|
||||
if _, err := ReadSession(p); err == nil {
|
||||
t.Fatal("accepted world-readable session")
|
||||
}
|
||||
os.Chmod(p, 0600)
|
||||
if _, err := ReadSession(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(p, []byte(strings.ReplaceAll(body, `"name":"token"`, `"name":"password"`)), 0600)
|
||||
if _, err := ReadSession(p); err == nil {
|
||||
t.Fatal("accepted password cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyAccountRejectsOtherLogin(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/evdata-sso/api/base/getUserInfo" || r.URL.Query().Get("token") != "fake" {
|
||||
t.Error("incorrect identity contract")
|
||||
}
|
||||
fmt.Fprint(w, `{"code":"0","data":{"loginname":"different-account"}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(Session{Authorization: "Bearer fake"})
|
||||
c.baseURL = srv.URL
|
||||
if c.VerifyAccount(context.Background(), "guangdonglingniu") == nil {
|
||||
t.Fatal("mixed account data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReportDay struct {
|
||||
Date string
|
||||
Rows, Known int64
|
||||
}
|
||||
|
||||
type DailyReport struct {
|
||||
Date, From, Through, Latest, LatestKnown, LastSuccess string
|
||||
Expected, Fetched, RetryDates int
|
||||
AuthExpired bool
|
||||
Recent []ReportDay
|
||||
Missing []string
|
||||
}
|
||||
|
||||
// LoadDailyReport reads one consistent database snapshot. NULL mileage stays missing;
|
||||
// a reported zero counts as known data.
|
||||
func LoadDailyReport(ctx context.Context, db *sql.DB, account, from string, now time.Time) (DailyReport, error) {
|
||||
today := ShanghaiToday(now)
|
||||
first, err := ParseDate(from)
|
||||
if err != nil || !first.Before(today) {
|
||||
return DailyReport{}, errors.New("report start must be before today")
|
||||
}
|
||||
r := DailyReport{Date: today.Format("2006-01-02"), From: from, Through: today.AddDate(0, 0, -1).Format("2006-01-02")}
|
||||
r.Expected = int(today.Sub(first).Hours() / 24)
|
||||
tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead})
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),last_success_at FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ? ORDER BY stat_date`, account, from, r.Through)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var latestSuccess time.Time
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var success time.Time
|
||||
if err = rows.Scan(&date, &success); err != nil {
|
||||
rows.Close()
|
||||
return r, err
|
||||
}
|
||||
seen[date] = true
|
||||
r.Fetched++
|
||||
r.Latest = date
|
||||
if success.After(latestSuccess) {
|
||||
latestSuccess = success
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if !latestSuccess.IsZero() {
|
||||
r.LastSuccess = latestSuccess.In(today.Location()).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
for d := first; d.Before(today); d = d.AddDate(0, 0, 1) {
|
||||
date := d.Format("2006-01-02")
|
||||
if !seen[date] {
|
||||
r.Missing = append(r.Missing, date)
|
||||
}
|
||||
}
|
||||
var known sql.NullString
|
||||
err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(MAX(stat_date),'%Y-%m-%d') FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? AND hydrogen_km IS NOT NULL`, account, from, r.Through).Scan(&known)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.LatestKnown = known.String
|
||||
recentFrom := today.AddDate(0, 0, -7).Format("2006-01-02")
|
||||
if recentFrom < from {
|
||||
recentFrom = from
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),COUNT(*),COUNT(hydrogen_km) FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? GROUP BY stat_date ORDER BY stat_date`, account, recentFrom, r.Through)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var d ReportDay
|
||||
if err = rows.Scan(&d.Date, &d.Rows, &d.Known); err != nil {
|
||||
rows.Close()
|
||||
return r, err
|
||||
}
|
||||
r.Recent = append(r.Recent, d)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT active FROM gzev_auth_alert WHERE account=?`, account).Scan(&r.AuthExpired)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return r, err
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gzev_mileage_attempt WHERE account=? AND stat_date BETWEEN ? AND ?`, account, from, r.Through).Scan(&r.RetryDates)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
return r, tx.Commit()
|
||||
}
|
||||
|
||||
func (r DailyReport) Message() (string, string) {
|
||||
conclusion := "同步已覆盖全部目标日期。"
|
||||
switch {
|
||||
case r.AuthExpired:
|
||||
conclusion = "登录已失效,需要更新登录状态;已入库数据仍保留。"
|
||||
case len(r.Missing) > 0:
|
||||
conclusion = fmt.Sprintf("同步尚未补齐,仍有 %d 个日期待采集。", len(r.Missing))
|
||||
default:
|
||||
for _, d := range r.Recent {
|
||||
if d.Date == r.Through {
|
||||
if d.Known == 0 {
|
||||
conclusion = "日期已补齐,但昨天上游纯氢里程全部为空,仍需后续重采。"
|
||||
} else if d.Known < d.Rows {
|
||||
conclusion = "日期已补齐,但昨天仍有部分车辆的上游纯氢里程缺失。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
show := func(v string) string {
|
||||
if v == "" {
|
||||
return "暂无"
|
||||
}
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "**结论:%s**\n\n统计截至:%s(北京时间,昨天及更早完整自然日)\n\n日期采集:%d / %d 天;最新采集日期:%s\n\n最新有纯氢里程值的日期:%s\n\n最后成功采集:%s(北京时间)\n\n待补日期:%d 天;失败待重试:%d 天\n", conclusion, r.Through, r.Fetched, r.Expected, show(r.Latest), show(r.LatestKnown), show(r.LastSuccess), len(r.Missing), r.RetryDates)
|
||||
if len(r.Missing) > 0 {
|
||||
n := len(r.Missing)
|
||||
if n > 14 {
|
||||
n = 14
|
||||
}
|
||||
fmt.Fprintf(&b, "\n待补日期(最多列出14天):%s\n", strings.Join(r.Missing[:n], "、"))
|
||||
}
|
||||
if r.AuthExpired {
|
||||
fmt.Fprintf(&b, "\n[更新广州平台登录状态](https://vehicle.d.lnoneos.com/gzev/session/)\n")
|
||||
}
|
||||
b.WriteString("\n最近7天:\n\n| 日期 | 车辆日记录 | 纯氢有值 | 纯氢缺失 |\n|---|---:|---:|---:|\n")
|
||||
for _, d := range r.Recent {
|
||||
fmt.Fprintf(&b, "| %s | %d | %d | %d |\n", d.Date, d.Rows, d.Known, d.Rows-d.Known)
|
||||
}
|
||||
b.WriteString("\n有值包含平台明确上报的 0;缺失不计为 0。日期采集完成不代表上游字段齐全。每小时 :15 持续同步及重采近期数据。")
|
||||
return "纯氢里程每日汇报 · " + r.Date, b.String()
|
||||
}
|
||||
|
||||
type reportReceipt struct {
|
||||
Date string `json:"date"`
|
||||
AttemptedAt time.Time `json:"attempted_at"`
|
||||
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
||||
}
|
||||
|
||||
// Persist the attempt before sending: an ambiguous timeout must not cause a
|
||||
// duplicate push when the same day's service is started again.
|
||||
func DeliverDailyReport(path, date string, send func() error) (bool, error) {
|
||||
sent := false
|
||||
err := recoveryLock(path, func() error {
|
||||
var receipt reportReceipt
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
if err = json.Unmarshal(b, &receipt); err != nil {
|
||||
return errors.New("invalid daily report receipt")
|
||||
}
|
||||
if receipt.Date == date {
|
||||
if receipt.AcceptedAt != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("daily report already attempted; delivery acceptance unknown, inspect before retrying")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
receipt = reportReceipt{Date: date, AttemptedAt: time.Now().UTC()}
|
||||
write := func() error {
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".daily-report-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
err = json.NewEncoder(f).Encode(receipt)
|
||||
if err == nil {
|
||||
err = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
if err = write(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = send(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
receipt.AcceptedAt = &now
|
||||
if err = write(); err != nil {
|
||||
return err
|
||||
}
|
||||
sent = true
|
||||
return nil
|
||||
})
|
||||
return sent, err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestDailyReportSnapshotAndMissingDates(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FROM gzev_mileage_day").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "success"}).AddRow("2026-10-21", time.Date(2026, 10, 23, 2, 0, 0, 0, time.UTC)).AddRow("2026-10-22", time.Date(2026, 10, 23, 2, 1, 0, 0, time.UTC)))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*MAX.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-10-21"))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*COUNT.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "rows", "known"}).AddRow("2026-10-21", 426, 1).AddRow("2026-10-22", 426, 0))
|
||||
mock.ExpectQuery("SELECT active FROM gzev_auth_alert").WithArgs("account").WillReturnRows(sqlmock.NewRows([]string{"active"}).AddRow(false))
|
||||
mock.ExpectQuery("SELECT COUNT.*FROM gzev_mileage_attempt").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectCommit()
|
||||
r, err := LoadDailyReport(context.Background(), db, "account", "2026-10-20", time.Date(2026, 10, 23, 2, 30, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Date != "2026-10-23" || r.Expected != 3 || r.Fetched != 2 || len(r.Missing) != 1 || r.Missing[0] != "2026-10-20" || r.LastSuccess != "2026-10-23 10:01:00" || r.RetryDates != 1 {
|
||||
t.Fatalf("incorrect snapshot: %+v", r)
|
||||
}
|
||||
_, body := r.Message()
|
||||
if !strings.Contains(body, "仍有 1 个日期待采集") || !strings.Contains(body, "| 2026-10-22 | 426 | 0 | 426 |") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportConclusionDistinguishesCoverageFromNull(t *testing.T) {
|
||||
r := DailyReport{Date: "2026-10-23", Through: "2026-10-22", Recent: []ReportDay{{Date: "2026-10-22", Rows: 426, Known: 0}}}
|
||||
_, body := r.Message()
|
||||
if !strings.Contains(body, "昨天上游纯氢里程全部为空") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
r.Recent[0].Known = 426
|
||||
_, body = r.Message()
|
||||
if !strings.Contains(body, "同步已覆盖全部目标日期") || strings.Contains(body, "昨天上游纯氢里程全部为空") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
r.AuthExpired = true
|
||||
_, body = r.Message()
|
||||
if !strings.Contains(body, "登录已失效") {
|
||||
t.Fatal(body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportDeliveryDeduplicatesAndPreservesAmbiguousAttempt(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "accepted", true: "timeout"}[fail], func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "report.json")
|
||||
calls := 0
|
||||
send := func() error {
|
||||
calls++
|
||||
if fail {
|
||||
return errors.New("timeout")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
sent, err := DeliverDailyReport(path, "2026-10-23", send)
|
||||
if sent == fail || (err != nil) != fail {
|
||||
t.Fatalf("sent=%v err=%v", sent, err)
|
||||
}
|
||||
sent, err = DeliverDailyReport(path, "2026-10-23", send)
|
||||
if sent || calls != 1 || (err != nil) != fail {
|
||||
t.Fatalf("duplicate was sent: calls=%d err=%v", calls, err)
|
||||
}
|
||||
info, _ := os.Stat(path)
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("receipt is not private")
|
||||
}
|
||||
if sent, err = DeliverDailyReport(path, "2026-10-24", func() error { calls++; return nil }); !sent || err != nil || calls != 2 {
|
||||
t.Fatal("next day was blocked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyReportPushDoesNotCreateRecoveryTicket(t *testing.T) {
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json")}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f, _ := url.ParseQuery(string(b))
|
||||
if f.Get("title") != "daily report" || f.Get("desp") != "summary" {
|
||||
t.Fatal("incorrect report")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"errno":0}}`))}, nil
|
||||
})}
|
||||
if err := s.SendMessage(context.Background(), "daily report", "summary"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(s.SessionPath + ".recovery.json"); !os.IsNotExist(err) {
|
||||
t.Fatal("daily summary created a recovery ticket")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recoveryTicket struct {
|
||||
Code string `json:"code"`
|
||||
Expires time.Time `json:"expires"`
|
||||
}
|
||||
|
||||
func recoveryLock(path string, fn func() error) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return err
|
||||
}
|
||||
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
return fn()
|
||||
}
|
||||
func EnsureRecoveryCode(path string) (string, error) {
|
||||
var code string
|
||||
err := recoveryLock(path, func() error {
|
||||
b, err := os.ReadFile(path)
|
||||
var ticket recoveryTicket
|
||||
if err == nil && json.Unmarshal(b, &ticket) == nil && ticket.Expires.After(time.Now()) && len(ticket.Code) == 48 {
|
||||
code = ticket.Code
|
||||
return nil
|
||||
}
|
||||
nonce := make([]byte, 24)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
code = hex.EncodeToString(nonce)
|
||||
ticket = recoveryTicket{Code: code, Expires: time.Now().Add(48 * time.Hour)}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".recovery-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err := json.NewEncoder(f).Encode(ticket); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
})
|
||||
return code, err
|
||||
}
|
||||
|
||||
// ConsumeRecovery serializes all writers and only consumes a ticket after a successful update.
|
||||
func ConsumeRecovery(path, code string, action func() error) error {
|
||||
return recoveryLock(path, func() error {
|
||||
b, err := os.ReadFile(path)
|
||||
var ticket recoveryTicket
|
||||
if err != nil || json.Unmarshal(b, &ticket) != nil || !ticket.Expires.After(time.Now()) || len(code) != 48 || subtle.ConstantTimeCompare([]byte(code), []byte(ticket.Code)) != 1 {
|
||||
return errors.New("invalid recovery code")
|
||||
}
|
||||
if err := action(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerChan struct {
|
||||
SendKey, SessionPath, UploadURL string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func ServerChanFromEnv() ServerChan {
|
||||
path := os.Getenv("GZEV_SESSION_FILE")
|
||||
if path == "" {
|
||||
path = "/var/lib/lingniu-gzev/session.json"
|
||||
}
|
||||
return ServerChan{SendKey: strings.TrimSpace(os.Getenv("SERVERCHAN_SENDKEY")), SessionPath: path, UploadURL: os.Getenv("GZEV_SESSION_UPLOAD_URL"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func (s ServerChan) Send(ctx context.Context) error { return s.send(ctx, false) }
|
||||
func (s ServerChan) SendTest(ctx context.Context) error { return s.send(ctx, true) }
|
||||
func (s ServerChan) send(ctx context.Context, test bool) error {
|
||||
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
|
||||
return errors.New("ServerChan SendKey missing or invalid")
|
||||
}
|
||||
if s.UploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
|
||||
return errors.New("unsupported recovery URL")
|
||||
}
|
||||
code, err := EnsureRecoveryCode(s.SessionPath + ".recovery.json")
|
||||
if err != nil {
|
||||
return errors.New("cannot create recovery code")
|
||||
}
|
||||
title := "广州里程采集登录失效"
|
||||
intro := "定时采集前验证未通过,当前采集已暂停。请登录广州平台并提交新的登录状态。"
|
||||
if test {
|
||||
title = "广州里程采集推送测试"
|
||||
intro = "这是 Server酱推送渠道连通性测试,不表示登录状态已失效。以后采集前发现登录失效,将通过此渠道通知。"
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\n[打开登录状态上传页面](%s)\n\n通知恢复码:\n\n`%s`\n\n恢复码有效期 48 小时,成功上传后失效。上传页面支持粘贴 token 或上传会话 JSON;验证通过后,下次定时采集自动继续。请勿转发恢复码。", intro, s.UploadURL, code)
|
||||
return s.SendMessage(ctx, title, body)
|
||||
}
|
||||
|
||||
// SendMessage uses the existing configured channel without creating a recovery ticket.
|
||||
func (s ServerChan) SendMessage(ctx context.Context, title, body string) error {
|
||||
if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) {
|
||||
return errors.New("ServerChan SendKey missing or invalid")
|
||||
}
|
||||
form := url.Values{"title": {title}, "desp": {body}}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://sctapi.ftqq.com/"+s.SendKey+".send", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return errors.New("cannot create push request")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := s.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("ServerChan transport failure; delivery status may be unknown")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code *int `json:"code"`
|
||||
Data struct {
|
||||
Errno *int `json:"errno"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code == nil {
|
||||
return errors.New("ServerChan did not confirm acceptance")
|
||||
}
|
||||
if *result.Code != 0 {
|
||||
return fmt.Errorf("ServerChan rejected push (code=%d)", *result.Code)
|
||||
}
|
||||
if result.Data.Errno != nil && *result.Data.Errno != 0 {
|
||||
return fmt.Errorf("ServerChan channel error (errno=%d)", *result.Data.Errno)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerChanPushAndAcceptance(t *testing.T) {
|
||||
for _, response := range []string{`{"code":0,"data":{"errno":0}}`, `{"code":40001}`, `{}`, `{"code":0,"data":{"errno":1}}`} {
|
||||
t.Run(response, func(t *testing.T) {
|
||||
calls := 0
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
if r.Method != "POST" || r.URL.Host != "sctapi.ftqq.com" || r.URL.Path != "/SCTtest12345678.send" {
|
||||
t.Fatal("wrong endpoint")
|
||||
}
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
if form.Get("title") != "广州里程采集登录失效" || !strings.Contains(form.Get("desp"), s.UploadURL) || !strings.Contains(form.Get("desp"), "通知恢复码") {
|
||||
t.Fatal("missing actionable content")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: http.Header{}}, nil
|
||||
})}
|
||||
err := s.Send(context.Background())
|
||||
if (err == nil) != (response == `{"code":0,"data":{"errno":0}}`) || calls != 1 {
|
||||
t.Fatalf("unexpected acceptance: %v", err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), s.SendKey) {
|
||||
t.Fatal("key leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestServerChanMissingKeyDoesNotCreateTicket(t *testing.T) {
|
||||
s := ServerChan{SessionPath: filepath.Join(t.TempDir(), "session.json")}
|
||||
if s.Send(context.Background()) == nil {
|
||||
t.Fatal("unconfigured notification accepted")
|
||||
}
|
||||
}
|
||||
func TestServerChanTestIsClearlyLabeled(t *testing.T) {
|
||||
s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
f, _ := url.ParseQuery(string(b))
|
||||
if !strings.Contains(f.Get("title"), "测试") || !strings.Contains(f.Get("desp"), "不表示登录状态已失效") {
|
||||
t.Fatal("false expiry alert")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0}`)), Header: http.Header{}}, nil
|
||||
})}
|
||||
if err := s.SendTest(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SMS struct {
|
||||
AccessKeyID, AccessKeySecret, SecurityToken, SignName, TemplateCode, TemplateParam string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func SMSFromEnv() SMS {
|
||||
return SMS{AccessKeyID: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"), SecurityToken: os.Getenv("ALIBABA_CLOUD_SECURITY_TOKEN"), SignName: os.Getenv("GZEV_SMS_SIGN_NAME"), TemplateCode: os.Getenv("GZEV_SMS_TEMPLATE_CODE"), TemplateParam: os.Getenv("GZEV_SMS_TEMPLATE_PARAM"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||
}
|
||||
func hashHex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
|
||||
|
||||
// ACS3 native HTTP signing follows Alibaba Cloud's published V3 specification.
|
||||
func signACS3(req *http.Request, payload, accessKey, secret string) {
|
||||
req.Header.Set("x-acs-content-sha256", hashHex([]byte(payload)))
|
||||
headers := map[string]string{"host": req.URL.Host}
|
||||
for key, values := range req.Header {
|
||||
k := strings.ToLower(key)
|
||||
if k == "content-type" || strings.HasPrefix(k, "x-acs-") {
|
||||
headers[k] = strings.TrimSpace(strings.Join(values, ","))
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonical strings.Builder
|
||||
for _, k := range names {
|
||||
fmt.Fprintf(&canonical, "%s:%s\n", k, headers[k])
|
||||
}
|
||||
signed := strings.Join(names, ";")
|
||||
canonicalRequest := req.Method + "\n/\n" + req.URL.RawQuery + "\n" + canonical.String() + "\n" + signed + "\n" + hashHex([]byte(payload))
|
||||
stringToSign := "ACS3-HMAC-SHA256\n" + hashHex([]byte(canonicalRequest))
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
req.Header.Set("Authorization", "ACS3-HMAC-SHA256 Credential="+accessKey+",SignedHeaders="+signed+",Signature="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
func (s SMS) Send(ctx context.Context) error {
|
||||
if s.AccessKeyID == "" || s.AccessKeySecret == "" || s.SignName == "" || s.TemplateCode == "" {
|
||||
return errors.New("Aliyun SMS not configured")
|
||||
}
|
||||
param := s.TemplateParam
|
||||
if param == "" {
|
||||
param = "{}"
|
||||
}
|
||||
var params map[string]string
|
||||
if json.Unmarshal([]byte(param), ¶ms) != nil || params == nil {
|
||||
return errors.New("SMS TemplateParam must be a JSON string map")
|
||||
}
|
||||
// Template must contain these two variables for a user-actionable recovery message.
|
||||
uploadURL := os.Getenv("GZEV_SESSION_UPLOAD_URL")
|
||||
if uploadURL != "" {
|
||||
if uploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" {
|
||||
return errors.New("unsupported recovery URL")
|
||||
}
|
||||
path := os.Getenv("GZEV_SESSION_FILE")
|
||||
if path == "" {
|
||||
path = "/var/lib/lingniu-gzev/session.json"
|
||||
}
|
||||
code, err := EnsureRecoveryCode(path + ".recovery.json")
|
||||
if err != nil {
|
||||
return errors.New("cannot create recovery code")
|
||||
}
|
||||
params["upload_url"] = uploadURL
|
||||
params["recovery_code"] = code
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
param = string(data)
|
||||
}
|
||||
form := url.Values{"PhoneNumbers": {"16605225837"}, "SignName": {s.SignName}, "TemplateCode": {s.TemplateCode}, "TemplateParam": {param}}
|
||||
payload := form.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", "https://dysmsapi.aliyuncs.com/", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, 16)
|
||||
if _, err = rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("x-acs-action", "SendSms")
|
||||
req.Header.Set("x-acs-version", "2017-05-25")
|
||||
req.Header.Set("x-acs-date", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
|
||||
req.Header.Set("x-acs-signature-nonce", hex.EncodeToString(nonce))
|
||||
if s.SecurityToken != "" {
|
||||
req.Header.Set("x-acs-security-token", s.SecurityToken)
|
||||
}
|
||||
signACS3(req, payload, s.AccessKeyID, s.AccessKeySecret)
|
||||
resp, err := s.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("SMS transport failure; delivery status may be unknown")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var result struct {
|
||||
Code string `json:"Code"`
|
||||
}
|
||||
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code != "OK" {
|
||||
return errors.New("SMS provider did not confirm acceptance")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
func TestSMSRequestAndBusinessFailure(t *testing.T) {
|
||||
for _, code := range []string{"OK", "isv.BUSINESS_LIMIT_CONTROL"} {
|
||||
calls := 0
|
||||
s := SMS{AccessKeyID: "test-id", AccessKeySecret: "test-secret", SecurityToken: "sts-token", SignName: "测试签名", TemplateCode: "SMS_TEST", TemplateParam: `{"system":"广州里程"}`}
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
if r.URL.String() != "https://dysmsapi.aliyuncs.com/" || form.Get("PhoneNumbers") != "16605225837" || form.Get("TemplateCode") != "SMS_TEST" {
|
||||
t.Fatal("wrong SMS destination")
|
||||
}
|
||||
if r.Header.Get("x-acs-content-sha256") != hashHex(b) || r.Header.Get("x-acs-action") != "SendSms" || !strings.Contains(r.Header.Get("Authorization"), "x-acs-security-token") {
|
||||
t.Fatal("incorrect signed request")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"Code":"` + code + `"}`)), Header: http.Header{}}, nil
|
||||
})}
|
||||
err := s.Send(context.Background())
|
||||
if (err == nil) != (code == "OK") || calls != 1 {
|
||||
t.Fatalf("%s: %v", code, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSMSMissingConfigDoesNotSend(t *testing.T) {
|
||||
if (SMS{}).Send(context.Background()) == nil {
|
||||
t.Fatal("missing config reported success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMSIncludesUploadURLAndSingleUseRecovery(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.json")
|
||||
t.Setenv("GZEV_SESSION_FILE", path)
|
||||
t.Setenv("GZEV_SESSION_UPLOAD_URL", "https://vehicle.d.lnoneos.com/gzev/session/")
|
||||
s := SMS{AccessKeyID: "id", AccessKeySecret: "secret", SignName: "sign", TemplateCode: "SMS_TEST"}
|
||||
var code string
|
||||
s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(b))
|
||||
var params map[string]string
|
||||
if json.Unmarshal([]byte(form.Get("TemplateParam")), ¶ms) != nil {
|
||||
t.Fatal("invalid template params")
|
||||
}
|
||||
if params["upload_url"] != "https://vehicle.d.lnoneos.com/gzev/session/" || len(params["recovery_code"]) != 48 {
|
||||
t.Fatal("missing actionable recovery information")
|
||||
}
|
||||
code = params["recovery_code"]
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"Code":"OK"}`))}, nil
|
||||
})}
|
||||
if err := s.Send(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err != nil {
|
||||
t.Fatal("SMS code not usable")
|
||||
}
|
||||
if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err == nil {
|
||||
t.Fatal("SMS code reused")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrBusy = errors.New("another GZEV worker holds the account lock")
|
||||
|
||||
type Store struct {
|
||||
Conn *sql.Conn
|
||||
Account string
|
||||
}
|
||||
|
||||
// WithLock uses a dedicated connection: MySQL advisory locks are connection scoped.
|
||||
func WithLock(ctx context.Context, db *sql.DB, account string, fn func(*Store) error) error {
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
sum := sha256.Sum256([]byte(account))
|
||||
key := "gzev:" + hex.EncodeToString(sum[:24])
|
||||
var ok int
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", key).Scan(&ok); err != nil {
|
||||
return err
|
||||
}
|
||||
if ok != 1 {
|
||||
return ErrBusy
|
||||
}
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", key)
|
||||
}()
|
||||
return fn(&Store{Conn: conn, Account: account})
|
||||
}
|
||||
func (s *Store) NeedsDay(ctx context.Context, date string, recent bool) (bool, error) {
|
||||
var cooling bool
|
||||
err := s.Conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM gzev_mileage_attempt WHERE account=? AND stat_date=? AND retry_after>UTC_TIMESTAMP(3))", s.Account, date).Scan(&cooling)
|
||||
if err != nil || cooling {
|
||||
return false, err
|
||||
}
|
||||
if recent {
|
||||
return true, nil
|
||||
}
|
||||
var retry bool
|
||||
err = s.Conn.QueryRowContext(ctx, "SELECT missing_count>0 AND last_success_at<UTC_TIMESTAMP()-INTERVAL 7 DAY FROM gzev_mileage_day WHERE account=? AND stat_date=?", s.Account, date).Scan(&retry)
|
||||
if err == sql.ErrNoRows {
|
||||
return true, nil
|
||||
}
|
||||
return retry, err
|
||||
}
|
||||
func (s *Store) RecordFailure(ctx context.Context, date string) error {
|
||||
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_mileage_attempt(account,stat_date,retry_after) VALUES(?,?,UTC_TIMESTAMP()+INTERVAL 6 HOUR) ON DUPLICATE KEY UPDATE retry_after=VALUES(retry_after)`, s.Account, date)
|
||||
return err
|
||||
}
|
||||
func (s *Store) SaveDay(ctx context.Context, d Day) error {
|
||||
if err := ValidateDate(d.Date, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(d.Rows) == 0 {
|
||||
return errors.New("empty source day requires review; not marked complete")
|
||||
}
|
||||
raw, err := json.Marshal(d.Pages)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hash := sha256.Sum256(raw)
|
||||
checksum := hex.EncodeToString(hash[:])
|
||||
tx, err := s.Conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Reject source regressions rather than replacing a previously measured day with nulls.
|
||||
previous, err := tx.QueryContext(ctx, "SELECT vin,total_km IS NOT NULL,hydrogen_km IS NOT NULL,charge_km IS NOT NULL FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
old := map[string][3]bool{}
|
||||
for previous.Next() {
|
||||
var vin string
|
||||
var flags [3]bool
|
||||
if err := previous.Scan(&vin, &flags[0], &flags[1], &flags[2]); err != nil {
|
||||
previous.Close()
|
||||
return err
|
||||
}
|
||||
old[vin] = flags
|
||||
}
|
||||
err = previous.Err()
|
||||
previous.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range d.Rows {
|
||||
flags := old[r.VIN]
|
||||
if flags[0] && !r.Total.Valid || flags[1] && !r.Hydrogen.Valid || flags[2] && !r.Charge.Valid {
|
||||
return errors.New("source regressed measured mileage to null; previous day retained")
|
||||
}
|
||||
delete(old, r.VIN)
|
||||
}
|
||||
if len(old) > 0 {
|
||||
return errors.New("source vehicle list shrank; previous day retained for review")
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "INSERT IGNORE INTO gzev_mileage_snapshot(account,stat_date,snapshot_hash,fetched_at,raw_pages) VALUES(?,?,?,?,?)", s.Account, d.Date, checksum, d.FetchedAt.UTC(), string(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing := 0
|
||||
for _, r := range d.Rows {
|
||||
status := "reported"
|
||||
if !r.Total.Valid || !r.Hydrogen.Valid || !r.Charge.Valid {
|
||||
status = "missing"
|
||||
missing++
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_daily_mileage(account,vin,stat_date,plate_number,vehicle_seq,production_company,operation_company,brand,model,total_km,hydrogen_km,charge_km,quality_status,raw_row,fetched_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.Account, r.VIN, d.Date, r.Plate, r.VehicleSeq, r.ProductionCompany, r.OperationCompany, r.Brand, r.Model, r.Total.SQLValue(), r.Hydrogen.SQLValue(), r.Charge.SQLValue(), status, string(r.Raw), d.FetchedAt.UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO gzev_mileage_day(account,stat_date,row_count,missing_count,last_success_at,snapshot_hash) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE row_count=VALUES(row_count),missing_count=VALUES(missing_count),last_success_at=VALUES(last_success_at),snapshot_hash=VALUES(snapshot_hash)`, s.Account, d.Date, len(d.Rows), missing, d.FetchedAt.UTC(), checksum)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, "DELETE FROM gzev_mileage_attempt WHERE account=? AND stat_date=?", s.Account, d.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *Store) ResolveAuth(ctx context.Context) error {
|
||||
_, err := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET active=FALSE,resolved_at=UTC_TIMESTAMP(3) WHERE account=? AND active=TRUE", s.Account)
|
||||
return err
|
||||
}
|
||||
|
||||
// AuthFailure persists delivery state. Retry at most hourly and remind at most daily.
|
||||
// A provider timeout is ambiguous: no immediate retry to avoid duplicate notifications.
|
||||
func (s *Store) AuthFailure(ctx context.Context, send func(context.Context) error) error {
|
||||
_, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_auth_alert(account,first_failed_at) VALUES(?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE first_failed_at=IF(active,first_failed_at,UTC_TIMESTAMP(3)),last_attempt_at=IF(active,last_attempt_at,NULL),last_sent_at=IF(active,last_sent_at,NULL),resolved_at=NULL,active=TRUE`, s.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var due bool
|
||||
err = s.Conn.QueryRowContext(ctx, `SELECT (last_attempt_at IS NULL OR last_attempt_at<=UTC_TIMESTAMP()-INTERVAL 1 HOUR) AND (last_sent_at IS NULL OR last_sent_at<=UTC_TIMESTAMP()-INTERVAL 24 HOUR) FROM gzev_auth_alert WHERE account=?`, s.Account).Scan(&due)
|
||||
if err != nil || !due {
|
||||
return err
|
||||
}
|
||||
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_attempt_at=UTC_TIMESTAMP(3) WHERE account=?", s.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = send(ctx); err != nil {
|
||||
_, persistErr := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_error='Notification delivery failed or not configured' WHERE account=?", s.Account)
|
||||
return errors.Join(fmt.Errorf("auth notification: %w", err), persistErr)
|
||||
}
|
||||
_, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_sent_at=UTC_TIMESTAMP(3),last_error='' WHERE account=?", s.Account)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveDayRollsBackIfAnyRowFails(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT vin").WithArgs("account", "2024-01-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}))
|
||||
mock.ExpectExec("INSERT IGNORE INTO gzev_mileage_snapshot").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("DELETE FROM gzev_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec("INSERT INTO gzev_daily_mileage").WillReturnError(errors.New("disk full"))
|
||||
mock.ExpectRollback()
|
||||
d := Day{Date: "2024-01-01", FetchedAt: time.Now(), Pages: []json.RawMessage{json.RawMessage(`{}`)}, Rows: []Row{{VIN: "LNXNEGRR7SR319456", Raw: json.RawMessage(`{}`)}}}
|
||||
if store.SaveDay(context.Background(), d) == nil {
|
||||
t.Fatal("expected transaction failure")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestSaveRejectsNullRegression(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery("SELECT vin").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}).AddRow("LNXNEGRR7SR319456", true, false, false))
|
||||
mock.ExpectRollback()
|
||||
if store.SaveDay(context.Background(), Day{Date: "2024-01-01", Rows: []Row{{VIN: "LNXNEGRR7SR319456"}}}) == nil {
|
||||
t.Fatal("overwrote measured value with null")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestAuthNotificationCooldown(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(false))
|
||||
if err := store.AuthFailure(context.Background(), func(context.Context) error { t.Fatal("duplicate SMS sent"); return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestAuthNotificationFailureIsPersisted(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
conn, _ := db.Conn(context.Background())
|
||||
defer conn.Close()
|
||||
store := Store{conn, "account"}
|
||||
mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(true))
|
||||
mock.ExpectExec("UPDATE gzev_auth_alert SET last_attempt_at").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE gzev_auth_alert SET last_error").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
if err := store.AuthFailure(context.Background(), func(context.Context) error { return errors.New("not configured") }); err == nil {
|
||||
t.Fatal("lost delivery failure")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UploadHandler accepts local SSH access or the configured HTTPS origin with a one-use recovery code.
|
||||
type UploadHandler struct {
|
||||
PublicOrigin string
|
||||
RecoveryPath string
|
||||
SessionPath string
|
||||
Account string
|
||||
Verify func(context.Context, Session, string) error
|
||||
csrf string
|
||||
}
|
||||
|
||||
func NewUploadHandler(path, account string) (*UploadHandler, error) {
|
||||
nonce := make([]byte, 32)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UploadHandler{SessionPath: path, Account: account, csrf: hex.EncodeToString(nonce), Verify: func(ctx context.Context, s Session, a string) error { return NewClient(s).VerifyAccount(ctx, a) }}, nil
|
||||
}
|
||||
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
// Keep the Origin header on same-origin form POSTs; no-referrer makes
|
||||
// browsers send Origin: null, which the strict check below must reject.
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
local := r.Host == "127.0.0.1:20312" || r.Host == "localhost:20312"
|
||||
public := h.PublicOrigin != "" && "https://"+r.Host == h.PublicOrigin
|
||||
if !local && !public {
|
||||
http.Error(w, "仅允许通过 SSH 安全连接访问", 403)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/" && r.URL.Path != "/gzev/session/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
uploadPage.Execute(w, map[string]string{"Account": h.Account, "CSRF": h.csrf, "Public": fmt.Sprint(h.PublicOrigin != "")})
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
expectedOrigin := "http://" + r.Host
|
||||
if public {
|
||||
expectedOrigin = h.PublicOrigin
|
||||
}
|
||||
if r.Header.Get("Origin") != expectedOrigin {
|
||||
http.Error(w, "请求来源不正确,请刷新上传页面", 403)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
if err := r.ParseMultipartForm(32 << 10); err != nil {
|
||||
http.Error(w, "文件或表单无效,大小不能超过 32 KB", 400)
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(h.csrf)) != 1 {
|
||||
http.Error(w, "页面已过期,请刷新后重新提交", 403)
|
||||
return
|
||||
}
|
||||
var s Session
|
||||
file, _, fileErr := r.FormFile("session")
|
||||
if fileErr == nil {
|
||||
defer file.Close()
|
||||
dec := json.NewDecoder(file)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
http.Error(w, "会话文件格式错误", 400)
|
||||
return
|
||||
}
|
||||
if dec.Decode(new(any)) != io.EOF {
|
||||
http.Error(w, "会话文件含多余内容", 400)
|
||||
return
|
||||
}
|
||||
} else if fileErr == http.ErrMissingFile {
|
||||
token := strings.TrimSpace(r.FormValue("token"))
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
s = Session{Authorization: "Bearer " + token, Cookies: []SessionCookie{{Name: "token", Value: token}}}
|
||||
if routing := strings.TrimSpace(r.FormValue("routing")); routing != "" {
|
||||
s.Cookies = append(s.Cookies, SessionCookie{Name: "bigs", Value: routing})
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "无法读取上传文件", 400)
|
||||
return
|
||||
}
|
||||
if ValidateSession(s) != nil {
|
||||
http.Error(w, "会话格式错误,只接受 token 和可选的 bigs Cookie", 400)
|
||||
return
|
||||
}
|
||||
action := func() error {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Verify(ctx, s, h.Account); err != nil {
|
||||
return errors.New("verification failed")
|
||||
}
|
||||
return WriteSession(h.SessionPath, s)
|
||||
}
|
||||
var err error
|
||||
if h.PublicOrigin != "" {
|
||||
err = ConsumeRecovery(h.RecoveryPath, r.FormValue("recovery_code"), action)
|
||||
} else {
|
||||
err = action()
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "验证或保存未通过。请检查恢复码、登录状态及账号;现有会话未覆盖。", 422)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, "<!doctype html><html lang=zh-CN><meta charset=utf-8><meta name=viewport content='width=device-width,initial-scale=1'><title>更新成功</title><body><h1>登录状态已更新</h1><p>验证通过,下一次定时采集将自动继续补齐里程数据。</p><a href=/>返回</a></body></html>")
|
||||
}
|
||||
func WriteSession(path string, s Session) error {
|
||||
if err := ValidateSession(s); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".session-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if err := json.NewEncoder(f).Encode(s); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(name, path)
|
||||
}
|
||||
|
||||
var uploadPage = template.Must(template.New("upload").Parse(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>更新广州平台登录状态</title><style>body{font:16px/1.6 system-ui,sans-serif;background:#f4f6f8;color:#14212e;margin:0;padding:32px 16px}main{max-width:620px;margin:0 auto;background:white;padding:32px;border-radius:16px}h1{font-size:26px}label{display:block;margin-top:18px}input{display:block;box-sizing:border-box;width:100%;padding:12px;border:1px solid #aab5c0;border-radius:6px;font:inherit}button{margin-top:24px;background:#125acf;color:white;border:0;border-radius:8px;padding:12px 24px;font:inherit}small{color:#536273}details{margin-top:24px}hr{border:0;border-top:1px solid #ddd;margin:24px 0}</style></head><body><main><h1>更新广州平台登录状态</h1><p>请先登录广州平台,账号:<strong>{{.Account}}</strong>。</p><p>提交后会验证账号和登录状态。验证通过后,下一次定时采集自动继续。</p><form method="post" enctype="multipart/form-data" autocomplete="off"><input type="hidden" name="csrf" value="{{.CSRF}}">{{if eq .Public "true"}}<label>通知恢复码<input type="password" name="recovery_code" required autocomplete="off" maxlength="48"></label>{{end}}<label>粘贴 token<input type="password" name="token" autocomplete="off" maxlength="8192"></label><label>bigs Cookie(可选)<input type="password" name="routing" autocomplete="off" maxlength="4096"></label><hr><label>或上传会话 JSON 文件<input type="file" name="session" accept=".json,application/json"></label><small>上传文件时优先使用文件内容。不要提交账号密码或短信验证码。</small><br><button type="submit">验证并更新</button></form><details><summary>如何取得 token?</summary><ol><li>在 Chrome 登录广州平台,完成必要的短信和图形验证码。</li><li>打开开发者工具 → Application → Cookies → gzev.d.lnoneos.com。</li><li>复制 token 的值;如有 bigs,也一并复制。</li><li>回到此页面粘贴并提交。</li></ol></details></main></body></html>`))
|
||||
@@ -0,0 +1,102 @@
|
||||
package gzev
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func uploadRequest(t *testing.T, h *UploadHandler, origin, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
form.WriteField("csrf", h.csrf)
|
||||
form.WriteField("token", "new-session")
|
||||
form.WriteField("recovery_code", code)
|
||||
form.Close()
|
||||
req := httptest.NewRequest("POST", "https://vehicle.d.lnoneos.com/gzev/session/", &body)
|
||||
req.Header.Set("Content-Type", form.FormDataContentType())
|
||||
req.Header.Set("Origin", origin)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
func TestPublicUploadRequiresCodeAndConsumesIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.json")
|
||||
h, _ := NewUploadHandler(path, "expected")
|
||||
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
|
||||
h.RecoveryPath = path + ".recovery.json"
|
||||
calls := 0
|
||||
h.Verify = func(_ context.Context, s Session, account string) error {
|
||||
calls++
|
||||
if account != "expected" || s.Authorization != "Bearer new-session" {
|
||||
t.Fatal("wrong account or session")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, ""); r.Code != 422 || calls != 0 {
|
||||
t.Fatal("missing code reached verification")
|
||||
}
|
||||
code, err := EnsureRecoveryCode(h.RecoveryPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, origin := range []string{"https://attacker.invalid", "null", ""} {
|
||||
if r := uploadRequest(t, h, origin, code); r.Code != 403 || calls != 0 {
|
||||
t.Fatalf("untrusted origin %q accepted", origin)
|
||||
}
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 200 {
|
||||
t.Fatalf("upload failed: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
session, err := ReadSession(path)
|
||||
if err != nil || session.Authorization != "Bearer new-session" {
|
||||
t.Fatal("session was not securely saved")
|
||||
}
|
||||
if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 422 || calls != 1 {
|
||||
t.Fatal("reused code accepted")
|
||||
}
|
||||
}
|
||||
func TestInvalidSessionDoesNotOverwriteOrConsume(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.json")
|
||||
os.WriteFile(path, []byte("original"), 0600)
|
||||
h, _ := NewUploadHandler(path, "expected")
|
||||
h.PublicOrigin = "https://vehicle.d.lnoneos.com"
|
||||
h.RecoveryPath = path + ".recovery.json"
|
||||
h.Verify = func(context.Context, Session, string) error { return errors.New("expired credential") }
|
||||
code, _ := EnsureRecoveryCode(h.RecoveryPath)
|
||||
r := uploadRequest(t, h, h.PublicOrigin, code)
|
||||
if r.Code != 422 {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "original" {
|
||||
t.Fatal("overwrote valid previous file")
|
||||
}
|
||||
if _, err := os.Stat(h.RecoveryPath); err != nil {
|
||||
t.Fatal("failed upload consumed recovery code")
|
||||
}
|
||||
if strings.Contains(r.Body.String(), "new-session") {
|
||||
t.Fatal("response leaked token")
|
||||
}
|
||||
}
|
||||
func TestUploadRejectsUntrustedHostAndHidesToken(t *testing.T) {
|
||||
h, _ := NewUploadHandler(filepath.Join(t.TempDir(), "session.json"), "expected")
|
||||
r := httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest("GET", "http://attacker.invalid/", nil))
|
||||
if r.Code != 403 {
|
||||
t.Fatal(r.Code)
|
||||
}
|
||||
r = httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest("GET", "http://127.0.0.1:20312/", nil))
|
||||
if r.Code != 200 || r.Header().Get("Cache-Control") != "no-store" || r.Header().Get("Referrer-Policy") != "same-origin" || !strings.Contains(r.Body.String(), "验证并更新") {
|
||||
t.Fatal("upload UI unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package hydrogen
|
||||
|
||||
import "math"
|
||||
|
||||
// PressureMassKg converts pressure (MPa), temperature (C) and total water volume (L)
|
||||
// using the existing real-gas compressibility equation.
|
||||
func PressureMassKg(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
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.10.0
|
||||
version: 1.12.2
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
@@ -16,6 +16,90 @@ tags:
|
||||
- name: 开放平台管理
|
||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||
paths:
|
||||
/api/v1/vehicles/operating-data/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 批量查询车辆综合运营数据
|
||||
description: |
|
||||
responseMode省略或FULL保持已上线完整结构;SIMPLE固定返回9个业务字段并在内部按质量过滤。
|
||||
SIMPLE的百公里氢耗字段暂固定null,不进行未经证明可比的日用氢/日里程相除;不影响summary成功计数。
|
||||
SIMPLE summary按所选展示字段可用性统计;FULL按子查询执行结果统计。未选类型对应的SIMPLE字段仍返回null但不参与计数。
|
||||
SIMPLE舍弃陈旧/无可信采集时间的剩余氢量、异常用氢及结转补零;原完整数据和来源状态仍在内部保留。
|
||||
车牌可选,省略或空数组查询所选时间范围均有效授权的全部车辆(取交集),无车辆返回空数组;省略或空数组不受200辆上限限制,不分页、不截断。显式指定车牌最多200项,超出返回BATCH_SIZE_EXCEEDED。规范化后不允许重复。
|
||||
dataTypes省略时查询实时、单日里程和单日用氢;未选块省略。含日统计时dailyDate必填且不得为未来日期。
|
||||
protocolPriority仅影响DAILY_MILEAGE,未选里程时不可提供;实时保持原有选源。
|
||||
appKey须当前有效,实时车辆授权覆盖requestAt,日统计授权完整覆盖查询自然日。任一越权整批403;先授权再读取。
|
||||
各数据块保留源字段、质量与时间,不是共同快照;不计算百公里氢耗。同日或NORMAL不能证明统计区间可比。
|
||||
FULL的queryStatus仅表示子服务可见执行结果,SUCCESS仍可能NO_DATA、DATA_ANOMALY或实时补充降级MISSING/UNKNOWN。
|
||||
里程结转沿用status=NORMAL及dataQuality=CARRIED_FORWARD,不能解释为实测零;储氢陈旧和估算来源原样保留。
|
||||
各块失败独立返回FAILED及错误码,不以NO_DATA或旧值掩盖。所有子查询失败仍返回200和准确summary。
|
||||
所有时间戳转为带+08:00的RFC3339,未知可选字段显式null;原独立接口不受影响。
|
||||
每应用每服务实例30批/60秒、最多2批并发;新端点全局每实例最多16批。429包含Retry-After,多实例配额由网关统一协调。
|
||||
总预算6秒,批量子查询共享最多4.5秒预算;上层网关需给出更长预算。未承诺未经压测的P95目标。
|
||||
operationId: queryVehicleOperatingData
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: 最大64KiB;不接受未知字段或显式null。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingDataQuery'
|
||||
example:
|
||||
responseMode: SIMPLE
|
||||
plateNumbers: [粤A09635F]
|
||||
dailyDate: '2026-10-23'
|
||||
dataTypes: [REALTIME, DAILY_MILEAGE, DAILY_HYDROGEN]
|
||||
protocolPriority: [GB32960, MQTT, JT808]
|
||||
responses:
|
||||
'200':
|
||||
description: 聚合处理完成;FULL逐块检查执行和质量,SIMPLE返回9字段并按所选展示字段可用性汇总。
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/OperatingDataResponse'
|
||||
- $ref: '#/components/schemas/OperatingSimpleResponse'
|
||||
'400':
|
||||
description: 参数、日期或协议不合法;显式plateNumbers超过200项时code为BATCH_SIZE_EXCEEDED
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'401':
|
||||
description: appKey当前无效
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'403':
|
||||
description: 车辆或所选时段未授权
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'413':
|
||||
description: 请求体超过64KiB
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'429':
|
||||
description: 应用或全局限额超限
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: {type: integer, minimum: 1}
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
'500':
|
||||
description: 授权依赖、身份映射或聚合内部异常
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatingErrorResponse'
|
||||
/api/v1/vehicles/hydrogen-remaining/history/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
@@ -137,7 +221,7 @@ paths:
|
||||
tags: [合作方数据接口]
|
||||
summary: 分页查询车辆区间日里程
|
||||
description: |
|
||||
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
最长支持 3 年(1096 天)。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
@@ -491,6 +575,391 @@ components:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
schemas:
|
||||
OperatingSimpleVehicle:
|
||||
type: "object"
|
||||
additionalProperties: false
|
||||
required: ["plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"]
|
||||
properties:
|
||||
plateNumber:
|
||||
type: "string"
|
||||
description: "规范化后的请求车牌,保持请求顺序。"
|
||||
reportTime:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "实时选中主记录的event_time,即真实源采集/上报时刻;缺失为null,不使用received_at、updated_at或请求时间替代。FULL recordTime仍保持原兼容口径。"
|
||||
format: "date-time"
|
||||
offline:
|
||||
type: "boolean"
|
||||
nullable: true
|
||||
description: "主实时记录可用且online已知时取反;无记录或查询失败为null,不能当成离线。"
|
||||
remainingHydrogenKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "剩余氢量kg。仅kg字段NORMAL、数值有效且有可信采集时间,响应时不超过300秒并不晚于服务时点1分钟时展示;陈旧/缺失/异常为null,真实0保留。"
|
||||
minimum: 0
|
||||
socPercent:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "动力电池SOC百分比;实时主记录有效且数值在0–100内,未知为null。"
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
totalMileageKm:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "实时主记录累计里程km;不使用日统计累计值兜底。"
|
||||
minimum: 0
|
||||
dailyHydrogenConsumptionKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "queryDate用氢量kg;子查询成功、status=NORMAL、qualityStatus=OK时返回有效非负数值,否则null。日内值可为PRELIMINARY,不保证终报。"
|
||||
minimum: 0
|
||||
dailyMileageKm:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "queryDate里程km;执行成功、业务NORMAL且非CARRIED_FORWARD时返回有效非负数值;实测零保留,结转补零和异常为null。"
|
||||
minimum: 0
|
||||
hydrogenConsumptionPer100KmKg:
|
||||
type: "number"
|
||||
nullable: true
|
||||
description: "kg/100km预留字段;当前缺少可比区间完整证据,固定null,不参与summary计数。禁止调用方以同日日指标直接相除。"
|
||||
enum: [null]
|
||||
OperatingSimpleSummary:
|
||||
type: "object"
|
||||
description: "按所选展示字段的可用性统计:实时5项+日里程1项+日用氢1项。全有值successCount、部分有值partialSuccessCount、全null failedCount。plateNumber和暂未支持的百公里氢耗不参与;未选类型不参与。null及failedCount并不等于查询执行失败,不作为自动重试依据。"
|
||||
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
|
||||
properties:
|
||||
requestedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/requestedCount"}
|
||||
returnedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/returnedCount"}
|
||||
successCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/successCount"}
|
||||
partialSuccessCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/partialSuccessCount"}
|
||||
failedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/failedCount"}
|
||||
OperatingSimpleResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
|
||||
properties:
|
||||
code: {"$ref": "#/components/schemas/OperatingDataResponse/properties/code"}
|
||||
message: {"$ref": "#/components/schemas/OperatingDataResponse/properties/message"}
|
||||
traceId: {"$ref": "#/components/schemas/OperatingDataResponse/properties/traceId"}
|
||||
queryDate: {"$ref": "#/components/schemas/OperatingDataResponse/properties/queryDate"}
|
||||
timezone: {"$ref": "#/components/schemas/OperatingDataResponse/properties/timezone"}
|
||||
requestAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/requestAt"}
|
||||
generatedAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/generatedAt"}
|
||||
data:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
items: {"$ref": "#/components/schemas/OperatingSimpleVehicle"}
|
||||
summary: {"$ref": "#/components/schemas/OperatingSimpleSummary"}
|
||||
OperatingDataQuery:
|
||||
type: "object"
|
||||
oneOf:
|
||||
- required: [dataTypes]
|
||||
properties:
|
||||
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
|
||||
- required: [dailyDate]
|
||||
not:
|
||||
required: [dataTypes]
|
||||
properties:
|
||||
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
|
||||
additionalProperties: false
|
||||
properties:
|
||||
responseMode:
|
||||
type: string
|
||||
enum: [FULL, SIMPLE]
|
||||
default: FULL
|
||||
description: 省略保持完整结构;显式SIMPLE返回固定9字段;不接受空字符串或null。
|
||||
plateNumbers:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: "可选;省略或空数组查询所选时间范围内均有效授权的全部车辆,按车牌排序。省略或空数组返回全部有效授权车辆,不受200辆上限限制,不分页、不截断;显式指定最多200项,超出返回BATCH_SIZE_EXCEEDED。去首尾空白、转大写后仍必须唯一。"
|
||||
dailyDate:
|
||||
type: "string"
|
||||
format: "date"
|
||||
description: "含日统计时必填;仅实时可省略;提供时仍校验。无固定历史下界,能否取得结果取决于有效授权及保留的统计投影,不承诺历史完整性。"
|
||||
dataTypes:
|
||||
type: "array"
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
enum: ["REALTIME", "DAILY_MILEAGE", "DAILY_HYDROGEN"]
|
||||
description: "省略默认全部;未选数据块不出现。"
|
||||
protocolPriority:
|
||||
type: "array"
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: "string"
|
||||
enum: ["GB32960", "MQTT", "JT808"]
|
||||
description: "仅选DAILY_MILEAGE时允许;未列出协议不兜底。"
|
||||
OperatingRealtimeVehicleResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "gpsFixStatus", "locationRecordTime", "coordinateSystem", "protocol", "longitude", "latitude", "speedKmh", "socPercent", "totalMileageKm", "recordTime", "timeDifferenceSeconds", "online", "activeToday", "motionStatus", "locationAvailable", "status", "hydrogenPressureTemperatureSource", "remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL", "hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa", "hydrogenEstimateTemperatureC", "hydrogenCalculationVersion", "hydrogenCapacitySource", "hydrogenPercentReason", "remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime", "hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus", "hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds", "hydrogenExpectedIntervalSeconds"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
gpsFixStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/gpsFixStatus"}
|
||||
locationRecordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
coordinateSystem: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/coordinateSystem"}
|
||||
protocol:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
longitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/longitude"}
|
||||
latitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/latitude"}
|
||||
speedKmh: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/speedKmh"}
|
||||
socPercent:
|
||||
type: "number"
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
nullable: true
|
||||
description: "动力电池SOC;未知为null。"
|
||||
totalMileageKm: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/totalMileageKm"}
|
||||
recordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
timeDifferenceSeconds:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
nullable: true
|
||||
description: "源主记录年龄,不表示实际离线时长。"
|
||||
online: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/online"}
|
||||
activeToday: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/activeToday"}
|
||||
motionStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/motionStatus"}
|
||||
locationAvailable: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/locationAvailable"}
|
||||
status: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/status"}
|
||||
hydrogenPressureTemperatureSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPressureTemperatureSource"}
|
||||
remainingHydrogenPercentSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentSource"}
|
||||
hydrogenFullCapacityKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullCapacityKg"}
|
||||
hydrogenTankCapacityL: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenTankCapacityL"}
|
||||
hydrogenFullPressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullPressureMPa"}
|
||||
hydrogenReferenceTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenReferenceTemperatureC"}
|
||||
hydrogenEstimatePressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimatePressureMPa"}
|
||||
hydrogenEstimateTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimateTemperatureC"}
|
||||
hydrogenCalculationVersion: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCalculationVersion"}
|
||||
hydrogenCapacitySource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCapacitySource"}
|
||||
hydrogenPercentReason: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPercentReason"}
|
||||
remainingHydrogenKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKg"}
|
||||
remainingHydrogenPercent: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercent"}
|
||||
hydrogenRecordTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
hydrogenDataStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenDataStatus"}
|
||||
remainingHydrogenKgStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKgStatus"}
|
||||
remainingHydrogenPercentStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentStatus"}
|
||||
hydrogenValueSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenValueSource"}
|
||||
hydrogenSourceProtocol: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenSourceProtocol"}
|
||||
hydrogenStaleAfterSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenStaleAfterSeconds"}
|
||||
hydrogenExpectedIntervalSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenExpectedIntervalSeconds"}
|
||||
OperatingMileageResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "date", "dailyMileageKm", "totalMileageKm", "dataTime", "updatedAt", "sourceProtocol", "dataQuality", "status"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
statisticsStartTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
statisticsEndTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
date: {"$ref": "#/components/schemas/MileageResult/properties/date"}
|
||||
dailyMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/dailyMileageKm"}
|
||||
totalMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/totalMileageKm"}
|
||||
dataTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
updatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
sourceProtocol: {"$ref": "#/components/schemas/MileageResult/properties/sourceProtocol"}
|
||||
dataQuality:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
status: {"$ref": "#/components/schemas/MileageResult/properties/status"}
|
||||
OperatingHydrogenResultSuccess:
|
||||
type: "object"
|
||||
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "updatedAt", "date", "hydrogenConsumptionKg", "calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason", "status"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
errorCode:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
statisticsStartTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
statisticsEndTime:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
updatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
|
||||
date: {"$ref": "#/components/schemas/HydrogenResult/properties/date"}
|
||||
hydrogenConsumptionKg: {"$ref": "#/components/schemas/HydrogenResult/properties/hydrogenConsumptionKg"}
|
||||
calculationPhase:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
algorithmVersion:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
qualityStatus:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
qualityReason:
|
||||
type: "string"
|
||||
nullable: true
|
||||
description: "沿用源字段含义;原可选字段缺失时输出null。"
|
||||
status: {"$ref": "#/components/schemas/HydrogenResult/properties/status"}
|
||||
OperatingDataBlockFailure:
|
||||
type: "object"
|
||||
additionalProperties: false
|
||||
required: ["queryStatus", "status", "errorCode", "errorMessage"]
|
||||
properties:
|
||||
queryStatus:
|
||||
type: "string"
|
||||
enum: ["FAILED"]
|
||||
status:
|
||||
type: "string"
|
||||
nullable: true
|
||||
enum: [null]
|
||||
errorCode:
|
||||
type: "string"
|
||||
enum: ["REALTIME_QUERY_FAILED", "REALTIME_QUERY_TIMEOUT", "MILEAGE_QUERY_FAILED", "MILEAGE_QUERY_TIMEOUT", "HYDROGEN_QUERY_FAILED", "HYDROGEN_QUERY_TIMEOUT", "SOURCE_DATA_INVALID"]
|
||||
errorMessage:
|
||||
type: "string"
|
||||
OperatingVehicleResult:
|
||||
type: "object"
|
||||
required: ["vin", "plateNumber"]
|
||||
properties:
|
||||
vin:
|
||||
type: "string"
|
||||
plateNumber:
|
||||
type: "string"
|
||||
realtime:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingRealtimeVehicleResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
dailyMileage:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingMileageResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
dailyHydrogen:
|
||||
oneOf: [{"$ref": "#/components/schemas/OperatingHydrogenResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
|
||||
OperatingDataSummary:
|
||||
type: "object"
|
||||
description: "按车辆统计所选块的queryStatus;NO_DATA/异常仍为执行成功。successCount+partialSuccessCount+failedCount=returnedCount=requestedCount。"
|
||||
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
|
||||
properties:
|
||||
requestedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
returnedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
successCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
partialSuccessCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
failedCount:
|
||||
type: "integer"
|
||||
minimum: 0
|
||||
OperatingDataResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
|
||||
properties:
|
||||
code:
|
||||
type: "string"
|
||||
enum: ["SUCCESS"]
|
||||
message:
|
||||
type: "string"
|
||||
traceId:
|
||||
type: "string"
|
||||
queryDate:
|
||||
type: "string"
|
||||
format: "date"
|
||||
nullable: true
|
||||
description: "包含日统计时为dailyDate;仅实时为null。"
|
||||
timezone:
|
||||
type: "string"
|
||||
enum: ["Asia/Shanghai"]
|
||||
requestAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
generatedAt:
|
||||
type: "string"
|
||||
format: "date-time"
|
||||
data:
|
||||
type: "array"
|
||||
minItems: 0
|
||||
items: {"$ref": "#/components/schemas/OperatingVehicleResult"}
|
||||
summary: {"$ref": "#/components/schemas/OperatingDataSummary"}
|
||||
OperatingErrorResponse:
|
||||
type: "object"
|
||||
required: ["code", "message", "traceId"]
|
||||
properties:
|
||||
code:
|
||||
type: "string"
|
||||
enum: ["INVALID_REQUEST", "BATCH_SIZE_EXCEEDED", "INVALID_DATE_FORMAT", "INVALID_DATE_RANGE", "UNAUTHORIZED", "FORBIDDEN", "PAYLOAD_TOO_LARGE", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR"]
|
||||
message:
|
||||
type: "string"
|
||||
traceId:
|
||||
type: "string"
|
||||
VehicleDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
@@ -541,7 +1010,7 @@ components:
|
||||
endDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 1095 天(含首尾共1096天)
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 5000
|
||||
|
||||
@@ -132,3 +132,31 @@ func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataDocumentationAndCatalog(t *testing.T) {
|
||||
for _, text := range []string{OperatingDataQueryPath, "queryStatus", "dailyDate", "partialSuccessCount", "CARRIED_FORWARD", "Retry-After", "MISSING"} {
|
||||
if !strings.Contains(string(openAPISpec), text) {
|
||||
t.Errorf("OpenAPI missing %s", text)
|
||||
}
|
||||
if !strings.Contains(string(simpleDocsHTML), text) {
|
||||
t.Errorf("HTML missing %s", text)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, product := range dataProducts() {
|
||||
if product.Path == OperatingDataQueryPath {
|
||||
found = product.Code == "operating_data"
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("aggregate API missing from catalog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingSimpleModeDocumentation(t *testing.T) {
|
||||
for _, field := range []string{"responseMode", "SIMPLE", "reportTime", "offline", "dailyHydrogenConsumptionKg", "hydrogenConsumptionPer100KmKg"} {
|
||||
if !strings.Contains(string(openAPISpec), field) || !strings.Contains(string(simpleDocsHTML), field) {
|
||||
t.Errorf("SIMPLE contract missing %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
||||
}
|
||||
|
||||
func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+OperatingDataQueryPath, h.operatingData)
|
||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
@@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() {
|
||||
|
||||
func NewDataHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.mux.HandleFunc("POST "+OperatingDataQueryPath, handler.operatingData)
|
||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
@@ -125,7 +127,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
return path == OperatingDataQueryPath || path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -720,6 +722,10 @@ func externalTraceID(r *http.Request) string {
|
||||
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
|
||||
return traceID
|
||||
}
|
||||
return newExternalTraceID()
|
||||
}
|
||||
|
||||
func newExternalTraceID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err == nil {
|
||||
return hex.EncodeToString(value[:])
|
||||
@@ -756,6 +762,7 @@ func requestRemoteAddress(r *http.Request) string {
|
||||
|
||||
func dataProducts() []DataProduct {
|
||||
return []DataProduct{
|
||||
{Code: "operating_data", Name: "车辆综合运营数据", Description: "批量聚合实时、单日里程与单日用氢,分别保留质量、时间和执行状态。", Version: "v1", Status: "available", Method: http.MethodPost, Path: OperatingDataQueryPath, Unit: ""},
|
||||
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
|
||||
{
|
||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||
|
||||
@@ -157,22 +157,23 @@ type RealtimeVehicleRequest struct {
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
LiveHydrogen RealtimeHydrogenData
|
||||
LocationObservedAt time.Time
|
||||
LocationEventID string
|
||||
LocationReceivedAt time.Time
|
||||
GPSFixStatus string
|
||||
CoordinateSystem string
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
LiveHydrogen RealtimeHydrogenData
|
||||
LocationObservedAt time.Time
|
||||
LocationEventID string
|
||||
LocationReceivedAt time.Time
|
||||
GPSFixStatus string
|
||||
CoordinateSystem string
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
TotalMileageAvailable *bool
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
}
|
||||
|
||||
type RealtimeVehicleResult struct {
|
||||
|
||||
@@ -63,6 +63,11 @@ WHERE app_key_hash=?
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
// Instant grants expire exclusively; a whole-day window may end at valid_to.
|
||||
endOperator := ">="
|
||||
if dayStart.Equal(dayEnd) {
|
||||
endOperator = ">"
|
||||
}
|
||||
args := make([]any, 0, len(plates)+3)
|
||||
args = append(args, appID, dayStart, dayEnd)
|
||||
query := `
|
||||
@@ -71,7 +76,7 @@ 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 (g.valid_to IS NULL OR g.valid_to` + endOperator + `?)
|
||||
AND TRIM(COALESCE(b.plate,''))<>''`
|
||||
if len(plates) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
|
||||
@@ -195,7 +200,7 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
|
||||
args = append(args, now.Add(-10*time.Minute))
|
||||
query := `
|
||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
||||
COALESCE(l.speed_kmh,0),l.soc_percent,l.total_mileage_km,l.updated_at,
|
||||
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today,
|
||||
l.event_time,l.received_at,l.event_id
|
||||
FROM vehicle_realtime_location l
|
||||
@@ -212,12 +217,15 @@ ORDER BY l.vin,
|
||||
onlineThreshold := now.Add(-time.Minute)
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
var soc sql.NullFloat64
|
||||
var soc, totalMileage sql.NullFloat64
|
||||
var locationAt, receivedAt sql.NullTime
|
||||
var eventID sql.NullString
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &totalMileage, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.TotalMileageKm = totalMileage.Float64 // Preserve the FULL endpoint's compatibility value.
|
||||
available := totalMileage.Valid
|
||||
point.TotalMileageAvailable = &available
|
||||
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
|
||||
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
||||
value := round3(soc.Float64)
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const OperatingDataQueryPath = "/api/v1/vehicles/operating-data/query"
|
||||
const operatingBudget = 6 * time.Second
|
||||
const operatingQueryBudget = 4500 * time.Millisecond
|
||||
|
||||
const (
|
||||
operatingRealtime = "REALTIME"
|
||||
operatingMileage = "DAILY_MILEAGE"
|
||||
operatingHydrogen = "DAILY_HYDROGEN"
|
||||
)
|
||||
|
||||
type OperatingDataRequest struct {
|
||||
ResponseMode string `json:"responseMode,omitempty"`
|
||||
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||
DailyDate string `json:"dailyDate,omitempty"`
|
||||
DataTypes []string `json:"dataTypes,omitempty"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
}
|
||||
|
||||
func (r *OperatingDataRequest) UnmarshalJSON(data []byte) error {
|
||||
type plain OperatingDataRequest
|
||||
var value plain
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("request must be an object")
|
||||
}
|
||||
if _, exists := fields["dailyDate"]; exists && value.DailyDate == "" {
|
||||
return fmt.Errorf("dailyDate must not be empty")
|
||||
}
|
||||
if _, exists := fields["responseMode"]; exists && value.ResponseMode == "" {
|
||||
return fmt.Errorf("responseMode must not be empty")
|
||||
}
|
||||
for key, raw := range fields {
|
||||
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
||||
return fmt.Errorf("%s must not be null", key)
|
||||
}
|
||||
}
|
||||
*r = OperatingDataRequest(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RawMessage retains source number precision while normalizing optional fields
|
||||
// without duplicating the existing result DTOs or changing their serializers.
|
||||
type OperatingDataBlock map[string]json.RawMessage
|
||||
|
||||
type OperatingVehicleResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Realtime OperatingDataBlock `json:"realtime,omitempty"`
|
||||
DailyMileage OperatingDataBlock `json:"dailyMileage,omitempty"`
|
||||
DailyHydrogen OperatingDataBlock `json:"dailyHydrogen,omitempty"`
|
||||
}
|
||||
|
||||
type OperatingDataSummary struct {
|
||||
RequestedCount int `json:"requestedCount"`
|
||||
ReturnedCount int `json:"returnedCount"`
|
||||
SuccessCount int `json:"successCount"`
|
||||
PartialSuccessCount int `json:"partialSuccessCount"`
|
||||
FailedCount int `json:"failedCount"`
|
||||
}
|
||||
|
||||
type OperatingDataResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
TraceID string `json:"traceId"`
|
||||
QueryDate *string `json:"queryDate"`
|
||||
Timezone string `json:"timezone"`
|
||||
RequestAt string `json:"requestAt"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Data []OperatingVehicleResult `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
|
||||
type operatingBatchSizeError struct{}
|
||||
|
||||
func (*operatingBatchSizeError) Error() string { return "plateNumbers exceeds 200" }
|
||||
func (*operatingBatchSizeError) Unwrap() error { return ErrInvalidRequest }
|
||||
|
||||
type operatingDateError struct{ code string }
|
||||
|
||||
func (e *operatingDateError) Error() string { return e.code }
|
||||
|
||||
func (s *Service) validateOperatingData(r OperatingDataRequest, now time.Time) ([]string, map[string]bool, []string, time.Time, error) {
|
||||
if r.ResponseMode != "" && r.ResponseMode != "FULL" && r.ResponseMode != "SIMPLE" {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: responseMode must be FULL or SIMPLE", ErrInvalidRequest)
|
||||
}
|
||||
if len(r.PlateNumbers) > 200 {
|
||||
return nil, nil, nil, time.Time{}, &operatingBatchSizeError{}
|
||||
}
|
||||
plates, err := normalizePlates(r.PlateNumbers, 200)
|
||||
if err != nil {
|
||||
return nil, nil, nil, time.Time{}, err
|
||||
}
|
||||
kinds := r.DataTypes
|
||||
if kinds == nil {
|
||||
kinds = []string{operatingRealtime, operatingMileage, operatingHydrogen}
|
||||
}
|
||||
selected := make(map[string]bool, 3)
|
||||
for _, kind := range kinds {
|
||||
if selected[kind] || (kind != operatingRealtime && kind != operatingMileage && kind != operatingHydrogen) {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: invalid dataTypes", ErrInvalidRequest)
|
||||
}
|
||||
selected[kind] = true
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: empty dataTypes", ErrInvalidRequest)
|
||||
}
|
||||
var day time.Time
|
||||
if r.DailyDate != "" || selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
day, err = time.ParseInLocation("2006-01-02", r.DailyDate, s.location)
|
||||
if err != nil || day.Format("2006-01-02") != r.DailyDate {
|
||||
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_FORMAT"}
|
||||
}
|
||||
if day.After(now) {
|
||||
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_RANGE"}
|
||||
}
|
||||
}
|
||||
if r.ProtocolPriority.Present && !selected[operatingMileage] {
|
||||
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: protocolPriority requires DAILY_MILEAGE", ErrInvalidRequest)
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(r.ProtocolPriority)
|
||||
return plates, selected, protocols, day, err
|
||||
}
|
||||
|
||||
// Validate every selected authorization scope before reading business data. The
|
||||
// same frozen VIN mapping is then reused by all three batched repositories.
|
||||
func (s *Service) authorizeOperatingScopes(ctx context.Context, key string, app AppCredential, plates []string, selected map[string]bool, now, day time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
|
||||
hash := sha256.Sum256([]byte(strings.ToLower(key)))
|
||||
allVehicles := len(plates) == 0
|
||||
var vehicles map[string]AuthorizedVehicle
|
||||
check := func(start, end time.Time) error {
|
||||
found, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !allVehicles && len(found) != len(plates) {
|
||||
return ErrForbidden
|
||||
}
|
||||
checkedPlates := plates
|
||||
if allVehicles {
|
||||
checkedPlates = vehiclePlates(found)
|
||||
}
|
||||
for _, plate := range checkedPlates {
|
||||
v, ok := found[plate]
|
||||
if !ok || strings.TrimSpace(v.VIN) == "" || (v.Plate != "" && v.Plate != plate) {
|
||||
return ErrForbidden
|
||||
}
|
||||
if previous, exists := vehicles[plate]; exists && previous.VIN != v.VIN {
|
||||
return errors.New("operating query identity changed")
|
||||
}
|
||||
}
|
||||
if allVehicles && vehicles != nil {
|
||||
// Only return vehicles authorized for every selected time window.
|
||||
for plate := range vehicles {
|
||||
if _, exists := found[plate]; !exists {
|
||||
delete(vehicles, plate)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
vehicles = found
|
||||
}
|
||||
if vehicles == nil {
|
||||
vehicles = make(map[string]AuthorizedVehicle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if selected[operatingRealtime] {
|
||||
if err := check(now, now); err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
}
|
||||
if selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
end := day.AddDate(0, 0, 1)
|
||||
historicalApp, err := s.repository.Authenticate(ctx, hash, now, day, end)
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
if historicalApp.ID != app.ID {
|
||||
return app, nil, errors.New("operating query app identity changed")
|
||||
}
|
||||
if err := check(day, end); err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
}
|
||||
return app, vehicles, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryOperatingData(ctx context.Context, key, traceID string, request OperatingDataRequest) (*OperatingDataResponse, error) {
|
||||
now := s.now().In(s.location)
|
||||
plates, selected, protocols, day, err := s.validateOperatingData(request, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, operatingBudget)
|
||||
defer cancel()
|
||||
// Authenticate before applying the existing per-app limiter. Independent state
|
||||
// avoids consuming the historical-hydrogen endpoint's quota.
|
||||
if !appKeyPattern.MatchString(key) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(key))), now, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
release, err := s.operatingLimiter.acquire(app.ID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
select {
|
||||
case s.operatingSlots <- struct{}{}:
|
||||
defer func() { <-s.operatingSlots }()
|
||||
default:
|
||||
return nil, &historicalHydrogenRateError{RetryAfter: 1}
|
||||
}
|
||||
// Authentication is shared with scope checks below, not repeated per type.
|
||||
app, vehicles, err := s.authorizeOperatingScopes(bounded, key, app, plates, selected, now, day)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "denied", traceID, len(plates), "scope validation failed")
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
result := &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: traceID, Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339Nano), Data: make([]OperatingVehicleResult, len(plates)), Summary: OperatingDataSummary{RequestedCount: len(plates), ReturnedCount: len(plates)}}
|
||||
if selected[operatingMileage] || selected[operatingHydrogen] {
|
||||
result.QueryDate = &request.DailyDate
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "success", traceID, 0, "")
|
||||
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
|
||||
return result, nil
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
type completed struct {
|
||||
kind string
|
||||
blocks []OperatingDataBlock
|
||||
}
|
||||
done := make(chan completed, len(selected))
|
||||
queryCtx, queryCancel := context.WithTimeout(bounded, operatingQueryBudget)
|
||||
defer queryCancel()
|
||||
for kind := range selected {
|
||||
go func(kind string) {
|
||||
started := time.Now()
|
||||
var blocks []OperatingDataBlock
|
||||
var err error
|
||||
switch kind {
|
||||
case operatingRealtime:
|
||||
var points map[string]RealtimeVehiclePoint
|
||||
points, err = s.repository.RealtimeVehicles(queryCtx, vins, now)
|
||||
if err == nil {
|
||||
values := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
prepareSimpleRealtime(values, plates, vehicles, points, s.location)
|
||||
}
|
||||
blocks = operatingBlocks(values, s.location)
|
||||
}
|
||||
case operatingMileage:
|
||||
var values map[string]DailyMileage
|
||||
values, err = s.repository.ReconciledMileageRange(queryCtx, vins, request.DailyDate, request.DailyDate, protocols)
|
||||
if err == nil {
|
||||
blocks = operatingBlocks(buildMileageResults(plates, vehicles, values, request.DailyDate), s.location)
|
||||
}
|
||||
case operatingHydrogen:
|
||||
var values map[string]DailyHydrogen
|
||||
values, err = s.repository.DailyHydrogen(queryCtx, vins, request.DailyDate)
|
||||
if err == nil {
|
||||
blocks = operatingBlocks(buildHydrogenResults(plates, vehicles, values, request.DailyDate), s.location)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
blocks = make([]OperatingDataBlock, len(plates))
|
||||
for i := range blocks {
|
||||
blocks[i] = operatingFailure(kind, errors.Is(err, context.DeadlineExceeded))
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("openplatform operating query unavailable: traceId=%s appId=%d type=%s durationMs=%d errorType=%T", traceID, app.ID, kind, time.Since(started).Milliseconds(), err)
|
||||
}
|
||||
done <- completed{kind, blocks}
|
||||
}(kind)
|
||||
}
|
||||
byKind := make(map[string][]OperatingDataBlock, len(selected))
|
||||
for len(byKind) < len(selected) {
|
||||
select {
|
||||
case result := <-done:
|
||||
byKind[result.kind] = result.blocks
|
||||
case <-queryCtx.Done():
|
||||
// Drain already completed work before timing out only the outstanding types.
|
||||
for {
|
||||
select {
|
||||
case result := <-done:
|
||||
byKind[result.kind] = result.blocks
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
for kind := range selected {
|
||||
if _, ok := byKind[kind]; !ok {
|
||||
blocks := make([]OperatingDataBlock, len(plates))
|
||||
for i := range blocks {
|
||||
blocks[i] = operatingFailure(kind, true)
|
||||
}
|
||||
byKind[kind] = blocks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, plate := range plates {
|
||||
row := OperatingVehicleResult{VIN: vehicles[plate].VIN, PlateNumber: plate}
|
||||
succeeded := 0
|
||||
for kind, blocks := range byKind {
|
||||
block := blocks[i]
|
||||
if string(block["queryStatus"]) == `"SUCCESS"` {
|
||||
succeeded++
|
||||
}
|
||||
switch kind {
|
||||
case operatingRealtime:
|
||||
row.Realtime = block
|
||||
case operatingMileage:
|
||||
row.DailyMileage = block
|
||||
case operatingHydrogen:
|
||||
row.DailyHydrogen = block
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case succeeded == len(selected):
|
||||
result.Summary.SuccessCount++
|
||||
case succeeded == 0:
|
||||
result.Summary.FailedCount++
|
||||
default:
|
||||
result.Summary.PartialSuccessCount++
|
||||
}
|
||||
result.Data[i] = row
|
||||
}
|
||||
status := "success"
|
||||
if result.Summary.SuccessCount != len(plates) {
|
||||
status = "partial"
|
||||
if result.Summary.FailedCount == len(plates) {
|
||||
status = "error"
|
||||
}
|
||||
}
|
||||
detail := ""
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
detail = "responseMode=SIMPLE"
|
||||
}
|
||||
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", status, traceID, len(plates), detail)
|
||||
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func operatingFailure(kind string, timeout bool) OperatingDataBlock {
|
||||
prefix := map[string]string{operatingRealtime: "REALTIME", operatingMileage: "MILEAGE", operatingHydrogen: "HYDROGEN"}[kind]
|
||||
suffix := "_QUERY_FAILED"
|
||||
if timeout {
|
||||
suffix = "_QUERY_TIMEOUT"
|
||||
}
|
||||
code, _ := json.Marshal(prefix + suffix)
|
||||
return OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": code, "errorMessage": json.RawMessage(`"data query unavailable"`)}
|
||||
}
|
||||
|
||||
func operatingBlocks[T any](values []T, location *time.Location) []OperatingDataBlock {
|
||||
blocks := make([]OperatingDataBlock, len(values))
|
||||
for i, value := range values {
|
||||
block, err := operatingBlock(value, location)
|
||||
if err != nil {
|
||||
block = OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": json.RawMessage(`"SOURCE_DATA_INVALID"`), "errorMessage": json.RawMessage(`"source data cannot be serialized"`)}
|
||||
}
|
||||
blocks[i] = block
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func operatingBlock(value any, location *time.Location) (OperatingDataBlock, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var block OperatingDataBlock
|
||||
if err = json.Unmarshal(data, &block); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(block, "vin")
|
||||
delete(block, "plateNumber")
|
||||
var optional []string
|
||||
switch value.(type) {
|
||||
case RealtimeVehicleResult:
|
||||
optional = []string{"protocol", "recordTime", "socPercent", "timeDifferenceSeconds"}
|
||||
case MileageResult:
|
||||
optional = []string{"dataQuality"}
|
||||
case HydrogenResult:
|
||||
optional = []string{"calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason"}
|
||||
}
|
||||
for _, name := range optional {
|
||||
if _, exists := block[name]; !exists {
|
||||
block[name] = json.RawMessage("null")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"recordTime", "locationRecordTime", "hydrogenRecordTime", "dataTime", "updatedAt", "statisticsStartTime", "statisticsEndTime"} {
|
||||
raw, ok := block[name]
|
||||
if !ok || string(raw) == "null" {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if err = json.Unmarshal(raw, &text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at, parseErr := time.Parse(time.RFC3339Nano, text)
|
||||
if parseErr != nil {
|
||||
at, parseErr = time.ParseInLocation("2006-01-02 15:04:05", text, location)
|
||||
}
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
block[name], _ = json.Marshal(at.In(location).Format(time.RFC3339Nano))
|
||||
}
|
||||
block["queryStatus"] = json.RawMessage(`"SUCCESS"`)
|
||||
block["errorCode"] = json.RawMessage("null")
|
||||
block["errorMessage"] = json.RawMessage("null")
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (h *Handler) operatingData(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := newExternalTraceID()
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
var request OperatingDataRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeOperatingDecodeError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeOperatingDecodeError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
result, err := h.service.QueryOperatingData(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
var limit *historicalHydrogenRateError
|
||||
var date *operatingDateError
|
||||
var batch *operatingBatchSizeError
|
||||
switch {
|
||||
case errors.As(err, &limit):
|
||||
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
|
||||
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMIT_EXCEEDED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
|
||||
case errors.As(err, &batch):
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "BATCH_SIZE_EXCEEDED", Message: "plateNumbers最多200项;查询全部授权车辆请省略plateNumbers或传空数组", TraceID: traceID})
|
||||
case errors.As(err, &date):
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: date.code, Message: "dailyDate无效或超出允许范围", TraceID: traceID})
|
||||
default:
|
||||
writeExternalError(w, traceID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Large batches need no pretty-printing: keep the new endpoint compact while
|
||||
// preserving all existing endpoints' response formatting.
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if request.ResponseMode == "SIMPLE" {
|
||||
response := simplifyOperatingData(result, h.service.now().In(h.service.location))
|
||||
response.GeneratedAt = h.service.now().In(h.service.location).Format(time.RFC3339Nano)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func writeOperatingDecodeError(w http.ResponseWriter, traceID string, err error) {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeExternal(w, http.StatusRequestEntityTooLarge, ExternalResponse{Code: "PAYLOAD_TOO_LARGE", Message: "请求体超过64KiB", TraceID: traceID})
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求参数不正确", TraceID: traceID})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All nine keys are serialized, including unknown and unselected values as null.
|
||||
type OperatingSimpleVehicle struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
ReportTime *string `json:"reportTime"`
|
||||
Offline *bool `json:"offline"`
|
||||
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||
SOCPercent *float64 `json:"socPercent"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DailyHydrogenConsumptionKg *float64 `json:"dailyHydrogenConsumptionKg"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
HydrogenConsumptionPer100KmKg *float64 `json:"hydrogenConsumptionPer100KmKg"`
|
||||
}
|
||||
|
||||
// Explicit fields shadow the embedded FULL data and summary in JSON; all shared
|
||||
// envelope metadata remains identical. The original result is not mutated.
|
||||
type OperatingSimpleResponse struct {
|
||||
*OperatingDataResponse
|
||||
Data []OperatingSimpleVehicle `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
|
||||
func simplifyOperatingData(full *OperatingDataResponse, now time.Time) *OperatingSimpleResponse {
|
||||
metadata := *full
|
||||
metadata.Data = nil // Do not retain the large internal FULL payload during encoding.
|
||||
result := &OperatingSimpleResponse{OperatingDataResponse: &metadata, Data: make([]OperatingSimpleVehicle, len(full.Data)), Summary: OperatingDataSummary{RequestedCount: full.Summary.RequestedCount, ReturnedCount: len(full.Data)}}
|
||||
for i, source := range full.Data {
|
||||
row := OperatingSimpleVehicle{PlateNumber: source.PlateNumber}
|
||||
expected, available := 0, 0
|
||||
if source.Realtime != nil {
|
||||
expected += 5
|
||||
if simpleBlockNormal(source.Realtime) {
|
||||
row.ReportTime = simpleTime(source.Realtime, "recordTime")
|
||||
var online *bool
|
||||
if json.Unmarshal(source.Realtime["online"], &online) == nil && online != nil {
|
||||
offline := !*online
|
||||
row.Offline = &offline
|
||||
}
|
||||
row.SOCPercent = simpleNumber(source.Realtime, "socPercent", 100)
|
||||
row.TotalMileageKm = simpleNumber(source.Realtime, "totalMileageKm", math.MaxFloat64)
|
||||
if simpleText(source.Realtime, "remainingHydrogenKgStatus") == "NORMAL" {
|
||||
if at := simpleTime(source.Realtime, "hydrogenRecordTime"); at != nil {
|
||||
observed, _ := time.Parse(time.RFC3339Nano, *at)
|
||||
if !observed.After(now.Add(time.Minute)) && now.Sub(observed) <= time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
||||
row.RemainingHydrogenKg = simpleNumber(source.Realtime, "remainingHydrogenKg", 500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, valid := range []bool{row.ReportTime != nil, row.Offline != nil, row.RemainingHydrogenKg != nil, row.SOCPercent != nil, row.TotalMileageKm != nil} {
|
||||
if valid {
|
||||
available++
|
||||
}
|
||||
}
|
||||
}
|
||||
if source.DailyMileage != nil {
|
||||
expected++
|
||||
if simpleBlockNormal(source.DailyMileage) && simpleText(source.DailyMileage, "dataQuality") != "CARRIED_FORWARD" {
|
||||
row.DailyMileageKm = simpleNumber(source.DailyMileage, "dailyMileageKm", math.MaxFloat64)
|
||||
}
|
||||
if row.DailyMileageKm != nil {
|
||||
available++
|
||||
}
|
||||
}
|
||||
if source.DailyHydrogen != nil {
|
||||
expected++
|
||||
if simpleBlockNormal(source.DailyHydrogen) && simpleText(source.DailyHydrogen, "qualityStatus") == "OK" {
|
||||
row.DailyHydrogenConsumptionKg = simpleNumber(source.DailyHydrogen, "hydrogenConsumptionKg", math.MaxFloat64)
|
||||
}
|
||||
if row.DailyHydrogenConsumptionKg != nil {
|
||||
available++
|
||||
}
|
||||
}
|
||||
// No comparable-coverage proof is available from the independent projections.
|
||||
// Do not manufacture kg/100km by dividing same-day values. The reserved field
|
||||
// stays null and is excluded from availability counts until formally supported.
|
||||
switch {
|
||||
case available == 0:
|
||||
result.Summary.FailedCount++
|
||||
case available == expected:
|
||||
result.Summary.SuccessCount++
|
||||
default:
|
||||
result.Summary.PartialSuccessCount++
|
||||
}
|
||||
result.Data[i] = row
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func simpleBlockNormal(block OperatingDataBlock) bool {
|
||||
return simpleText(block, "queryStatus") == "SUCCESS" && simpleText(block, "status") == StatusNormal
|
||||
}
|
||||
func simpleText(block OperatingDataBlock, key string) string {
|
||||
var value string
|
||||
_ = json.Unmarshal(block[key], &value)
|
||||
return value
|
||||
}
|
||||
func simpleNumber(block OperatingDataBlock, key string, maximum float64) *float64 {
|
||||
var value *float64
|
||||
if json.Unmarshal(block[key], &value) != nil || value == nil || math.IsNaN(*value) || math.IsInf(*value, 0) || *value < 0 || *value > maximum {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
func simpleTime(block OperatingDataBlock, key string) *string {
|
||||
value := simpleText(block, key)
|
||||
at, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || at.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// The legacy FULL recordTime follows projection.updated_at, and its total
|
||||
// mileage historically coerces SQL NULL to zero. SIMPLE uses the actual event
|
||||
// time and nullable source evidence without changing the FULL contract.
|
||||
func prepareSimpleRealtime(values []RealtimeVehicleResult, plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, location *time.Location) {
|
||||
for i, plate := range plates {
|
||||
point, ok := points[vehicles[plate].VIN]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[i].RecordTime = ""
|
||||
if !point.LocationObservedAt.IsZero() {
|
||||
values[i].RecordTime = point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
|
||||
}
|
||||
if point.TotalMileageAvailable != nil && !*point.TotalMileageAvailable {
|
||||
values[i].TotalMileageKm = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func simpleFixture(t testing.TB) (*OperatingDataResponse, time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
block := func(text string) OperatingDataBlock {
|
||||
var b OperatingDataBlock
|
||||
if err := json.Unmarshal([]byte(text), &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
return &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: "trace", QueryDate: stringPointer("2026-10-23"), Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), Summary: OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}, Data: []OperatingVehicleResult{{VIN: "PRIVATE_VIN", PlateNumber: "粤A12345F",
|
||||
Realtime: block(`{"queryStatus":"SUCCESS","status":"NORMAL","recordTime":"2026-10-23T11:59:59+08:00","online":false,"remainingHydrogenKg":0,"remainingHydrogenKgStatus":"NORMAL","hydrogenRecordTime":"2026-10-23T11:59:59+08:00","socPercent":0,"totalMileageKm":0}`),
|
||||
DailyMileage: block(`{"queryStatus":"SUCCESS","status":"NORMAL","dailyMileageKm":0,"dataQuality":null,"totalMileageKm":999}`),
|
||||
DailyHydrogen: block(`{"queryStatus":"SUCCESS","status":"NORMAL","qualityStatus":"OK","hydrogenConsumptionKg":0}`),
|
||||
}}}, now
|
||||
}
|
||||
func TestSimpleOperatingDataNineFieldsAndZeroValues(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
result := simplifyOperatingData(full, now)
|
||||
row := result.Data[0]
|
||||
if row.Offline == nil || !*row.Offline || row.ReportTime == nil {
|
||||
t.Fatal("report time/offline mapping lost")
|
||||
}
|
||||
for _, v := range []*float64{row.RemainingHydrogenKg, row.SOCPercent, row.TotalMileageKm, row.DailyHydrogenConsumptionKg, row.DailyMileageKm} {
|
||||
if v == nil || *v != 0 {
|
||||
t.Fatal("real zero dropped")
|
||||
}
|
||||
}
|
||||
if row.HydrogenConsumptionPer100KmKg != nil || result.Summary.SuccessCount != 1 {
|
||||
t.Fatal("reserved field affects display availability")
|
||||
}
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var encoded map[string]json.RawMessage
|
||||
if err = json.Unmarshal(data, &encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := []string{"code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"}
|
||||
if len(encoded) != len(expected) {
|
||||
t.Fatalf("extra envelope fields: %s", data)
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, ok := encoded[key]; !ok {
|
||||
t.Fatalf("missing %s", key)
|
||||
}
|
||||
}
|
||||
var rows []map[string]json.RawMessage
|
||||
_ = json.Unmarshal(encoded["data"], &rows)
|
||||
keys := []string{"plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"}
|
||||
if len(rows[0]) != len(keys) {
|
||||
t.Fatalf("not exactly nine fields: %s", data)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, ok := rows[0][key]; !ok {
|
||||
t.Fatalf("missing simple key %s", key)
|
||||
}
|
||||
}
|
||||
if string(rows[0]["hydrogenConsumptionPer100KmKg"]) != "null" || strings.Contains(string(data), "PRIVATE_VIN") {
|
||||
t.Fatal("unproven derived value or internal identity exposed")
|
||||
}
|
||||
if !reflect.DeepEqual(full.Summary, OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}) {
|
||||
t.Fatal("FULL result mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataQualityAndPartialFailures(t *testing.T) {
|
||||
cases := []struct{ name, block, key, raw, field string }{
|
||||
{"stale hydrogen", "realtime", "remainingHydrogenKgStatus", `"STALE"`, "remainingHydrogenKg"},
|
||||
{"missing hydrogen", "realtime", "remainingHydrogenKgStatus", `"MISSING"`, "remainingHydrogenKg"},
|
||||
{"unsupported hydrogen", "realtime", "remainingHydrogenKgStatus", `"UNSUPPORTED"`, "remainingHydrogenKg"},
|
||||
{"expired hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T11:54:59+08:00"`, "remainingHydrogenKg"},
|
||||
{"future hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T12:01:01+08:00"`, "remainingHydrogenKg"},
|
||||
{"missing hydrogen time", "realtime", "hydrogenRecordTime", `null`, "remainingHydrogenKg"},
|
||||
{"negative mass", "realtime", "remainingHydrogenKg", `-1`, "remainingHydrogenKg"},
|
||||
{"invalid soc", "realtime", "socPercent", `101`, "socPercent"},
|
||||
{"missing realtime total", "realtime", "totalMileageKm", `null`, "totalMileageKm"},
|
||||
{"carried zero", "mileage", "dataQuality", `"CARRIED_FORWARD"`, "dailyMileageKm"},
|
||||
{"mileage anomaly", "mileage", "status", `"DATA_ANOMALY"`, "dailyMileageKm"},
|
||||
{"hydrogen anomaly", "hydrogen", "status", `"DATA_ANOMALY"`, "dailyHydrogenConsumptionKg"},
|
||||
{"hydrogen suspect", "hydrogen", "qualityStatus", `"SUSPECT"`, "dailyHydrogenConsumptionKg"},
|
||||
{"hydrogen failure", "hydrogen", "queryStatus", `"FAILED"`, "dailyHydrogenConsumptionKg"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
row := &full.Data[0]
|
||||
b := row.Realtime
|
||||
if tc.block == "mileage" {
|
||||
b = row.DailyMileage
|
||||
}
|
||||
if tc.block == "hydrogen" {
|
||||
b = row.DailyHydrogen
|
||||
}
|
||||
b[tc.key] = json.RawMessage(tc.raw)
|
||||
result := simplifyOperatingData(full, now)
|
||||
data, _ := json.Marshal(result.Data[0])
|
||||
var fields map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &fields)
|
||||
if string(fields[tc.field]) != "null" || result.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatalf("unsafe value or summary: %s %+v", data, result.Summary)
|
||||
}
|
||||
// Unrelated known values survive a failure.
|
||||
if result.Data[0].ReportTime == nil || result.Data[0].Offline == nil {
|
||||
t.Fatal("unrelated display data erased")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataSelectionsAndNoData(t *testing.T) {
|
||||
full, now := simpleFixture(t)
|
||||
full.Data[0].DailyMileage = nil
|
||||
full.Data[0].DailyHydrogen = nil
|
||||
full.QueryDate = nil
|
||||
result := simplifyOperatingData(full, now)
|
||||
if result.QueryDate != nil || result.Data[0].DailyMileageKm != nil || result.Data[0].DailyHydrogenConsumptionKg != nil || result.Summary.SuccessCount != 1 {
|
||||
t.Fatal("unselected fields affected counts")
|
||||
}
|
||||
full.Data[0].Realtime = operatingFailure(operatingRealtime, true)
|
||||
result = simplifyOperatingData(full, now)
|
||||
if result.Data[0].Offline != nil || result.Data[0].ReportTime != nil || result.Summary.FailedCount != 1 {
|
||||
t.Fatal("failure became offline or display success")
|
||||
}
|
||||
full.Data[0].Realtime = OperatingDataBlock{"queryStatus": json.RawMessage(`"SUCCESS"`), "status": json.RawMessage(`"NO_DATA"`), "online": json.RawMessage(`false`)}
|
||||
result = simplifyOperatingData(full, now)
|
||||
if result.Data[0].Offline != nil || result.Summary.FailedCount != 1 {
|
||||
t.Fatal("missing main record became offline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleOperatingDataHTTPModeAndCompatibility(t *testing.T) {
|
||||
for _, mode := range []string{"", "FULL", "SIMPLE"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
f.query = func(_ context.Context, kind string) error {
|
||||
if kind == operatingHydrogen {
|
||||
return errors.New("upstream unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
body := map[string]any{"plateNumbers": []string{"粤A12345F"}, "dailyDate": "2026-10-23"}
|
||||
if mode != "" {
|
||||
body["responseMode"] = mode
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(string(raw)))
|
||||
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("HTTP=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var value struct {
|
||||
Data []map[string]json.RawMessage `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "SIMPLE" {
|
||||
if len(value.Data[0]) != 9 || value.Summary.FailedCount != 1 {
|
||||
t.Fatal("SIMPLE shape or availability wrong")
|
||||
}
|
||||
} else {
|
||||
if _, ok := value.Data[0]["realtime"]; !ok || value.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatal("FULL compatibility lost")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, raw := range []string{`null`, `""`, `"simple"`, `"UNKNOWN"`, `[]`} {
|
||||
t.Run("invalid "+raw, func(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"responseMode":`+raw+`,"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`))
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("invalid mode HTTP=%d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSimpleOperatingData200Vehicles(b *testing.B) {
|
||||
full, now := simpleFixture(b)
|
||||
full.Data = make([]OperatingVehicleResult, 200)
|
||||
fixture, _ := simpleFixture(b)
|
||||
for i := range full.Data {
|
||||
full.Data[i] = fixture.Data[0]
|
||||
}
|
||||
full.Summary.RequestedCount = 200
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := json.Marshal(simplifyOperatingData(full, now)); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleUsesSourceEventTimeAndNullableMileage(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
eventAt := now.Add(-10 * time.Minute)
|
||||
mock.ExpectQuery("l.soc_percent,l.total_mileage_km,l.updated_at").WithArgs(time.Date(2026, 10, 23, 0, 0, 0, 0, now.Location()), "vin", now.Add(-10*time.Minute)).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today", "event_time", "received_at", "event_id"}).AddRow("vin", "GB32960", 120, 30, 0, 0, nil, now, true, eventAt, now, "event"))
|
||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{"vin"}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plates := []string{"粤A12345F"}
|
||||
vehicles := map[string]AuthorizedVehicle{plates[0]: {VIN: "vin", Plate: plates[0]}}
|
||||
full := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
|
||||
if full[0].TotalMileageKm == nil || *full[0].TotalMileageKm != 0 || full[0].RecordTime != now.Format("2006-01-02 15:04:05") {
|
||||
t.Fatal("FULL compatibility changed")
|
||||
}
|
||||
prepareSimpleRealtime(full, plates, vehicles, points, now.Location())
|
||||
if full[0].TotalMileageKm != nil || full[0].RecordTime != eventAt.Format(time.RFC3339Nano) {
|
||||
t.Fatal("SIMPLE used update time or fabricated zero")
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An absent event time cannot be replaced with received_at or updated_at.
|
||||
point := points["vin"]
|
||||
point.LocationObservedAt = time.Time{}
|
||||
available := true
|
||||
point.TotalMileageAvailable = &available
|
||||
points["vin"] = point
|
||||
values := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
|
||||
prepareSimpleRealtime(values, plates, vehicles, points, now.Location())
|
||||
if values[0].RecordTime != "" || values[0].TotalMileageKm == nil || *values[0].TotalMileageKm != 0 {
|
||||
t.Fatal("missing source time or real zero mishandled")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const operatingTestKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
type operatingRepository struct {
|
||||
*fakeRepository
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
auth func(time.Time, time.Time) error
|
||||
scope func(time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||
query func(context.Context, string) error
|
||||
protocols []string
|
||||
}
|
||||
|
||||
func (f *operatingRepository) Authenticate(_ context.Context, _ [sha256.Size]byte, _ time.Time, start, end time.Time) (AppCredential, error) {
|
||||
f.mu.Lock()
|
||||
f.calls["auth"]++
|
||||
f.mu.Unlock()
|
||||
if f.auth != nil {
|
||||
if err := f.auth(start, end); err != nil {
|
||||
return AppCredential{}, err
|
||||
}
|
||||
}
|
||||
return f.app, nil
|
||||
}
|
||||
func (f *operatingRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
f.mu.Lock()
|
||||
f.calls["scope"]++
|
||||
f.mu.Unlock()
|
||||
if f.scope != nil {
|
||||
return f.scope(start, end)
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(f.vehicles)
|
||||
}
|
||||
found := make(map[string]AuthorizedVehicle, len(plates))
|
||||
for _, plate := range plates {
|
||||
if v, ok := f.vehicles[plate]; ok {
|
||||
found[plate] = v
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
func (f *operatingRepository) before(ctx context.Context, kind string) error {
|
||||
f.mu.Lock()
|
||||
f.calls[kind]++
|
||||
f.mu.Unlock()
|
||||
if f.query != nil {
|
||||
return f.query(ctx, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (f *operatingRepository) RealtimeVehicles(ctx context.Context, _ []string, _ time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||
return f.realtime, f.before(ctx, operatingRealtime)
|
||||
}
|
||||
func (f *operatingRepository) DailyHydrogen(ctx context.Context, _ []string, _ string) (map[string]DailyHydrogen, error) {
|
||||
return f.hydrogen, f.before(ctx, operatingHydrogen)
|
||||
}
|
||||
func (f *operatingRepository) ReconciledMileageRange(ctx context.Context, _ []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.mu.Lock()
|
||||
f.protocols = append([]string(nil), protocols...)
|
||||
f.mu.Unlock()
|
||||
return f.mileage, f.before(ctx, operatingMileage)
|
||||
}
|
||||
func newOperatingFixture() (*Service, *operatingRepository) {
|
||||
f := &operatingRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345F": {VIN: "LTEST32960VIN0001", Plate: "粤A12345F"}, "粤B12345F": {VIN: "LTEST32960VIN0002", Plate: "粤B12345F"}}}, calls: map[string]int{}}
|
||||
s := NewService(f)
|
||||
s.now = func() time.Time { return time.Date(2026, 10, 23, 9, 30, 0, 0, s.location) }
|
||||
return s, f
|
||||
}
|
||||
func operatingRequest() OperatingDataRequest {
|
||||
return OperatingDataRequest{PlateNumbers: []string{"粤B12345F", " 粤a12345f "}, DailyDate: "2026-10-23"}
|
||||
}
|
||||
func blockText(b OperatingDataBlock, key string) string {
|
||||
var v string
|
||||
_ = json.Unmarshal(b[key], &v)
|
||||
return v
|
||||
}
|
||||
|
||||
func TestOperatingDataBatchesInParallelAndPreservesSemantics(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
now := s.now()
|
||||
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: now.Add(-time.Second), Online: true, SpeedKmh: 5, LiveHydrogen: RealtimeHydrogenData{RemainingHydrogenKg: float64Pointer(0), RemainingHydrogenKgStatus: "NORMAL", RemainingHydrogenPercentStatus: "MISSING", HydrogenDataStatus: "PARTIAL", HydrogenValueSource: stringPointer("REPORTED"), HydrogenRecordTime: stringPointer("2026-10-23T09:29:59+08:00")}}}
|
||||
f.hydrogen = map[string]DailyHydrogen{"LTEST32960VIN0001": {QualityStatus: "SUSPECT", QualityReason: "test anomaly", ConsumptionKg: 3, CalculationPhase: "PRELIMINARY", AlgorithmVersion: "test-v1"}}
|
||||
f.mileage = map[string]DailyMileage{dailyMileageKey("LTEST32960VIN0001", "2026-10-23"): {Date: "2026-10-22", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 200, DataTime: "2026-10-22T23:00:00+08:00", UpdatedAt: "2026-10-23T05:00:00+08:00", DataQuality: "CARRIED_FORWARD"}}
|
||||
started := make(chan string, 3)
|
||||
release := make(chan struct{})
|
||||
f.query = func(ctx context.Context, kind string) error {
|
||||
started <- kind
|
||||
select {
|
||||
case <-release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
done := make(chan *OperatingDataResponse, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
r, e := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest())
|
||||
done <- r
|
||||
errs <- e
|
||||
}()
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("batched queries did not start concurrently")
|
||||
}
|
||||
}
|
||||
close(release)
|
||||
result := <-done
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Data) != 2 || result.Data[0].PlateNumber != "粤B12345F" || result.Summary.SuccessCount != 2 || result.QueryDate == nil {
|
||||
t.Fatalf("unexpected response: %+v", result)
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 1 {
|
||||
t.Fatalf("%s calls=%d", kind, f.calls[kind])
|
||||
}
|
||||
}
|
||||
if f.calls["auth"] != 2 || f.calls["scope"] != 2 {
|
||||
t.Fatalf("duplicate authorization reads: %v", f.calls)
|
||||
}
|
||||
row := result.Data[1]
|
||||
if string(row.Realtime["remainingHydrogenKg"]) != "0" || blockText(row.Realtime, "hydrogenDataStatus") != "PARTIAL" || blockText(row.Realtime, "recordTime") != "2026-10-23T09:29:59+08:00" {
|
||||
t.Fatalf("live quality/zero/time lost: %s", mustOperatingJSON(row.Realtime))
|
||||
}
|
||||
if blockText(row.DailyMileage, "dataQuality") != "CARRIED_FORWARD" || blockText(row.DailyMileage, "status") != "NORMAL" || string(row.DailyMileage["dailyMileageKm"]) != "0" {
|
||||
t.Fatal("carry-forward semantics changed")
|
||||
}
|
||||
if blockText(row.DailyHydrogen, "status") != "DATA_ANOMALY" || string(row.DailyHydrogen["hydrogenConsumptionKg"]) != "3" {
|
||||
t.Fatal("anomaly evidence lost")
|
||||
}
|
||||
if blockText(result.Data[0].Realtime, "status") != "NO_DATA" || string(result.Data[0].Realtime["socPercent"]) != "null" {
|
||||
t.Fatal("missing record not preserved")
|
||||
}
|
||||
// Compare with the independent endpoints through their original query services.
|
||||
f.query = nil
|
||||
old := NewService(f.fakeRepository)
|
||||
old.now = s.now
|
||||
plates := []string{"粤B12345F", "粤A12345F"}
|
||||
rt, err := old.QueryRealtimeVehicles(context.Background(), operatingTestKey, "old", RealtimeVehicleRequest{PlateNumbers: plates})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := old.QueryHydrogen(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := old.QueryMileage(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range rt {
|
||||
a, _ := operatingBlock(rt[i], s.location)
|
||||
b, _ := operatingBlock(h[i], s.location)
|
||||
c, _ := operatingBlock(m[i], s.location)
|
||||
if !reflect.DeepEqual(a, result.Data[i].Realtime) || !reflect.DeepEqual(b, result.Data[i].DailyHydrogen) || !reflect.DeepEqual(c, result.Data[i].DailyMileage) {
|
||||
t.Fatal("independent query mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
func mustOperatingJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
|
||||
|
||||
func TestOperatingDataAuthorizationFailsBeforeReadingData(t *testing.T) {
|
||||
for _, scenario := range []string{"invalid key", "current key", "history key", "current scope", "history scope", "identity conflict", "scope unavailable", "auth unavailable"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
key := operatingTestKey
|
||||
want := ErrForbidden
|
||||
if scenario == "invalid key" {
|
||||
key = "bad"
|
||||
want = ErrUnauthorized
|
||||
}
|
||||
f.auth = func(start, end time.Time) error {
|
||||
if scenario == "current key" && start.Equal(end) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if scenario == "history key" && !start.Equal(end) {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if scenario == "auth unavailable" {
|
||||
return errors.New("database unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
if scenario == "current scope" && start.Equal(end) || scenario == "history scope" && !start.Equal(end) {
|
||||
return map[string]AuthorizedVehicle{}, nil
|
||||
}
|
||||
if scenario == "scope unavailable" {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
found := map[string]AuthorizedVehicle{}
|
||||
for k, v := range f.vehicles {
|
||||
found[k] = v
|
||||
}
|
||||
if scenario == "identity conflict" && !start.Equal(end) {
|
||||
found["粤A12345F"] = AuthorizedVehicle{VIN: "different"}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
_, err := s.QueryOperatingData(context.Background(), key, "trace", operatingRequest())
|
||||
switch scenario {
|
||||
case "current key":
|
||||
want = ErrUnauthorized
|
||||
case "auth unavailable", "scope unavailable", "identity conflict":
|
||||
want = nil
|
||||
}
|
||||
if err == nil || want != nil && !errors.Is(err, want) {
|
||||
t.Fatalf("err=%v want=%v", err, want)
|
||||
}
|
||||
if want == nil && (errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden)) {
|
||||
t.Fatal("infrastructure error hidden as denial")
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 0 {
|
||||
t.Fatal("queried data before all scopes passed")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataSelectionsAndFailures(t *testing.T) {
|
||||
for _, scenario := range []string{"only realtime", "only mileage", "hydrogen failure", "all failed", "invalid source"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := operatingRequest()
|
||||
switch scenario {
|
||||
case "only realtime":
|
||||
r.DataTypes = []string{operatingRealtime}
|
||||
r.DailyDate = ""
|
||||
case "only mileage":
|
||||
r.DataTypes = []string{operatingMileage}
|
||||
r.ProtocolPriority = ProtocolPriority{Present: true, Values: []string{"JT808"}}
|
||||
case "invalid source":
|
||||
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {ObservedAt: s.now(), LiveHydrogen: RealtimeHydrogenData{HydrogenDataStatus: "NORMAL", HydrogenRecordTime: stringPointer("bad timestamp")}}}
|
||||
}
|
||||
f.query = func(_ context.Context, kind string) error {
|
||||
if scenario == "all failed" || scenario == "hydrogen failure" && kind == operatingHydrogen {
|
||||
return errors.New("SQL secret should not escape")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch scenario {
|
||||
case "only realtime":
|
||||
if result.QueryDate != nil || result.Data[0].DailyMileage != nil || result.Data[0].DailyHydrogen != nil || f.calls["auth"] != 1 || f.calls["scope"] != 1 {
|
||||
t.Fatal("unselected work executed")
|
||||
}
|
||||
case "only mileage":
|
||||
if result.Data[0].Realtime != nil || f.calls["scope"] != 1 || !reflect.DeepEqual(f.protocols, []string{"JT808"}) {
|
||||
t.Fatalf("selection/protocol ignored: %v", f.protocols)
|
||||
}
|
||||
case "hydrogen failure":
|
||||
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_FAILED" || len(result.Data[0].DailyHydrogen) != 4 {
|
||||
t.Fatal("failure envelope incorrect")
|
||||
}
|
||||
case "all failed":
|
||||
if result.Summary.FailedCount != 2 {
|
||||
t.Fatal("all-failed count incorrect")
|
||||
}
|
||||
case "invalid source":
|
||||
if blockText(result.Data[1].Realtime, "errorCode") != "SOURCE_DATA_INVALID" || result.Summary.PartialSuccessCount != 1 {
|
||||
t.Fatal("invalid source not isolated")
|
||||
}
|
||||
}
|
||||
if strings.Contains(mustOperatingJSON(result), "SQL secret") {
|
||||
t.Fatal("upstream details leaked")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataDeadlineKeepsCompletedBlocks(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
exited := make(chan struct{})
|
||||
f.query = func(ctx context.Context, kind string) error {
|
||||
if kind == operatingHydrogen {
|
||||
<-ctx.Done()
|
||||
close(exited)
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
result, err := s.QueryOperatingData(ctx, operatingTestKey, "trace", operatingRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_TIMEOUT" {
|
||||
t.Fatalf("deadline discarded successful blocks: %+v", result.Summary)
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("query not canceled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataHTTPValidationAndRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`, 200},
|
||||
{`{"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"plateNumbers":[],"dailyDate":"2026-10-23"}`, 200},
|
||||
{`{"dataTypes":["REALTIME"]}`, 200},
|
||||
{`{"plateNumbers":[],"dataTypes":["REALTIME"],"responseMode":"SIMPLE"}`, 200},
|
||||
{`{"plateNumbers":null,"dataTypes":["REALTIME"]}`, 400},
|
||||
{`{}`, 400}, {`null`, 400}, {`[]`, 400}, {`{"plateNumbers":[]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"," 粤a12345f "],"dailyDate":"2026-10-23"}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-24"}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":[]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":["REALTIME","REALTIME"]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"],"protocolPriority":["GB32960"]}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","protocolPriority":null}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","extra":1}`, 400},
|
||||
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]} {}`, 400},
|
||||
{`{"plateNumbers":["` + strings.Repeat("x", 65<<10) + `"]}`, 413},
|
||||
}
|
||||
for i, tc := range tests {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(tc.body))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
if !IsPublicPath(OperatingDataQueryPath) {
|
||||
t.Fatal("new endpoint not registered as appKey route")
|
||||
}
|
||||
for _, construct := range []func(*Service) *Handler{NewHandler, func(s *Service) *Handler { return NewExternalHandler(s, nil) }} {
|
||||
s, _ := newOperatingFixture()
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
construct(s).ServeHTTP(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("handler route missing: %d", w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataLimitsAndBatchBoundary(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := OperatingDataRequest{DataTypes: []string{operatingRealtime}}
|
||||
for i := 0; i < 200; i++ {
|
||||
plate := fmt.Sprintf("TEST%03d", i)
|
||||
r.PlateNumbers = append(r.PlateNumbers, plate)
|
||||
f.vehicles[plate] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
// A repository returns only the authorized requested subset.
|
||||
f.scope = func(_, _ time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
v := map[string]AuthorizedVehicle{}
|
||||
for _, p := range r.PlateNumbers {
|
||||
v[p] = f.vehicles[p]
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if err != nil || len(result.Data) != 200 {
|
||||
t.Fatalf("boundary: %v", err)
|
||||
}
|
||||
r.PlateNumbers = append(r.PlateNumbers, "TEST201")
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatal("201 accepted")
|
||||
}
|
||||
s, f = newOperatingFixture()
|
||||
_ = f
|
||||
for i := 0; i < 30; i++ {
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
NewDataHandler(s).ServeHTTP(w, req)
|
||||
if w.Code != 429 || w.Header().Get("Retry-After") == "" || !strings.Contains(w.Body.String(), "RATE_LIMIT_EXCEEDED") {
|
||||
t.Fatalf("limit: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Global capacity rejects work before any scope/data reads.
|
||||
s, f = newOperatingFixture()
|
||||
for i := 0; i < cap(s.operatingSlots); i++ {
|
||||
s.operatingSlots <- struct{}{}
|
||||
}
|
||||
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err == nil || f.calls["scope"] != 0 {
|
||||
t.Fatal("global capacity ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkOperatingData200Vehicles(b *testing.B) {
|
||||
s, f := newOperatingFixture()
|
||||
r := operatingRequest()
|
||||
r.PlateNumbers = nil
|
||||
f.vehicles = map[string]AuthorizedVehicle{}
|
||||
for i := 0; i < 200; i++ {
|
||||
p := fmt.Sprintf("TEST%03d", i)
|
||||
r.PlateNumbers = append(r.PlateNumbers, p)
|
||||
f.vehicles[p] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
s.operatingLimiter = historicalHydrogenLimiter{}
|
||||
f.audits = nil
|
||||
if _, err := s.QueryOperatingData(context.Background(), operatingTestKey, "bench", r); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingInstantGrantExpiresExclusively(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Date(2026, 10, 23, 10, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("AND (g.valid_to IS NULL OR g.valid_to>?)")).WithArgs(uint64(7), now, now, "粤A12345F").WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}))
|
||||
_, err = NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, []string{"粤A12345F"}, now, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataRetainsAllBusinessFields(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
for _, value := range []any{RealtimeVehicleResult{}, MileageResult{}, HydrogenResult{}} {
|
||||
block, err := operatingBlock(value, s.location)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var check func(reflect.Type)
|
||||
check = func(typ reflect.Type) {
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Anonymous {
|
||||
check(field.Type)
|
||||
continue
|
||||
}
|
||||
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if name == "vin" || name == "plateNumber" || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := block[name]; !ok {
|
||||
t.Errorf("%s missing %s", typ.Name(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
check(reflect.TypeOf(value))
|
||||
if _, exists := block["vin"]; exists {
|
||||
t.Fatal("duplicated identity")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataGeneratesDistinctTraceIDs(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
handler := NewDataHandler(s)
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 2; i++ {
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
r.Header.Set("X-Trace-Id", "reused-client-id")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
var result OperatingDataResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || result.TraceID == "reused-client-id" || len(result.TraceID) != 32 || seen[result.TraceID] {
|
||||
t.Fatalf("invalid trace: %s", w.Body.String())
|
||||
}
|
||||
seen[result.TraceID] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataOptionalPlatesAuthorization(t *testing.T) {
|
||||
for _, scenario := range []string{"all", "realtime only", "history only", "intersection", "empty current", "empty history", "no grants", "identity conflict", "large all", "large before intersection"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
r := OperatingDataRequest{DailyDate: "2026-10-23"}
|
||||
if scenario == "realtime only" {
|
||||
r.DataTypes = []string{operatingRealtime}
|
||||
r.DailyDate = ""
|
||||
}
|
||||
if scenario == "history only" {
|
||||
r.DataTypes = []string{operatingMileage, operatingHydrogen}
|
||||
}
|
||||
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
found := map[string]AuthorizedVehicle{}
|
||||
for plate, v := range f.vehicles {
|
||||
found[plate] = v
|
||||
}
|
||||
current := start.Equal(end)
|
||||
if scenario == "no grants" || scenario == "empty current" && current || scenario == "empty history" && !current {
|
||||
return map[string]AuthorizedVehicle{}, nil
|
||||
}
|
||||
if scenario == "intersection" && !current {
|
||||
delete(found, "粤B12345F")
|
||||
found["NEW"] = AuthorizedVehicle{VIN: "OTHER"}
|
||||
}
|
||||
if scenario == "identity conflict" && !current {
|
||||
found["粤B12345F"] = AuthorizedVehicle{VIN: "CHANGED"}
|
||||
}
|
||||
if scenario == "large all" || scenario == "large before intersection" && current {
|
||||
for i := 0; i < 201; i++ {
|
||||
found[fmt.Sprintf("EXTRA%03d", i)] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
|
||||
if scenario == "identity conflict" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected authorization error, got %v", err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 2
|
||||
if scenario == "large all" {
|
||||
want = 203
|
||||
}
|
||||
if scenario == "intersection" {
|
||||
want = 1
|
||||
}
|
||||
if scenario == "empty current" || scenario == "empty history" || scenario == "no grants" {
|
||||
want = 0
|
||||
}
|
||||
if result.Data == nil || len(result.Data) != want || result.Summary.RequestedCount != want || result.Summary.ReturnedCount != want {
|
||||
t.Fatalf("incorrect authorized result: %+v", result)
|
||||
}
|
||||
if want > 0 && scenario != "large all" && result.Data[0].PlateNumber != "粤A12345F" {
|
||||
t.Fatal("default vehicle order is not stable")
|
||||
}
|
||||
}
|
||||
if err != nil || len(result.Data) == 0 {
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 0 {
|
||||
t.Fatal("business data read without an authorized batch")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingData1151AuthorizedVehiclesHTTP(t *testing.T) {
|
||||
for _, mode := range []string{"FULL", "SIMPLE"} {
|
||||
for _, empty := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/empty=%t", mode, empty), func(t *testing.T) {
|
||||
s, f := newOperatingFixture()
|
||||
f.vehicles = map[string]AuthorizedVehicle{}
|
||||
for i := 0; i < 1151; i++ {
|
||||
plate := fmt.Sprintf("TEST%04d", i)
|
||||
f.vehicles[plate] = AuthorizedVehicle{Plate: plate, VIN: fmt.Sprintf("VIN%014d", i)}
|
||||
}
|
||||
payload := map[string]any{"responseMode": mode, "dailyDate": "2026-10-23"}
|
||||
if empty {
|
||||
payload["plateNumbers"] = []string{}
|
||||
}
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(payload)))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
var result struct {
|
||||
Data []struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
} `json:"data"`
|
||||
Summary OperatingDataSummary `json:"summary"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 200 || len(result.Data) != 1151 || result.Summary.RequestedCount != 1151 || result.Summary.ReturnedCount != 1151 {
|
||||
t.Fatalf("status=%d rows=%d summary=%+v", w.Code, len(result.Data), result.Summary)
|
||||
}
|
||||
for i, row := range result.Data {
|
||||
if row.PlateNumber != fmt.Sprintf("TEST%04d", i) {
|
||||
t.Fatal("missing, duplicate or unordered authorized vehicle")
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
|
||||
if f.calls[kind] != 1 {
|
||||
t.Fatalf("%s not queried in one batch", kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatingDataExplicitBatchSizeErrorHTTP(t *testing.T) {
|
||||
s, _ := newOperatingFixture()
|
||||
plates := make([]string, 201)
|
||||
for i := range plates {
|
||||
plates[i] = fmt.Sprintf("TEST%04d", i)
|
||||
}
|
||||
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(map[string]any{"plateNumbers": plates, "dataTypes": []string{operatingRealtime}})))
|
||||
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
NewDataHandler(s).ServeHTTP(w, r)
|
||||
var response ExternalResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != 400 || response.Code != "BATCH_SIZE_EXCEEDED" || !strings.Contains(response.Message, "200") || response.TraceID == "" {
|
||||
t.Fatalf("status=%d response=%+v", w.Code, response)
|
||||
}
|
||||
}
|
||||
@@ -282,18 +282,24 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildRealtimeVehicleResults(plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, now time.Time, location *time.Location) []RealtimeVehicleResult {
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
difference := int64(now.Sub(point.ObservedAt.In(location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||
applyRealtimeLiveData(&item, point, s.location)
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
applyRealtimeLiveData(&item, point, location)
|
||||
item.RecordTime = point.ObservedAt.In(location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
item.ActiveToday = point.ActiveToday
|
||||
@@ -315,8 +321,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
@@ -349,6 +354,8 @@ func validCoordinate(longitude, latitude float64) bool {
|
||||
|
||||
type Service struct {
|
||||
historicalLimiter historicalHydrogenLimiter
|
||||
operatingLimiter historicalHydrogenLimiter
|
||||
operatingSlots chan struct{}
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
@@ -362,9 +369,10 @@ type parsedGrant struct {
|
||||
|
||||
func NewService(repository Repository) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
repository: repository,
|
||||
operatingSlots: make(chan struct{}, 16),
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +395,12 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildHydrogenResults(plates, vehicles, values, date)
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildHydrogenResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyHydrogen, date string) []HydrogenResult {
|
||||
results := make([]HydrogenResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
@@ -414,8 +428,7 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
|
||||
@@ -441,6 +454,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := buildMileageResults(plates, vehicles, values, date)
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildMileageResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyMileage, date string) []MileageResult {
|
||||
results := make([]MileageResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
@@ -450,8 +469,7 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
return results
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
|
||||
@@ -716,8 +734,8 @@ func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, s
|
||||
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)
|
||||
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 1096 {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 1096 days", ErrInvalidRequest)
|
||||
}
|
||||
pageSize := request.PageSize
|
||||
if pageSize == 0 {
|
||||
|
||||
@@ -661,7 +661,7 @@ func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testin
|
||||
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, request := range []MileageRangeRequest{
|
||||
{StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100},
|
||||
{StartDate: "2023-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},
|
||||
} {
|
||||
@@ -801,3 +801,12 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
||||
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the already deployed three-year window when adding new public APIs.
|
||||
func TestMileageRangePreservesProductionThreeYearWindow(t *testing.T) {
|
||||
s := NewService(&fakeRepository{})
|
||||
_, _, _, _, _, _, _, err := s.validateMileageRange(MileageRangeRequest{StartDate: "2023-07-01", EndDate: "2026-06-30", PageSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("production 1096-day window rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/hydrogen"
|
||||
)
|
||||
|
||||
var hydrogenMassFields = []string{
|
||||
@@ -818,23 +820,7 @@ ORDER BY event_time ` + direction + ` LIMIT 1`
|
||||
}
|
||||
|
||||
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
|
||||
return hydrogen.PressureMassKg(pressureMPa, temperatureC, capacityLiter)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
|
||||
@@ -124,6 +124,9 @@ func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, in
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"}
|
||||
}
|
||||
input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if err := authorizeVehicleVIN(ctx, input.VIN); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if !validAccessClaimVIN(input.VIN) {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"}
|
||||
}
|
||||
@@ -230,6 +233,9 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
order := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
vin := strings.TrimSpace(item.VIN)
|
||||
if p, ok := PrincipalFromContext(ctx); ok && !p.CanVIN(vin) {
|
||||
continue
|
||||
}
|
||||
if _, exists := byVIN[vin]; !exists {
|
||||
order = append(order, vin)
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ func (s *Service) alertStore() (alertStore, error) {
|
||||
}
|
||||
|
||||
func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
query.AllowedVINs = principalVehicleVINScope(ctx)
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertSummary{}, err
|
||||
@@ -52,6 +53,7 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
|
||||
query.AllowedVINs = principalVehicleVINScope(ctx)
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if strings.TrimSpace(query.Keyword) == "" {
|
||||
return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"}
|
||||
@@ -91,6 +93,9 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, event.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
@@ -404,10 +409,22 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
existing, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, existing.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
}
|
||||
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
if err := authorizeVehicleVIN(ctx, event.VIN); err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
@@ -497,7 +514,7 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
|
||||
default:
|
||||
return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() {
|
||||
query.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
@@ -515,7 +532,7 @@ 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" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() {
|
||||
request.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(request.AllowedVINs) == 0 {
|
||||
return 0, nil
|
||||
|
||||
@@ -106,6 +106,15 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
for _, item := range m.alertNotifications {
|
||||
if item.Channel == "in_app" && !item.Read {
|
||||
if query.AllowedVINs != nil {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
allowed = allowed || item.VehicleVIN == vin
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result.UnreadNotifications++
|
||||
}
|
||||
}
|
||||
@@ -157,6 +166,15 @@ func (m *MockStore) AlertEvents(_ context.Context, query AlertQuery) (Page[Alert
|
||||
}
|
||||
|
||||
func keepMockAlertEvent(event AlertEvent, query AlertQuery) bool {
|
||||
if query.AllowedVINs != nil {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
allowed = allowed || event.VIN == vin
|
||||
}
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
keyword := strings.ToLower(query.Keyword)
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(event.VIN), keyword) && !strings.Contains(strings.ToLower(event.Plate), keyword) && !strings.Contains(strings.ToLower(event.RuleName), keyword) {
|
||||
return false
|
||||
|
||||
@@ -37,7 +37,15 @@ FROM vehicle_alert_event e WHERE `+where, args...).Scan(&result.Active, &result.
|
||||
if err != nil {
|
||||
return AlertSummary{}, err
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE channel='in_app' AND is_read=0`).Scan(&result.UnreadNotifications); err != nil {
|
||||
notificationWhere, notificationArgs := []string{"channel='in_app'", "is_read=0"}, []any{}
|
||||
if query.AllowedVINs != nil {
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
notificationWhere = append(notificationWhere, "1=0")
|
||||
} else {
|
||||
notificationWhere, notificationArgs = appendVINListFilter(notificationWhere, notificationArgs, "e.vin", strings.Join(query.AllowedVINs, ","))
|
||||
}
|
||||
}
|
||||
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 `+strings.Join(notificationWhere, " AND "), notificationArgs...).Scan(&result.UnreadNotifications); err != nil {
|
||||
return AlertSummary{}, err
|
||||
}
|
||||
result.AsOf = time.Now().Format(time.RFC3339)
|
||||
@@ -73,6 +81,13 @@ func (s *ProductionStore) ActiveAlertVINs(ctx context.Context, protocol string)
|
||||
func buildAlertWhere(query AlertQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.AllowedVINs != nil {
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
where = append(where, "1=0")
|
||||
} else {
|
||||
where, args = appendVINListFilter(where, args, "e.vin", strings.Join(query.AllowedVINs, ","))
|
||||
}
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(e.vin LIKE ? OR e.plate LIKE ? OR e.rule_name LIKE ?)")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CustomerHydrogenJob struct {
|
||||
Date string `json:"date"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
Attempts int `json:"attempts"`
|
||||
}
|
||||
type customerHydrogenJobStore interface {
|
||||
RequestCustomerHydrogenDays(context.Context, []string) ([]CustomerHydrogenJob, error)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RequestCustomerHydrogenDays(ctx context.Context, dates []string) ([]CustomerHydrogenJob, error) {
|
||||
values := make([]string, len(dates))
|
||||
args := make([]any, len(dates))
|
||||
for i, date := range dates {
|
||||
values[i] = "(?)"
|
||||
args[i] = date
|
||||
}
|
||||
if len(dates) == 0 {
|
||||
return []CustomerHydrogenJob{}, nil
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_hydrogen_job(stat_date) VALUES `+strings.Join(values, ","), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Recent dates can receive late telemetry. Completed older days remain cached;
|
||||
// current/recent days are recomputed at most once per hour, outside requests.
|
||||
_, err = s.db.ExecContext(ctx, `UPDATE platform_customer_hydrogen_job SET status='pending',requested_at=NOW(3),attempts=0,error_message='' WHERE stat_date>=DATE_SUB(CURDATE(),INTERVAL 2 DAY) AND stat_date BETWEEN ? AND ? AND status='complete' AND completed_at<DATE_SUB(NOW(3),INTERVAL 1 HOUR)`, dates[0], dates[len(dates)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),status,COALESCE(DATE_FORMAT(completed_at,'%Y-%m-%dT%H:%i:%s+08:00'),''),attempts FROM platform_customer_hydrogen_job WHERE stat_date BETWEEN ? AND ? ORDER BY stat_date`, dates[0], dates[len(dates)-1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerHydrogenJob{}
|
||||
for rows.Next() {
|
||||
var j CustomerHydrogenJob
|
||||
if err = rows.Scan(&j.Date, &j.Status, &j.CompletedAt, &j.Attempts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, j)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestCustomerHydrogenRefreshResetsRetryBudget(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectExec("INSERT IGNORE INTO platform_customer_hydrogen_job").WithArgs("2026-09-29").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
// Only completed recent jobs may be refreshed. A new refresh gets its own
|
||||
// retry budget instead of inheriting all previous successful executions.
|
||||
mock.ExpectExec("UPDATE platform_customer_hydrogen_job SET .*attempts=0.*status='complete'.*INTERVAL 1 HOUR").WithArgs("2026-09-29", "2026-09-29").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT DATE_FORMAT.*FROM platform_customer_hydrogen_job").WithArgs("2026-09-29", "2026-09-29").WillReturnRows(sqlmock.NewRows([]string{"date", "status", "completed", "attempts"}).AddRow("2026-09-29", "pending", "2026-09-30T03:00:00+08:00", 0))
|
||||
jobs, err := (&ProductionStore{db: db}).RequestCustomerHydrogenDays(context.Background(), []string{"2026-09-29"})
|
||||
if err != nil || len(jobs) != 1 || jobs[0].Status != "pending" {
|
||||
t.Fatalf("refresh result: %+v, %v", jobs, err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CustomerMileageRules apply to each vehicle-day before customer aggregation.
|
||||
// Comparing customer totals would allow opposite vehicle discrepancies to cancel.
|
||||
type CustomerMileageRules struct {
|
||||
Version int64 `json:"version"`
|
||||
LowMileageKm float64 `json:"lowMileageKm"`
|
||||
TargetMileageKm float64 `json:"targetMileageKm"`
|
||||
DeviationKm float64 `json:"deviationKm"`
|
||||
DeviationPercent float64 `json:"deviationPercent"`
|
||||
MinimumGPSKm float64 `json:"minimumGpsKm"`
|
||||
IncludeToday bool `json:"includeToday"`
|
||||
}
|
||||
|
||||
func DefaultCustomerMileageRules() CustomerMileageRules {
|
||||
return CustomerMileageRules{Version: 1, LowMileageKm: 50, TargetMileageKm: 200, DeviationKm: 30, DeviationPercent: 20, MinimumGPSKm: 50}
|
||||
}
|
||||
|
||||
func (r CustomerMileageRules) Validate() error {
|
||||
if r.Version < 0 {
|
||||
return fmt.Errorf("规则版本无效")
|
||||
}
|
||||
for _, value := range []float64{r.LowMileageKm, r.TargetMileageKm, r.DeviationKm, r.DeviationPercent, r.MinimumGPSKm} {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return fmt.Errorf("预警阈值必须为非负有限数值")
|
||||
}
|
||||
}
|
||||
if r.TargetMileageKm <= r.LowMileageKm || r.TargetMileageKm > 3000 {
|
||||
return fmt.Errorf("达标里程必须大于低里程阈值且不超过 3000 km")
|
||||
}
|
||||
if r.DeviationKm <= 0 || r.DeviationKm > 3000 || r.DeviationPercent <= 0 || r.DeviationPercent > 100 || r.MinimumGPSKm > 3000 {
|
||||
return fmt.Errorf("偏差阈值应为 0–3000 km、0–100%%(不含零),最低 GPS 里程不超过 3000 km")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type CustomerVehicleAssignment struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Customer string `json:"customer"`
|
||||
Department string `json:"department"`
|
||||
ResponsiblePerson string `json:"responsiblePerson"`
|
||||
}
|
||||
|
||||
type CustomerVehicleDay struct {
|
||||
Restricted bool `json:"restricted"`
|
||||
HydrogenCoveragePercent *float64 `json:"hydrogenCoveragePercent"`
|
||||
HydrogenObservedKm *float64 `json:"hydrogenObservedKm"`
|
||||
HydrogenSource string `json:"hydrogenSource,omitempty"`
|
||||
CustomerVehicleAssignment
|
||||
Date string `json:"date"`
|
||||
GPSKm *float64 `json:"gpsKm"`
|
||||
HydrogenKm *float64 `json:"hydrogenKm"`
|
||||
GPSStatus string `json:"gpsStatus"`
|
||||
HydrogenStatus string `json:"hydrogenStatus"`
|
||||
DifferenceKm *float64 `json:"differenceKm"`
|
||||
DifferencePercent *float64 `json:"differencePercent"`
|
||||
Alert string `json:"alert"`
|
||||
Provisional bool `json:"provisional"`
|
||||
QualityReason string `json:"qualityReason,omitempty"`
|
||||
}
|
||||
|
||||
func validCustomerMileage(v *float64) bool {
|
||||
return v != nil && !math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0
|
||||
}
|
||||
|
||||
func customerMileageBand(value *float64, rules CustomerMileageRules) string {
|
||||
if !validCustomerMileage(value) {
|
||||
return "missing"
|
||||
}
|
||||
if *value < rules.LowMileageKm {
|
||||
return "low"
|
||||
}
|
||||
if *value < rules.TargetMileageKm {
|
||||
return "below-target"
|
||||
}
|
||||
return "on-target"
|
||||
}
|
||||
|
||||
func evaluateCustomerVehicleDay(day CustomerVehicleDay, rules CustomerMileageRules) CustomerVehicleDay {
|
||||
if day.Restricted {
|
||||
day.GPSKm = nil
|
||||
day.HydrogenKm = nil
|
||||
day.GPSStatus = "restricted"
|
||||
day.HydrogenStatus = "restricted"
|
||||
day.DifferenceKm = nil
|
||||
day.DifferencePercent = nil
|
||||
day.Alert = "restricted"
|
||||
return day
|
||||
}
|
||||
day.GPSStatus = customerMileageBand(day.GPSKm, rules)
|
||||
day.HydrogenStatus = customerMileageBand(day.HydrogenKm, rules)
|
||||
day.DifferenceKm, day.DifferencePercent = nil, nil
|
||||
day.Alert = "normal"
|
||||
if day.GPSStatus == "missing" || day.HydrogenStatus == "missing" {
|
||||
day.Alert = "missing-data"
|
||||
return day
|
||||
}
|
||||
difference := *day.GPSKm - *day.HydrogenKm
|
||||
day.DifferenceKm = &difference
|
||||
if *day.GPSKm > 0 {
|
||||
percent := difference / *day.GPSKm * 100
|
||||
day.DifferencePercent = &percent
|
||||
}
|
||||
if strings.TrimSpace(day.QualityReason) != "" {
|
||||
day.Alert = "data-quality"
|
||||
return day
|
||||
}
|
||||
if day.Provisional && !rules.IncludeToday {
|
||||
day.Alert = "provisional"
|
||||
return day
|
||||
}
|
||||
if difference < -rules.DeviationKm {
|
||||
day.Alert = "reverse-deviation"
|
||||
return day
|
||||
}
|
||||
if *day.GPSKm >= rules.MinimumGPSKm && difference >= rules.DeviationKm && day.DifferencePercent != nil && *day.DifferencePercent >= rules.DeviationPercent {
|
||||
day.Alert = "suspected-charging"
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// CustomerMileageObservation holds independently selected GPS and hydrogen
|
||||
// measurements. A missing source is nil, never an artificial zero.
|
||||
type CustomerMileageObservation struct {
|
||||
Restricted bool
|
||||
HydrogenCoveragePercent *float64
|
||||
HydrogenObservedKm *float64
|
||||
HydrogenSource string
|
||||
VIN string
|
||||
Date string
|
||||
GPSKm *float64
|
||||
HydrogenKm *float64
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type CustomerMileageDay struct {
|
||||
Date string `json:"date"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
GPSVehicleCount int `json:"gpsVehicleCount"`
|
||||
HydrogenVehicleCount int `json:"hydrogenVehicleCount"`
|
||||
GPSKm *float64 `json:"gpsKm"`
|
||||
HydrogenKm *float64 `json:"hydrogenKm"`
|
||||
SuspectedChargingCount int `json:"suspectedChargingCount"`
|
||||
DataIssueCount int `json:"dataIssueCount"`
|
||||
LowMileageCount int `json:"lowMileageCount"`
|
||||
Provisional bool `json:"provisional"`
|
||||
}
|
||||
|
||||
type CustomerMileageGroup struct {
|
||||
Customer string `json:"customer"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
Departments []string `json:"departments"`
|
||||
ResponsiblePeople []string `json:"responsiblePeople"`
|
||||
Days []CustomerMileageDay `json:"days"`
|
||||
Vehicles []CustomerVehicleDay `json:"vehicles"`
|
||||
}
|
||||
|
||||
// aggregateCustomerMileage receives authorized assignments only. It excludes
|
||||
// unassigned VINs and refuses conflicting current assignments instead of placing
|
||||
// the same vehicle under multiple customers. Caller must scope measurements too.
|
||||
func aggregateCustomerMileage(assignments []CustomerVehicleAssignment, observations []CustomerMileageObservation, dates []string, today string, rules CustomerMileageRules) ([]CustomerMileageGroup, error) {
|
||||
if err := rules.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byVIN := map[string]CustomerVehicleAssignment{}
|
||||
for _, a := range assignments {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
a.Customer = strings.TrimSpace(a.Customer)
|
||||
a.Department = strings.TrimSpace(a.Department)
|
||||
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
|
||||
if a.VIN == "" || a.Customer == "" {
|
||||
continue
|
||||
}
|
||||
if prev, ok := byVIN[a.VIN]; ok && prev != a {
|
||||
return nil, fmt.Errorf("车辆客户归属存在冲突,请刷新 OneOS 车辆数据")
|
||||
}
|
||||
byVIN[a.VIN] = a
|
||||
}
|
||||
type key struct{ vin, date string }
|
||||
measured := map[key]CustomerMileageObservation{}
|
||||
for _, o := range observations {
|
||||
o.VIN = strings.ToUpper(strings.TrimSpace(o.VIN))
|
||||
if _, ok := byVIN[o.VIN]; !ok {
|
||||
continue
|
||||
}
|
||||
k := key{o.VIN, o.Date}
|
||||
if _, ok := measured[k]; ok {
|
||||
return nil, fmt.Errorf("同一车辆日期存在重复里程结果")
|
||||
}
|
||||
measured[k] = o
|
||||
}
|
||||
groups := map[string]*CustomerMileageGroup{}
|
||||
vins := make([]string, 0, len(byVIN))
|
||||
for vin := range byVIN {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
for _, vin := range vins {
|
||||
a := byVIN[vin]
|
||||
g := groups[a.Customer]
|
||||
if g == nil {
|
||||
g = &CustomerMileageGroup{Customer: a.Customer, Departments: []string{}, ResponsiblePeople: []string{}, Days: make([]CustomerMileageDay, len(dates)), Vehicles: []CustomerVehicleDay{}}
|
||||
groups[a.Customer] = g
|
||||
for i, date := range dates {
|
||||
g.Days[i] = CustomerMileageDay{Date: date, Provisional: date == today}
|
||||
}
|
||||
}
|
||||
g.VehicleCount++
|
||||
if a.Department != "" {
|
||||
g.Departments = appendUniqueCustomerValue(g.Departments, a.Department)
|
||||
}
|
||||
if a.ResponsiblePerson != "" {
|
||||
g.ResponsiblePeople = appendUniqueCustomerValue(g.ResponsiblePeople, a.ResponsiblePerson)
|
||||
}
|
||||
for i, date := range dates {
|
||||
o := measured[key{vin, date}]
|
||||
d := evaluateCustomerVehicleDay(CustomerVehicleDay{CustomerVehicleAssignment: a, Date: date, GPSKm: o.GPSKm, HydrogenKm: o.HydrogenKm, Restricted: o.Restricted, HydrogenCoveragePercent: o.HydrogenCoveragePercent, HydrogenObservedKm: o.HydrogenObservedKm, HydrogenSource: o.HydrogenSource, QualityReason: o.QualityReason, Provisional: date == today}, rules)
|
||||
g.Vehicles = append(g.Vehicles, d)
|
||||
day := &g.Days[i]
|
||||
if !d.Restricted {
|
||||
day.VehicleCount++
|
||||
}
|
||||
if validCustomerMileage(d.GPSKm) {
|
||||
addCustomerMileage(&day.GPSKm, *d.GPSKm)
|
||||
day.GPSVehicleCount++
|
||||
}
|
||||
if validCustomerMileage(d.HydrogenKm) {
|
||||
addCustomerMileage(&day.HydrogenKm, *d.HydrogenKm)
|
||||
day.HydrogenVehicleCount++
|
||||
}
|
||||
if d.Alert == "suspected-charging" {
|
||||
day.SuspectedChargingCount++
|
||||
}
|
||||
if d.Alert == "missing-data" || d.Alert == "data-quality" || d.Alert == "reverse-deviation" {
|
||||
day.DataIssueCount++
|
||||
}
|
||||
if d.GPSStatus == "low" && (!d.Provisional || rules.IncludeToday) {
|
||||
day.LowMileageCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]CustomerMileageGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
sort.Strings(g.Departments)
|
||||
sort.Strings(g.ResponsiblePeople)
|
||||
result = append(result, *g)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Customer < result[j].Customer })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func appendUniqueCustomerValue(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
func addCustomerMileage(total **float64, value float64) {
|
||||
if *total == nil {
|
||||
*total = new(float64)
|
||||
}
|
||||
**total += value
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (h *Handler) handleCustomerMileage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := h.service.CustomerMileage(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
func (h *Handler) handleCustomerMileageRules(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodGet {
|
||||
data, err := h.service.CustomerMileageRules(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
return
|
||||
}
|
||||
var rules CustomerMileageRules
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&rules); err != nil {
|
||||
h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "预警规则格式无效"})
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "请求只能包含一份规则"})
|
||||
return
|
||||
}
|
||||
data, err := h.service.SaveCustomerMileageRules(r.Context(), rules)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomerAssignmentSnapshot struct {
|
||||
Rows []CustomerVehicleAssignment
|
||||
ObservedAt time.Time
|
||||
Tenant string
|
||||
Live bool
|
||||
}
|
||||
type customerAssignmentContextKey struct{}
|
||||
|
||||
func WithCustomerAssignments(ctx context.Context, snapshot CustomerAssignmentSnapshot) context.Context {
|
||||
return context.WithValue(ctx, customerAssignmentContextKey{}, snapshot)
|
||||
}
|
||||
|
||||
type CustomerMileageResponse struct {
|
||||
HydrogenJobs []CustomerHydrogenJob `json:"hydrogenJobs"`
|
||||
Groups []CustomerMileageGroup `json:"groups"`
|
||||
Rules CustomerMileageRules `json:"rules"`
|
||||
Dates []string `json:"dates"`
|
||||
AssignmentSource string `json:"assignmentSource"`
|
||||
AssignmentObservedAt string `json:"assignmentObservedAt"`
|
||||
AssignmentStale bool `json:"assignmentStale"`
|
||||
AssignmentMissingCount int `json:"assignmentMissingCount"`
|
||||
ExcludedUnassignedCount int `json:"excludedUnassignedCount"`
|
||||
CanConfigure bool `json:"canConfigure"`
|
||||
}
|
||||
|
||||
func customerMileageAccess(ctx context.Context) (Principal, error) {
|
||||
p, ok := PrincipalFromContext(ctx)
|
||||
if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu("customer-mileage") {
|
||||
return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看客户运营里程"}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
func customerMileageScope(ctx context.Context, p Principal) string {
|
||||
tenant := p.TenantRef
|
||||
if snapshot, ok := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot); ok {
|
||||
tenant = snapshot.Tenant
|
||||
}
|
||||
return "tenant:" + tenant
|
||||
}
|
||||
func (s *Service) customerMileageStore() (CustomerMileageStore, error) {
|
||||
store, ok := s.store.(CustomerMileageStore)
|
||||
if !ok {
|
||||
return nil, clientError{Code: "CUSTOMER_MILEAGE_UNAVAILABLE", Message: "当前数据源尚未启用客户运营里程"}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
func (s *Service) CustomerMileageRules(ctx context.Context) (CustomerMileageRules, error) {
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
return store.CustomerMileageRules(ctx, customerMileageScope(ctx, p))
|
||||
}
|
||||
func (s *Service) SaveCustomerMileageRules(ctx context.Context, r CustomerMileageRules) (CustomerMileageRules, error) {
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if p.Role != "admin" || p.ReadOnly {
|
||||
return r, clientError{Code: "PERMISSION_DENIED", Message: "仅管理员可修改客户运营里程规则"}
|
||||
}
|
||||
if err = r.Validate(); err != nil {
|
||||
return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()}
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
return store.SaveCustomerMileageRules(ctx, customerMileageScope(ctx, p), r, ActorFromContext(ctx))
|
||||
}
|
||||
|
||||
func customerMileageDates(q url.Values, now time.Time) ([]string, error) {
|
||||
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
today := now.In(zone).Format("2006-01-02")
|
||||
from, to := q.Get("dateFrom"), q.Get("dateTo")
|
||||
if to == "" {
|
||||
to = now.In(zone).AddDate(0, 0, -1).Format("2006-01-02")
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02", to, zone)
|
||||
if err != nil {
|
||||
return nil, clientError{Code: "INVALID_DATE", Message: "结束日期格式应为 YYYY-MM-DD"}
|
||||
}
|
||||
if from == "" {
|
||||
from = end.AddDate(0, 0, -6).Format("2006-01-02")
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", from, zone)
|
||||
if err != nil || start.After(end) || to > today || end.Sub(start) > 365*24*time.Hour {
|
||||
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "请选择不晚于今天、最多 366 天的有效日期范围"}
|
||||
}
|
||||
dates := []string{}
|
||||
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
|
||||
dates = append(dates, d.Format("2006-01-02"))
|
||||
}
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func (s *Service) CustomerMileage(ctx context.Context, q url.Values) (CustomerMileageResponse, error) {
|
||||
response := CustomerMileageResponse{Groups: []CustomerMileageGroup{}}
|
||||
p, err := customerMileageAccess(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
store, err := s.customerMileageStore()
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
dates, err := customerMileageDates(q, time.Now())
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.Dates = dates
|
||||
snapshot, live := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot)
|
||||
live = live && snapshot.Live
|
||||
tenant := p.TenantRef
|
||||
if snapshot.Tenant != "" {
|
||||
tenant = snapshot.Tenant
|
||||
}
|
||||
if live {
|
||||
snapshot.Rows, err = normalizeCustomerAssignments(snapshot.Rows)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.AssignmentSource = "oneos-live"
|
||||
// Persist only a complete, authenticated upstream snapshot. Never use client
|
||||
// submitted metadata or another session's credentials to refresh it.
|
||||
if err = store.SaveCustomerAssignments(ctx, tenant, snapshot.Rows, snapshot.ObservedAt); err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
snapshot.Rows, snapshot.ObservedAt, err = store.CustomerAssignments(ctx, tenant)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
response.AssignmentSource = "oneos-synced"
|
||||
}
|
||||
response.AssignmentStale = snapshot.ObservedAt.IsZero() || time.Since(snapshot.ObservedAt) > 24*time.Hour
|
||||
if !snapshot.ObservedAt.IsZero() {
|
||||
response.AssignmentObservedAt = snapshot.ObservedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
response.CanConfigure = p.Role == "admin" && !p.ReadOnly
|
||||
response.Rules, err = store.CustomerMileageRules(ctx, customerMileageScope(ctx, p))
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
selected := []CustomerVehicleAssignment{}
|
||||
vins := []string{}
|
||||
unassigned := map[string]bool{}
|
||||
knownAssignments := map[string]bool{}
|
||||
for _, a := range snapshot.Rows {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
if !isLikelyVIN(a.VIN) || !p.CanVIN(a.VIN) {
|
||||
continue
|
||||
}
|
||||
knownAssignments[a.VIN] = true
|
||||
if strings.TrimSpace(a.Customer) == "" {
|
||||
unassigned[a.VIN] = true
|
||||
continue
|
||||
}
|
||||
if q.Get("customer") != "" && !strings.Contains(strings.ToLower(a.Customer), strings.ToLower(q.Get("customer"))) {
|
||||
continue
|
||||
}
|
||||
if q.Get("department") != "" && !strings.Contains(strings.ToLower(a.Department), strings.ToLower(strings.TrimSpace(q.Get("department")))) {
|
||||
continue
|
||||
}
|
||||
if q.Get("responsiblePerson") != "" && !strings.Contains(strings.ToLower(a.ResponsiblePerson), strings.ToLower(strings.TrimSpace(q.Get("responsiblePerson")))) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, a)
|
||||
vins = append(vins, a.VIN)
|
||||
}
|
||||
response.ExcludedUnassignedCount = len(unassigned)
|
||||
if !live && p.HasVehicleScope() {
|
||||
for _, vin := range p.VehicleVINs {
|
||||
if p.CanVIN(vin) && !knownAssignments[vin] {
|
||||
response.AssignmentMissingCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
return response, nil
|
||||
}
|
||||
if len(vins)*len(dates) > 100000 {
|
||||
return response, clientError{Code: "CUSTOMER_MILEAGE_RANGE_TOO_LARGE", Message: "本次查询超过 100,000 个车辆日,请缩短日期或筛选客户、部门"}
|
||||
}
|
||||
// Construct a new query to discard all user-supplied authorization fields.
|
||||
query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "vins": {strings.Join(vins, ",")}}
|
||||
query, err = applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if err = requirePrincipalHistoricalGrantScope(ctx, query); err != nil {
|
||||
return response, err
|
||||
}
|
||||
observations, err := store.CustomerMileageObservations(ctx, query)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
if p.HasHistoryTimeScope() {
|
||||
starts := map[string]string{}
|
||||
if err = json.Unmarshal([]byte(query.Get("scopeVehicleFrom")), &starts); err != nil {
|
||||
return response, err
|
||||
}
|
||||
for _, a := range selected {
|
||||
for _, date := range dates {
|
||||
if date < starts[a.VIN] {
|
||||
observations = append(observations, CustomerMileageObservation{VIN: a.VIN, Date: date, Restricted: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
response.Groups, err = aggregateCustomerMileage(selected, observations, dates, time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02"), response.Rules)
|
||||
return response, err
|
||||
}
|
||||
|
||||
func normalizeCustomerAssignments(rows []CustomerVehicleAssignment) ([]CustomerVehicleAssignment, error) {
|
||||
result := []CustomerVehicleAssignment{}
|
||||
seen := map[string]CustomerVehicleAssignment{}
|
||||
for _, a := range rows {
|
||||
a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN))
|
||||
a.Plate = strings.TrimSpace(a.Plate)
|
||||
a.Customer = strings.TrimSpace(a.Customer)
|
||||
a.Department = strings.TrimSpace(a.Department)
|
||||
a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson)
|
||||
if !isLikelyVIN(a.VIN) {
|
||||
continue
|
||||
}
|
||||
if old, ok := seen[a.VIN]; ok {
|
||||
if old != a {
|
||||
return nil, clientError{Code: "CUSTOMER_ASSIGNMENT_CONFLICT", Message: "OneOS 同一 VIN 存在冲突归属,请先核查车辆目录"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen[a.VIN] = a
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type customerMileageTestStore struct {
|
||||
Store
|
||||
assignments []CustomerVehicleAssignment
|
||||
query url.Values
|
||||
saved int
|
||||
rules CustomerMileageRules
|
||||
}
|
||||
|
||||
func (s *customerMileageTestStore) CustomerMileageRules(context.Context, string) (CustomerMileageRules, error) {
|
||||
return s.rules, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) SaveCustomerMileageRules(_ context.Context, _ string, r CustomerMileageRules, _ string) (CustomerMileageRules, error) {
|
||||
s.saved++
|
||||
s.rules = r
|
||||
return r, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) CustomerMileageObservations(_ context.Context, q url.Values) ([]CustomerMileageObservation, error) {
|
||||
s.query = q
|
||||
return []CustomerMileageObservation{}, nil
|
||||
}
|
||||
func (s *customerMileageTestStore) SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error {
|
||||
return nil
|
||||
}
|
||||
func (s *customerMileageTestStore) CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
return s.assignments, time.Now(), nil
|
||||
}
|
||||
|
||||
func TestCustomerMileageServiceScopesAssignmentsAndHistory(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
other := "LNX00000000000002"
|
||||
store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: other, Customer: "乙"}}}
|
||||
service := NewService(store)
|
||||
from := time.Date(2026, 1, 10, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: from}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
result, err := service.CustomerMileage(ctx, url.Values{"dateFrom": {"2026-01-01"}, "dateTo": {"2026-01-31"}, "scopeVins": {other}, "scopeVehicleFrom": {"{}"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Groups) != 1 || result.Groups[0].Customer != "甲" {
|
||||
t.Fatalf("unauthorized assignment exposed: %+v", result.Groups)
|
||||
}
|
||||
if store.query.Get("vins") != vin || store.query.Get("scopeVins") != vin || !strings.Contains(store.query.Get("scopeVehicleFrom"), "2026-01-11") {
|
||||
t.Fatalf("lost history scope: %v", store.query)
|
||||
}
|
||||
if _, err = service.SaveCustomerMileageRules(ctx, store.rules); err == nil || store.saved != 0 {
|
||||
t.Fatal("customer changed shared rules")
|
||||
}
|
||||
p.MenuKeys = []string{"statistics"}
|
||||
ctx = WithPrincipal(context.Background(), p)
|
||||
if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil {
|
||||
t.Fatal("statistics permission implicitly exposed hydrogen")
|
||||
}
|
||||
p.MenuKeys = []string{"customer-mileage"}
|
||||
p.VehicleGrants = nil
|
||||
ctx = WithPrincipal(context.Background(), p)
|
||||
if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil {
|
||||
t.Fatal("native historical grant bypassed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageDateRangeValidation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 30, 1, 0, 0, 0, time.UTC)
|
||||
dates, err := customerMileageDates(url.Values{}, now)
|
||||
if err != nil || len(dates) != 7 || dates[0] != "2026-09-23" || dates[6] != "2026-09-29" {
|
||||
t.Fatalf("bad default: %v %v", dates, err)
|
||||
}
|
||||
for _, q := range []url.Values{{"dateFrom": {"2026-10-01"}}, {"dateTo": {"2026-09-31"}}, {"dateFrom": {"2020-01-01"}}, {"dateFrom": {"2026-09-30"}, "dateTo": {"2026-09-29"}}} {
|
||||
if _, err = customerMileageDates(q, now); err == nil {
|
||||
t.Fatalf("bad dates accepted: %v", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageSQLPreservesScopeAndIndependentSources(t *testing.T) {
|
||||
q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "vins": {"LNX00000000000001"}, "scopeVins": {"LNX00000000000001"}, "scopeVehicleFrom": {`{"LNX00000000000001":"2026-09-10"}`}}
|
||||
sql := buildCustomerMileageSQL(q)
|
||||
for _, want := range []string{"m.protocol = 'JT808'", "m.vin IN", "m.stat_date >="} {
|
||||
if !strings.Contains(sql.Text, want) {
|
||||
t.Fatalf("missing %s in query", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql.Text, "LNX00000000000001") {
|
||||
t.Fatal("VIN interpolated into SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAssignmentConflictPreventsPersistence(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
if _, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: vin, Customer: ""}}); err == nil {
|
||||
t.Fatal("assigned/unassigned conflict accepted")
|
||||
}
|
||||
got, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: " 甲 "}, {VIN: vin, Customer: "甲"}})
|
||||
if err != nil || len(got) != 1 || got[0].Customer != "甲" {
|
||||
t.Fatalf("normalized duplicates not collapsed: %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageHistoricalRestrictionsAreNotMissingDataAlerts(t *testing.T) {
|
||||
rules := DefaultCustomerMileageRules()
|
||||
groups, err := aggregateCustomerMileage([]CustomerVehicleAssignment{{VIN: "VIN", Customer: "甲"}}, []CustomerMileageObservation{{VIN: "VIN", Date: "2026-09-01", Restricted: true}}, []string{"2026-09-01"}, "2026-09-30", rules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if groups[0].Days[0].DataIssueCount != 0 || groups[0].Days[0].VehicleCount != 0 || groups[0].Vehicles[0].Alert != "restricted" {
|
||||
t.Fatalf("authorization absence misclassified: %+v", groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageFuzzyAssignmentFiltersKeepVINScope(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{
|
||||
{VIN: vin, Customer: "上海客户甲", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"},
|
||||
{VIN: "LNX00000000000002", Customer: "上海客户乙", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"},
|
||||
}}
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
q := url.Values{"dateFrom": {"2026-01-10"}, "dateTo": {"2026-01-10"}, "customer": {"上海"}, "department": {" 一部 "}, "responsiblePerson": {"alice"}}
|
||||
result, err := NewService(store).CustomerMileage(ctx, q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Groups) != 1 || result.Groups[0].Customer != "上海客户甲" || store.query.Get("vins") != vin {
|
||||
t.Fatalf("partial filters lost scope: %+v %v", result.Groups, store.query)
|
||||
}
|
||||
q.Set("responsiblePerson", "不存在")
|
||||
result, err = NewService(store).CustomerMileage(ctx, q)
|
||||
if err != nil || len(result.Groups) != 0 {
|
||||
t.Fatalf("non-matching keyword returned data: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CustomerMileageStore interface {
|
||||
CustomerMileageRules(context.Context, string) (CustomerMileageRules, error)
|
||||
SaveCustomerMileageRules(context.Context, string, CustomerMileageRules, string) (CustomerMileageRules, error)
|
||||
CustomerMileageObservations(context.Context, url.Values) ([]CustomerMileageObservation, error)
|
||||
SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error
|
||||
CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerMileageRules(ctx context.Context, scope string) (CustomerMileageRules, error) {
|
||||
var raw []byte
|
||||
var version int64
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version,rules_json FROM platform_customer_mileage_rules WHERE scope_key=?`, scope).Scan(&version, &raw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
r := DefaultCustomerMileageRules()
|
||||
r.Version = 0
|
||||
return r, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
var r CustomerMileageRules
|
||||
if err = json.Unmarshal(raw, &r); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Version = version
|
||||
return r, r.Validate()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SaveCustomerMileageRules(ctx context.Context, scope string, r CustomerMileageRules, actor string) (CustomerMileageRules, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()}
|
||||
}
|
||||
previous := r.Version
|
||||
r.Version++
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
var result sql.Result
|
||||
if previous == 0 {
|
||||
result, err = s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_mileage_rules(scope_key,version,rules_json,updated_by) VALUES(?,?,?,?)`, scope, r.Version, string(raw), actor)
|
||||
} else {
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE platform_customer_mileage_rules SET version=?,rules_json=?,updated_by=? WHERE scope_key=? AND version=?`, r.Version, string(raw), actor, scope, previous)
|
||||
}
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return CustomerMileageRules{}, err
|
||||
}
|
||||
if n != 1 {
|
||||
return CustomerMileageRules{}, clientError{Code: "CUSTOMER_MILEAGE_RULE_CONFLICT", Message: "规则已被其他用户更新,请重新加载后保存"}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SaveCustomerAssignments(ctx context.Context, tenant string, rows []CustomerVehicleAssignment, observed time.Time) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// A caller-scoped snapshot never deletes other users' metadata. Blank customer
|
||||
// values deliberately replace old assignments when a vehicle is unassigned.
|
||||
for start := 0; start < len(rows); start += 200 {
|
||||
end := start + 200
|
||||
if end > len(rows) {
|
||||
end = len(rows)
|
||||
}
|
||||
placeholders := make([]string, 0, end-start)
|
||||
args := make([]any, 0, (end-start)*7)
|
||||
for _, r := range rows[start:end] {
|
||||
placeholders = append(placeholders, "(?,?,?,?,?,?,?)")
|
||||
args = append(args, tenant, r.VIN, r.Plate, r.Customer, r.Department, r.ResponsiblePerson, observed)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_vehicle_assignment(tenant_ref,vin,plate,customer_name,department_name,responsible_person,observed_at) VALUES `+strings.Join(placeholders, ",")+` ON DUPLICATE KEY UPDATE plate=IF(VALUES(observed_at)>=observed_at,VALUES(plate),plate), customer_name=IF(VALUES(observed_at)>=observed_at,VALUES(customer_name),customer_name), department_name=IF(VALUES(observed_at)>=observed_at,VALUES(department_name),department_name), responsible_person=IF(VALUES(observed_at)>=observed_at,VALUES(responsible_person),responsible_person), observed_at=GREATEST(observed_at,VALUES(observed_at))`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerAssignments(ctx context.Context, tenant string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT vin,plate,customer_name,department_name,responsible_person,observed_at FROM platform_customer_vehicle_assignment WHERE tenant_ref=? ORDER BY vin`, tenant)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerVehicleAssignment{}
|
||||
var oldest time.Time
|
||||
for rows.Next() {
|
||||
var a CustomerVehicleAssignment
|
||||
var observed time.Time
|
||||
if err = rows.Scan(&a.VIN, &a.Plate, &a.Customer, &a.Department, &a.ResponsiblePerson, &observed); err != nil {
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
result = append(result, a)
|
||||
if oldest.IsZero() || observed.Before(oldest) {
|
||||
oldest = observed
|
||||
}
|
||||
}
|
||||
return result, oldest, rows.Err()
|
||||
}
|
||||
|
||||
func buildCustomerMileageSQL(q url.Values) SQLQuery {
|
||||
where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.protocol = 'JT808'"}
|
||||
args := []any{q.Get("dateFrom"), q.Get("dateTo")}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom"))
|
||||
return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.daily_mileage_km FROM vehicle_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args}
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CustomerMileageObservations(ctx context.Context, q url.Values) ([]CustomerMileageObservation, error) {
|
||||
built := buildCustomerMileageSQL(q)
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []CustomerMileageObservation{}
|
||||
indexes := map[string]int{}
|
||||
for rows.Next() {
|
||||
var vin, date string
|
||||
var daily float64
|
||||
if err = rows.Scan(&vin, &date, &daily); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := vin + "/" + date
|
||||
index, ok := indexes[key]
|
||||
if !ok {
|
||||
index = len(result)
|
||||
indexes[key] = index
|
||||
result = append(result, CustomerMileageObservation{VIN: vin, Date: date})
|
||||
}
|
||||
o := &result[index]
|
||||
if daily < 0 || daily > 3000 {
|
||||
o.QualityReason = "GPS 日里程超出有效范围,需核查累计里程跳变"
|
||||
continue
|
||||
}
|
||||
o.GPSKm = &daily
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
return s.enrichCustomerHydrogen(ctx, q, result)
|
||||
}
|
||||
|
||||
func buildCustomerHydrogenProjectionSQL(q url.Values) SQLQuery {
|
||||
where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.account = 'guangdonglingniu'"}
|
||||
args := []any{q.Get("dateFrom"), q.Get("dateTo")}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins"))
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom"))
|
||||
return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.hydrogen_km FROM gzev_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args}
|
||||
}
|
||||
|
||||
func (s *ProductionStore) enrichCustomerHydrogen(ctx context.Context, q url.Values, result []CustomerMileageObservation) ([]CustomerMileageObservation, error) {
|
||||
built := buildCustomerHydrogenProjectionSQL(q)
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
indexes := map[string]int{}
|
||||
for i, r := range result {
|
||||
indexes[r.VIN+"/"+r.Date] = i
|
||||
}
|
||||
selected := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var vin, date string
|
||||
var hydrogen sql.NullFloat64
|
||||
if err = rows.Scan(&vin, &date, &hydrogen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := vin + "/" + date
|
||||
if selected[key] {
|
||||
continue
|
||||
}
|
||||
selected[key] = true
|
||||
index, ok := indexes[key]
|
||||
if !ok {
|
||||
index = len(result)
|
||||
indexes[key] = index
|
||||
result = append(result, CustomerMileageObservation{VIN: vin, Date: date})
|
||||
}
|
||||
o := &result[index]
|
||||
o.HydrogenSource = "GZEV / hyRunMileage"
|
||||
if hydrogen.Valid && hydrogen.Float64 >= 0 && hydrogen.Float64 <= 3000 {
|
||||
value := hydrogen.Float64
|
||||
o.HydrogenKm = &value
|
||||
} else if hydrogen.Valid {
|
||||
o.QualityReason = "GZEV 氢能源日里程超出有效范围,需核查源数据"
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomerMileageStoreUsesGZEVAndPreservesMissing(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT m.vin.*vehicle_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "gps"}).AddRow("A", "2026-10-01", 250).AddRow("B", "2026-10-01", 100).AddRow("C", "2026-10-01", 100))
|
||||
mock.ExpectQuery("SELECT m.vin.*gzev_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "hydrogen"}).AddRow("A", "2026-10-01", 180).AddRow("B", "2026-10-01", nil).AddRow("C", "2026-10-01", 0).AddRow("D", "2026-10-01", 80))
|
||||
result, err := (&ProductionStore{db: db}).CustomerMileageObservations(context.Background(), url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-01"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 4 || *result[0].GPSKm != 250 || *result[0].HydrogenKm != 180 || result[0].HydrogenSource != "GZEV / hyRunMileage" {
|
||||
t.Fatalf("wrong source: %+v", result)
|
||||
}
|
||||
if result[1].HydrogenKm != nil || result[2].HydrogenKm == nil || *result[2].HydrogenKm != 0 || result[3].GPSKm != nil {
|
||||
t.Fatal("null, zero, or independent sources lost")
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerGZEVSQLPreservesAuthorization(t *testing.T) {
|
||||
q := url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-07"}, "vins": {"VIN-A"}, "scopeVins": {"VIN-A"}, "scopeVehicleFrom": {`{"VIN-A":"2026-10-03"}`}}
|
||||
built := buildCustomerHydrogenProjectionSQL(q)
|
||||
for _, want := range []string{"gzev_daily_mileage", "m.account = 'guangdonglingniu'", "m.vin IN", "m.stat_date >="} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "VIN-A") {
|
||||
t.Fatal("scope interpolated into SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageRulesConcurrentSaveReturnsConflict(t *testing.T) {
|
||||
for _, version := range []int64{0, 4} {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rules := DefaultCustomerMileageRules()
|
||||
rules.Version = version
|
||||
pattern := "UPDATE platform_customer_mileage_rules"
|
||||
if version == 0 {
|
||||
pattern = "INSERT IGNORE INTO platform_customer_mileage_rules"
|
||||
}
|
||||
mock.ExpectExec(pattern).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
_, err = (&ProductionStore{db: db}).SaveCustomerMileageRules(context.Background(), "tenant:000000", rules, "admin")
|
||||
ce, ok := asClientError(err)
|
||||
if !ok || ce.Code != "CUSTOMER_MILEAGE_RULE_CONFLICT" {
|
||||
t.Fatalf("concurrent save lost: %v", err)
|
||||
}
|
||||
if err = mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustomerMileageDeviationRequiresComparableEvidence(t *testing.T) {
|
||||
rules := DefaultCustomerMileageRules()
|
||||
ptr := func(v float64) *float64 { return &v }
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
gps, h *float64
|
||||
today bool
|
||||
quality, want string
|
||||
}{
|
||||
{"large positive discrepancy", ptr(200), ptr(100), false, "", "suspected-charging"},
|
||||
{"relative threshold alone insufficient", ptr(50), ptr(25), false, "", "normal"},
|
||||
{"absolute threshold alone insufficient", ptr(300), ptr(265), false, "", "normal"},
|
||||
{"missing hydrogen is not zero", ptr(200), nil, false, "", "missing-data"},
|
||||
{"observed zero hydrogen", ptr(200), ptr(0), false, "", "suspected-charging"},
|
||||
{"no driving", ptr(0), ptr(0), false, "", "normal"},
|
||||
{"reverse discrepancy", ptr(100), ptr(200), false, "", "reverse-deviation"},
|
||||
{"unfinished day", ptr(200), ptr(0), true, "", "provisional"},
|
||||
{"unreliable evidence", ptr(200), ptr(0), false, "采样不完整", "data-quality"},
|
||||
{"invalid gps", ptr(math.NaN()), ptr(0), false, "", "missing-data"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := evaluateCustomerVehicleDay(CustomerVehicleDay{GPSKm: tt.gps, HydrogenKm: tt.h, Provisional: tt.today, QualityReason: tt.quality}, rules)
|
||||
if got.Alert != tt.want {
|
||||
t.Fatalf("got %s want %s", got.Alert, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
day := CustomerVehicleDay{GPSKm: ptr(200), HydrogenKm: ptr(100), Provisional: true}
|
||||
rules.IncludeToday = true
|
||||
if evaluateCustomerVehicleDay(day, rules).Alert != "suspected-charging" {
|
||||
t.Fatal("live-day preference ignored")
|
||||
}
|
||||
rules.DeviationKm = 150
|
||||
if evaluateCustomerVehicleDay(day, rules).Alert != "normal" {
|
||||
t.Fatal("changed rules did not change evaluation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageBandsAndRuleValidation(t *testing.T) {
|
||||
r := DefaultCustomerMileageRules()
|
||||
if err := r.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
km float64
|
||||
band string
|
||||
}{{0, "low"}, {49.9, "low"}, {50, "below-target"}, {199.9, "below-target"}, {200, "on-target"}} {
|
||||
if got := customerMileageBand(&tt.km, r); got != tt.band {
|
||||
t.Fatalf("%v: %s", tt.km, got)
|
||||
}
|
||||
}
|
||||
for _, mutate := range []func(*CustomerMileageRules){func(r *CustomerMileageRules) { r.LowMileageKm = r.TargetMileageKm }, func(r *CustomerMileageRules) { r.DeviationPercent = 101 }, func(r *CustomerMileageRules) { r.DeviationKm = 0 }, func(r *CustomerMileageRules) { r.MinimumGPSKm = math.Inf(1) }} {
|
||||
invalid := r
|
||||
mutate(&invalid)
|
||||
if invalid.Validate() == nil {
|
||||
t.Fatal("invalid rule accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerMileageAggregationScopeCoverageAndVehicleAlerts(t *testing.T) {
|
||||
ptr := func(v float64) *float64 { return &v }
|
||||
assignments := []CustomerVehicleAssignment{
|
||||
{VIN: "A", Customer: " 客户甲 ", Department: "业务一部", ResponsiblePerson: "张"},
|
||||
{VIN: "B", Customer: "客户甲", Department: "业务二部", ResponsiblePerson: "李"},
|
||||
{VIN: "C", Customer: "客户甲"},
|
||||
{VIN: "D", Customer: ""},
|
||||
}
|
||||
assignments = append(assignments, assignments[0])
|
||||
observations := []CustomerMileageObservation{
|
||||
{VIN: "A", Date: "2026-09-28", GPSKm: ptr(200), HydrogenKm: ptr(100)},
|
||||
{VIN: "B", Date: "2026-09-28", GPSKm: ptr(100), HydrogenKm: ptr(200)},
|
||||
{VIN: "D", Date: "2026-09-28", GPSKm: ptr(9000), HydrogenKm: ptr(9000)},
|
||||
{VIN: "UNAUTHORIZED", Date: "2026-09-28", GPSKm: ptr(9000), HydrogenKm: ptr(9000)},
|
||||
}
|
||||
got, err := aggregateCustomerMileage(assignments, observations, []string{"2026-09-28", "2026-09-29"}, "2026-09-30", DefaultCustomerMileageRules())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].VehicleCount != 3 || len(got[0].Vehicles) != 6 {
|
||||
t.Fatalf("wrong customer/vehicle cardinality: %+v", got)
|
||||
}
|
||||
g := got[0]
|
||||
d := g.Days[0]
|
||||
if *d.GPSKm != 300 || *d.HydrogenKm != 300 || d.GPSVehicleCount != 2 || d.HydrogenVehicleCount != 2 {
|
||||
t.Fatalf("incorrect scope or coverage: %+v", d)
|
||||
}
|
||||
if d.SuspectedChargingCount != 1 || d.DataIssueCount != 2 {
|
||||
t.Fatalf("vehicle-level discrepancies cancelled: %+v", d)
|
||||
}
|
||||
if g.Days[1].GPSKm != nil || g.Days[1].HydrogenKm != nil || g.Days[1].DataIssueCount != 3 {
|
||||
t.Fatalf("missing day became zero: %+v", g.Days[1])
|
||||
}
|
||||
if len(g.Departments) != 2 || len(g.ResponsiblePeople) != 2 {
|
||||
t.Fatal("grouping metadata lost")
|
||||
}
|
||||
assignments = append(assignments, CustomerVehicleAssignment{VIN: "A", Customer: "客户乙"})
|
||||
if _, err := aggregateCustomerMileage(assignments, observations, []string{"2026-09-28"}, "2026-09-30", DefaultCustomerMileageRules()); err == nil {
|
||||
t.Fatal("conflicting customer assignment counted twice")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
{
|
||||
"source": "码头车辆编号.xlsx",
|
||||
"sheet": "总表",
|
||||
"sha256": "01b6510d55e41659f506b1fd3adfb430e137b95b2b935f281acb47caa0f830f2",
|
||||
"vehicles": [
|
||||
{
|
||||
"vin": "LA9GG64L9NBAF4191",
|
||||
"plate": "浙F05388F",
|
||||
"number": "01Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L3NBAF4204",
|
||||
"plate": "浙F05798F",
|
||||
"number": "02Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L2NBAF4193",
|
||||
"plate": "浙F35688F",
|
||||
"number": "03Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L1NBAF4203",
|
||||
"plate": "浙F08268F",
|
||||
"number": "04Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L3NBAF4168",
|
||||
"plate": "浙F31668F",
|
||||
"number": "05Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L1NBAF4198",
|
||||
"plate": "浙F01208F",
|
||||
"number": "06Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4268",
|
||||
"plate": "浙F07033F",
|
||||
"number": "07Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L3NBAF4185",
|
||||
"plate": "浙F09300F",
|
||||
"number": "08Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4286",
|
||||
"plate": "浙F39086F",
|
||||
"number": "09Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L7PBAF4820",
|
||||
"plate": "浙F36386F",
|
||||
"number": "10Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4808",
|
||||
"plate": "浙F08076F",
|
||||
"number": "11Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4201",
|
||||
"plate": "浙F07599F",
|
||||
"number": "12Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L7NBAF4187",
|
||||
"plate": "浙F01355F",
|
||||
"number": "13Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4787",
|
||||
"plate": "浙F08021F",
|
||||
"number": "14Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4775",
|
||||
"plate": "浙F08197F",
|
||||
"number": "15Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4795",
|
||||
"plate": "浙F05186F",
|
||||
"number": "16Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4281",
|
||||
"plate": "浙F03383F",
|
||||
"number": "17Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4267",
|
||||
"plate": "浙F06909F",
|
||||
"number": "18Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4241",
|
||||
"plate": "浙F09038F",
|
||||
"number": "19Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4805",
|
||||
"plate": "浙F05519F",
|
||||
"number": "20Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L3NBAF4199",
|
||||
"plate": "浙F08886F",
|
||||
"number": "21Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L5NBAF4186",
|
||||
"plate": "浙F32999F",
|
||||
"number": "22Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64LXNBAF4197",
|
||||
"plate": "浙F08699F",
|
||||
"number": "23Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4272",
|
||||
"plate": "浙F09070F",
|
||||
"number": "24Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L6NBAF4200",
|
||||
"plate": "浙F02698F",
|
||||
"number": "25Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L6NBAF4195",
|
||||
"plate": "浙F05969F",
|
||||
"number": "26Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L5NBAF4012",
|
||||
"plate": "浙F08833F",
|
||||
"number": "27Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L2NBAF4016",
|
||||
"plate": "浙F02818F",
|
||||
"number": "28Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L3NBAF4011",
|
||||
"plate": "浙F00900F",
|
||||
"number": "29Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L4NBAF4017",
|
||||
"plate": "浙F08066F",
|
||||
"number": "30Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L4NBAF4020",
|
||||
"plate": "浙F39885F",
|
||||
"number": "31Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L6NBAF4018",
|
||||
"plate": "浙F36000F",
|
||||
"number": "32Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L0NBAF4015",
|
||||
"plate": "浙F00688F",
|
||||
"number": "33Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L7NBAF4013",
|
||||
"plate": "浙F02228F",
|
||||
"number": "34Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L8PBAF4289",
|
||||
"plate": "浙F06196F",
|
||||
"number": "35Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4019",
|
||||
"plate": "浙F32111F",
|
||||
"number": "36Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L5NBAF4172",
|
||||
"plate": "浙F05202F",
|
||||
"number": "37Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L9NBAF4174",
|
||||
"plate": "浙F08860F",
|
||||
"number": "38Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L1NBAF4167",
|
||||
"plate": "浙F09968F",
|
||||
"number": "39Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L1NBAF4170",
|
||||
"plate": "浙F08133F",
|
||||
"number": "40Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L7NBAF4173",
|
||||
"plate": "浙F06885F",
|
||||
"number": "41Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64LXNBAF4166",
|
||||
"plate": "浙F08112F",
|
||||
"number": "42Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4179",
|
||||
"plate": "浙F00661F",
|
||||
"number": "43Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L4NBAF4177",
|
||||
"plate": "浙F08863F",
|
||||
"number": "44Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4165",
|
||||
"plate": "浙F01505F",
|
||||
"number": "45Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L5NBAF4169",
|
||||
"plate": "浙F05521F",
|
||||
"number": "46Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L0NBAF4192",
|
||||
"plate": "浙F07559F",
|
||||
"number": "47Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L2NBAF4176",
|
||||
"plate": "浙F06508F",
|
||||
"number": "48Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L4NBAF4180",
|
||||
"plate": "浙F05110F",
|
||||
"number": "49Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L0NBAF4175",
|
||||
"plate": "浙F06618F",
|
||||
"number": "50Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L6NBAF4181",
|
||||
"plate": "浙F00399F",
|
||||
"number": "51Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4271",
|
||||
"plate": "浙F01689F",
|
||||
"number": "52Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L1NBAF4184",
|
||||
"plate": "浙F32233F",
|
||||
"number": "53Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64LXNBAF4183",
|
||||
"plate": "浙F03100F",
|
||||
"number": "54Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4196",
|
||||
"plate": "浙F07808F",
|
||||
"number": "55Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L8NBAF4182",
|
||||
"plate": "浙F06220F",
|
||||
"number": "56Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4785",
|
||||
"plate": "浙F03738F",
|
||||
"number": "57Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L7PBAF4817",
|
||||
"plate": "浙F08907F",
|
||||
"number": "58Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68LXPBAF4794",
|
||||
"plate": "浙F08662F",
|
||||
"number": "59Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4811",
|
||||
"plate": "浙F08207F",
|
||||
"number": "60Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4779",
|
||||
"plate": "浙F06221F",
|
||||
"number": "61Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68LXPBAF4780",
|
||||
"plate": "浙F07681F",
|
||||
"number": "62Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4781",
|
||||
"plate": "浙F08709F",
|
||||
"number": "63Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4783",
|
||||
"plate": "浙F08233F",
|
||||
"number": "64Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4792",
|
||||
"plate": "浙F38919F",
|
||||
"number": "65Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4801",
|
||||
"plate": "浙F39987F",
|
||||
"number": "66Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4802",
|
||||
"plate": "浙F36788F",
|
||||
"number": "67Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4773",
|
||||
"plate": "浙F09995F",
|
||||
"number": "68Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L4PBAF4774",
|
||||
"plate": "浙F00582F",
|
||||
"number": "69Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L8PBAF4776",
|
||||
"plate": "浙F00780F",
|
||||
"number": "70Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L4PBAF4791",
|
||||
"plate": "浙F08921F",
|
||||
"number": "71Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4819",
|
||||
"plate": "浙F08191F",
|
||||
"number": "72Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4778",
|
||||
"plate": "浙F05268F",
|
||||
"number": "73Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4782",
|
||||
"plate": "浙F08117F",
|
||||
"number": "74Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L9NBAF4188",
|
||||
"plate": "浙F03228F",
|
||||
"number": "75Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4789",
|
||||
"plate": "浙F05696F",
|
||||
"number": "76Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4797",
|
||||
"plate": "浙F31088F",
|
||||
"number": "77Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L7PBAF4798",
|
||||
"plate": "浙F05191F",
|
||||
"number": "78Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4799",
|
||||
"plate": "浙F32918F",
|
||||
"number": "79Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L7PBAF4803",
|
||||
"plate": "浙F02798F",
|
||||
"number": "80Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L8PBAF4812",
|
||||
"plate": "浙F03360F",
|
||||
"number": "81Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4771",
|
||||
"plate": "浙F31066F",
|
||||
"number": "82Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4796",
|
||||
"plate": "浙F37838F",
|
||||
"number": "83Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4800",
|
||||
"plate": "浙F02078F",
|
||||
"number": "84Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4804",
|
||||
"plate": "浙F02881F",
|
||||
"number": "85Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4806",
|
||||
"plate": "浙F32110F",
|
||||
"number": "86Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4816",
|
||||
"plate": "浙F08821F",
|
||||
"number": "87Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L7PBAF4784",
|
||||
"plate": "浙F32578F",
|
||||
"number": "88Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L8PBAF4793",
|
||||
"plate": "浙F03890F",
|
||||
"number": "89Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4815",
|
||||
"plate": "浙F06269F",
|
||||
"number": "90Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4772",
|
||||
"plate": "浙F38009F",
|
||||
"number": "91Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68LXPBAF4777",
|
||||
"plate": "浙F05086F",
|
||||
"number": "92Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L0PBAF4786",
|
||||
"plate": "浙F08000F",
|
||||
"number": "93Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"plate": "浙F09282F",
|
||||
"number": "94Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L4PBAF4807",
|
||||
"plate": "浙F03208F",
|
||||
"number": "95Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L8PBAF4809",
|
||||
"plate": "浙F01987F",
|
||||
"number": "96Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L4PBAF4810",
|
||||
"plate": "浙F31398F",
|
||||
"number": "97Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68LXPBAF4813",
|
||||
"plate": "浙F02678F",
|
||||
"number": "98Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4814",
|
||||
"plate": "浙F05569F",
|
||||
"number": "99Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4818",
|
||||
"plate": "浙F03131F",
|
||||
"number": "100Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L6NBAF4178",
|
||||
"plate": "浙F08278F",
|
||||
"number": "备用车101Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG64L7NBAF4190",
|
||||
"plate": "浙F03000F",
|
||||
"number": "备用车102Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L4PBAF4788",
|
||||
"plate": "浙F07179F",
|
||||
"number": "备用车103Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4265",
|
||||
"plate": "浙F07667F",
|
||||
"number": "备用车104Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L5PBAF4279",
|
||||
"plate": "浙F07869F",
|
||||
"number": "备用车105Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L9PBAF4284",
|
||||
"plate": "浙F01758F",
|
||||
"number": "备用车106Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L1PBAF4263",
|
||||
"plate": "浙F02218F",
|
||||
"number": "备用车107Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L6PBAF4274",
|
||||
"plate": "浙F02289F",
|
||||
"number": "备用车108Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L3PBAF4264",
|
||||
"plate": "浙F05633F",
|
||||
"number": "备用车109Q"
|
||||
},
|
||||
{
|
||||
"vin": "LA9GG68L2PBAF4269",
|
||||
"plate": "浙F08832F",
|
||||
"number": "备用车110Q"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,10 +27,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/operation-compliance", h.handleOperationCompliance)
|
||||
h.mux.HandleFunc("GET /api/v2/operation-compliance/options", h.handleOperationComplianceOptions)
|
||||
h.mux.HandleFunc("GET /api/v2/operation-compliance/detail", h.handleOperationComplianceDetail)
|
||||
h.mux.HandleFunc("GET /api/v2/customer-mileage", h.handleCustomerMileage)
|
||||
h.mux.HandleFunc("GET /api/v2/customer-mileage/rules", h.handleCustomerMileageRules)
|
||||
h.mux.HandleFunc("PUT /api/v2/customer-mileage/rules", h.handleCustomerMileageRules)
|
||||
h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles)
|
||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/vehicle-options", h.handleMileageVehicleOptions)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/vehicles", h.handleMileageVehicles)
|
||||
h.mux.HandleFunc("POST /api/v2/statistics/vehicle-resolve", h.handleMileageVehicleResolve)
|
||||
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)
|
||||
@@ -761,7 +770,13 @@ func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "VEHICLE_KEY_REQUIRED", "车辆关键词不能为空", "", traceID(r))
|
||||
return
|
||||
}
|
||||
data, err := h.service.VehicleDetail(r.Context(), keyword, r.URL.Query().Get("protocol"))
|
||||
var data VehicleDetail
|
||||
var err error
|
||||
if r.URL.Query().Get("includeHistory") == "0" {
|
||||
data, err = h.service.VehicleLiveDetail(r.Context(), keyword, r.URL.Query().Get("protocol"))
|
||||
} else {
|
||||
data, err = h.service.VehicleDetail(r.Context(), keyword, r.URL.Query().Get("protocol"))
|
||||
}
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
@@ -955,7 +970,7 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er
|
||||
if clientErr, ok := asClientError(err); ok {
|
||||
status := http.StatusBadRequest
|
||||
switch {
|
||||
case clientErr.Code == "VEHICLE_PERMISSION_DENIED":
|
||||
case clientErr.Code == "VEHICLE_PERMISSION_DENIED", clientErr.Code == "MENU_PERMISSION_DENIED", clientErr.Code == "PERMISSION_DENIED":
|
||||
status = http.StatusForbidden
|
||||
case strings.HasPrefix(clientErr.Code, "HISTORY_SCOPE_"), clientErr.Code == "HISTORY_BEFORE_AUTHORIZATION":
|
||||
status = http.StatusForbidden
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestHistoryLocationsPreservesTDengineFailures(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
skipCount bool
|
||||
message string
|
||||
}{
|
||||
{name: "count connection failure", message: "Fail to get table info, error: Unable to establish connection"},
|
||||
{name: "query connection failure", skipCount: true, message: "Fail to get table info, error: Unable to establish connection"},
|
||||
{name: "metadata failure", message: "Fail to get table info"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tdengine, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tdengine.Close()
|
||||
query := url.Values{"vin": {"VIN_TEST"}, "protocol": {"GB32960"}}
|
||||
statement := "SELECT COUNT"
|
||||
if test.skipCount {
|
||||
query.Set("skipCount", "1")
|
||||
statement = `SELECT CAST\(ts`
|
||||
}
|
||||
failure := errors.New(test.message)
|
||||
mock.ExpectQuery(statement).WillReturnError(failure)
|
||||
store := &ProductionStore{tdengine: tdengine, tdDatabase: "vehicle_test"}
|
||||
_, err = store.HistoryLocationsFromTDengine(context.Background(), query)
|
||||
if !errors.Is(err, failure) {
|
||||
t.Fatalf("database failure must reach the caller instead of becoming an empty track: got %v, want %v", err, failure)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryLocationsReturnsEmptyPageForMissingSubtable(t *testing.T) {
|
||||
for _, skipCount := range []bool{false, true} {
|
||||
name := "count"
|
||||
if skipCount {
|
||||
name = "query"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tdengine, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tdengine.Close()
|
||||
query := url.Values{"vin": {"VIN_WITHOUT_SUBTABLE"}, "protocol": {"GB32960"}, "limit": {"20"}, "offset": {"40"}}
|
||||
statement := "SELECT COUNT"
|
||||
if skipCount {
|
||||
query.Set("skipCount", "1")
|
||||
statement = `SELECT CAST\(ts`
|
||||
}
|
||||
mock.ExpectQuery(statement).WillReturnError(errors.New("[0x2603] Fail to get table info, error: Table does not exist"))
|
||||
store := &ProductionStore{tdengine: tdengine, tdDatabase: "vehicle_test"}
|
||||
page, err := store.HistoryLocationsFromTDengine(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Items == nil || len(page.Items) != 0 || page.Total != 0 || page.Limit != 20 || page.Offset != 40 {
|
||||
t.Fatalf("missing subtable should preserve empty page and pagination: %+v", page)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Mileage pickers expose only identifiers and export labels. Their service calls
|
||||
// retain the caller's existing VIN scope; statistics access grants no telemetry.
|
||||
type mileageVehicleOption struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
BrandName string `json:"brandName,omitempty"`
|
||||
ModelName string `json:"modelName,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageVehicleOptions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleCoverage(r.Context(), r.URL.Query())
|
||||
result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset}
|
||||
for _, row := range data.Items {
|
||||
result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate, BrandName: row.BrandName, ModelName: row.ModelName})
|
||||
}
|
||||
h.write(w, r, result, err)
|
||||
}
|
||||
func (h *Handler) handleMileageVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.Vehicles(r.Context(), r.URL.Query())
|
||||
result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset}
|
||||
for _, row := range data.Items {
|
||||
result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate})
|
||||
}
|
||||
h.write(w, r, result, err)
|
||||
}
|
||||
func (h *Handler) handleMileageVehicleResolve(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var query VehicleOverviewBatchQuery
|
||||
if err := json.NewDecoder(r.Body).Decode(&query); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r))
|
||||
return
|
||||
}
|
||||
data, err := h.service.VehicleServiceOverviews(r.Context(), query)
|
||||
result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset}
|
||||
for _, row := range data.Items {
|
||||
result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate})
|
||||
}
|
||||
h.write(w, r, result, err)
|
||||
}
|
||||
@@ -180,6 +180,15 @@ 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.AllowedVINs != nil {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
allowed = allowed || item.VIN == vin
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if query.Scope == "archived" && item.ArchivedAt == "" {
|
||||
continue
|
||||
}
|
||||
@@ -1089,13 +1098,13 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
batch := strings.TrimSpace(query.Get("keywords")) != ""
|
||||
if batch {
|
||||
for _, keyword := range keywords {
|
||||
if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) {
|
||||
if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) || strings.EqualFold(vehicleNumber(vin), keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
haystack := strings.ToLower(vin + plate + phone + oem)
|
||||
haystack := strings.ToLower(vin + " " + plate + " " + phone + " " + oem + " " + vehicleNumber(vin))
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(haystack, strings.ToLower(keyword)) {
|
||||
return true
|
||||
@@ -1187,6 +1196,7 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items := make([]VehicleRealtimeRow, 0, len(byVIN))
|
||||
for _, row := range byVIN {
|
||||
row.VehicleNumber = vehicleNumber(row.VIN)
|
||||
sort.Strings(row.Protocols)
|
||||
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
||||
for _, location := range rows {
|
||||
@@ -1216,6 +1226,9 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items = append(items, *row)
|
||||
}
|
||||
if sortMonitorEnergyRows(items, query) {
|
||||
return page(items, query), nil
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -13,20 +13,21 @@ type Page[T any] struct {
|
||||
}
|
||||
|
||||
type MonitorSummary struct {
|
||||
TotalVehicles int `json:"totalVehicles"`
|
||||
LocationVehicles int `json:"locationVehicles"`
|
||||
NoLocationVehicles int `json:"noLocationVehicles"`
|
||||
OnlineVehicles int `json:"onlineVehicles"`
|
||||
OfflineVehicles int `json:"offlineVehicles"`
|
||||
DrivingVehicles int `json:"drivingVehicles"`
|
||||
IdleVehicles int `json:"idleVehicles"`
|
||||
AlertVehicles int `json:"alertVehicles"`
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
ActiveToday int `json:"activeToday"`
|
||||
FrameToday int `json:"frameToday"`
|
||||
AlertDataAvailable bool `json:"alertDataAvailable"`
|
||||
Truncated bool `json:"truncated"`
|
||||
AsOf string `json:"asOf"`
|
||||
TotalVehicles int `json:"totalVehicles"`
|
||||
LocationVehicles int `json:"locationVehicles"`
|
||||
NoLocationVehicles int `json:"noLocationVehicles"`
|
||||
OnlineVehicles int `json:"onlineVehicles"`
|
||||
OfflineVehicles int `json:"offlineVehicles"`
|
||||
DrivingVehicles int `json:"drivingVehicles"`
|
||||
IdleVehicles int `json:"idleVehicles"`
|
||||
AlertVehicles int `json:"alertVehicles"`
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
ActiveToday int `json:"activeToday,omitempty"`
|
||||
FrameToday int `json:"frameToday,omitempty"`
|
||||
ActivityDataAvailable bool `json:"activityDataAvailable"`
|
||||
AlertDataAvailable bool `json:"alertDataAvailable"`
|
||||
Truncated bool `json:"truncated"`
|
||||
AsOf string `json:"asOf"`
|
||||
}
|
||||
|
||||
type MonitorMapResponse struct {
|
||||
@@ -834,15 +835,16 @@ type AccessThresholdUpdate struct {
|
||||
}
|
||||
|
||||
type AlertQuery struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
RuleID string `json:"ruleId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
AllowedVINs []string `json:"-"`
|
||||
Keyword string `json:"keyword"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
RuleID string `json:"ruleId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type AlertSummary struct {
|
||||
@@ -1145,16 +1147,17 @@ type AlertNotificationRetryResult struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
AllowedVINs []string `json:"-"`
|
||||
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 {
|
||||
@@ -1410,6 +1413,7 @@ type VehicleIdentityResolution struct {
|
||||
}
|
||||
|
||||
type VehicleDetail struct {
|
||||
HistoryIncluded bool `json:"historyIncluded"`
|
||||
VIN string `json:"vin"`
|
||||
LookupKey string `json:"lookupKey"`
|
||||
LookupResolved bool `json:"lookupResolved"`
|
||||
@@ -1780,34 +1784,39 @@ type RealtimeLocationRow struct {
|
||||
}
|
||||
|
||||
type VehicleRealtimeRow struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Phone string `json:"phone"`
|
||||
OEM string `json:"oem"`
|
||||
Protocols []string `json:"protocols"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
SourceCount int `json:"sourceCount"`
|
||||
OnlineSourceCount int `json:"onlineSourceCount"`
|
||||
Online bool `json:"online"`
|
||||
BindingStatus string `json:"bindingStatus"`
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
PrimaryProtocol string `json:"primaryProtocol"`
|
||||
LocationSource string `json:"locationSource"`
|
||||
LocationConflict bool `json:"locationConflict"`
|
||||
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
|
||||
LocationAvailable bool `json:"locationAvailable"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedAvailable bool `json:"speedAvailable"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
SOCAvailable bool `json:"socAvailable"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
MileageAvailable bool `json:"mileageAvailable"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
TodayMileageAvailable bool `json:"todayMileageAvailable"`
|
||||
TodayMileageKm float64 `json:"todayMileageKm"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
|
||||
VehicleNumber string `json:"vehicleNumber,omitempty"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
Phone string `json:"phone"`
|
||||
OEM string `json:"oem"`
|
||||
Protocols []string `json:"protocols"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
SourceCount int `json:"sourceCount"`
|
||||
OnlineSourceCount int `json:"onlineSourceCount"`
|
||||
Online bool `json:"online"`
|
||||
BindingStatus string `json:"bindingStatus"`
|
||||
ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"`
|
||||
PrimaryProtocol string `json:"primaryProtocol"`
|
||||
LocationSource string `json:"locationSource"`
|
||||
LocationConflict bool `json:"locationConflict"`
|
||||
ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"`
|
||||
LocationAvailable bool `json:"locationAvailable"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedAvailable bool `json:"speedAvailable"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
SOCAvailable bool `json:"socAvailable"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
HydrogenPressureMPa *float64 `json:"hydrogenPressureMpa,omitempty"`
|
||||
HydrogenMassSource string `json:"hydrogenMassSource,omitempty"`
|
||||
HydrogenMassKg *float64 `json:"hydrogenMassKg,omitempty"`
|
||||
HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"`
|
||||
MileageAvailable bool `json:"mileageAvailable"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
TodayMileageAvailable bool `json:"todayMileageAvailable"`
|
||||
TodayMileageKm float64 `json:"todayMileageKm"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
|
||||
}
|
||||
|
||||
type HistoryLocationRow struct {
|
||||
|
||||
@@ -2,8 +2,11 @@ package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type monitorActivityTestStore struct {
|
||||
@@ -31,3 +34,40 @@ func TestMonitorWorkspaceUsesOnlyRequiredActivityCounters(t *testing.T) {
|
||||
t.Fatal("lost fleet data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedMonitorDoesNotReadOrExposeGlobalActivity(t *testing.T) {
|
||||
store := &monitorActivityTestStore{MockStore: NewMockStore()}
|
||||
service := NewService(store)
|
||||
for _, vin := range []string{"LB9A32A24R0LS1426", "LB9A32A24R0LS1427"} {
|
||||
ctx := WithPrincipal(context.Background(), Principal{UserType: "customer", AuthProvider: "oneos", VehicleVINs: []string{vin}})
|
||||
summary, err := service.buildMonitorSummary(ctx, url.Values{}, Page[VehicleRealtimeRow]{Total: 1, Items: []VehicleRealtimeRow{{VIN: vin, Online: true, LastSeen: "2026-09-23T00:00:00Z"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.TotalVehicles != 1 || summary.ActivityDataAvailable || summary.ActiveToday != 0 || summary.FrameToday != 0 {
|
||||
t.Fatalf("scope leaked global metrics: %+v", summary)
|
||||
}
|
||||
raw, _ := json.Marshal(summary)
|
||||
if strings.Contains(string(raw), "frameToday") || strings.Contains(string(raw), "activeToday") {
|
||||
t.Fatal("unavailable metrics serialized as values", string(raw))
|
||||
}
|
||||
}
|
||||
if store.activityCalls != 0 {
|
||||
t.Fatal("read fleet-wide counters for scoped users")
|
||||
}
|
||||
}
|
||||
func TestOneOSGrantExpirationRecheckedBeforeHistoryAndVehicleQuery(t *testing.T) {
|
||||
expired := time.Now().Add(-time.Second)
|
||||
p := Principal{UserType: "customer", AuthProvider: "oneos", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: expired.Add(-time.Hour), ValidTo: &expired}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
if _, err := applyPrincipalHistoryTimeScope(ctx, "LB9A32A24R0LS1426", url.Values{}); err == nil {
|
||||
t.Fatal("expired grant can read history")
|
||||
}
|
||||
scoped, err := applyPrincipalVehicleScope(ctx, url.Values{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scoped.Get("scopeVins") != "__NO_VEHICLE_SCOPE__" {
|
||||
t.Fatal("expired VIN remained in query")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/hydrogen"
|
||||
)
|
||||
|
||||
const monitorEnergyNumberPattern = `^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$`
|
||||
|
||||
var monitorEnergyNumericString = regexp.MustCompile(monitorEnergyNumberPattern)
|
||||
|
||||
// Read only the current authorized page, in bounded batches, without querying
|
||||
// historical storage or issuing a telemetry request for each vehicle.
|
||||
func (s *ProductionStore) populateMonitorEnergy(ctx context.Context, items []VehicleRealtimeRow) error {
|
||||
byVIN := make(map[string]*VehicleRealtimeRow, len(items))
|
||||
vins := make([]string, 0, len(items))
|
||||
for i := range items {
|
||||
if !containsString(items[i].Protocols, "GB32960") {
|
||||
continue
|
||||
}
|
||||
byVIN[items[i].VIN] = &items[i]
|
||||
vins = append(vins, items[i].VIN)
|
||||
}
|
||||
for start := 0; start < len(vins); start += 200 {
|
||||
batch := vins[start:min(start+200, len(vins))]
|
||||
args := make([]any, len(batch))
|
||||
for i, vin := range batch {
|
||||
args[i] = vin
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT s.vin, COALESCE(s.parsed_json, ''), COALESCE(c.tank_capacity_l, 0) FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_hydrogen_tank_capacity c ON BINARY c.vin = BINARY s.vin AND c.active = 1 WHERE s.protocol = 'GB32960' AND s.vin IN (`+strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")+`)`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin, payload string
|
||||
var capacity float64
|
||||
if err := rows.Scan(&vin, &payload, &capacity); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if row := byVIN[vin]; row != nil {
|
||||
applyMonitorEnergyWithCapacity(row, parsedFieldsFromString(payload), capacity)
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
closeErr := rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMonitorEnergy(row *VehicleRealtimeRow, fields map[string]any) {
|
||||
// The pressure is the reported maximum hydrogen pressure probe reading.
|
||||
// Mass always comes from pressure, temperature and configured volume, never reported kg.
|
||||
row.HydrogenPressureMPa = monitorEnergyNumber(fields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa", "fuel_cell_max_hydrogen_pressure_mpa")
|
||||
row.HydrogenMassKg = nil
|
||||
row.HydrogenMassSource = ""
|
||||
row.HydrogenConsumptionKgPer100Km = monitorEnergyNumber(fields, "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km", "fuel_cell_hydrogen_consumption_kg_per_100km")
|
||||
}
|
||||
|
||||
func monitorEnergyNumber(fields map[string]any, keys ...string) *float64 {
|
||||
for _, key := range keys {
|
||||
var value float64
|
||||
switch raw := fields[key].(type) {
|
||||
case float64:
|
||||
value = raw
|
||||
case string:
|
||||
normalized := strings.TrimSpace(raw)
|
||||
if !monitorEnergyNumericString.MatchString(normalized) {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(normalized, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
value = parsed
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 {
|
||||
return &value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMonitorEnergyWithCapacity(row *VehicleRealtimeRow, fields map[string]any, capacity float64) {
|
||||
applyMonitorEnergy(row, fields)
|
||||
var temperature *float64
|
||||
for _, key := range []string{"gb32960.fuel_cell.max_hydrogen_temperature_c", "fuel_cell_max_hydrogen_temperature_c"} {
|
||||
// Temperature can legitimately be below zero.
|
||||
raw, exists := fields[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
temperature = &v
|
||||
case string:
|
||||
if monitorEnergyNumericString.MatchString(strings.TrimSpace(v)) {
|
||||
if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
temperature = &n
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if row.HydrogenPressureMPa == nil || temperature == nil || *temperature < -40 {
|
||||
return
|
||||
}
|
||||
if mass, ok := hydrogen.PressureMassKg(*row.HydrogenPressureMPa, *temperature, capacity); ok {
|
||||
row.HydrogenMassKg = &mass
|
||||
row.HydrogenMassSource = "ESTIMATED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"math"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/hydrogen"
|
||||
)
|
||||
|
||||
func TestVehicleRealtimeLoadsEnergyOnlyWhenRequested(t *testing.T) {
|
||||
for _, mode := range []string{"map", "list", "pressure"} {
|
||||
includeEnergy := mode != "map"
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
query := url.Values{"limit": {"20"}}
|
||||
if includeEnergy {
|
||||
query.Set("includeEnergy", "1")
|
||||
}
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\)`).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
|
||||
if mode == "pressure" {
|
||||
query.Set("sort", "hydrogenPressureMpa")
|
||||
}
|
||||
columns := []string{
|
||||
"vin", "plate", "phone", "oem", "protocols", "online_protocols", "source_count", "online_source_count", "online", "binding_status",
|
||||
"primary_protocol", "location_source", "location_conflict", "conflict_distance_m", "location_available", "longitude", "latitude", "speed_available", "speed_kmh",
|
||||
"soc_available", "soc_percent", "mileage_available", "total_mileage_km", "today_mileage_available", "today_mileage_km", "last_seen", "report_interval_ms",
|
||||
}
|
||||
values := []driver.Value{"VIN-1", "测试车", "", "", "GB32960", "GB32960", 1, 1, 1, "bound", "GB32960", "GB32960:canonical", 0, nil, 1, "113", "23", 1, "42", 1, "65.4", 1, "12345", 1, "128.6", "2026-10-26 15:00:00", 10000}
|
||||
selectPattern := `SELECT v.vin,`
|
||||
if mode == "pressure" {
|
||||
columns = append(columns, "parsed_json", "tank_capacity_l")
|
||||
values = append(values, `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 520)
|
||||
selectPattern = `SELECT r.\*,`
|
||||
}
|
||||
mock.ExpectQuery(selectPattern).WillReturnRows(sqlmock.NewRows(columns).AddRow(values...))
|
||||
if includeEnergy && mode != "pressure" {
|
||||
mock.ExpectQuery(`SELECT s.vin,`).WithArgs("VIN-1").WillReturnRows(sqlmock.NewRows([]string{"vin", "parsed_json", "tank_capacity_l"}).AddRow("VIN-1", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 520))
|
||||
}
|
||||
result, err := (&ProductionStore{db: db}).VehicleRealtime(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].SOCPercent != 65.4 || (result.Items[0].HydrogenMassKg != nil) != includeEnergy {
|
||||
t.Fatalf("unexpected realtime energy result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergyReadsOnlyGBVehiclesOnCurrentPage(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
items := []VehicleRealtimeRow{
|
||||
{VIN: "VIN-1", Protocols: []string{"GB32960", "JT808"}},
|
||||
{VIN: "VIN-2", Protocols: []string{"JT808"}},
|
||||
{VIN: "VIN-3", Protocols: []string{"GB32960"}},
|
||||
}
|
||||
mock.ExpectQuery(`SELECT s.vin,.*WHERE s.protocol = 'GB32960' AND s.vin IN \(\?,\?\)`).
|
||||
WithArgs("VIN-1", "VIN-3").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "parsed_json", "tank_capacity_l"}).
|
||||
AddRow("VIN-1", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":32.5,"gb32960.fuel_cell.max_hydrogen_temperature_c":15,"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":8.76}`, 520).
|
||||
AddRow("VIN-3", `{"fuel_cell_max_hydrogen_pressure_mpa":0,"fuel_cell_max_hydrogen_temperature_c":15,"gd_fc_vehicle_hydrogen_mass_kg":99,"fuel_cell_hydrogen_consumption_kg_per_100km":0}`, 520))
|
||||
if err := (&ProductionStore{db: db}).populateMonitorEnergy(context.Background(), items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items[0].HydrogenPressureMPa == nil || *items[0].HydrogenPressureMPa != 32.5 || items[0].HydrogenMassKg == nil || *items[0].HydrogenMassKg <= 0 || *items[0].HydrogenMassKg >= 99 || items[0].HydrogenMassSource != "ESTIMATED" || items[0].HydrogenConsumptionKgPer100Km == nil || *items[0].HydrogenConsumptionKgPer100Km != 8.76 {
|
||||
t.Fatalf("missing reported energy values: %+v", items[0])
|
||||
}
|
||||
if items[1].HydrogenMassKg != nil || items[2].HydrogenMassKg == nil || *items[2].HydrogenMassKg != 0 || items[2].HydrogenPressureMPa == nil || *items[2].HydrogenPressureMPa != 0 || items[2].HydrogenConsumptionKgPer100Km == nil || *items[2].HydrogenConsumptionKgPer100Km != 0 {
|
||||
t.Fatal("missing data and reported zero must remain distinct")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergyDoesNotInventMissingMassOrConsumption(t *testing.T) {
|
||||
for _, payload := range []string{
|
||||
`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":32.5}`,
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":-1}`,
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"invalid"}`,
|
||||
`invalid json`,
|
||||
} {
|
||||
row := VehicleRealtimeRow{}
|
||||
applyMonitorEnergy(&row, parsedFieldsFromString(payload))
|
||||
if row.HydrogenMassKg != nil || row.HydrogenConsumptionKgPer100Km != nil {
|
||||
t.Fatalf("invalid/missing values became usable energy data: %+v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergyAcceptsNumericStringsFromDurableSnapshots(t *testing.T) {
|
||||
row := VehicleRealtimeRow{}
|
||||
applyMonitorEnergy(&row, parsedFieldsFromString(`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":" 13.6 ","gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"9.2","gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":"1.66"}`))
|
||||
if row.HydrogenPressureMPa == nil || *row.HydrogenPressureMPa != 13.6 || row.HydrogenMassKg != nil || row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 1.66 {
|
||||
t.Fatalf("numeric string telemetry was lost: %+v", row)
|
||||
}
|
||||
for _, invalid := range []any{"", "invalid", "NaN", "Infinity", "0x1p2", "12 MPa", "-1", true, nil} {
|
||||
if got := monitorEnergyNumber(map[string]any{"pressure": invalid}, "pressure"); got != nil {
|
||||
t.Fatalf("invalid value %v was accepted: %v", invalid, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergySurfacesStorageFailure(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
want := errors.New("snapshot unavailable")
|
||||
mock.ExpectQuery(`SELECT s.vin,`).WithArgs("VIN-1").WillReturnError(want)
|
||||
if err := (&ProductionStore{db: db}).populateMonitorEnergy(context.Background(), []VehicleRealtimeRow{{VIN: "VIN-1", Protocols: []string{"GB32960"}}}); !errors.Is(err, want) {
|
||||
t.Fatalf("storage error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorHydrogenMassEstimateUsesConfiguredVolumeAndTemperature(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, payload string
|
||||
capacity float64
|
||||
estimated bool
|
||||
}{
|
||||
{"feichi numeric strings", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"35","gb32960.fuel_cell.max_hydrogen_temperature_c":"15"}`, 520, true},
|
||||
{"cold tank", `{"fuel_cell_max_hydrogen_pressure_mpa":"20","fuel_cell_max_hydrogen_temperature_c":"-20"}`, 520, true},
|
||||
{"empty tank", `{"fuel_cell_max_hydrogen_pressure_mpa":0,"fuel_cell_max_hydrogen_temperature_c":15}`, 520, true},
|
||||
{"missing capacity", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 0, false},
|
||||
{"missing temperature", `{"fuel_cell_max_hydrogen_pressure_mpa":35}`, 520, false},
|
||||
{"bad pressure", `{"fuel_cell_max_hydrogen_pressure_mpa":80,"fuel_cell_max_hydrogen_temperature_c":15}`, 520, false},
|
||||
{"bad temperature", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":"NaN"}`, 520, false},
|
||||
{"reported invalid mass does not affect calculation", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15,"gd_fc_vehicle_hydrogen_mass_kg":-1}`, 520, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
row := VehicleRealtimeRow{}
|
||||
fields := parsedFieldsFromString(tc.payload)
|
||||
applyMonitorEnergyWithCapacity(&row, fields, tc.capacity)
|
||||
if (row.HydrogenMassKg != nil) != tc.estimated {
|
||||
t.Fatalf("unexpected mass: %+v", row)
|
||||
}
|
||||
if tc.estimated {
|
||||
if row.HydrogenMassSource != "ESTIMATED" {
|
||||
t.Fatal("estimate must be identified")
|
||||
}
|
||||
twice := VehicleRealtimeRow{}
|
||||
applyMonitorEnergyWithCapacity(&twice, fields, tc.capacity*2)
|
||||
if twice.HydrogenMassKg == nil || math.Abs(*twice.HydrogenMassKg-2**row.HydrogenMassKg) > 1e-8 {
|
||||
t.Fatal("mass must scale with configured volume")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorMassAlwaysCalculatedRegardlessOfReportedMass(t *testing.T) {
|
||||
for _, key := range []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"} {
|
||||
for _, reported := range []any{0.0, 99.0, -1.0, "invalid", "12.3", nil} {
|
||||
fields := map[string]any{"fuel_cell_max_hydrogen_pressure_mpa": 35.0, "fuel_cell_max_hydrogen_temperature_c": 15.0, key: reported}
|
||||
row := VehicleRealtimeRow{}
|
||||
applyMonitorEnergyWithCapacity(&row, fields, 520)
|
||||
want, _ := hydrogen.PressureMassKg(35, 15, 520)
|
||||
if row.HydrogenMassKg == nil || math.Abs(*row.HydrogenMassKg-want) > 1e-10 || row.HydrogenMassSource != "ESTIMATED" {
|
||||
t.Fatalf("reported value %v affected calculation: %+v", reported, row)
|
||||
}
|
||||
// A subsequent incomplete snapshot must clear any previous or reported kg.
|
||||
applyMonitorEnergyWithCapacity(&row, fields, 0)
|
||||
if row.HydrogenMassKg != nil || row.HydrogenMassSource != "" {
|
||||
t.Fatal("missing capacity must not fall back to reported or previous mass")
|
||||
}
|
||||
delete(fields, "fuel_cell_max_hydrogen_temperature_c")
|
||||
applyMonitorEnergyWithCapacity(&row, fields, 520)
|
||||
if row.HydrogenMassKg != nil {
|
||||
t.Fatal("missing temperature must not fall back to reported mass")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sort the complete authorized and filtered result before applying pagination.
|
||||
// The outer projection keeps the existing realtime response columns unchanged.
|
||||
func monitorEnergySortSQL(selectSQL string, query url.Values) (string, bool) {
|
||||
join, value := "", ""
|
||||
switch query.Get("sort") {
|
||||
case "socPercent":
|
||||
soc := `CAST(NULLIF(r.soc_percent, '') AS DECIMAL(18,6))`
|
||||
value = `CASE WHEN r.soc_available = 1 AND ` + soc + ` BETWEEN 0 AND 100 THEN ` + soc + ` END`
|
||||
case "lastSeen":
|
||||
// last_seen is projected as fixed-width YYYY-MM-DD HH:mm:ss in one timezone.
|
||||
// Its lexical order is chronological; avoid MySQL's CAST(NULLIF(...)) datetime coercion.
|
||||
value = `NULLIF(r.last_seen, '')`
|
||||
case "speedKmh", "todayMileageKm", "totalMileageKm":
|
||||
column, available := "today_mileage_km", "today_mileage_available"
|
||||
if query.Get("sort") == "speedKmh" {
|
||||
column, available = "speed_kmh", "speed_available"
|
||||
} else if query.Get("sort") == "totalMileageKm" {
|
||||
column, available = "total_mileage_km", "mileage_available"
|
||||
}
|
||||
number := `CAST(NULLIF(r.` + column + `, '') AS DECIMAL(18,6))`
|
||||
value = `CASE WHEN r.` + available + ` = 1 AND ` + number + ` >= 0 THEN ` + number + ` END`
|
||||
case "hydrogenPressureMpa":
|
||||
join = ` LEFT JOIN vehicle_realtime_snapshot energy_s ON energy_s.vin = r.vin AND energy_s.protocol = 'GB32960' AND FIND_IN_SET('GB32960', r.protocols) > 0`
|
||||
value = monitorPressureSortSQL()
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
projection := `r.*`
|
||||
if query.Get("sort") == "hydrogenPressureMpa" && query.Get("includeEnergy") == "1" {
|
||||
// Return the exact snapshot used by ORDER BY, avoiding a second read after live updates.
|
||||
projection += `, COALESCE(energy_s.parsed_json, ''), COALESCE(energy_c.tank_capacity_l, 0)`
|
||||
join += ` LEFT JOIN vehicle_hydrogen_tank_capacity energy_c ON BINARY energy_c.vin = BINARY r.vin AND energy_c.active = 1`
|
||||
}
|
||||
direction := "ASC"
|
||||
if strings.EqualFold(query.Get("sortOrder"), "desc") {
|
||||
direction = "DESC"
|
||||
}
|
||||
return `SELECT ` + projection + ` FROM (` + selectSQL + `) r` + join + ` ORDER BY (` + value + `) IS NULL ASC, ` + value + ` ` + direction + `, r.vin ASC LIMIT ? OFFSET ?`, true
|
||||
}
|
||||
|
||||
func monitorPressureSortSQL() string {
|
||||
// Match applyMonitorEnergy: only numeric, nonnegative reported pressure is
|
||||
// usable; a missing/invalid canonical field falls back to the legacy field.
|
||||
document := `CASE WHEN JSON_VALID(energy_s.parsed_json) THEN energy_s.parsed_json ELSE '{}' END`
|
||||
validNumber := func(key string) string {
|
||||
field := `JSON_EXTRACT(` + document + `, '$."` + key + `"')`
|
||||
number := `CAST(JSON_UNQUOTE(` + field + `) AS DECIMAL(18,6))`
|
||||
numeric := `(JSON_TYPE(` + field + `) IN ('INTEGER', 'DOUBLE') OR (JSON_TYPE(` + field + `) = 'STRING' AND TRIM(JSON_UNQUOTE(` + field + `)) REGEXP '` + monitorEnergyNumberPattern + `'))`
|
||||
return `CASE WHEN ` + numeric + ` AND ` + number + ` >= 0 THEN ` + number + ` END`
|
||||
}
|
||||
return `COALESCE(` + validNumber("gb32960.fuel_cell.max_hydrogen_pressure_mpa") + `, ` + validNumber("fuel_cell_max_hydrogen_pressure_mpa") + `)`
|
||||
}
|
||||
|
||||
func sortMonitorEnergyRows(items []VehicleRealtimeRow, query url.Values) bool {
|
||||
key := query.Get("sort")
|
||||
if key != "speedKmh" && key != "lastSeen" && key != "socPercent" && key != "hydrogenPressureMpa" && key != "todayMileageKm" && key != "totalMileageKm" {
|
||||
return false
|
||||
}
|
||||
value := func(row VehicleRealtimeRow) *float64 {
|
||||
var result *float64
|
||||
if key == "lastSeen" {
|
||||
timestamp, err := time.Parse(time.RFC3339, normalizeTDengineTime(row.LastSeen))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seconds := float64(timestamp.UnixNano()) / 1e9
|
||||
return &seconds
|
||||
} else if key == "speedKmh" {
|
||||
if row.SpeedAvailable {
|
||||
result = &row.SpeedKmh
|
||||
}
|
||||
} else if key == "socPercent" {
|
||||
if row.SOCAvailable && row.SOCPercent <= 100 {
|
||||
result = &row.SOCPercent
|
||||
}
|
||||
} else if key == "todayMileageKm" {
|
||||
if row.TodayMileageAvailable {
|
||||
result = &row.TodayMileageKm
|
||||
}
|
||||
} else if key == "totalMileageKm" {
|
||||
if row.MileageAvailable {
|
||||
result = &row.TotalMileageKm
|
||||
}
|
||||
} else {
|
||||
result = row.HydrogenPressureMPa
|
||||
}
|
||||
if result == nil || math.IsNaN(*result) || math.IsInf(*result, 0) || *result < 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
descending := strings.EqualFold(query.Get("sortOrder"), "desc")
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left, right := value(items[i]), value(items[j])
|
||||
if left == nil || right == nil {
|
||||
if (left == nil) != (right == nil) {
|
||||
return left != nil
|
||||
}
|
||||
} else if *left != *right {
|
||||
if descending {
|
||||
return *left > *right
|
||||
}
|
||||
return *left < *right
|
||||
}
|
||||
return items[i].VIN < items[j].VIN
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonitorEnergySortSQLPreservesScopeAndPaginatesAfterSorting(t *testing.T) {
|
||||
for _, key := range []string{"speedKmh", "lastSeen", "socPercent", "hydrogenPressureMpa", "todayMileageKm", "totalMileageKm"} {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
query := url.Values{"sort": {key}, "sortOrder": {order}, "scopeVins": {"VIN-1,VIN-2"}, "protocol": {"GB32960"}, "limit": {"1"}, "offset": {"1"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
if !strings.HasPrefix(built.Text, "SELECT r.* FROM (SELECT v.vin,") || strings.Count(built.Text, "LIMIT ? OFFSET ?") != 1 || !strings.HasSuffix(built.Text, strings.ToUpper(order)+", r.vin ASC LIMIT ? OFFSET ?") || !strings.Contains(built.Text, ") IS NULL ASC,") {
|
||||
t.Fatalf("sort is not stable, null-last, and before pagination: %s", built.Text)
|
||||
}
|
||||
if !strings.Contains(built.Text, "v.vin IN (?,?)") || !strings.Contains(built.CountText, "v.vin IN (?,?)") || !strings.Contains(built.Text, "l.protocol = ?") || !strings.Contains(built.CountText, "l.protocol = ?") {
|
||||
t.Fatal("sorting changed the authorized filter scope")
|
||||
}
|
||||
if !reflect.DeepEqual(built.Args, []any{"VIN-1", "VIN-2", "GB32960", 1, 1}) || !reflect.DeepEqual(built.CountArgs, []any{"VIN-1", "VIN-2", "GB32960"}) {
|
||||
t.Fatalf("sort changed filter/pagination arguments: %#v / %#v", built.Args, built.CountArgs)
|
||||
}
|
||||
if key == "hydrogenPressureMpa" {
|
||||
for _, required := range []string{"JSON_VALID", "JSON_TYPE", "'INTEGER', 'DOUBLE'", "gb32960.fuel_cell.max_hydrogen_pressure_mpa", "fuel_cell_max_hydrogen_pressure_mpa", "FIND_IN_SET('GB32960', r.protocols)"} {
|
||||
if !strings.Contains(built.Text, required) {
|
||||
t.Fatalf("pressure sort missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "hydrogen_mass_kg") || strings.Contains(built.CountText, "JSON_EXTRACT") {
|
||||
t.Fatal("pressure ordering must not use kg or change the count query")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergySortRejectsUnlistedOrderExpressions(t *testing.T) {
|
||||
query := url.Values{"sort": {"socPercent"}, "sortOrder": {"DESC; DROP TABLE vehicle_identity_binding"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
if strings.Contains(built.Text, "DROP") || !strings.HasSuffix(built.Text, "ASC, r.vin ASC LIMIT ? OFFSET ?") {
|
||||
t.Fatal("untrusted sort direction reached SQL")
|
||||
}
|
||||
query.Set("sort", "hydrogen_mass_kg DESC")
|
||||
if strings.HasPrefix(buildVehicleRealtimeSQL(query).Text, "SELECT r.*") {
|
||||
t.Fatal("unlisted sort field was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorEnergySortOrdersNumericValuesWithNullsLast(t *testing.T) {
|
||||
for _, key := range []string{"speedKmh", "socPercent", "hydrogenPressureMpa", "todayMileageKm", "totalMileageKm"} {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
items := []VehicleRealtimeRow{
|
||||
{VIN: "missing"},
|
||||
{VIN: "ten-b", SpeedAvailable: true, SpeedKmh: 10, MileageAvailable: true, TotalMileageKm: 10, TodayMileageAvailable: true, TodayMileageKm: 10, SOCAvailable: true, SOCPercent: 10, HydrogenPressureMPa: energySortTestPointer(10), HydrogenMassKg: energySortTestPointer(1)},
|
||||
{VIN: "two", SpeedAvailable: true, SpeedKmh: 2, MileageAvailable: true, TotalMileageKm: 2, TodayMileageAvailable: true, TodayMileageKm: 2, SOCAvailable: true, SOCPercent: 2, HydrogenPressureMPa: energySortTestPointer(2), HydrogenMassKg: energySortTestPointer(100)},
|
||||
{VIN: "zero", SpeedAvailable: true, SpeedKmh: 0, MileageAvailable: true, TotalMileageKm: 0, TodayMileageAvailable: true, TodayMileageKm: 0, SOCAvailable: true, SOCPercent: 0, HydrogenPressureMPa: energySortTestPointer(0)},
|
||||
{VIN: "ten-a", SpeedAvailable: true, SpeedKmh: 10, MileageAvailable: true, TotalMileageKm: 10, TodayMileageAvailable: true, TodayMileageKm: 10, SOCAvailable: true, SOCPercent: 10, HydrogenPressureMPa: energySortTestPointer(10)},
|
||||
}
|
||||
query := url.Values{"sort": {key}, "sortOrder": {order}, "limit": {"2"}, "offset": {"1"}}
|
||||
if !sortMonitorEnergyRows(items, query) {
|
||||
t.Fatal("energy sort not accepted")
|
||||
}
|
||||
got := make([]string, len(items))
|
||||
for i := range items {
|
||||
got[i] = items[i].VIN
|
||||
}
|
||||
want := []string{"zero", "two", "ten-a", "ten-b", "missing"}
|
||||
wantPage := []string{"two", "ten-a"}
|
||||
if order == "desc" {
|
||||
want = []string{"ten-a", "ten-b", "two", "zero", "missing"}
|
||||
wantPage = []string{"ten-b", "two"}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s %s: got %v want %v", key, order, got, want)
|
||||
}
|
||||
result := page(items, query)
|
||||
if result.Items[0].VIN != wantPage[0] || result.Items[1].VIN != wantPage[1] {
|
||||
t.Fatal("sorting must happen before slicing a page")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func energySortTestPointer(value float64) *float64 { return &value }
|
||||
|
||||
func TestMonitorPressureSortReturnsSameSnapshotItOrders(t *testing.T) {
|
||||
q := buildVehicleRealtimeSQL(url.Values{"sort": {"hydrogenPressureMpa"}, "includeEnergy": {"1"}})
|
||||
if !strings.HasPrefix(q.Text, "SELECT r.*, COALESCE(energy_s.parsed_json, ''), COALESCE(energy_c.tank_capacity_l, 0) FROM") {
|
||||
t.Fatal("pressure sorting and displayed snapshot must be atomic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorReportTimeSortChronologicalWithMissingLast(t *testing.T) {
|
||||
for _, order := range []string{"asc", "desc"} {
|
||||
items := []VehicleRealtimeRow{
|
||||
{VIN: "missing"},
|
||||
{VIN: "invalid", LastSeen: "bad-date"},
|
||||
{VIN: "new-b", LastSeen: "2026-10-26 00:00:00"},
|
||||
{VIN: "old", LastSeen: "2026-09-30 23:59:59"},
|
||||
{VIN: "new-a", LastSeen: "2026-10-25T16:00:00Z"},
|
||||
}
|
||||
query := url.Values{"sort": {"lastSeen"}, "sortOrder": {order}, "limit": {"2"}, "offset": {"1"}}
|
||||
if !sortMonitorEnergyRows(items, query) {
|
||||
t.Fatal("report time sort rejected")
|
||||
}
|
||||
want := []string{"old", "new-a", "new-b", "invalid", "missing"}
|
||||
if order == "desc" {
|
||||
want = []string{"new-a", "new-b", "old", "invalid", "missing"}
|
||||
}
|
||||
for i, row := range items {
|
||||
if row.VIN != want[i] {
|
||||
t.Fatalf("%s: got %v want %v", order, items, want)
|
||||
}
|
||||
}
|
||||
result := page(items, query)
|
||||
if result.Items[0].VIN != want[1] || result.Items[1].VIN != want[2] {
|
||||
t.Fatal("pagination preceded sorting")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
where = append(where, "(v.vin LIKE ? OR l.plate LIKE ? OR v.plate LIKE ? OR v.phone LIKE ? OR v.oem LIKE ?)")
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
// Extend only the identity predicate; the authorized VIN scope remains ANDed.
|
||||
if vins := vehicleNumberVINs(keywords, strings.TrimSpace(query.Get("keywords")) != ""); len(vins) > 0 {
|
||||
last := len(where) - 1
|
||||
where[last] = "(" + where[last] + " OR v.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",") + "))"
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
@@ -486,35 +494,39 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC`
|
||||
}
|
||||
selectSQL := `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
||||
`COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
||||
`COUNT(DISTINCT l.protocol) AS source_count, ` +
|
||||
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
|
||||
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||
`v.binding_status, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
|
||||
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
|
||||
primaryLocationAvailable + ` AS location_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
|
||||
groupSQL
|
||||
text := selectSQL + `ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`
|
||||
if sorted, ok := monitorEnergySortSQL(selectSQL, query); ok {
|
||||
text = sorted
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
||||
`COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
||||
`COUNT(DISTINCT l.protocol) AS source_count, ` +
|
||||
`COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` +
|
||||
`CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` +
|
||||
`v.binding_status, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` +
|
||||
`SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` +
|
||||
primaryLocationAvailable + ` AS location_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` +
|
||||
`COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
|
||||
`CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` +
|
||||
groupSQL +
|
||||
`ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`,
|
||||
Text: text,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
|
||||
CountArgs: countArgs,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOneOSQueryScopeRestrictsAdministratorReadsAndWrites(t *testing.T) {
|
||||
allowed := "LB9A32A24R0LS1426"
|
||||
denied := "LNXNEGRR7SR318212"
|
||||
p := Principal{Role: "admin", UserType: "admin", AuthProvider: "oneos", VehicleScopeSource: "oneos-vehicle-query", VehicleVINs: []string{allowed}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
if !p.CanVIN(allowed) || p.CanVIN(denied) {
|
||||
t.Fatal("administrator bypassed OneOS vehicle scope")
|
||||
}
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, url.Values{"scopeVehicleFrom": {"forged"}})
|
||||
if err != nil || scopedQuery.Get("scopeVehicleFrom") != "" {
|
||||
t.Fatal("query scope must not inject fictitious date boundaries")
|
||||
}
|
||||
s := NewService(NewMockStore())
|
||||
page, err := s.VehicleRealtime(ctx, url.Values{"scopeVins": {denied}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, v := range page.Items {
|
||||
if v.VIN != allowed {
|
||||
t.Fatal("forged query widened scope")
|
||||
}
|
||||
}
|
||||
if _, err = s.SaveVehicleProfile(ctx, denied, VehicleProfileInput{}); err == nil {
|
||||
t.Fatal("cross-scope write accepted")
|
||||
}
|
||||
if _, err = s.UpdateVehicleSourcePolicy(ctx, denied, VehicleSourcePolicyUpdate{}); err == nil {
|
||||
t.Fatal("cross-scope policy write accepted")
|
||||
}
|
||||
if _, err = applyPrincipalHistoryTimeScope(ctx, denied, url.Values{}); err == nil {
|
||||
t.Fatal("cross-scope history accepted")
|
||||
}
|
||||
q, err := applyPrincipalHistoryTimeScope(ctx, allowed, url.Values{"dateFrom": {"2020-01-01T00:00:00+08:00"}})
|
||||
if err != nil || q.Get("dateFrom") != "2020-01-01T00:00:00+08:00" {
|
||||
t.Fatal("vehicle query must not invent historical grant timestamps")
|
||||
}
|
||||
local := WithPrincipal(context.Background(), Principal{Role: "customer", UserType: "customer", AuthProvider: "local", VehicleVINs: []string{allowed}})
|
||||
if _, err = applyPrincipalHistoryTimeScope(local, allowed, url.Values{}); err == nil {
|
||||
t.Fatal("local history grant rules changed")
|
||||
}
|
||||
empty := WithPrincipal(context.Background(), Principal{Role: "admin", UserType: "admin", VehicleScopeSource: "oneos-vehicle-query"})
|
||||
page, err = s.VehicleRealtime(empty, url.Values{})
|
||||
if err != nil || len(page.Items) != 0 {
|
||||
t.Fatal("empty scope exposed vehicles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleQueryScopeAlsoLimitsAlertAndReconciliationSQL(t *testing.T) {
|
||||
for _, vins := range [][]string{{"LNXNEGRR3SR321379"}, {}} {
|
||||
where, args := buildAlertWhere(AlertQuery{AllowedVINs: vins})
|
||||
if len(vins) == 0 && !strings.Contains(where, "1=0") {
|
||||
t.Fatal("empty alert scope widened")
|
||||
}
|
||||
if len(vins) > 0 && (len(args) != 1 || args[0] != vins[0] || !strings.Contains(where, "e.vin")) {
|
||||
t.Fatal("alert scope not bound")
|
||||
}
|
||||
where, args = buildReconciliationWhere(ReconciliationQuery{AllowedVINs: vins})
|
||||
if len(vins) == 0 && !strings.Contains(where, "1=0") {
|
||||
t.Fatal("empty reconciliation scope widened")
|
||||
}
|
||||
if len(vins) > 0 && (len(args) != 1 || args[0] != vins[0] || !strings.Contains(where, "i.vin")) {
|
||||
t.Fatal("reconciliation scope not bound")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const operationComplianceMenu = "operation-compliance"
|
||||
|
||||
var operationMileageDecimal = regexp.MustCompile(`^[0-9]{1,15}(\.[0-9]{1,3})?$`)
|
||||
|
||||
type OperationMileageTotal struct {
|
||||
VIN string
|
||||
Meters *int64
|
||||
ValidDays int
|
||||
InvalidDays int
|
||||
}
|
||||
|
||||
type OperationComplianceStore interface {
|
||||
OperationVehicleRoster(context.Context, url.Values) ([]CustomerVehicleAssignment, error)
|
||||
OperationAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error)
|
||||
OperationMileageTotals(context.Context, url.Values) ([]OperationMileageTotal, error)
|
||||
OperationMileageDays(context.Context, url.Values) ([]OperationMileageDay, error)
|
||||
}
|
||||
|
||||
type OperationComplianceRow struct {
|
||||
CustomerVehicleAssignment
|
||||
MileageKm *float64 `json:"mileageKm"`
|
||||
Status string `json:"status"`
|
||||
ValidDays int `json:"validDays"`
|
||||
ExpectedDays int `json:"expectedDays"`
|
||||
AuthorizedDays int `json:"authorizedDays"`
|
||||
InvalidDays int `json:"invalidDays"`
|
||||
MissingDays int `json:"missingDays"`
|
||||
Incomplete bool `json:"incomplete"`
|
||||
Reasons []string `json:"reasons"`
|
||||
}
|
||||
|
||||
type OperationComplianceSummary struct {
|
||||
Total int `json:"total"`
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
Insufficient int `json:"insufficient"`
|
||||
PassRate *float64 `json:"passRate"`
|
||||
}
|
||||
|
||||
type OperationComplianceOptions struct {
|
||||
Customers []string `json:"customers"`
|
||||
Departments []string `json:"departments"`
|
||||
ResponsiblePeople []string `json:"responsiblePeople"`
|
||||
}
|
||||
|
||||
type OperationComplianceResponse struct {
|
||||
Items []OperationComplianceRow `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Summary OperationComplianceSummary `json:"summary"`
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
TargetKm float64 `json:"targetKm"`
|
||||
MileageSource string `json:"mileageSource"`
|
||||
AssignmentSource string `json:"assignmentSource"`
|
||||
AssignmentObservedAt string `json:"assignmentObservedAt"`
|
||||
AssignmentStale bool `json:"assignmentStale"`
|
||||
AssignmentMissingCount int `json:"assignmentMissingCount"`
|
||||
}
|
||||
|
||||
func operationMileageSource(q url.Values) (string, error) {
|
||||
if len(q["mileageSource"]) > 1 {
|
||||
return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "请选择一个里程数据源"}
|
||||
}
|
||||
source := q.Get("mileageSource")
|
||||
if source == "" {
|
||||
source = "gps"
|
||||
}
|
||||
if source != "gps" && source != "odometer" && source != "hydrogen" {
|
||||
return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "里程数据源仅支持GPS、仪表或纯氢里程"}
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func operationSourceLabel(source string) string {
|
||||
if source == "odometer" {
|
||||
return "仪表"
|
||||
}
|
||||
if source == "hydrogen" {
|
||||
return "纯氢"
|
||||
}
|
||||
return "GPS"
|
||||
}
|
||||
|
||||
func operationDates(q url.Values) ([]string, error) {
|
||||
datesQuery := url.Values{"dateFrom": {q.Get("dateFrom")}, "dateTo": {q.Get("dateTo")}}
|
||||
if datesQuery.Get("dateFrom") == "" && datesQuery.Get("dateTo") == "" {
|
||||
now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
datesQuery.Set("dateFrom", now.AddDate(0, 0, -30).Format("2006-01-02"))
|
||||
datesQuery.Set("dateTo", now.AddDate(0, 0, -1).Format("2006-01-02"))
|
||||
}
|
||||
return customerMileageDates(datesQuery, time.Now())
|
||||
}
|
||||
|
||||
func operationComplianceAccess(ctx context.Context) (Principal, error) {
|
||||
p, ok := PrincipalFromContext(ctx)
|
||||
if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu(operationComplianceMenu) {
|
||||
return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看车辆运营达标统计"}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func parseOperationTarget(q url.Values) (int64, error) {
|
||||
values, exists := q["targetKm"]
|
||||
if !exists {
|
||||
return 3000000, nil
|
||||
}
|
||||
if len(values) != 1 || !operationMileageDecimal.MatchString(values[0]) {
|
||||
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须为正数,最多保留三位小数"}
|
||||
}
|
||||
r, ok := new(big.Rat).SetString(values[0])
|
||||
if !ok {
|
||||
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程无效"}
|
||||
}
|
||||
r.Mul(r, big.NewRat(1000, 1))
|
||||
if !r.IsInt() || !r.Num().IsInt64() || r.Sign() <= 0 {
|
||||
return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须大于零且在有效数值范围内"}
|
||||
}
|
||||
return r.Num().Int64(), nil
|
||||
}
|
||||
|
||||
func operationSelections(q url.Values, key string) (map[string]bool, error) {
|
||||
if len(q[key]) > 200 {
|
||||
return nil, clientError{Code: "INVALID_FILTER", Message: "单项筛选最多选择200项"}
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, value := range q[key] {
|
||||
value = strings.TrimSpace(value)
|
||||
if len([]rune(value)) > 512 {
|
||||
return nil, clientError{Code: "INVALID_FILTER", Message: "筛选项过长"}
|
||||
}
|
||||
if value != "" {
|
||||
out[value] = true
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// The roster is authoritative: measurements and ownership are left joins, so
|
||||
// unreported or unassigned authorized vehicles remain in the report.
|
||||
func (s *Service) operationRoster(ctx context.Context) ([]CustomerVehicleAssignment, OperationComplianceResponse, error) {
|
||||
meta := OperationComplianceResponse{Items: []OperationComplianceRow{}}
|
||||
p, err := operationComplianceAccess(ctx)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
store, ok := s.store.(OperationComplianceStore)
|
||||
if !ok {
|
||||
return nil, meta, clientError{Code: "OPERATION_COMPLIANCE_UNAVAILABLE", Message: "当前数据源尚未启用车辆运营达标统计"}
|
||||
}
|
||||
snapshot, _ := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot)
|
||||
var roster, assignments []CustomerVehicleAssignment
|
||||
observed := snapshot.ObservedAt
|
||||
if snapshot.Live {
|
||||
assignments, err = normalizeCustomerAssignments(snapshot.Rows)
|
||||
roster = assignments
|
||||
meta.AssignmentSource = "oneos-live"
|
||||
} else {
|
||||
tenant := p.TenantRef
|
||||
if snapshot.Tenant != "" {
|
||||
tenant = snapshot.Tenant
|
||||
}
|
||||
assignments, observed, err = store.OperationAssignments(ctx, tenant)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
assignments, err = normalizeCustomerAssignments(assignments)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
query, scopeErr := applyPrincipalVehicleScope(ctx, url.Values{})
|
||||
if scopeErr != nil {
|
||||
return nil, meta, scopeErr
|
||||
}
|
||||
roster, err = store.OperationVehicleRoster(ctx, query)
|
||||
meta.AssignmentSource = "oneos-synced"
|
||||
}
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
meta.AssignmentStale = observed.IsZero() || time.Since(observed) > 24*time.Hour
|
||||
if !observed.IsZero() {
|
||||
meta.AssignmentObservedAt = observed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
byVIN := map[string]CustomerVehicleAssignment{}
|
||||
for _, row := range assignments {
|
||||
byVIN[row.VIN] = row
|
||||
}
|
||||
result := []CustomerVehicleAssignment{}
|
||||
seen := map[string]bool{}
|
||||
for _, row := range roster {
|
||||
row.VIN = strings.ToUpper(strings.TrimSpace(row.VIN))
|
||||
if !isLikelyVIN(row.VIN) || !p.CanVIN(row.VIN) || seen[row.VIN] {
|
||||
continue
|
||||
}
|
||||
seen[row.VIN] = true
|
||||
if assignment, exists := byVIN[row.VIN]; exists {
|
||||
if assignment.Plate == "" {
|
||||
assignment.Plate = row.Plate
|
||||
}
|
||||
row = assignment
|
||||
} else {
|
||||
meta.AssignmentMissingCount++
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN })
|
||||
return result, meta, nil
|
||||
}
|
||||
|
||||
func (s *Service) OperationComplianceOptions(ctx context.Context) (OperationComplianceOptions, error) {
|
||||
roster, _, err := s.operationRoster(ctx)
|
||||
out := OperationComplianceOptions{Customers: []string{}, Departments: []string{}, ResponsiblePeople: []string{}}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
customers, departments, people := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for _, r := range roster {
|
||||
if r.Customer != "" {
|
||||
customers[r.Customer] = true
|
||||
}
|
||||
if r.Department != "" {
|
||||
departments[r.Department] = true
|
||||
}
|
||||
if r.ResponsiblePerson != "" {
|
||||
people[r.ResponsiblePerson] = true
|
||||
}
|
||||
}
|
||||
for value := range customers {
|
||||
out.Customers = append(out.Customers, value)
|
||||
}
|
||||
for value := range departments {
|
||||
out.Departments = append(out.Departments, value)
|
||||
}
|
||||
for value := range people {
|
||||
out.ResponsiblePeople = append(out.ResponsiblePeople, value)
|
||||
}
|
||||
sort.Strings(out.Customers)
|
||||
sort.Strings(out.Departments)
|
||||
sort.Strings(out.ResponsiblePeople)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func operationAuthorizedBounds(p Principal, vin, from, to string) (string, string, int) {
|
||||
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
start, _ := time.ParseInLocation("2006-01-02", from, zone)
|
||||
end, _ := time.ParseInLocation("2006-01-02", to, zone)
|
||||
if p.HasHistoryTimeScope() {
|
||||
grant, ok := p.VehicleGrant(vin)
|
||||
if !ok || grant.ValidFrom.IsZero() {
|
||||
return "", "", 0
|
||||
}
|
||||
g := grant.ValidFrom.In(zone)
|
||||
first := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone)
|
||||
if !first.Equal(g) {
|
||||
first = first.AddDate(0, 0, 1)
|
||||
}
|
||||
if first.After(start) {
|
||||
start = first
|
||||
}
|
||||
if grant.ValidTo != nil {
|
||||
g = grant.ValidTo.In(zone)
|
||||
last := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone).AddDate(0, 0, -1)
|
||||
if last.Before(end) {
|
||||
end = last
|
||||
}
|
||||
}
|
||||
}
|
||||
if start.After(end) {
|
||||
return "", "", 0
|
||||
}
|
||||
return start.Format("2006-01-02"), end.Format("2006-01-02"), int(end.Sub(start)/(24*time.Hour)) + 1
|
||||
}
|
||||
|
||||
func (s *Service) OperationCompliance(ctx context.Context, q url.Values) (OperationComplianceResponse, error) {
|
||||
var empty OperationComplianceResponse
|
||||
source, err := operationMileageSource(q)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
target, err := parseOperationTarget(q)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
dates, err := operationDates(q)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
filters := make([]map[string]bool, 3)
|
||||
for i, key := range []string{"customer", "department", "responsiblePerson"} {
|
||||
filters[i], err = operationSelections(q, key)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
}
|
||||
status := q.Get("status")
|
||||
if status != "" && status != "passed" && status != "failed" && status != "insufficient" {
|
||||
return empty, clientError{Code: "INVALID_STATUS", Message: "达标状态无效"}
|
||||
}
|
||||
order := q.Get("sort")
|
||||
if order == "" {
|
||||
order = "mileage-desc"
|
||||
}
|
||||
if order != "mileage-desc" && order != "mileage-asc" {
|
||||
return empty, clientError{Code: "INVALID_SORT", Message: "里程排序无效"}
|
||||
}
|
||||
limit, offset := 30, 0
|
||||
if q.Has("limit") {
|
||||
limit, err = strconv.Atoi(q.Get("limit"))
|
||||
if err != nil || limit < 1 || limit > 100 {
|
||||
return empty, clientError{Code: "INVALID_PAGINATION", Message: "每页数量应为1至100"}
|
||||
}
|
||||
}
|
||||
if q.Has("offset") {
|
||||
offset, err = strconv.Atoi(q.Get("offset"))
|
||||
if err != nil || offset < 0 {
|
||||
return empty, clientError{Code: "INVALID_PAGINATION", Message: "分页位置无效"}
|
||||
}
|
||||
}
|
||||
roster, response, err := s.operationRoster(ctx)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
p, _ := PrincipalFromContext(ctx)
|
||||
selected := []CustomerVehicleAssignment{}
|
||||
query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "mileageSource": {source}}
|
||||
starts, ends, authorized := map[string]string{}, map[string]string{}, map[string]int{}
|
||||
vins := []string{}
|
||||
for _, row := range roster {
|
||||
if len(filters[0]) > 0 && !filters[0][row.Customer] || len(filters[1]) > 0 && !filters[1][row.Department] || len(filters[2]) > 0 && !filters[2][row.ResponsiblePerson] {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, row)
|
||||
from, to, days := operationAuthorizedBounds(p, row.VIN, dates[0], dates[len(dates)-1])
|
||||
authorized[row.VIN] = days
|
||||
if days > 0 {
|
||||
vins = append(vins, row.VIN)
|
||||
starts[row.VIN] = from
|
||||
ends[row.VIN] = to
|
||||
}
|
||||
}
|
||||
if len(selected)*len(dates) > 100000 {
|
||||
return response, clientError{Code: "OPERATION_RANGE_TOO_LARGE", Message: "本次查询超过100,000个车辆日,请缩短日期或筛选车辆归属"}
|
||||
}
|
||||
totals := map[string]OperationMileageTotal{}
|
||||
if len(vins) > 0 {
|
||||
query.Set("vins", strings.Join(vins, ","))
|
||||
encoded, _ := json.Marshal(starts)
|
||||
query.Set("scopeVehicleFrom", string(encoded))
|
||||
encoded, _ = json.Marshal(ends)
|
||||
query.Set("operationVehicleTo", string(encoded))
|
||||
rows, queryErr := s.store.(OperationComplianceStore).OperationMileageTotals(ctx, query)
|
||||
if queryErr != nil {
|
||||
return response, queryErr
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, exists := totals[row.VIN]; exists {
|
||||
return response, clientError{Code: "OPERATION_DUPLICATE_TOTAL", Message: "同一车辆存在重复区间汇总"}
|
||||
}
|
||||
totals[row.VIN] = row
|
||||
}
|
||||
}
|
||||
response.DateFrom, response.DateTo, response.TargetKm = dates[0], dates[len(dates)-1], float64(target)/1000
|
||||
response.MileageSource = source
|
||||
response.Limit, response.Offset = limit, offset
|
||||
rows := []OperationComplianceRow{}
|
||||
for _, vehicle := range selected {
|
||||
total := totals[vehicle.VIN]
|
||||
row, rowErr := evaluateOperationRow(vehicle, total, authorized[vehicle.VIN], len(dates), target, source)
|
||||
if rowErr != nil {
|
||||
return response, rowErr
|
||||
}
|
||||
if status != "" && row.Status != status {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
if a.MileageKm == nil || b.MileageKm == nil {
|
||||
if (a.MileageKm == nil) != (b.MileageKm == nil) {
|
||||
return a.MileageKm != nil
|
||||
}
|
||||
return a.VIN < b.VIN
|
||||
}
|
||||
// Ordering uses the exact integer totals too; displayed decimals are presentation only.
|
||||
am, bm := *totals[a.VIN].Meters, *totals[b.VIN].Meters
|
||||
if am == bm {
|
||||
return a.VIN < b.VIN
|
||||
}
|
||||
if order == "mileage-asc" {
|
||||
return am < bm
|
||||
}
|
||||
return am > bm
|
||||
})
|
||||
response.Total = len(rows)
|
||||
response.Summary.Total = len(rows)
|
||||
for _, row := range rows {
|
||||
switch row.Status {
|
||||
case "passed":
|
||||
response.Summary.Passed++
|
||||
case "failed":
|
||||
response.Summary.Failed++
|
||||
default:
|
||||
response.Summary.Insufficient++
|
||||
}
|
||||
}
|
||||
if denominator := response.Summary.Passed + response.Summary.Failed; denominator > 0 {
|
||||
rate := float64(response.Summary.Passed) / float64(denominator) * 100
|
||||
response.Summary.PassRate = &rate
|
||||
}
|
||||
if offset < len(rows) {
|
||||
end := offset + limit
|
||||
if end > len(rows) || end < offset {
|
||||
end = len(rows)
|
||||
}
|
||||
response.Items = rows[offset:end]
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func evaluateOperationRow(vehicle CustomerVehicleAssignment, total OperationMileageTotal, authorized, expected int, target int64, source string) (OperationComplianceRow, error) {
|
||||
row := OperationComplianceRow{CustomerVehicleAssignment: vehicle, ExpectedDays: expected, AuthorizedDays: authorized, ValidDays: total.ValidDays, InvalidDays: total.InvalidDays, Reasons: []string{}}
|
||||
row.MissingDays = authorized - total.ValidDays - total.InvalidDays
|
||||
if row.MissingDays < 0 || row.ValidDays < 0 || row.InvalidDays < 0 {
|
||||
return row, clientError{Code: "OPERATION_INVALID_COVERAGE", Message: "里程覆盖计数异常"}
|
||||
}
|
||||
row.Incomplete = row.ValidDays != expected
|
||||
if authorized < expected {
|
||||
row.Reasons = append(row.Reasons, "部分日期不在完整自然日历史授权范围内")
|
||||
}
|
||||
if row.InvalidDays > 0 {
|
||||
row.Reasons = append(row.Reasons, "存在异常或冲突日里程,已排除")
|
||||
}
|
||||
if row.MissingDays > 0 {
|
||||
row.Reasons = append(row.Reasons, "部分授权日期缺少"+operationSourceLabel(source)+"日里程")
|
||||
}
|
||||
row.Status = "insufficient"
|
||||
if total.Meters != nil && row.ValidDays > 0 {
|
||||
value := float64(*total.Meters) / 1000
|
||||
row.MileageKm = &value
|
||||
if *total.Meters >= target {
|
||||
row.Status = "passed"
|
||||
} else if !row.Incomplete {
|
||||
row.Status = "failed"
|
||||
}
|
||||
} else if authorized > 0 {
|
||||
row.Reasons = append(row.Reasons, "暂无有效"+operationSourceLabel(source)+"日里程")
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type OperationMileageDay struct {
|
||||
VIN, Date, Protocol string
|
||||
Meters *int64
|
||||
}
|
||||
|
||||
type OperationComplianceDay struct {
|
||||
Date string `json:"date"`
|
||||
MileageKm *float64 `json:"mileageKm"`
|
||||
Protocol string `json:"protocol"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type OperationComplianceDetail struct {
|
||||
Vehicle OperationComplianceRow `json:"vehicle"`
|
||||
DateFrom string `json:"dateFrom"`
|
||||
DateTo string `json:"dateTo"`
|
||||
TargetKm float64 `json:"targetKm"`
|
||||
MileageSource string `json:"mileageSource"`
|
||||
Days []OperationComplianceDay `json:"days"`
|
||||
}
|
||||
|
||||
func (s *Service) OperationComplianceDetail(ctx context.Context, q url.Values) (OperationComplianceDetail, error) {
|
||||
out := OperationComplianceDetail{Days: []OperationComplianceDay{}}
|
||||
if _, err := operationComplianceAccess(ctx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
source, err := operationMileageSource(q)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
target, err := parseOperationTarget(q)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
dates, err := operationDates(q)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
vin := strings.ToUpper(strings.TrimSpace(q.Get("vin")))
|
||||
if len(q["vin"]) != 1 || !isLikelyVIN(vin) {
|
||||
return out, clientError{Code: "INVALID_VIN", Message: "请选择一辆有效车辆"}
|
||||
}
|
||||
roster, _, err := s.operationRoster(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var vehicle CustomerVehicleAssignment
|
||||
found := false
|
||||
for _, row := range roster {
|
||||
if row.VIN == vin {
|
||||
vehicle, found = row, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return out, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||||
}
|
||||
p, _ := PrincipalFromContext(ctx)
|
||||
first, last, authorized := operationAuthorizedBounds(p, vin, dates[0], dates[len(dates)-1])
|
||||
byDate := map[string]OperationMileageDay{}
|
||||
if authorized > 0 {
|
||||
// Reconstruct scope from the caller; never forward browser-supplied VIN or
|
||||
// historical authorization filters to the store.
|
||||
starts, _ := json.Marshal(map[string]string{vin: first})
|
||||
ends, _ := json.Marshal(map[string]string{vin: last})
|
||||
query := url.Values{"vins": {vin}, "dateFrom": {first}, "dateTo": {last}, "scopeVehicleFrom": {string(starts)}, "operationVehicleTo": {string(ends)}, "mileageSource": {source}}
|
||||
days, readErr := s.store.(OperationComplianceStore).OperationMileageDays(ctx, query)
|
||||
if readErr != nil {
|
||||
return out, readErr
|
||||
}
|
||||
for _, day := range days {
|
||||
if _, exists := byDate[day.Date]; exists || day.VIN != vin || day.Date < first || day.Date > last {
|
||||
return out, clientError{Code: "OPERATION_INVALID_DAY", Message: "车辆每日里程结果异常"}
|
||||
}
|
||||
byDate[day.Date] = day
|
||||
}
|
||||
}
|
||||
total := OperationMileageTotal{VIN: vin}
|
||||
var meters int64
|
||||
for _, date := range dates {
|
||||
day := OperationComplianceDay{Date: date, Status: "missing", Reason: "暂无" + operationSourceLabel(source) + "日里程,不补为零"}
|
||||
if authorized == 0 || date < first || date > last {
|
||||
day.Status, day.Reason = "restricted", "不在完整自然日历史授权范围内"
|
||||
} else if observation, ok := byDate[date]; ok {
|
||||
day.Protocol = observation.Protocol
|
||||
if observation.Meters == nil {
|
||||
day.Status, day.Reason = "invalid", "日里程异常或冲突,未计入区间里程"
|
||||
total.InvalidDays++
|
||||
} else {
|
||||
value := float64(*observation.Meters) / 1000
|
||||
day.MileageKm, day.Status, day.Reason = &value, "valid", "已计入区间里程"
|
||||
meters += *observation.Meters
|
||||
total.ValidDays++
|
||||
}
|
||||
}
|
||||
out.Days = append(out.Days, day)
|
||||
}
|
||||
if total.ValidDays > 0 {
|
||||
total.Meters = &meters
|
||||
}
|
||||
out.Vehicle, err = evaluateOperationRow(vehicle, total, authorized, len(dates), target, source)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.DateFrom, out.DateTo, out.TargetKm, out.MileageSource = dates[0], dates[len(dates)-1], float64(target)/1000, source
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package platform
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (h *Handler) handleOperationCompliance(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := h.service.OperationCompliance(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
func (h *Handler) handleOperationComplianceOptions(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := h.service.OperationComplianceOptions(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
func (h *Handler) handleOperationComplianceDetail(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := h.service.OperationComplianceDetail(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Exercise MySQL aggregation with SELECT-only fixtures, without writing source data.
|
||||
func TestOperationComplianceMySQLHydrogenQualityAndScope(t *testing.T) {
|
||||
dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("OPERATION_ACCEPTANCE_DSN not provided")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal("acceptance database configuration invalid")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
vin := "LNX00000000000001"
|
||||
type sample struct {
|
||||
account, vin, date string
|
||||
km any
|
||||
}
|
||||
samples := []sample{
|
||||
{"guangdonglingniu", vin, "2026-09-01", 777},
|
||||
{"guangdonglingniu", vin, "2026-09-02", 0}, {"guangdonglingniu", vin, "2026-09-02", 0},
|
||||
{"guangdonglingniu", vin, "2026-09-03", "1500.125"}, {"guangdonglingniu", vin, "2026-09-03", "1500.125"},
|
||||
{"guangdonglingniu", vin, "2026-09-04", 1}, {"guangdonglingniu", vin, "2026-09-04", 2},
|
||||
{"guangdonglingniu", vin, "2026-09-05", nil},
|
||||
{"guangdonglingniu", vin, "2026-09-06", -1},
|
||||
{"guangdonglingniu", vin, "2026-09-07", "3000.001"},
|
||||
{"guangdonglingniu", vin, "2026-09-08", 3000},
|
||||
{"guangdonglingniu", vin, "2026-09-09", 777},
|
||||
{"another-account", vin, "2026-09-03", 999},
|
||||
{"guangdonglingniu", "LNX00000000000002", "2026-09-03", 999},
|
||||
}
|
||||
selects, args := []string{}, []any{}
|
||||
for _, s := range samples {
|
||||
selects = append(selects, "SELECT ? AS account,? AS vin,CAST(? AS DATE) AS stat_date,CAST(? AS DECIMAL(18,3)) AS hydrogen_km")
|
||||
args = append(args, s.account, s.vin, s.date, s.km)
|
||||
}
|
||||
q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-10"}, "mileageSource": {"hydrogen"}, "vins": {vin}, "scopeVehicleFrom": {`{"LNX00000000000001":"2026-09-02"}`}, "operationVehicleTo": {`{"LNX00000000000001":"2026-09-08"}`}}
|
||||
fixtureSQL := "FROM (" + strings.Join(selects, " UNION ALL ") + ") m WHERE"
|
||||
built, err := buildOperationMileageDaysSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relation := strings.Replace(built.Text, "FROM gzev_daily_mileage m WHERE", fixtureSQL, 1)
|
||||
rows, err := db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,km FROM (`+relation+`) days ORDER BY stat_date`, append(args, built.Args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wants := []sql.NullFloat64{{Float64: 0, Valid: true}, {Float64: 1500.125, Valid: true}, {}, {}, {}, {}, {Float64: 3000, Valid: true}}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var date, protocol string
|
||||
var km sql.NullFloat64
|
||||
if err = rows.Scan(&date, &protocol, &km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count >= len(wants) || date != time.Date(2026, 9, 2+count, 0, 0, 0, 0, time.UTC).Format("2006-01-02") || protocol != "GZEV" || km != wants[count] {
|
||||
t.Fatalf("unexpected hydrogen day: %s %s %+v", date, protocol, km)
|
||||
}
|
||||
count++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil || count != len(wants) {
|
||||
t.Fatalf("hydrogen day count=%d error=%v", count, err)
|
||||
}
|
||||
totals, err := buildOperationMileageSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var selectedVIN string
|
||||
var meters int64
|
||||
var valid, invalid int
|
||||
err = db.QueryRowContext(ctx, strings.Replace(totals.Text, "FROM gzev_daily_mileage m WHERE", fixtureSQL, 1), append(args, totals.Args...)...).Scan(&selectedVIN, &meters, &valid, &invalid)
|
||||
if err != nil || selectedVIN != vin || meters != 4500125 || valid != 3 || invalid != 4 {
|
||||
t.Fatalf("hydrogen totals: vin=%s meters=%d valid=%d invalid=%d error=%v", selectedVIN, meters, valid, invalid, err)
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Opt-in acceptance against the existing read-only data path. The synthetic
|
||||
// cases are SELECT-derived relations; no database table or business row is changed.
|
||||
func TestOperationComplianceMySQLAcceptance(t *testing.T) {
|
||||
dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("OPERATION_ACCEPTANCE_DSN not provided")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal("acceptance database configuration invalid")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
t.Fatal("acceptance database unavailable")
|
||||
}
|
||||
vin := func(n int) string { return fmt.Sprintf("LNX%014d", n) }
|
||||
type sample struct {
|
||||
n int
|
||||
date, protocol string
|
||||
km any
|
||||
}
|
||||
samples := []sample{
|
||||
{1, "2026-09-01", "JT808", "1000"}, {1, "2026-09-01", "JT808", "1000"}, {1, "2026-09-02", "JT808", "1500"}, {1, "2026-09-03", "JT808", "500"}, {1, "2026-09-01", "GB32960", "9000"},
|
||||
{2, "2026-09-01", "JT808", "2999.999"}, {2, "2026-09-02", "JT808", "0"}, {2, "2026-09-03", "JT808", "0"},
|
||||
{3, "2026-09-01", "JT808", "0"}, {3, "2026-09-02", "JT808", "0"}, {3, "2026-09-03", "JT808", "0"},
|
||||
{4, "2026-09-01", "JT808", "100"}, {4, "2026-09-01", "JT808", "200"}, {4, "2026-09-02", "JT808", "-1"}, {4, "2026-09-03", "JT808", "3001"},
|
||||
{5, "2026-09-01", "JT808", "1000"}, {5, "2026-09-02", "JT808", "2000"},
|
||||
{6, "2026-09-01", "JT808", nil},
|
||||
}
|
||||
selects, args := []string{}, []any{}
|
||||
for _, s := range samples {
|
||||
selects = append(selects, "SELECT ? AS vin,CAST(? AS DATE) AS stat_date,? AS protocol,CAST(? AS DECIMAL(18,3)) AS daily_mileage_km")
|
||||
args = append(args, vin(s.n), s.date, s.protocol, s.km)
|
||||
}
|
||||
q := operationQuery()
|
||||
vins := []string{}
|
||||
starts, ends := map[string]string{}, map[string]string{}
|
||||
for n := 1; n <= 6; n++ {
|
||||
v := vin(n)
|
||||
vins = append(vins, v)
|
||||
starts[v] = "2026-09-01"
|
||||
ends[v] = "2026-09-03"
|
||||
}
|
||||
q.Set("vins", strings.Join(vins, ","))
|
||||
encoded, _ := json.Marshal(starts)
|
||||
q.Set("scopeVehicleFrom", string(encoded))
|
||||
encoded, _ = json.Marshal(ends)
|
||||
q.Set("operationVehicleTo", string(encoded))
|
||||
built, err := buildOperationMileageSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := strings.Replace(built.Text, "FROM vehicle_daily_mileage m WHERE", "FROM ("+strings.Join(selects, " UNION ALL ")+") m WHERE", 1)
|
||||
args = append(args, built.Args...)
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := map[string]OperationMileageTotal{}
|
||||
for rows.Next() {
|
||||
var r OperationMileageTotal
|
||||
var meters sql.NullInt64
|
||||
if err = rows.Scan(&r.VIN, &meters, &r.ValidDays, &r.InvalidDays); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meters.Valid {
|
||||
v := meters.Int64
|
||||
r.Meters = &v
|
||||
}
|
||||
actual[r.VIN] = r
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []OperationMileageTotal{{VIN: vin(1), Meters: operationMeters(3000000), ValidDays: 3}, {VIN: vin(2), Meters: operationMeters(2999999), ValidDays: 3}, {VIN: vin(3), Meters: operationMeters(0), ValidDays: 3}, {VIN: vin(4), InvalidDays: 3}, {VIN: vin(5), Meters: operationMeters(3000000), ValidDays: 2}, {VIN: vin(6), InvalidDays: 1}} {
|
||||
got := actual[want.VIN]
|
||||
if got.ValidDays != want.ValidDays || got.InvalidDays != want.InvalidDays || (got.Meters == nil) != (want.Meters == nil) || got.Meters != nil && *got.Meters != *want.Meters {
|
||||
t.Fatalf("SQL aggregation mismatch for %s", want.VIN)
|
||||
}
|
||||
}
|
||||
|
||||
tenant := os.Getenv("OPERATION_ACCEPTANCE_TENANT")
|
||||
if tenant == "" {
|
||||
tenant = "000000"
|
||||
}
|
||||
p := Principal{Role: "admin", UserType: "admin", AuthProvider: "local", TenantRef: tenant, ReadOnly: true, MenuKeys: []string{operationComplianceMenu}}
|
||||
request := httptest.NewRequest("GET", "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-30&limit=100", nil).WithContext(WithPrincipal(ctx, p))
|
||||
w := httptest.NewRecorder()
|
||||
NewHandler(NewService(NewProductionStore(db, nil, ""))).ServeHTTP(w, request)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("real-data handler failed: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data OperationComplianceResponse `json:"data"`
|
||||
}
|
||||
if err = json.Unmarshal(w.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := envelope.Data
|
||||
if len(result.Items) == 0 {
|
||||
t.Fatal("real acceptance found no authorized vehicles")
|
||||
}
|
||||
checked := 0
|
||||
realSamples := []map[string]any{}
|
||||
for _, r := range result.Items {
|
||||
if checked == 5 {
|
||||
break
|
||||
}
|
||||
var meters sql.NullInt64
|
||||
var valid, invalid int
|
||||
err = db.QueryRowContext(ctx, `SELECT SUM(CASE WHEN daily_mileage_km BETWEEN 0 AND 3000 THEN CAST(ROUND(daily_mileage_km*1000,0) AS SIGNED) END),COALESCE(SUM(daily_mileage_km BETWEEN 0 AND 3000),0),COALESCE(SUM(daily_mileage_km<0 OR daily_mileage_km>3000),0) FROM vehicle_daily_mileage WHERE vin=? AND protocol='JT808' AND stat_date BETWEEN ? AND ?`, r.VIN, result.DateFrom, result.DateTo).Scan(&meters, &valid, &invalid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if valid != r.ValidDays || invalid != r.InvalidDays || meters.Valid != (r.MileageKm != nil) || meters.Valid && float64(meters.Int64)/1000 != *r.MileageKm {
|
||||
t.Fatalf("independent real-data comparison failed for %s", r.VIN)
|
||||
}
|
||||
want := "insufficient"
|
||||
if meters.Valid && meters.Int64 >= 3000000 {
|
||||
want = "passed"
|
||||
} else if valid == 30 {
|
||||
want = "failed"
|
||||
}
|
||||
if r.Status != want {
|
||||
t.Fatalf("real status mismatch for %s", r.VIN)
|
||||
}
|
||||
realSamples = append(realSamples, map[string]any{"vin": r.VIN, "plate": r.Plate, "apiMileageKm": r.MileageKm, "sqlMeters": meters, "validDays": valid, "invalidDays": invalid, "status": r.Status, "matched": true})
|
||||
checked++
|
||||
}
|
||||
report := map[string]any{"syntheticSqlCases": 6, "realSamplesChecked": checked, "realSamples": realSamples, "realDateFrom": result.DateFrom, "realDateTo": result.DateTo, "summary": result.Summary, "assignmentMissingCount": result.AssignmentMissingCount, "syntheticWrites": 0, "businessWrites": 0, "authentication": "in-process read-only principal; middleware isolation verified separately"}
|
||||
if path := os.Getenv("OPERATION_ACCEPTANCE_REPORT"); path != "" {
|
||||
raw, _ := json.MarshalIndent(report, "", " ")
|
||||
if err = os.WriteFile(path, append(raw, '\n'), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Logf("synthetic SQL cases=6; independently checked real vehicles=%d; total=%d", checked, result.Total)
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All synthetic cases use SELECT relations; acceptance never inserts test rows.
|
||||
func TestOperationComplianceMySQLSourcesAndDailyReconciliation(t *testing.T) {
|
||||
dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("OPERATION_ACCEPTANCE_DSN not provided")
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatal("acceptance database configuration invalid")
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
vin := "LNX00000000000001"
|
||||
type sample struct {
|
||||
date, protocol string
|
||||
km any
|
||||
}
|
||||
samples := []sample{
|
||||
{"2026-09-01", "GB32960", 100}, {"2026-09-01", "YUTONG_MQTT", 200},
|
||||
{"2026-09-02", "GB32960", 0}, {"2026-09-02", "YUTONG_MQTT", 200},
|
||||
{"2026-09-03", "GB32960", -1}, {"2026-09-03", "YUTONG_MQTT", 0},
|
||||
{"2026-09-04", "GB32960", nil}, {"2026-09-04", "YUTONG_MQTT", 3001},
|
||||
{"2026-09-05", "GB32960", 100}, {"2026-09-05", "GB32960", 200}, {"2026-09-05", "YUTONG_MQTT", 50},
|
||||
{"2026-09-06", "JT808", 999},
|
||||
{"2026-09-07", "GB32960", 0}, {"2026-09-07", "YUTONG_MQTT", 0},
|
||||
}
|
||||
selects, args := []string{}, []any{}
|
||||
for _, s := range samples {
|
||||
selects = append(selects, "SELECT ? AS vin,CAST(? AS DATE) AS stat_date,? AS protocol,CAST(? AS DECIMAL(18,3)) AS daily_mileage_km")
|
||||
args = append(args, vin, s.date, s.protocol, s.km)
|
||||
}
|
||||
q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-07"}, "mileageSource": {"odometer"}, "vins": {vin}, "operationVehicleTo": {`{}`}}
|
||||
built, err := buildOperationMileageDaysSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relation := strings.Replace(built.Text, "FROM vehicle_daily_mileage m WHERE", "FROM ("+strings.Join(selects, " UNION ALL ")+") m WHERE", 1)
|
||||
rows, err := db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,km FROM (`+relation+`) days ORDER BY stat_date`, append(args, built.Args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type selected struct {
|
||||
date, protocol string
|
||||
km sql.NullFloat64
|
||||
}
|
||||
var got []selected
|
||||
for rows.Next() {
|
||||
var r selected
|
||||
if err = rows.Scan(&r.date, &r.protocol, &r.km); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, r)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wants := []selected{{"2026-09-01", "GB32960", sql.NullFloat64{Float64: 100, Valid: true}}, {"2026-09-02", "YUTONG_MQTT", sql.NullFloat64{Float64: 200, Valid: true}}, {"2026-09-03", "YUTONG_MQTT", sql.NullFloat64{Float64: 0, Valid: true}}, {"2026-09-04", "GB32960", sql.NullFloat64{}}, {"2026-09-05", "YUTONG_MQTT", sql.NullFloat64{Float64: 50, Valid: true}}, {"2026-09-07", "GB32960", sql.NullFloat64{Float64: 0, Valid: true}}}
|
||||
if len(got) != len(wants) {
|
||||
t.Fatalf("selected %d days, want %d", len(got), len(wants))
|
||||
}
|
||||
for i, want := range wants {
|
||||
if got[i] != want {
|
||||
t.Fatalf("day %d got %+v want %+v", i, got[i], want)
|
||||
}
|
||||
}
|
||||
// Read actual historical data with the same permission-limited service and
|
||||
// prove each returned day reconciles with the list's integer-meter total.
|
||||
tenant := os.Getenv("OPERATION_ACCEPTANCE_TENANT")
|
||||
if tenant == "" {
|
||||
tenant = "000000"
|
||||
}
|
||||
p := Principal{Role: "admin", UserType: "admin", AuthProvider: "local", TenantRef: tenant, ReadOnly: true, MenuKeys: []string{operationComplianceMenu}}
|
||||
ctx = WithPrincipal(ctx, p)
|
||||
svc := NewService(NewProductionStore(db, nil, ""))
|
||||
type audit struct {
|
||||
Source string `json:"source"`
|
||||
Summary OperationComplianceSummary `json:"summary"`
|
||||
CheckedVINs []string `json:"checkedVINs"`
|
||||
}
|
||||
report := []audit{}
|
||||
for _, source := range []string{"gps", "odometer", "hydrogen"} {
|
||||
query := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "mileageSource": {source}, "limit": {"5"}}
|
||||
list, err := svc.OperationCompliance(ctx, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := audit{Source: source, Summary: list.Summary, CheckedVINs: []string{}}
|
||||
for _, vehicle := range list.Items {
|
||||
query.Set("vin", vehicle.VIN)
|
||||
detail, err := svc.OperationComplianceDetail(ctx, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.MileageSource != source || detail.Vehicle.Status != vehicle.Status || detail.Vehicle.ValidDays != vehicle.ValidDays || detail.Vehicle.InvalidDays != vehicle.InvalidDays || detail.Vehicle.MissingDays != vehicle.MissingDays || (detail.Vehicle.MileageKm == nil) != (vehicle.MileageKm == nil) || vehicle.MileageKm != nil && *detail.Vehicle.MileageKm != *vehicle.MileageKm {
|
||||
t.Fatalf("list/detail mismatch source=%s vin=%s", source, vehicle.VIN)
|
||||
}
|
||||
if len(detail.Days) != 30 {
|
||||
t.Fatal("missing calendar dates")
|
||||
}
|
||||
if source == "hydrogen" {
|
||||
observations, readErr := svc.store.(*ProductionStore).CustomerMileageObservations(ctx, url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "vins": {vehicle.VIN}})
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var expectedMeters int64
|
||||
validDays := 0
|
||||
for _, day := range observations {
|
||||
if day.HydrogenKm != nil {
|
||||
expectedMeters += int64(math.Round(*day.HydrogenKm * 1000))
|
||||
validDays++
|
||||
}
|
||||
}
|
||||
if vehicle.ValidDays != validDays || (vehicle.MileageKm == nil) != (validDays == 0) || vehicle.MileageKm != nil && int64(math.Round(*vehicle.MileageKm*1000)) != expectedMeters {
|
||||
t.Fatalf("customer operations hydrogen mismatch vin=%s", vehicle.VIN)
|
||||
}
|
||||
}
|
||||
a.CheckedVINs = append(a.CheckedVINs, vehicle.VIN)
|
||||
}
|
||||
report = append(report, a)
|
||||
t.Logf("source=%s vehicles=%d passed=%d failed=%d insufficient=%d checked=%d", source, list.Total, list.Summary.Passed, list.Summary.Failed, list.Summary.Insufficient, len(a.CheckedVINs))
|
||||
}
|
||||
if path := os.Getenv("OPERATION_SOURCE_REPORT"); path != "" {
|
||||
encoded, _ := json.MarshalIndent(report, "", " ")
|
||||
if err = os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatal(fmt.Errorf("write acceptance report: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *ProductionStore) OperationAssignments(ctx context.Context, tenant string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
return s.CustomerAssignments(ctx, tenant)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) OperationVehicleRoster(ctx context.Context, q url.Values) ([]CustomerVehicleAssignment, error) {
|
||||
set, args := buildVehicleCoverageSetSQL(q.Get("scopeVins"))
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT v.vin,COALESCE(NULLIF(MAX(s.plate),''),MAX(b.plate),'') FROM (`+set+`) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_realtime_snapshot s ON s.vin=v.vin GROUP BY v.vin ORDER BY v.vin`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []CustomerVehicleAssignment{}
|
||||
for rows.Next() {
|
||||
var r CustomerVehicleAssignment
|
||||
if err := rows.Scan(&r.VIN, &r.Plate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Both interval totals and daily detail read this exact selection. GPS reads the
|
||||
// existing JT808 projection (already selected across GPS platforms). Odometer
|
||||
// protocols are alternatives for one day, never distances to add together.
|
||||
// Hydrogen uses the same GZEV account/day projection as customer operations.
|
||||
func buildOperationMileageDaysSQL(q url.Values) (SQLQuery, error) {
|
||||
source, err := operationMileageSource(q)
|
||||
if err != nil {
|
||||
return SQLQuery{}, err
|
||||
}
|
||||
protocolFilter := "m.protocol = 'JT808'"
|
||||
from, mileage, protocol := "vehicle_daily_mileage m", "m.daily_mileage_km", "m.protocol"
|
||||
groupBy := "m.vin,m.stat_date,m.protocol"
|
||||
if source == "odometer" {
|
||||
protocolFilter = "m.protocol IN ('GB32960','YUTONG_MQTT')"
|
||||
} else if source == "hydrogen" {
|
||||
from, mileage, protocol = "gzev_daily_mileage m", "m.hydrogen_km", "'GZEV'"
|
||||
protocolFilter = "m.account = 'guangdonglingniu'"
|
||||
groupBy = "m.vin,m.stat_date"
|
||||
}
|
||||
where := []string{protocolFilter, "m.stat_date >= ?", "m.stat_date <= ?"}
|
||||
args := []any{q.Get("dateFrom"), q.Get("dateTo")}
|
||||
// A nonempty, server-created VIN list is mandatory, even for administrators.
|
||||
if q.Get("vins") == "" {
|
||||
where = append(where, "1=0")
|
||||
}
|
||||
where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins"))
|
||||
where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom"))
|
||||
ends := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(q.Get("operationVehicleTo")), &ends); err != nil {
|
||||
return SQLQuery{}, err
|
||||
}
|
||||
vins := make([]string, 0, len(ends))
|
||||
for vin := range ends {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
for _, vin := range vins {
|
||||
where = append(where, "(m.vin <> ? OR m.stat_date <= ?)")
|
||||
args = append(args, vin, ends[vin])
|
||||
}
|
||||
// Identical duplicates collapse to one day. Conflicting values become an
|
||||
// invalid day, never an arbitrary maximum or the sum of duplicate reports.
|
||||
days := `SELECT m.vin,m.stat_date,` + protocol + ` AS protocol,CASE WHEN COUNT(` + mileage + `)=COUNT(*) AND COUNT(DISTINCT ` + mileage + `)=1 AND MIN(` + mileage + `)>=0 AND MAX(` + mileage + `)<=3000 THEN MIN(` + mileage + `) ELSE NULL END AS km FROM ` + from + ` WHERE ` + strings.Join(where, " AND ") + ` GROUP BY ` + groupBy
|
||||
if source == "odometer" {
|
||||
order := `CASE WHEN km IS NULL THEN 1 ELSE 0 END, ` + mileageDailySelectionOrder("km", "protocol", []string{"GB32960", "YUTONG_MQTT"})
|
||||
days = `SELECT vin,stat_date,protocol,km FROM (SELECT candidates.*,ROW_NUMBER() OVER (PARTITION BY vin,stat_date ORDER BY ` + order + `) AS source_rank FROM (` + days + `) candidates) ranked WHERE source_rank=1`
|
||||
}
|
||||
return SQLQuery{Text: days, Args: args}, nil
|
||||
}
|
||||
|
||||
func buildOperationMileageSQL(q url.Values) (SQLQuery, error) {
|
||||
days, err := buildOperationMileageDaysSQL(q)
|
||||
if err != nil {
|
||||
return SQLQuery{}, err
|
||||
}
|
||||
return SQLQuery{Text: `SELECT vin,SUM(CAST(ROUND(km*1000,0) AS SIGNED)),COUNT(km),SUM(CASE WHEN km IS NULL THEN 1 ELSE 0 END) FROM (` + days.Text + `) operation_days GROUP BY vin`, Args: days.Args}, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) OperationMileageTotals(ctx context.Context, q url.Values) ([]OperationMileageTotal, error) {
|
||||
built, err := buildOperationMileageSQL(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, built.Text, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []OperationMileageTotal{}
|
||||
for rows.Next() {
|
||||
var r OperationMileageTotal
|
||||
var meters sql.NullInt64
|
||||
if err := rows.Scan(&r.VIN, &meters, &r.ValidDays, &r.InvalidDays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meters.Valid {
|
||||
value := meters.Int64
|
||||
r.Meters = &value
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) OperationMileageDays(ctx context.Context, q url.Values) ([]OperationMileageDay, error) {
|
||||
built, err := buildOperationMileageDaysSQL(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,CAST(ROUND(km*1000,0) AS SIGNED) FROM (`+built.Text+`) operation_days ORDER BY stat_date`, built.Args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []OperationMileageDay{}
|
||||
for rows.Next() {
|
||||
var day OperationMileageDay
|
||||
var meters sql.NullInt64
|
||||
if err := rows.Scan(&day.VIN, &day.Date, &day.Protocol, &meters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meters.Valid {
|
||||
day.Meters = &meters.Int64
|
||||
}
|
||||
out = append(out, day)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *MockStore) OperationVehicleRoster(_ context.Context, q url.Values) ([]CustomerVehicleAssignment, error) {
|
||||
out := []CustomerVehicleAssignment{}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range s.vehicles {
|
||||
if seen[r.VIN] || q.Get("scopeVins") != "" && !containsString(splitCSV(q.Get("scopeVins")), r.VIN) {
|
||||
continue
|
||||
}
|
||||
seen[r.VIN] = true
|
||||
out = append(out, CustomerVehicleAssignment{VIN: r.VIN, Plate: r.Plate})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *MockStore) OperationAssignments(_ context.Context, _ string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
out := []CustomerVehicleAssignment{}
|
||||
for vin, r := range s.businessRelations {
|
||||
out = append(out, CustomerVehicleAssignment{VIN: vin, Plate: r.PlateNumber, Customer: r.CustomerName, Department: r.DepartmentName, ResponsiblePerson: r.ResponsibleUserName})
|
||||
}
|
||||
return out, time.Now(), nil
|
||||
}
|
||||
func (s *MockStore) OperationMileageTotals(_ context.Context, _ url.Values) ([]OperationMileageTotal, error) {
|
||||
return []OperationMileageTotal{}, nil
|
||||
}
|
||||
func (s *MockStore) OperationMileageDays(_ context.Context, _ url.Values) ([]OperationMileageDay, error) {
|
||||
return []OperationMileageDay{}, nil
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type operationTestStore struct {
|
||||
*MockStore
|
||||
roster []CustomerVehicleAssignment
|
||||
assignments []CustomerVehicleAssignment
|
||||
totals []OperationMileageTotal
|
||||
query url.Values
|
||||
reads int
|
||||
days []OperationMileageDay
|
||||
dayReads int
|
||||
}
|
||||
|
||||
func (s *operationTestStore) OperationVehicleRoster(context.Context, url.Values) ([]CustomerVehicleAssignment, error) {
|
||||
return s.roster, nil
|
||||
}
|
||||
func (s *operationTestStore) OperationAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) {
|
||||
return s.assignments, time.Now(), nil
|
||||
}
|
||||
func (s *operationTestStore) OperationMileageTotals(_ context.Context, q url.Values) ([]OperationMileageTotal, error) {
|
||||
s.query = q
|
||||
s.reads++
|
||||
return s.totals, nil
|
||||
}
|
||||
func (s *operationTestStore) OperationMileageDays(_ context.Context, q url.Values) ([]OperationMileageDay, error) {
|
||||
s.query = q
|
||||
s.dayReads++
|
||||
return s.days, nil
|
||||
}
|
||||
func operationContext() context.Context {
|
||||
return WithPrincipal(context.Background(), Principal{Role: "admin", UserType: "admin", MenuKeys: []string{operationComplianceMenu}})
|
||||
}
|
||||
func operationQuery() url.Values {
|
||||
return url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-03"}}
|
||||
}
|
||||
func operationMeters(v int64) *int64 { return &v }
|
||||
|
||||
func TestOperationComplianceTotalsCoverageThresholdAndPagination(t *testing.T) {
|
||||
vins := []string{"LNX00000000000001", "LNX00000000000002", "LNX00000000000003", "LNX00000000000004", "LNX00000000000005", "LNX00000000000006"}
|
||||
store := &operationTestStore{MockStore: NewMockStore(), totals: []OperationMileageTotal{
|
||||
{VIN: vins[0], Meters: operationMeters(3000000), ValidDays: 3},
|
||||
{VIN: vins[1], Meters: operationMeters(2999999), ValidDays: 3},
|
||||
{VIN: vins[3], Meters: operationMeters(3200000), ValidDays: 2},
|
||||
{VIN: vins[4], Meters: operationMeters(2500000), ValidDays: 2},
|
||||
{VIN: vins[5], Meters: operationMeters(0), ValidDays: 3},
|
||||
}}
|
||||
for _, vin := range vins {
|
||||
store.roster = append(store.roster, CustomerVehicleAssignment{VIN: vin, Plate: "测试车"})
|
||||
}
|
||||
// The roster may repeat one vehicle; it must still be counted once.
|
||||
store.roster = append(store.roster, store.roster[0])
|
||||
service := NewService(store)
|
||||
q := operationQuery()
|
||||
q.Set("limit", "2")
|
||||
result, err := service.OperationCompliance(operationContext(), q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Total != 6 || len(result.Items) != 2 || result.Summary.Passed != 2 || result.Summary.Failed != 2 || result.Summary.Insufficient != 2 || result.Summary.PassRate == nil || *result.Summary.PassRate != 50 {
|
||||
t.Fatalf("bad full-result summary: %+v", result)
|
||||
}
|
||||
if result.Items[0].VIN != vins[3] || !result.Items[0].Incomplete || result.Items[0].MissingDays != 1 || result.Items[1].Status != "passed" {
|
||||
t.Fatalf("wrong order or boundary: %+v", result.Items)
|
||||
}
|
||||
q.Set("limit", "100")
|
||||
q.Set("sort", "mileage-asc")
|
||||
result, err = service.OperationCompliance(operationContext(), q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Items[0].MileageKm == nil || *result.Items[0].MileageKm != 0 || result.Items[0].Status != "failed" || result.Items[5].MileageKm != nil {
|
||||
t.Fatal("zero and absent data conflated")
|
||||
}
|
||||
byVIN := map[string]OperationComplianceRow{}
|
||||
for _, r := range result.Items {
|
||||
byVIN[r.VIN] = r
|
||||
}
|
||||
if byVIN[vins[1]].Status != "failed" || byVIN[vins[4]].Status != "insufficient" || byVIN[vins[0]].Customer != "" {
|
||||
t.Fatal("boundary, partial data or unassigned vehicle lost")
|
||||
}
|
||||
q.Set("offset", "2")
|
||||
result, err = service.OperationCompliance(operationContext(), q)
|
||||
if err != nil || result.Summary.Total != 6 {
|
||||
t.Fatal("pagination changed summary")
|
||||
}
|
||||
q.Set("targetKm", "4000")
|
||||
q.Set("offset", "0")
|
||||
result, err = service.OperationCompliance(operationContext(), q)
|
||||
if err != nil || result.Summary.Passed != 0 || result.Summary.Failed != 3 || result.Summary.Insufficient != 3 {
|
||||
t.Fatalf("target not reevaluated: %+v %v", result, err)
|
||||
}
|
||||
q.Set("status", "failed")
|
||||
result, err = service.OperationCompliance(operationContext(), q)
|
||||
if err != nil || result.Total != 3 || result.Summary.Total != 3 || result.Summary.Failed != 3 {
|
||||
t.Fatal("status-filter summary must describe the complete status-filtered result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceMultiSelectAndScopedOptions(t *testing.T) {
|
||||
rows := []CustomerVehicleAssignment{
|
||||
{VIN: "LNX00000000000001", Customer: "客户甲", Department: "一部", ResponsiblePerson: "张"},
|
||||
{VIN: "LNX00000000000002", Customer: "客户乙", Department: "一部", ResponsiblePerson: "李"},
|
||||
{VIN: "LNX00000000000003", Customer: "客户甲分公司", Department: "二部", ResponsiblePerson: "张"},
|
||||
{VIN: "LNX00000000000004", Customer: "范围外客户", Department: "外部", ResponsiblePerson: "外部"},
|
||||
}
|
||||
store := &operationTestStore{MockStore: NewMockStore(), roster: rows, assignments: rows}
|
||||
p := Principal{Role: "customer", UserType: "customer", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{rows[0].VIN, rows[1].VIN, rows[2].VIN}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
service := NewService(store)
|
||||
options, err := service.OperationComplianceOptions(ctx)
|
||||
if err != nil || len(options.Customers) != 3 || strings.Contains(strings.Join(options.Customers, ","), "范围外") {
|
||||
t.Fatalf("unscoped options: %+v %v", options, err)
|
||||
}
|
||||
q := operationQuery()
|
||||
q["customer"] = []string{"客户甲", "客户乙"}
|
||||
q["department"] = []string{"一部"}
|
||||
q["responsiblePerson"] = []string{"李", "张"}
|
||||
q.Set("scopeVins", rows[3].VIN)
|
||||
result, err := service.OperationCompliance(ctx, q)
|
||||
if err != nil || result.Total != 2 {
|
||||
t.Fatalf("OR/AND failed: %+v %v", result, err)
|
||||
}
|
||||
if strings.Contains(store.query.Get("vins"), rows[3].VIN) || strings.Contains(store.query.Get("vins"), rows[2].VIN) {
|
||||
t.Fatal("selected values were treated as fuzzy authorization or client scope trusted")
|
||||
}
|
||||
p.MenuKeys = []string{"statistics"}
|
||||
if _, err = service.OperationCompliance(WithPrincipal(context.Background(), p), q); err == nil {
|
||||
t.Fatal("statistics-only principal gained new permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceHistoricalBoundsAndMissingGrant(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
end := time.Date(2026, 9, 4, 12, 0, 0, 0, zone)
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, zone), ValidTo: &end}}}
|
||||
store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin}}, totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(0), ValidDays: 2}}}
|
||||
result, err := NewService(store).OperationCompliance(WithPrincipal(context.Background(), p), operationQuery())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := result.Items[0]
|
||||
if row.Status != "insufficient" || row.AuthorizedDays != 2 || row.ExpectedDays != 3 || row.MissingDays != 0 {
|
||||
t.Fatalf("partial authorization treated as complete zero: %+v", row)
|
||||
}
|
||||
var starts map[string]string
|
||||
json.Unmarshal([]byte(store.query.Get("scopeVehicleFrom")), &starts)
|
||||
if starts[vin] != "2026-09-02" {
|
||||
t.Fatal("partial first day exposed")
|
||||
}
|
||||
p.VehicleGrants[0].ValidTo = func() *time.Time { v := time.Date(2026, 9, 3, 12, 0, 0, 0, zone); return &v }()
|
||||
_, last, days := operationAuthorizedBounds(p, vin, "2026-09-01", "2026-09-03")
|
||||
if last != "2026-09-02" || days != 1 {
|
||||
t.Fatal("partial last day exposed")
|
||||
}
|
||||
p.VehicleGrants = nil
|
||||
store.reads = 0
|
||||
result, err = NewService(store).OperationCompliance(WithPrincipal(context.Background(), p), operationQuery())
|
||||
if err != nil || store.reads != 0 || result.Items[0].AuthorizedDays != 0 || result.Items[0].MileageKm != nil {
|
||||
t.Fatal("unverifiable history grant reached data store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceTargetValidationAndSQL(t *testing.T) {
|
||||
for _, raw := range []string{"", "0", "0.000", "-1", "NaN", "Infinity", "1e3", "0.0001", "9999999999999999"} {
|
||||
if _, err := parseOperationTarget(url.Values{"targetKm": {raw}}); err == nil {
|
||||
t.Fatalf("accepted invalid target %q", raw)
|
||||
}
|
||||
}
|
||||
for raw, want := range map[string]int64{"3000": 3000000, "4000": 4000000, "0.001": 1, "50.001": 50001} {
|
||||
got, err := parseOperationTarget(url.Values{"targetKm": {raw}})
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("decimal lost: %s %d %v", raw, got, err)
|
||||
}
|
||||
}
|
||||
q := operationQuery()
|
||||
q.Set("vins", "LNX00000000000001")
|
||||
q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2026-09-02"}`)
|
||||
q.Set("operationVehicleTo", `{"LNX00000000000001":"2026-09-03"}`)
|
||||
built, err := buildOperationMileageSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, text := range []string{"m.protocol = 'JT808'", "COUNT(DISTINCT m.daily_mileage_km)=1", "GROUP BY m.vin,m.stat_date", "MAX(m.daily_mileage_km)<=3000", "km*1000", "m.stat_date <= ?"} {
|
||||
if !strings.Contains(built.Text, text) {
|
||||
t.Fatalf("missing semantic guard: %s", text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(built.Text, "LNX00000000000001") {
|
||||
t.Fatal("VIN embedded in SQL")
|
||||
}
|
||||
q.Del("vins")
|
||||
built, err = buildOperationMileageSQL(q)
|
||||
if err != nil || !strings.Contains(built.Text, "1=0") {
|
||||
t.Fatal("empty scope queried every vehicle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceDailyDetailsSourceAndHistoricalScope(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
zone := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, zone)}}}
|
||||
store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin, Plate: "测试车"}}, days: []OperationMileageDay{
|
||||
{VIN: vin, Date: "2026-09-02", Protocol: "GB32960", Meters: operationMeters(0)},
|
||||
{VIN: vin, Date: "2026-09-03", Protocol: "YUTONG_MQTT", Meters: operationMeters(100000)},
|
||||
{VIN: vin, Date: "2026-09-04", Protocol: "GB32960"},
|
||||
}, totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(100000), ValidDays: 2, InvalidDays: 1}}}
|
||||
q := operationQuery()
|
||||
q.Set("dateTo", "2026-09-05")
|
||||
q.Set("vin", vin)
|
||||
q.Set("mileageSource", "odometer")
|
||||
q.Set("targetKm", "100")
|
||||
q.Set("vins", "FORGED")
|
||||
q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2000-01-01"}`)
|
||||
svc := NewService(store)
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
detail, err := svc.OperationComplianceDetail(ctx, q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.MileageSource != "odometer" || len(detail.Days) != 5 || detail.Vehicle.Status != "passed" || detail.Vehicle.MileageKm == nil || *detail.Vehicle.MileageKm != 100 || detail.Vehicle.ValidDays != 2 || detail.Vehicle.InvalidDays != 1 || detail.Vehicle.MissingDays != 1 || detail.Vehicle.AuthorizedDays != 4 {
|
||||
t.Fatalf("bad detail: %+v", detail)
|
||||
}
|
||||
for i, want := range []string{"restricted", "valid", "valid", "invalid", "missing"} {
|
||||
if detail.Days[i].Status != want {
|
||||
t.Fatalf("day %d: %+v", i, detail.Days[i])
|
||||
}
|
||||
}
|
||||
if detail.Days[1].MileageKm == nil || *detail.Days[1].MileageKm != 0 || detail.Days[0].MileageKm != nil || detail.Days[2].Protocol != "YUTONG_MQTT" {
|
||||
t.Fatal("zero, missing or protocol lost")
|
||||
}
|
||||
if store.query.Get("vins") != vin || store.query.Get("dateFrom") != "2026-09-02" || store.query.Get("mileageSource") != "odometer" || strings.Contains(store.query.Encode(), "FORGED") {
|
||||
t.Fatalf("scope trusted caller: %+v", store.query)
|
||||
}
|
||||
list, err := svc.OperationCompliance(ctx, q)
|
||||
if err != nil || list.MileageSource != detail.MileageSource || list.Items[0].Status != detail.Vehicle.Status || *list.Items[0].MileageKm != *detail.Vehicle.MileageKm {
|
||||
t.Fatalf("list/detail differ: %+v %v", list, err)
|
||||
}
|
||||
q.Set("vin", "LNX00000000000002")
|
||||
if _, err = svc.OperationComplianceDetail(ctx, q); err == nil || store.dayReads != 1 {
|
||||
t.Fatal("unauthorized VIN reached daily store")
|
||||
}
|
||||
q.Set("vin", vin)
|
||||
p.VehicleGrants = nil
|
||||
detail, err = svc.OperationComplianceDetail(WithPrincipal(context.Background(), p), q)
|
||||
if err != nil || detail.Vehicle.MileageKm != nil || store.dayReads != 1 {
|
||||
t.Fatal("missing historical grant reached store")
|
||||
}
|
||||
p.MenuKeys = []string{"statistics"}
|
||||
if _, err = svc.OperationComplianceDetail(WithPrincipal(context.Background(), p), q); err == nil {
|
||||
t.Fatal("other menu gained daily access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceSourceValidationAndSelection(t *testing.T) {
|
||||
for _, source := range []string{"all", "GB32960", "gps,odometer", "unknown"} {
|
||||
q := operationQuery()
|
||||
q.Set("mileageSource", source)
|
||||
if _, err := NewService(&operationTestStore{MockStore: NewMockStore()}).OperationCompliance(operationContext(), q); err == nil {
|
||||
t.Fatalf("accepted source %s", source)
|
||||
}
|
||||
}
|
||||
q := operationQuery()
|
||||
q.Set("mileageSource", "odometer")
|
||||
q.Set("vins", "LNX00000000000001")
|
||||
q.Set("operationVehicleTo", `{}`)
|
||||
built, err := buildOperationMileageDaysSQL(q)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, guard := range []string{"m.protocol IN ('GB32960','YUTONG_MQTT')", "GROUP BY m.vin,m.stat_date,m.protocol", "PARTITION BY vin,stat_date", "CASE WHEN km IS NULL THEN 1 ELSE 0 END", "CASE WHEN COALESCE(km, 0) > 0 THEN 0 ELSE 1 END", "WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2", "source_rank=1"} {
|
||||
if !strings.Contains(built.Text, guard) {
|
||||
t.Fatalf("missing source selection %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationComplianceHydrogenUsesCallerScopeAndReconcilesDaily(t *testing.T) {
|
||||
vin := "LNX00000000000001"
|
||||
store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin}},
|
||||
totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(1500125), ValidDays: 2}},
|
||||
days: []OperationMileageDay{{VIN: vin, Date: "2026-09-02", Protocol: "GZEV", Meters: operationMeters(0)}, {VIN: vin, Date: "2026-09-03", Protocol: "GZEV", Meters: operationMeters(1500125)}},
|
||||
}
|
||||
p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))}}}
|
||||
ctx := WithPrincipal(context.Background(), p)
|
||||
q := operationQuery()
|
||||
q.Set("mileageSource", "hydrogen")
|
||||
q.Set("targetKm", "1500.125")
|
||||
q.Set("vins", "FORGED")
|
||||
q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2000-01-01"}`)
|
||||
svc := NewService(store)
|
||||
list, err := svc.OperationCompliance(ctx, q)
|
||||
if err != nil || list.MileageSource != "hydrogen" || list.Items[0].Status != "passed" || !list.Items[0].Incomplete {
|
||||
t.Fatalf("hydrogen threshold or coverage: %+v %v", list, err)
|
||||
}
|
||||
q.Set("vin", vin)
|
||||
detail, err := svc.OperationComplianceDetail(ctx, q)
|
||||
if err != nil || detail.MileageSource != "hydrogen" || *detail.Vehicle.MileageKm != *list.Items[0].MileageKm || detail.Vehicle.Status != list.Items[0].Status {
|
||||
t.Fatalf("hydrogen list/detail differ: %+v %v", detail, err)
|
||||
}
|
||||
if detail.Days[0].Status != "restricted" || detail.Days[0].MileageKm != nil || *detail.Days[1].MileageKm != 0 || detail.Days[2].Protocol != "GZEV" {
|
||||
t.Fatalf("hydrogen authorization, zero or source lost: %+v", detail.Days)
|
||||
}
|
||||
if store.query.Get("vins") != vin || store.query.Get("dateFrom") != "2026-09-02" || store.query.Get("mileageSource") != "hydrogen" || strings.Contains(store.query.Encode(), "FORGED") {
|
||||
t.Fatalf("hydrogen trusted client scope: %+v", store.query)
|
||||
}
|
||||
q.Set("dateTo", "2026-09-04")
|
||||
detail, err = svc.OperationComplianceDetail(ctx, q)
|
||||
if err != nil || detail.Days[3].MileageKm != nil || detail.Days[3].Reason != "暂无纯氢日里程,不补为零" {
|
||||
t.Fatalf("missing hydrogen became zero: %+v %v", detail, err)
|
||||
}
|
||||
}
|
||||
@@ -13,25 +13,31 @@ 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"`
|
||||
BusinessScopeLevel string `json:"businessScopeLevel,omitempty"`
|
||||
DepartmentIDs []string `json:"departmentIds,omitempty"`
|
||||
ResponsibleUserID string `json:"responsibleUserId,omitempty"`
|
||||
VehicleScopeSource string `json:"vehicleScopeSource,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
DataSource string `json:"dataSource,omitempty"`
|
||||
AuthorizationStatus string `json:"authorizationStatus,omitempty"`
|
||||
PermissionKeys []string `json:"-"`
|
||||
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.PermissionKeys = append([]string(nil), p.PermissionKeys...)
|
||||
p.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...)
|
||||
@@ -41,6 +47,9 @@ func (p Principal) Clone() Principal {
|
||||
}
|
||||
|
||||
func (p Principal) CanMenu(key string) bool {
|
||||
if p.AuthorizationStatus == "loading" {
|
||||
return false
|
||||
}
|
||||
for _, value := range p.MenuKeys {
|
||||
if value == key {
|
||||
return true
|
||||
@@ -55,12 +64,31 @@ func (p Principal) CanMenu(key string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p Principal) HasVehicleScope() bool {
|
||||
return p.UserType == "customer" || p.VehicleScopeSource == "oneos-vehicle-query"
|
||||
}
|
||||
|
||||
// Vehicle-query authorization grants history for currently visible VINs. It
|
||||
// carries no assignment dates; existing local/contract time grants stay intact.
|
||||
func (p Principal) HasHistoryTimeScope() bool {
|
||||
return p.HasVehicleScope() && p.VehicleScopeSource != "oneos-vehicle-query"
|
||||
}
|
||||
|
||||
func (p Principal) CanVIN(vin string) bool {
|
||||
if p.UserType != "customer" {
|
||||
if p.AuthorizationStatus == "loading" {
|
||||
return false
|
||||
}
|
||||
if !p.HasVehicleScope() {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range p.VehicleVINs {
|
||||
if allowed == vin {
|
||||
if p.AuthProvider == "oneos" {
|
||||
if grant, ok := p.VehicleGrant(vin); ok && !grant.ValidFrom.IsZero() {
|
||||
now := time.Now()
|
||||
return !grant.ValidFrom.After(now) && (grant.ValidTo == nil || grant.ValidTo.After(now))
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +387,7 @@ func (s *ProductionStore) coverageMissingSourceStats(ctx context.Context, query
|
||||
|
||||
func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
inlineEnergy := query.Get("includeEnergy") == "1" && query.Get("sort") == "hydrogenPressureMpa"
|
||||
total := 0
|
||||
if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
@@ -412,9 +413,16 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
||||
var todayMileageAvailable int
|
||||
var reportIntervalMs int64
|
||||
var todayMileage string
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &row.LastSeen, &reportIntervalMs); err != nil {
|
||||
var energyPayload string
|
||||
var tankCapacity float64
|
||||
scanArgs := []any{&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &row.LastSeen, &reportIntervalMs}
|
||||
if inlineEnergy {
|
||||
scanArgs = append(scanArgs, &energyPayload, &tankCapacity)
|
||||
}
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
row.VehicleNumber = vehicleNumber(row.VIN)
|
||||
row.Protocols = splitCSV(protocols)
|
||||
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen)
|
||||
row.Online = online == 1
|
||||
@@ -440,11 +448,22 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
||||
if reportIntervalMs > 0 {
|
||||
row.ReportIntervalMs = &reportIntervalMs
|
||||
}
|
||||
if inlineEnergy {
|
||||
applyMonitorEnergyWithCapacity(&row, parsedFieldsFromString(energyPayload), tankCapacity)
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
if query.Get("includeEnergy") == "1" && !inlineEnergy {
|
||||
if err := s.populateMonitorEnergy(ctx, items); err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
}
|
||||
limit, offset := buildLimitOffset(query)
|
||||
return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
@@ -621,7 +640,9 @@ func isTDengineTableNotExist(err error) bool {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "table does not exist") || strings.Contains(message, "fail to get table info")
|
||||
// "Fail to get table info" also wraps connection and other metadata errors.
|
||||
// Only an explicit missing table can safely become an empty history result.
|
||||
return strings.Contains(message, "table does not exist")
|
||||
}
|
||||
|
||||
func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
@@ -1367,6 +1388,7 @@ func hydrogenRatePer100Km(consumptionKg, dailyMileageKm float64, dataDays int) *
|
||||
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
where, args = appendVINListFilter(where, args, "q.vin", query.Get("scopeVins"))
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "q.vin = ?")
|
||||
args = append(args, vin)
|
||||
|
||||
@@ -50,6 +50,9 @@ func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfil
|
||||
}
|
||||
|
||||
func (s *Service) SaveVehicleProfile(ctx context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return VehicleProfile{}, err
|
||||
}
|
||||
vin = strings.TrimSpace(vin)
|
||||
if err := validateProfileVIN(vin); err != nil {
|
||||
return VehicleProfile{}, err
|
||||
@@ -85,6 +88,11 @@ func (s *Service) SyncVehicleProfiles(ctx context.Context, request VehicleProfil
|
||||
if err := validateVehicleProfileSyncRequest(request); err != nil {
|
||||
return VehicleProfileSyncResult{}, err
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
if err := authorizeVehicleVIN(ctx, item.VIN); err != nil {
|
||||
return VehicleProfileSyncResult{}, err
|
||||
}
|
||||
}
|
||||
return store.SyncVehicleProfiles(ctx, request)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ func (s *Service) reconciliationStore() (ReconciliationStore, error) {
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) {
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
return s.scopedReconciliationSummary(ctx)
|
||||
}
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationSummary{}, err
|
||||
@@ -36,6 +39,7 @@ func (s *Service) ReconciliationSummary(ctx context.Context, days int) (Reconcil
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
|
||||
query.AllowedVINs = principalVehicleVINScope(ctx)
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
@@ -222,6 +226,11 @@ func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string,
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
return store.SetReconciliationIssueArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
@@ -276,6 +285,11 @@ func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, requ
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
return store.AssignReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
@@ -319,7 +333,14 @@ func (s *Service) ReconciliationIssue(ctx context.Context, id string) (Reconcili
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.ReconciliationIssue(ctx, id)
|
||||
item, err := store.ReconciliationIssue(ctx, id)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if err = authorizeVehicleVIN(ctx, item.VIN); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
|
||||
@@ -332,6 +353,11 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
||||
if _, err := s.ReconciliationIssue(ctx, id); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
return store.UpdateReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
@@ -397,3 +423,62 @@ func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEva
|
||||
}
|
||||
return store.EvaluateReconciliation(ctx)
|
||||
}
|
||||
|
||||
// Historical run totals have no VIN dimension. Scoped users receive current
|
||||
// issue totals only, rather than another user's fleet-wide trend.
|
||||
func (s *Service) scopedReconciliationSummary(ctx context.Context) (ReconciliationSummary, error) {
|
||||
if store, ok := s.store.(interface {
|
||||
ScopedReconciliationSummary(context.Context) (ReconciliationSummary, error)
|
||||
}); ok {
|
||||
return store.ScopedReconciliationSummary(ctx)
|
||||
}
|
||||
out := ReconciliationSummary{ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{}, Trend: []ReconciliationTrendPoint{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
rules, severities := map[string]int{}, map[string]int{}
|
||||
for _, scope := range []string{"current", "archived"} {
|
||||
for offset := 0; ; offset += 200 {
|
||||
page, err := s.ReconciliationIssues(ctx, ReconciliationQuery{Scope: scope, Limit: 200, Offset: offset})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if page.Total > reconciliationFindingLimit {
|
||||
return out, clientError{Code: "SCOPE_TOO_LARGE", Message: "授权范围内差异过多,请缩小查询范围"}
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if scope == "archived" {
|
||||
out.Archived++
|
||||
continue
|
||||
}
|
||||
out.Current++
|
||||
active := item.Status == "pending" || item.Status == "confirmed_source_a" || item.Status == "confirmed_source_b"
|
||||
switch item.Status {
|
||||
case "pending":
|
||||
out.Pending++
|
||||
case "confirmed_source_a", "confirmed_source_b":
|
||||
out.Confirmed++
|
||||
case "recovered":
|
||||
out.Recovered++
|
||||
}
|
||||
if active {
|
||||
out.Active++
|
||||
rules[item.RuleCode]++
|
||||
severities[item.Severity]++
|
||||
if due, ok := parseTrackRequestTime(item.DueAt); ok && due.Before(time.Now()) {
|
||||
out.OverSLA++
|
||||
}
|
||||
}
|
||||
}
|
||||
if offset+len(page.Items) >= page.Total || len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, count := range rules {
|
||||
out.ByRule = append(out.ByRule, ReconciliationBucket{Name: name, Count: count})
|
||||
}
|
||||
for name, count := range severities {
|
||||
out.BySeverity = append(out.BySeverity, ReconciliationBucket{Name: name, Count: count})
|
||||
}
|
||||
sort.Slice(out.ByRule, func(i, j int) bool { return out.ByRule[i].Name < out.ByRule[j].Name })
|
||||
sort.Slice(out.BySeverity, func(i, j int) bool { return out.BySeverity[i].Name < out.BySeverity[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -392,6 +392,13 @@ WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
|
||||
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where := []string{"i.archived_at IS NULL"}
|
||||
args := []any{}
|
||||
if query.AllowedVINs != nil {
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
where = append(where, "1=0")
|
||||
} else {
|
||||
where, args = appendVINListFilter(where, args, "i.vin", strings.Join(query.AllowedVINs, ","))
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
where[0] = "i.archived_at IS NOT NULL"
|
||||
}
|
||||
@@ -838,3 +845,57 @@ GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Aggregate in SQL before pagination; walking all issue pages made scoped
|
||||
// operations dashboards exceed the browser's request deadline.
|
||||
func (s *ProductionStore) ScopedReconciliationSummary(ctx context.Context) (ReconciliationSummary, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationSummary{}, err
|
||||
}
|
||||
vins := principalVehicleVINScope(ctx)
|
||||
where, args := []string{"1=1"}, []any{}
|
||||
if len(vins) == 0 {
|
||||
where = append(where, "1=0")
|
||||
} else {
|
||||
where, args = appendVINListFilter(where, args, "vin", strings.Join(vins, ","))
|
||||
}
|
||||
filter := strings.Join(where, " AND ")
|
||||
out := ReconciliationSummary{ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{}, Trend: []ReconciliationTrendPoint{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(archived_at IS NULL),0),COALESCE(SUM(archived_at IS NOT NULL),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='pending'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status='recovered'),0),
|
||||
COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b') AND COALESCE(due_at,DATE_ADD(first_seen_at,INTERVAL 24 HOUR))<NOW()),0)
|
||||
FROM vehicle_reconciliation_issue WHERE `+filter, args...).Scan(&out.Current, &out.Archived, &out.Active, &out.Pending, &out.Confirmed, &out.Recovered, &out.OverSLA)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, column := range []string{"rule_code", "severity"} {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue WHERE `+filter+` AND archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b') GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column, args...)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
buckets := []ReconciliationBucket{}
|
||||
for rows.Next() {
|
||||
var b ReconciliationBucket
|
||||
if err := rows.Scan(&b.Name, &b.Count); err != nil {
|
||||
rows.Close()
|
||||
return out, err
|
||||
}
|
||||
buckets = append(buckets, b)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if column == "rule_code" {
|
||||
out.ByRule = buckets
|
||||
} else {
|
||||
out.BySeverity = buckets
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -549,21 +549,22 @@ type monitorActivityStore interface {
|
||||
}
|
||||
|
||||
func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, vehicles Page[VehicleRealtimeRow]) (MonitorSummary, error) {
|
||||
var dashboard DashboardSummary
|
||||
var err error
|
||||
if store, ok := s.store.(monitorActivityStore); ok {
|
||||
dashboard, err = store.MonitorActivitySummary(ctx)
|
||||
} else {
|
||||
dashboard, err = s.store.DashboardSummary(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return MonitorSummary{}, err
|
||||
}
|
||||
result := MonitorSummary{
|
||||
ActiveToday: dashboard.ActiveToday,
|
||||
FrameToday: dashboard.FrameToday,
|
||||
Truncated: vehicles.Total > len(vehicles.Items),
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
result := MonitorSummary{Truncated: vehicles.Total > len(vehicles.Items), AsOf: time.Now().UTC().Format(time.RFC3339)}
|
||||
principal, scoped := PrincipalFromContext(ctx)
|
||||
if !scoped || !principal.HasVehicleScope() {
|
||||
var dashboard DashboardSummary
|
||||
var err error
|
||||
if store, ok := s.store.(monitorActivityStore); ok {
|
||||
dashboard, err = store.MonitorActivitySummary(ctx)
|
||||
} else {
|
||||
dashboard, err = s.store.DashboardSummary(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return MonitorSummary{}, err
|
||||
}
|
||||
result.ActiveToday = dashboard.ActiveToday
|
||||
result.FrameToday = dashboard.FrameToday
|
||||
result.ActivityDataAvailable = true
|
||||
}
|
||||
activeAlertVINs := map[string]struct{}{}
|
||||
if store, ok := s.store.(monitorAlertStore); ok {
|
||||
@@ -860,6 +861,15 @@ func (s *Service) SourceReadiness(ctx context.Context) (SourceReadinessPlan, err
|
||||
}
|
||||
|
||||
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
// A partial fleet number may also match a plate/phone. Do not resolve that
|
||||
// search to the first vehicle before the list's OR predicate can run.
|
||||
if strings.TrimSpace(query.Get("vin")) == "" && len(vehicleNumberVINs(vehicleSearchKeywords(query), false)) > 0 {
|
||||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
}
|
||||
return s.store.VehicleRealtime(ctx, scopedQuery)
|
||||
}
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleRealtimeRow]{}, err
|
||||
@@ -870,7 +880,7 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V
|
||||
func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() {
|
||||
resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol)
|
||||
if err != nil {
|
||||
return VehicleServiceOverview{}, err
|
||||
@@ -965,7 +975,7 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
||||
|
||||
func principalVehicleVINScope(ctx context.Context) []string {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasVehicleScope() {
|
||||
return nil
|
||||
}
|
||||
scope := make([]string, 0, len(principal.VehicleVINs))
|
||||
@@ -979,7 +989,7 @@ func principalVehicleVINScope(ctx context.Context) []string {
|
||||
|
||||
func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasVehicleScope() {
|
||||
return page
|
||||
}
|
||||
for index := range page.Items {
|
||||
@@ -992,6 +1002,17 @@ func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOv
|
||||
}
|
||||
|
||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
return s.vehicleDetail(ctx, vin, protocol, true)
|
||||
}
|
||||
|
||||
// VehicleLiveDetail loads the single-vehicle workspace without historical
|
||||
// previews. Those previews belong to the history pages and must not make live
|
||||
// identity, location, profile and business data depend on historical storage.
|
||||
func (s *Service) VehicleLiveDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
return s.vehicleDetail(ctx, vin, protocol, false)
|
||||
}
|
||||
|
||||
func (s *Service) vehicleDetail(ctx context.Context, vin string, protocol string, includeHistory bool) (VehicleDetail, error) {
|
||||
keyword := strings.TrimSpace(vin)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
|
||||
@@ -1028,6 +1049,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
return VehicleDetail{
|
||||
HistoryIncluded: includeHistory,
|
||||
VIN: "",
|
||||
LookupKey: keyword,
|
||||
LookupResolved: false,
|
||||
@@ -1080,24 +1102,31 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
history, err := s.store.HistoryLocationsFromTDengine(ctx, historyQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
mileage, err := s.store.DailyMileage(ctx, mileageQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
history := Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20}
|
||||
raw := Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10}
|
||||
mileage := Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20}
|
||||
if includeHistory {
|
||||
history, err = s.store.HistoryLocationsFromTDengine(ctx, historyQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
raw, err = s.vehicleDetailRawPreview(ctx, rawQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
mileage, err = s.store.DailyMileage(ctx, mileageQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
}
|
||||
quality, err := s.store.QualityIssues(ctx, qualityQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
observedSourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
||||
observedSourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, observedSourceStatus)
|
||||
if includeHistory {
|
||||
observedSourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, observedSourceStatus)
|
||||
}
|
||||
availableSources := sourceNamesForProtocol(observedSourceStatus, protocol)
|
||||
sourceStatus := completeVehicleSourceStatus(observedSourceStatus, protocol)
|
||||
serviceStatus := buildVehicleServiceStatus(true, sourceStatus)
|
||||
@@ -1125,6 +1154,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
}
|
||||
}
|
||||
return VehicleDetail{
|
||||
HistoryIncluded: includeHistory,
|
||||
VIN: resolvedVIN,
|
||||
LookupKey: keyword,
|
||||
LookupResolved: resolvedVIN != "",
|
||||
@@ -1150,10 +1180,11 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
}
|
||||
|
||||
const vehicleDetailRawProjectionLookback = 24 * time.Hour
|
||||
const vehicleDetailRawPreviewTimeout = time.Second
|
||||
|
||||
type vehicleDetailRawPreviewResult struct {
|
||||
anchor RawFrameRow
|
||||
page Page[RawFrameRow]
|
||||
index int
|
||||
page Page[RawFrameRow]
|
||||
}
|
||||
|
||||
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
|
||||
@@ -1187,32 +1218,47 @@ func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQue
|
||||
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
previewCtx, cancel := context.WithTimeout(ctx, vehicleDetailRawPreviewTimeout)
|
||||
defer cancel()
|
||||
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
|
||||
for _, anchor := range anchors.Items {
|
||||
anchor := anchor
|
||||
previews := make([][]RawFrameRow, len(anchors.Items))
|
||||
for index, anchor := range anchors.Items {
|
||||
previews[index] = []RawFrameRow{anchor}
|
||||
go func() {
|
||||
bounded, valid := boundedRawPreviewQuery(query, anchor)
|
||||
if !valid {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
results <- vehicleDetailRawPreviewResult{index: index}
|
||||
return
|
||||
}
|
||||
page, queryErr := s.store.RawFrames(ctx, bounded)
|
||||
page, queryErr := s.store.RawFrames(previewCtx, bounded)
|
||||
if queryErr != nil {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
results <- vehicleDetailRawPreviewResult{index: index}
|
||||
return
|
||||
}
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
|
||||
results <- vehicleDetailRawPreviewResult{index: index, page: page}
|
||||
}()
|
||||
}
|
||||
|
||||
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
|
||||
// Some historical drivers return after their context expires. Keep a
|
||||
// buffered result channel and select on the budget so a slow driver cannot
|
||||
// prevent the already-read durable projections from reaching the caller.
|
||||
collect:
|
||||
for range anchors.Items {
|
||||
result := <-results
|
||||
if len(result.page.Items) == 0 {
|
||||
items = append(items, result.anchor)
|
||||
continue
|
||||
select {
|
||||
case result := <-results:
|
||||
if len(result.page.Items) > 0 {
|
||||
previews[result.index] = result.page.Items
|
||||
}
|
||||
case <-previewCtx.Done():
|
||||
if ctx.Err() != nil {
|
||||
return Page[RawFrameRow]{}, ctx.Err()
|
||||
}
|
||||
break collect
|
||||
}
|
||||
items = append(items, result.page.Items...)
|
||||
}
|
||||
items := make([]RawFrameRow, 0, len(anchors.Items))
|
||||
for _, preview := range previews {
|
||||
items = append(items, preview...)
|
||||
}
|
||||
items = newestRawFrames(items, query.Limit)
|
||||
limit := query.Limit
|
||||
@@ -1361,7 +1407,7 @@ func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date st
|
||||
|
||||
func authorizeVehicleDailyEvidenceDate(ctx context.Context, vin string, date string) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasHistoryTimeScope() {
|
||||
return nil
|
||||
}
|
||||
grant, ok := principal.VehicleGrant(vin)
|
||||
@@ -4287,7 +4333,14 @@ func canAccessHistoryExport(principal Principal, job *HistoryExportJob) bool {
|
||||
if job == nil {
|
||||
return false
|
||||
}
|
||||
if principal.Role == "admin" || principal.UserType == "admin" {
|
||||
if principal.HasVehicleScope() {
|
||||
for _, vin := range job.VehicleVINs {
|
||||
if !principal.CanVIN(vin) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if (principal.Role == "admin" || principal.UserType == "admin") && !principal.HasVehicleScope() {
|
||||
return true
|
||||
}
|
||||
ownerID := historyExportOwnerID(principal)
|
||||
@@ -5841,11 +5894,19 @@ func offlineDurationMinutes(online bool, lastSeen string, now time.Time) *int {
|
||||
}
|
||||
|
||||
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
||||
return s.store.QualitySummary(ctx, query)
|
||||
q, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return QualitySummary{}, err
|
||||
}
|
||||
return s.store.QualitySummary(ctx, q)
|
||||
}
|
||||
|
||||
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||
return s.store.QualityIssues(ctx, query)
|
||||
q, err := applyPrincipalVehicleScope(ctx, query)
|
||||
if err != nil {
|
||||
return Page[QualityIssueRow]{}, err
|
||||
}
|
||||
return s.store.QualityIssues(ctx, q)
|
||||
}
|
||||
|
||||
func (s *Service) QualityNotificationPlan(ctx context.Context, query url.Values) (QualityNotificationPlan, error) {
|
||||
@@ -6127,7 +6188,7 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur
|
||||
return nil, err
|
||||
}
|
||||
if resolvedVIN != "" {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) {
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() && !principal.CanVIN(resolvedVIN) {
|
||||
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||||
}
|
||||
resolved.Set("vin", resolvedVIN)
|
||||
@@ -6162,11 +6223,16 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
|
||||
func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasVehicleScope() {
|
||||
return next, nil
|
||||
}
|
||||
allowed := make(map[string]bool, len(principal.VehicleVINs))
|
||||
activeVINs := make([]string, 0, len(principal.VehicleVINs))
|
||||
for _, vin := range principal.VehicleVINs {
|
||||
if !principal.CanVIN(vin) {
|
||||
continue
|
||||
}
|
||||
activeVINs = append(activeVINs, vin)
|
||||
allowed[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
for _, value := range []string{next.Get("vin"), next.Get("vins")} {
|
||||
@@ -6177,10 +6243,14 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(principal.VehicleVINs) == 0 {
|
||||
if len(activeVINs) == 0 {
|
||||
next.Set("scopeVins", "__NO_VEHICLE_SCOPE__")
|
||||
} else {
|
||||
next.Set("scopeVins", strings.Join(principal.VehicleVINs, ","))
|
||||
next.Set("scopeVins", strings.Join(activeVINs, ","))
|
||||
}
|
||||
if !principal.HasHistoryTimeScope() {
|
||||
next.Del("scopeVehicleFrom")
|
||||
return next, nil
|
||||
}
|
||||
validFromByVIN := make(map[string]string, len(principal.VehicleGrants))
|
||||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
@@ -6205,9 +6275,12 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu
|
||||
}
|
||||
|
||||
func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.Values) (url.Values, error) {
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := cloneValues(query)
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasHistoryTimeScope() {
|
||||
return next, nil
|
||||
}
|
||||
grant, ok := principal.VehicleGrant(vin)
|
||||
@@ -6251,7 +6324,7 @@ func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.V
|
||||
|
||||
func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasHistoryTimeScope() {
|
||||
return nil
|
||||
}
|
||||
requested := make([]string, 0)
|
||||
@@ -6277,7 +6350,7 @@ func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values)
|
||||
|
||||
func authorizeVehicleVIN(ctx context.Context, vin string) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
if !ok || !principal.HasVehicleScope() {
|
||||
return nil
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
|
||||
@@ -128,6 +128,9 @@ func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (Vehi
|
||||
}
|
||||
|
||||
func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, update VehicleSourcePolicyUpdate) (VehicleSourceDiagnostic, error) {
|
||||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type unavailableDetailHistoryStore struct {
|
||||
*MockStore
|
||||
historyCalls int
|
||||
}
|
||||
|
||||
func (s *unavailableDetailHistoryStore) HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.historyCalls++
|
||||
return Page[HistoryLocationRow]{}, errors.New("historical storage unavailable")
|
||||
}
|
||||
|
||||
func (s *unavailableDetailHistoryStore) RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
s.historyCalls++
|
||||
return Page[RawFrameRow]{}, errors.New("historical storage unavailable")
|
||||
}
|
||||
|
||||
func (s *unavailableDetailHistoryStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||
s.historyCalls++
|
||||
return Page[DailyMileageRow]{}, errors.New("historical preview unavailable")
|
||||
}
|
||||
|
||||
func TestVehicleLiveDetailSurvivesHistoricalStorageFailure(t *testing.T) {
|
||||
store := &unavailableDetailHistoryStore{MockStore: NewMockStore()}
|
||||
service := NewService(store)
|
||||
detail, err := service.VehicleLiveDetail(exportAdminContext(), "粤AG18312", "JT808")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.historyCalls != 0 || detail.HistoryIncluded {
|
||||
t.Fatalf("live detail queried historical previews: calls=%d detail=%+v", store.historyCalls, detail)
|
||||
}
|
||||
if !detail.LookupResolved || detail.VIN != "LB9A32A24R0LS1426" || detail.Identity == nil || detail.RealtimeSummary == nil || detail.Profile == nil || detail.BusinessRelation == nil {
|
||||
t.Fatalf("live vehicle workspace lost required data: %+v", detail)
|
||||
}
|
||||
if len(detail.Sources) != 1 || detail.Sources[0] != "JT808" || len(detail.Realtime) == 0 {
|
||||
t.Fatalf("protocol filter or live sources lost: %+v", detail)
|
||||
}
|
||||
if detail.History.Items == nil || detail.Raw.Items == nil || detail.Mileage.Items == nil {
|
||||
t.Fatal("omitted previews must still serialize as arrays")
|
||||
}
|
||||
if _, err := service.VehicleDetail(exportAdminContext(), detail.VIN, ""); err == nil || store.historyCalls == 0 {
|
||||
t.Fatal("legacy full-detail endpoint no longer queries historical data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerLiveDetailKeepsVehicleAuthorization(t *testing.T) {
|
||||
const vin = "LB9A32A24R0LS1426"
|
||||
for _, allowed := range []bool{true, false} {
|
||||
store := &unavailableDetailHistoryStore{MockStore: NewMockStore()}
|
||||
principal := Principal{Role: "admin", UserType: "admin", AuthProvider: "oneos", VehicleScopeSource: "oneos-vehicle-query", VehicleVINs: []string{}}
|
||||
if allowed {
|
||||
principal.VehicleVINs = []string{vin}
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword="+vin+"&includeHistory=0", nil)
|
||||
request = request.WithContext(WithPrincipal(request.Context(), principal))
|
||||
response := httptest.NewRecorder()
|
||||
NewHandler(NewService(store)).ServeHTTP(response, request)
|
||||
if !allowed {
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("live mode bypassed OneOS scope: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleDetail `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil || response.Code != http.StatusOK || !body.Data.LookupResolved || body.Data.HistoryIncluded || store.historyCalls != 0 {
|
||||
t.Fatalf("live handler failed: %d %s err=%v", response.Code, response.Body.String(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Historical drivers can keep waiting even after cancellation. The caller
|
||||
// must still receive the durable latest frame within its preview budget.
|
||||
type delayedDetailRawStore struct {
|
||||
*MockStore
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (s *delayedDetailRawStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
rows := []RawFrameRow{}
|
||||
for _, protocol := range []string{"GB32960", "JT808"} {
|
||||
if query.Protocol == "" || query.Protocol == protocol {
|
||||
rows = append(rows, RawFrameRow{ID: "snapshot-" + protocol, VIN: "LB9A32A24R0LS1426", Protocol: protocol, FrameType: "realtime_projection", ServerTime: "2026-10-16T21:00:00+08:00", ParsedFields: map[string]any{"speed": 42}})
|
||||
}
|
||||
}
|
||||
return Page[RawFrameRow]{Items: rows, Total: len(rows)}, nil
|
||||
}
|
||||
|
||||
func (s *delayedDetailRawStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
if query.Protocol == "GB32960" {
|
||||
<-s.release
|
||||
return Page[RawFrameRow]{}, errors.New("slow historical read failed")
|
||||
}
|
||||
return Page[RawFrameRow]{Items: []RawFrameRow{{ID: "fast-jt", VIN: query.VIN, Protocol: "JT808", FrameType: "raw", ServerTime: "2026-10-16T21:00:00+08:00", ParsedFields: map[string]any{"speed": 43}}}}, nil
|
||||
}
|
||||
|
||||
func TestVehicleRawPreviewTimesOutToDurableProjection(t *testing.T) {
|
||||
store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})}
|
||||
defer close(store.release)
|
||||
started := time.Now()
|
||||
page, err := NewService(store).vehicleDetailRawPreview(context.Background(), RawFrameQuery{VIN: "LB9A32A24R0LS1426", IncludeFields: true, Limit: 10, SkipCount: true})
|
||||
if err != nil || time.Since(started) > 2*time.Second {
|
||||
t.Fatalf("historical storage blocked projection fallback: %s err=%v", time.Since(started), err)
|
||||
}
|
||||
if len(page.Items) != 2 {
|
||||
t.Fatalf("preview lost fast history or slow-source projection: %+v", page)
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, row := range page.Items {
|
||||
ids[row.ID] = true
|
||||
}
|
||||
if !ids["snapshot-GB32960"] || !ids["fast-jt"] {
|
||||
t.Fatalf("fallback replaced completed reads or lost a source: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestTelemetrySurvivesUnresponsiveHistoricalDriver(t *testing.T) {
|
||||
store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})}
|
||||
defer close(store.release)
|
||||
started := time.Now()
|
||||
result, err := NewService(store).LatestTelemetry(exportAdminContext(), "LB9A32A24R0LS1426")
|
||||
if err != nil || time.Since(started) > 2*time.Second || len(result.Values) == 0 {
|
||||
t.Fatalf("latest telemetry blocked by historical storage: elapsed=%s values=%+v err=%v", time.Since(started), result.Values, err)
|
||||
}
|
||||
found := false
|
||||
for _, value := range result.Values {
|
||||
if value.FrameID == "snapshot-GB32960" && value.Protocol == "GB32960" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("latest durable source was lost: %+v", result.Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleRawPreviewHonorsCallerCancellation(t *testing.T) {
|
||||
store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})}
|
||||
defer close(store.release)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := NewService(store).vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: "LB9A32A24R0LS1426", Limit: 10})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("caller cancellation was hidden by preview fallback: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The user-supplied fleet number registry is bundled with the API release, so
|
||||
// labels and search always use the same version. VINs remain the identity key.
|
||||
// Source workbook, sheet and SHA-256 are recorded alongside the imported rows.
|
||||
//
|
||||
//go:embed data/vehicle_numbers.json
|
||||
var vehicleNumberData []byte
|
||||
|
||||
var vehicleNumbers = loadVehicleNumbers(vehicleNumberData)
|
||||
|
||||
func loadVehicleNumbers(data []byte) map[string]string {
|
||||
var source struct {
|
||||
Vehicles []struct {
|
||||
VIN string `json:"vin"`
|
||||
Number string `json:"number"`
|
||||
} `json:"vehicles"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &source); err != nil {
|
||||
panic(fmt.Errorf("invalid vehicle number registry: %w", err))
|
||||
}
|
||||
numbers := make(map[string]string, len(source.Vehicles))
|
||||
for _, row := range source.Vehicles {
|
||||
vin, number := strings.ToUpper(strings.TrimSpace(row.VIN)), strings.TrimSpace(row.Number)
|
||||
if len(vin) != 17 || number == "" || numbers[vin] != "" {
|
||||
panic("invalid or duplicate VIN in vehicle number registry")
|
||||
}
|
||||
numbers[vin] = number
|
||||
}
|
||||
return numbers
|
||||
}
|
||||
|
||||
func vehicleNumber(vin string) string {
|
||||
return vehicleNumbers[strings.ToUpper(strings.TrimSpace(vin))]
|
||||
}
|
||||
|
||||
func vehicleNumberVINs(keywords []string, exact bool) []string {
|
||||
vins := []string{}
|
||||
for vin, number := range vehicleNumbers {
|
||||
for _, keyword := range keywords {
|
||||
keyword = strings.ToUpper(strings.TrimSpace(keyword))
|
||||
if keyword == "" {
|
||||
continue
|
||||
}
|
||||
if exact && strings.EqualFold(number, keyword) || !exact && strings.Contains(strings.ToUpper(number), keyword) {
|
||||
vins = append(vins, vin)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVehicleNumberRegistryPreservesWorkbookIdentifiers(t *testing.T) {
|
||||
if len(vehicleNumbers) != 110 || vehicleNumber("LA9GG64L9NBAF4191") != "01Q" || vehicleNumber("LA9GG68L2PBAF4269") != "备用车110Q" {
|
||||
t.Fatal("workbook identifiers or leading zeros changed")
|
||||
}
|
||||
if vehicleNumber("UNKNOWN") != "" {
|
||||
t.Fatal("unmapped vehicle received a number")
|
||||
}
|
||||
encoded, _ := json.Marshal(VehicleRealtimeRow{VIN: "UNKNOWN"})
|
||||
if strings.Contains(string(encoded), "vehicleNumber") {
|
||||
t.Fatal("unmapped number must be omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleNumberSearchMatchesPartialAndCaseInsensitiveNumbers(t *testing.T) {
|
||||
vin := "LA9GG64L9NBAF4191"
|
||||
if got := vehicleNumberVINs([]string{"01q"}, true); !reflect.DeepEqual(got, []string{vin}) {
|
||||
t.Fatalf("exact number search: %v", got)
|
||||
}
|
||||
if got := vehicleNumberVINs([]string{"1q"}, false); len(got) <= 1 || !slices.Contains(got, vin) {
|
||||
t.Fatalf("partial number search: %v", got)
|
||||
}
|
||||
if got := vehicleNumberVINs([]string{"110q"}, false); !reflect.DeepEqual(got, []string{"LA9GG68L2PBAF4269"}) {
|
||||
t.Fatalf("backup vehicle search: %v", got)
|
||||
}
|
||||
for _, keyword := range []string{"", "%", "_", "unassigned"} {
|
||||
if len(vehicleNumberVINs([]string{keyword}, false)) != 0 {
|
||||
t.Fatalf("unexpected match for %q", keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleNumberSQLKeepsScopeCountAndPagination(t *testing.T) {
|
||||
for _, key := range []string{"keyword", "keywords"} {
|
||||
q := buildVehicleRealtimeSQL(url.Values{key: {"01q"}, "scopeVins": {"AUTHORIZED-VIN"}, "limit": {"20"}, "offset": {"20"}, "sort": {"hydrogenPressureMpa"}})
|
||||
for _, sql := range []string{q.Text, q.CountText} {
|
||||
if !strings.Contains(sql, "v.vin IN (?) AND (") || !strings.Contains(sql, " OR v.vin IN (") {
|
||||
t.Fatalf("number predicate must be inside identity search and under authorization: %s", sql)
|
||||
}
|
||||
}
|
||||
if q.CountArgs[0] != "AUTHORIZED-VIN" || !slices.Contains(q.CountArgs, any("LA9GG64L9NBAF4191")) || !reflect.DeepEqual(q.Args[:len(q.Args)-2], q.CountArgs) {
|
||||
t.Fatalf("data/count scope differ: %v / %v", q.Args, q.CountArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleNumberMockSearchNeverExpandsAuthorizedScope(t *testing.T) {
|
||||
m := NewMockStore()
|
||||
m.locations = nil
|
||||
m.vehicles = []VehicleRow{
|
||||
{VIN: "LA9GG64L9NBAF4191", Plate: "浙F05388F"},
|
||||
{VIN: "UNMAPPED", Plate: "测试车"},
|
||||
}
|
||||
ctx := context.Background()
|
||||
q := url.Values{"keyword": {"01q"}}
|
||||
page, err := m.VehicleRealtime(ctx, q)
|
||||
if err != nil || page.Total != 1 || page.Items[0].VehicleNumber != "01Q" {
|
||||
t.Fatalf("number search failed: %+v %v", page, err)
|
||||
}
|
||||
q.Set("scopeVins", "UNMAPPED")
|
||||
page, err = m.VehicleRealtime(ctx, q)
|
||||
if err != nil || page.Total != 0 {
|
||||
t.Fatalf("number search expanded permission scope: %+v %v", page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleNumberServiceKeepsFuzzyNumberAndPlateMatches(t *testing.T) {
|
||||
m := NewMockStore()
|
||||
m.locations = nil
|
||||
m.vehicles = []VehicleRow{
|
||||
{VIN: "LA9GG64L9NBAF4191", Plate: "浙F05388F"},
|
||||
{VIN: "UNMAPPED", Plate: "测试01车"},
|
||||
}
|
||||
result, err := NewService(m).VehicleRealtime(context.Background(), url.Values{"keyword": {"01"}})
|
||||
if err != nil || result.Total != 2 {
|
||||
t.Fatalf("partial number search was resolved to a single plate match: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user