feat: 推广 V3.5 实时氢耗并完善导出

This commit is contained in:
lingniu
2026-09-04 15:42:02 +08:00
parent a26179c8fe
commit e402eb5e60
53 changed files with 2855 additions and 320 deletions
@@ -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())