feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} {
|
||||
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426", `"brandName":"飞驰"`, `"modelName":"新能源运营车"`} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
|
||||
m.profileMu.RLock()
|
||||
defer m.profileMu.RUnlock()
|
||||
vehicles := filterVehicles(m.vehicles, query)
|
||||
byVIN := map[string]*VehicleCoverageRow{}
|
||||
for _, vehicle := range vehicles {
|
||||
@@ -805,6 +807,12 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items := make([]VehicleCoverageRow, 0, len(byVIN))
|
||||
for _, row := range byVIN {
|
||||
if profile, ok := m.profiles[row.VIN]; ok {
|
||||
row.BrandName = firstNonEmpty(profile.BrandName, row.OEM)
|
||||
row.ModelName = profile.ModelName
|
||||
} else {
|
||||
row.BrandName = row.OEM
|
||||
}
|
||||
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
|
||||
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
|
||||
for _, vehicle := range vehicles {
|
||||
@@ -1230,8 +1238,24 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
|
||||
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
|
||||
var allowedVINs map[string]bool
|
||||
if query.ScopeVINs != nil {
|
||||
allowedVINs = make(map[string]bool, len(query.ScopeVINs))
|
||||
for _, vin := range query.ScopeVINs {
|
||||
allowedVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
}
|
||||
for _, keyword := range normalizedKeywords(query.Keywords) {
|
||||
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
|
||||
if allowedVINs != nil {
|
||||
scoped := vehicles[:0]
|
||||
for _, vehicle := range vehicles {
|
||||
if allowedVINs[strings.ToUpper(strings.TrimSpace(vehicle.VIN))] {
|
||||
scoped = append(scoped, vehicle)
|
||||
}
|
||||
}
|
||||
vehicles = scoped
|
||||
}
|
||||
resolution := buildVehicleIdentityResolution(keyword, vehicles)
|
||||
identity := resolveVehicleIdentity(keyword, vehicles)
|
||||
resolvedVIN := ""
|
||||
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
|
||||
resolvedVIN = resolution.VIN
|
||||
}
|
||||
if resolvedVIN == "" {
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
if query.ScopeVINs != nil {
|
||||
missing.VIN = ""
|
||||
}
|
||||
items = append(items, missing)
|
||||
continue
|
||||
}
|
||||
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
|
||||
|
||||
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
|
||||
Plate string `json:"plate"`
|
||||
Phone string `json:"phone"`
|
||||
OEM string `json:"oem"`
|
||||
BrandName string `json:"brandName"`
|
||||
ModelName string `json:"modelName"`
|
||||
Protocols []string `json:"protocols"`
|
||||
MissingProtocols []string `json:"missingProtocols"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
|
||||
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_profile p ON p.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
@@ -169,6 +170,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
|
||||
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` +
|
||||
`COALESCE(MAX(p.model_name), '') AS model_name, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
|
||||
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
|
||||
`COUNT(DISTINCT s.protocol) AS source_count, ` +
|
||||
|
||||
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
|
||||
}
|
||||
|
||||
func (p Principal) CanMenu(key string) bool {
|
||||
if p.UserType == "admin" || p.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, value := range p.MenuKeys {
|
||||
if value == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Legacy admin principals did not carry an explicit menu list. Keep those
|
||||
// principals unrestricted while allowing persisted admin menus to remove a
|
||||
// sensitive surface such as account management.
|
||||
if len(p.MenuKeys) == 0 && (p.UserType == "admin" || p.Role == "admin") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
|
||||
var protocols string
|
||||
var onlineProtocols string
|
||||
var online int
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.BrandName, &row.ModelName, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
|
||||
return Page[VehicleCoverageRow]{}, err
|
||||
}
|
||||
row.Protocols = splitCSV(protocols)
|
||||
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
|
||||
continue
|
||||
}
|
||||
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
|
||||
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
|
||||
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
if query.ScopeVINs != nil {
|
||||
missing.VIN = ""
|
||||
}
|
||||
items = append(items, missing)
|
||||
}
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
@@ -518,6 +522,18 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
|
||||
protocolJoin = " AND s.protocol = ? "
|
||||
args = append(args, protocol)
|
||||
}
|
||||
scopeWhere := ""
|
||||
if query.ScopeVINs != nil {
|
||||
scopeVINs := normalizedKeywords(query.ScopeVINs)
|
||||
if len(scopeVINs) == 0 {
|
||||
scopeWhere = " AND 1 = 0 "
|
||||
} else {
|
||||
scopeWhere = " AND i.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(scopeVINs)), ",") + ") "
|
||||
for _, vin := range scopeVINs {
|
||||
args = append(args, strings.ToUpper(vin))
|
||||
}
|
||||
}
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
|
||||
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
|
||||
@@ -534,7 +550,7 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
|
||||
`WHERE ` + realtimeWhere + ` ` +
|
||||
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
|
||||
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
|
||||
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`,
|
||||
`WHERE i.vin IS NOT NULL AND i.vin <> ''` + scopeWhere + `GROUP BY i.vin`,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,23 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleServiceOverviewBatchSQLRestrictsCustomerVINScope(t *testing.T) {
|
||||
built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{
|
||||
Keywords: []string{"粤A"}, ScopeVINs: []string{"vin001", "VIN002"},
|
||||
})
|
||||
if !strings.Contains(built.Text, "i.vin IN (?,?)") {
|
||||
t.Fatalf("batch overview SQL should restrict results to granted VINs, got %s", built.Text)
|
||||
}
|
||||
if got := built.Args[len(built.Args)-2:]; got[0] != "VIN001" || got[1] != "VIN002" {
|
||||
t.Fatalf("customer VIN scope should be normalized and bound last, got %#v", built.Args)
|
||||
}
|
||||
|
||||
empty := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{Keywords: []string{"粤A"}, ScopeVINs: []string{}})
|
||||
if !strings.Contains(empty.Text, "AND 1 = 0") {
|
||||
t.Fatalf("empty customer VIN scope must fail closed, got %s", empty.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
||||
func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
|
||||
for _, want := range []string{"LEFT JOIN vehicle_profile p ON p.vin = v.vin", "brand_name", "model_name", "GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
|
||||
@@ -91,10 +91,11 @@ type RawFrameQuery struct {
|
||||
}
|
||||
|
||||
type VehicleOverviewBatchQuery struct {
|
||||
Keywords []string `json:"keywords"`
|
||||
Protocol string `json:"protocol"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Protocol string `json:"protocol"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
ScopeVINs []string `json:"-"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
@@ -869,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
|
||||
}
|
||||
}
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
scopeVINs := principalVehicleVINScope(ctx)
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||
Keywords: []string{keyword},
|
||||
Protocol: protocol,
|
||||
Limit: 1,
|
||||
Keywords: []string{keyword},
|
||||
Protocol: protocol,
|
||||
Limit: 1,
|
||||
ScopeVINs: scopeVINs,
|
||||
})
|
||||
if err != nil {
|
||||
return VehicleServiceOverview{}, err
|
||||
@@ -927,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
||||
query.Keywords = keywords
|
||||
query.Limit = limit
|
||||
query.Offset = offset
|
||||
query.ScopeVINs = principalVehicleVINScope(ctx)
|
||||
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
|
||||
page, err := batchStore.VehicleServiceOverviews(ctx, query)
|
||||
if err != nil {
|
||||
return Page[VehicleServiceOverview]{}, err
|
||||
}
|
||||
page = restrictVehicleOverviewPage(ctx, page)
|
||||
page.Total = total
|
||||
page.Limit = limit
|
||||
page.Offset = offset
|
||||
@@ -948,6 +953,34 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
|
||||
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||
}
|
||||
|
||||
func principalVehicleVINScope(ctx context.Context) []string {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return nil
|
||||
}
|
||||
scope := make([]string, 0, len(principal.VehicleVINs))
|
||||
for _, vin := range principal.VehicleVINs {
|
||||
if vin = strings.ToUpper(strings.TrimSpace(vin)); vin != "" {
|
||||
scope = append(scope, vin)
|
||||
}
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok || principal.UserType != "customer" {
|
||||
return page
|
||||
}
|
||||
for index := range page.Items {
|
||||
if principal.CanVIN(strings.ToUpper(strings.TrimSpace(page.Items[index].VIN))) {
|
||||
continue
|
||||
}
|
||||
page.Items[index] = *buildVehicleServiceOverview("", "", &VehicleIdentityResolution{Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
|
||||
keyword := strings.TrimSpace(vin)
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
|
||||
@@ -547,6 +547,7 @@ type countingStore struct {
|
||||
vehiclesCalls int
|
||||
vehicleRealtimeCalls int
|
||||
overviewBatchCalls int
|
||||
lastOverviewBatchQuery VehicleOverviewBatchQuery
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
lastHistoryQuery url.Values
|
||||
@@ -1010,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
|
||||
|
||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
s.overviewBatchCalls++
|
||||
s.lastOverviewBatchQuery = query
|
||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||
}
|
||||
|
||||
@@ -1046,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
service := NewService(store)
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
})
|
||||
page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
|
||||
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("customer batch lookup returned error: %v", err)
|
||||
}
|
||||
if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs)
|
||||
}
|
||||
if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" {
|
||||
t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
summary, err := service.VehicleServiceSummary(context.Background())
|
||||
|
||||
Reference in New Issue
Block a user