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:
lingniu
2026-10-26 18:05:59 +08:00
parent 8b32ae2988
commit d6b957dfc8
290 changed files with 28238 additions and 1647 deletions
@@ -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)
}
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
}