feat: 推广 V3.5 实时氢耗并完善导出
This commit is contained in:
@@ -26,6 +26,7 @@ func main() {
|
||||
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
|
||||
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
|
||||
vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild")
|
||||
brand := flag.String("brand", "", "optional exact vehicle brand for an atomic scoped rebuild")
|
||||
vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day")
|
||||
allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin")
|
||||
dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL")
|
||||
@@ -62,9 +63,16 @@ func main() {
|
||||
log.Fatal("-vin-workers must be between 1 and 16")
|
||||
}
|
||||
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
|
||||
normalizedBrand := strings.TrimSpace(*brand)
|
||||
if normalizedVIN != "" && normalizedBrand != "" {
|
||||
log.Fatal("-vin and -brand cannot be combined")
|
||||
}
|
||||
if *seedStream && normalizedVIN != "" {
|
||||
log.Fatal("-seed-stream-state requires an all-vehicle rebuild")
|
||||
}
|
||||
if *seedStream && normalizedBrand != "" {
|
||||
log.Fatal("-seed-stream-state cannot be combined with -brand")
|
||||
}
|
||||
if *seedStream && *dryRun {
|
||||
log.Fatal("-seed-stream-state cannot be combined with -dry-run")
|
||||
}
|
||||
@@ -84,6 +92,29 @@ func main() {
|
||||
log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
|
||||
}
|
||||
}
|
||||
var brandVINs []string
|
||||
if normalizedBrand != "" {
|
||||
brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand)
|
||||
if err != nil {
|
||||
log.Fatalf("load VINs for brand %q: %v", normalizedBrand, err)
|
||||
}
|
||||
if len(brandVINs) == 0 {
|
||||
log.Fatalf("no vehicles found for brand %q", normalizedBrand)
|
||||
}
|
||||
brandSet := make(map[string]struct{}, len(brandVINs))
|
||||
for _, brandVIN := range brandVINs {
|
||||
brandSet[brandVIN] = struct{}{}
|
||||
}
|
||||
for capacityVIN := range capacities {
|
||||
if _, ok := brandSet[capacityVIN]; !ok {
|
||||
delete(capacities, capacityVIN)
|
||||
}
|
||||
}
|
||||
if len(capacities) == 0 {
|
||||
log.Fatalf("no active hydrogen tank capacities found for brand %q", normalizedBrand)
|
||||
}
|
||||
fmt.Printf("brand=%s scoped_vins=%d capacity_vins=%d\n", normalizedBrand, len(brandVINs), len(capacities))
|
||||
}
|
||||
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1))
|
||||
if *allDates {
|
||||
var first, last time.Time
|
||||
@@ -112,6 +143,8 @@ func main() {
|
||||
scope := "all-vehicles"
|
||||
if normalizedVIN != "" {
|
||||
scope = normalizedVIN
|
||||
} else if normalizedBrand != "" {
|
||||
scope = "brand:" + normalizedBrand
|
||||
}
|
||||
fmt.Printf("scope=%s date_from=%s date_to=%s mode=all-dates\n", scope, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
||||
}
|
||||
@@ -141,6 +174,8 @@ func main() {
|
||||
if !*dryRun {
|
||||
if *seedStream {
|
||||
err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats)
|
||||
} else if normalizedBrand != "" {
|
||||
err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, stats)
|
||||
} else if normalizedVIN == "" {
|
||||
err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats)
|
||||
} else {
|
||||
|
||||
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") {
|
||||
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
required := requiredRole(r)
|
||||
if roleRank(principal.Role) < roleRank(required) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
|
||||
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
|
||||
var adminMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
|
||||
"alerts": true, "access": true, "operations": true, "users": true,
|
||||
}
|
||||
|
||||
type authUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
@@ -551,14 +556,13 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local
|
||||
}
|
||||
|
||||
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
menus := []string{}
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
businessScopeLevel := ""
|
||||
departmentIDs := []string{}
|
||||
responsibleUserID := ""
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
if user.UserType == "customer" || user.UserType == "admin" {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if customerMenuSet[value] {
|
||||
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
}
|
||||
if user.UserType == "admin" && len(menus) == 0 {
|
||||
menus = append([]string(nil), adminMenus...)
|
||||
}
|
||||
if user.UserType == "customer" {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
|
||||
@@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations"))
|
||||
|
||||
principal, err := store.principalForUser(context.Background(), authUser{
|
||||
ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !principal.CanMenu("operations") || principal.CanMenu("users") {
|
||||
t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) {
|
||||
token := "restricted-admin-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
authenticator := &apiAuthenticator{
|
||||
mode: "enforce",
|
||||
tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin",
|
||||
MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
handler := authenticator.middleware(next)
|
||||
|
||||
usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)
|
||||
usersRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
usersResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(usersResponse, usersRequest)
|
||||
if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") {
|
||||
t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String())
|
||||
}
|
||||
|
||||
operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil)
|
||||
operationsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
operationsResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(operationsResponse, operationsRequest)
|
||||
if operationsResponse.Code != http.StatusNoContent {
|
||||
t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
@@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) {
|
||||
const token = "customer-token-at-least-16"
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
for _, menu := range customerMenuKeys {
|
||||
t.Run(menu, func(t *testing.T) {
|
||||
authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{
|
||||
hash: hash,
|
||||
principal: platform.Principal{
|
||||
Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu},
|
||||
},
|
||||
}}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
authenticator.middleware(next).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
@@ -620,6 +620,13 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;无数据时为 null
|
||||
calculationPhase:
|
||||
type: string
|
||||
enum: [PRELIMINARY, FINAL]
|
||||
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
||||
algorithmVersion:
|
||||
type: string
|
||||
description: 氢耗计算算法版本
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
|
||||
@@ -49,6 +49,8 @@ type HydrogenResult struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||
CalculationPhase string `json:"calculationPhase,omitempty"`
|
||||
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
@@ -281,11 +283,13 @@ type AppCredential struct {
|
||||
}
|
||||
|
||||
type DailyHydrogen struct {
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
CalculationPhase string
|
||||
AlgorithmVersion string
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
|
||||
@@ -312,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
|
||||
return map[string]DailyHydrogen{}, nil
|
||||
}
|
||||
query, args := inQuery(`
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
|
||||
calculation_phase,algorithm_version
|
||||
FROM vehicle_open_daily_energy
|
||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
@@ -323,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
out := make(map[string]DailyHydrogen, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyHydrogen
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
|
||||
@@ -391,10 +391,14 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
// Sampling sufficiency is decided by the producer and persisted in
|
||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||
// with one transaction, while pressure-derived rows require two samples.
|
||||
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
if value, ok := values[vehicle.VIN]; ok {
|
||||
item.CalculationPhase = value.CalculationPhase
|
||||
item.AlgorithmVersion = value.AlgorithmVersion
|
||||
if strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
@@ -282,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||
}
|
||||
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
|
||||
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
|
||||
}
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenBrandVINs(ctx context.Context, db *sql.DB, brand string) ([]string, error) {
|
||||
brand = strings.TrimSpace(brand)
|
||||
if brand == "" {
|
||||
return nil, errors.New("hydrogen vehicle brand is required")
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin))
|
||||
FROM vehicle_profile
|
||||
WHERE TRIM(brand_name)=?
|
||||
ORDER BY vin`, brand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
vins := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if validHydrogenVIN(vin) {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
return vins, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
|
||||
FROM vehicle_hydrogen_energy_parameter
|
||||
@@ -811,7 +838,7 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, false)
|
||||
}
|
||||
|
||||
// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment
|
||||
@@ -819,7 +846,7 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta
|
||||
// included in the rebuild, so Kafka backlog already covered by the rebuild is
|
||||
// ignored instead of counted twice.
|
||||
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
return replaceHydrogenDailyStats(ctx, db, date, "", stats, true)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, true)
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error {
|
||||
@@ -827,10 +854,30 @@ func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false)
|
||||
return replaceHydrogenDailyStats(ctx, db, date, []string{vin}, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
func ReplaceHydrogenDailyStatsForVINs(ctx context.Context, db *sql.DB, date string, vins []string, stats []HydrogenDailyStat) error {
|
||||
normalized := make([]string, 0, len(vins))
|
||||
seen := make(map[string]struct{}, len(vins))
|
||||
for _, vin := range vins {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
if !validHydrogenVIN(vin) {
|
||||
return fmt.Errorf("invalid VIN %q", vin)
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
normalized = append(normalized, vin)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return errors.New("at least one scoped VIN is required")
|
||||
}
|
||||
return replaceHydrogenDailyStats(ctx, db, date, normalized, stats, false)
|
||||
}
|
||||
|
||||
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, scopedVINs []string, stats []HydrogenDailyStat, seedStream bool) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -839,12 +886,26 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
// A pressure-based rebuild is authoritative for the whole day. Delete every
|
||||
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
|
||||
// for vehicles without valid pressure observations in this run.
|
||||
if vin == "" {
|
||||
allowedVINs := make(map[string]struct{}, len(scopedVINs))
|
||||
for _, vin := range scopedVINs {
|
||||
allowedVINs[vin] = struct{}{}
|
||||
}
|
||||
if len(scopedVINs) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(scopedVINs) == 1 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, scopedVINs[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(scopedVINs)), ",")
|
||||
args := make([]any, 0, len(scopedVINs)+1)
|
||||
args = append(args, date)
|
||||
for _, vin := range scopedVINs {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (`+placeholders+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
|
||||
}
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) {
|
||||
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin)
|
||||
statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN))
|
||||
if len(allowedVINs) > 0 {
|
||||
if _, allowed := allowedVINs[statVIN]; !allowed {
|
||||
return fmt.Errorf("refusing to write VIN %q outside scoped rebuild", stat.VIN)
|
||||
}
|
||||
}
|
||||
parameterJSON, err := json.Marshal(stat.CalculationParameters)
|
||||
if err != nil {
|
||||
@@ -872,8 +936,8 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
|
||||
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
|
||||
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
|
||||
invalid_segment_count,algorithm_version,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
|
||||
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
|
||||
@@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy(
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if seedStream {
|
||||
// A NO_DATA row can legitimately have no effective boundary observation.
|
||||
// Keep its daily result, but do not write a zero timestamp into the stream
|
||||
// watermark table; the next valid realtime frame will create the state.
|
||||
if seedStream && !stat.LastObservation.ObservedAt.IsZero() {
|
||||
stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -893,10 +960,10 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
|
||||
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
|
||||
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`,
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount,
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "",
|
||||
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, stat.LastObservation.EventID,
|
||||
stateJSON, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
@@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
|
||||
}
|
||||
|
||||
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
|
||||
type segmentWindow struct {
|
||||
Count int `json:"count"`
|
||||
First []any `json:"first"`
|
||||
Tail []any `json:"tail"`
|
||||
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"`
|
||||
type point struct {
|
||||
EventID string `json:"eventId"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
MassKg float64 `json:"massKg"`
|
||||
PressureMpa float64 `json:"pressureMpa"`
|
||||
TemperatureC float64 `json:"temperatureC"`
|
||||
NoiseKg float64 `json:"noiseKg"`
|
||||
MileageKm float64 `json:"mileageKm"`
|
||||
MileageKnown bool `json:"mileageKnown"`
|
||||
SocPercent float64 `json:"socPercent"`
|
||||
SocKnown bool `json:"socKnown"`
|
||||
RunningMode int `json:"runningMode"`
|
||||
FuelCellActive bool `json:"fuelCellActive"`
|
||||
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
|
||||
FuelCellPowerKw float64 `json:"fuelCellPowerKw"`
|
||||
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
|
||||
}
|
||||
toPoint := func(value HydrogenObservation) point {
|
||||
return point{
|
||||
EventID: value.EventID, ObservedAt: value.ObservedAt, MassKg: value.MassKg,
|
||||
PressureMpa: value.PressureMPa, TemperatureC: value.TemperatureC, NoiseKg: value.NoiseKg,
|
||||
MileageKm: value.MileageKm, MileageKnown: value.MileageKnown,
|
||||
SocPercent: value.SOCPercent, SocKnown: value.SOCKnown, RunningMode: value.RunningMode,
|
||||
FuelCellActive: value.FuelCellActive, FuelCellStateKnown: value.FuelCellStateKnown,
|
||||
FuelCellPowerKw: value.FuelCellVoltageV * value.FuelCellCurrentA / 1000,
|
||||
FuelCellPowerKnown: value.FuelCellPowerKnown,
|
||||
}
|
||||
}
|
||||
first, last := toPoint(stat.FirstObservation), toPoint(stat.LastObservation)
|
||||
lastIntervalType := ""
|
||||
mixedCycles := 0
|
||||
for _, interval := range stat.Intervals {
|
||||
if interval.Type == "MIXED" {
|
||||
mixedCycles++
|
||||
}
|
||||
lastIntervalType = interval.Type
|
||||
}
|
||||
cycle := map[string]any{
|
||||
"first": last, "last": last, "hasRun": !last.ObservedAt.IsZero(),
|
||||
"startsPure": lastIntervalType == "PURE_ELECTRIC", "mixedLocked": lastIntervalType == "MIXED",
|
||||
"mixedConfirmed": lastIntervalType == "MIXED", "mixedStart": last,
|
||||
}
|
||||
state := struct {
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
EligibleIntervalCount int `json:"eligibleIntervalCount"`
|
||||
QualifiedSegmentCount int `json:"qualifiedSegmentCount"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
RemainingMassKg float64 `json:"remainingMassKg"`
|
||||
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"`
|
||||
LastObservedMassKg float64 `json:"lastObservedMassKg"`
|
||||
TankCapacityLiters float64 `json:"tankCapacityLiters"`
|
||||
FirstPressureMpa float64 `json:"firstPressureMpa"`
|
||||
LastPressureMpa float64 `json:"lastPressureMpa"`
|
||||
FirstTemperatureC float64 `json:"firstTemperatureC"`
|
||||
LastTemperatureC float64 `json:"lastTemperatureC"`
|
||||
FirstEventTime time.Time `json:"firstEventTime"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastFuelCellActive bool `json:"lastFuelCellActive"`
|
||||
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"`
|
||||
Segment segmentWindow `json:"segment"`
|
||||
AlgorithmVersion string `json:"algorithmVersion"`
|
||||
SourceEndpoint string `json:"sourceEndpoint"`
|
||||
SampleCount int `json:"sampleCount"`
|
||||
LastEventID string `json:"lastEventId"`
|
||||
LastEventTime time.Time `json:"lastEventTime"`
|
||||
Parameters map[string]float64 `json:"parameters"`
|
||||
FirstMassKg float64 `json:"firstMassKg"`
|
||||
LastMassKg float64 `json:"lastMassKg"`
|
||||
FirstEffective point `json:"firstEffective"`
|
||||
LastEffective point `json:"lastEffective"`
|
||||
HasEffective bool `json:"hasEffective"`
|
||||
EffectiveRunCount int `json:"effectiveRunCount"`
|
||||
Cycle map[string]any `json:"cycle"`
|
||||
InitialStateUsed bool `json:"initialStateUsed"`
|
||||
ChargeCount int `json:"chargeCount"`
|
||||
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
|
||||
CompletedPureKm float64 `json:"completedPureKm"`
|
||||
CompletedMixedKm float64 `json:"completedMixedKm"`
|
||||
CompletedSOCDelta float64 `json:"completedSocDelta"`
|
||||
CompletedMixedCycles int `json:"completedMixedCycles"`
|
||||
HydrogenSegmentStart point `json:"hydrogenSegmentStart"`
|
||||
LastHydrogenRun point `json:"lastHydrogenRun"`
|
||||
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
|
||||
HydrogenSegmentRuns int `json:"hydrogenSegmentRuns"`
|
||||
FinalizedHydrogenKg float64 `json:"finalizedHydrogenKg"`
|
||||
HydrogenSegments int `json:"hydrogenSegments"`
|
||||
RefuelCount int `json:"refuelCount"`
|
||||
RefuelAmountKg float64 `json:"refuelAmountKg"`
|
||||
MinimumPressureMpa float64 `json:"minimumPressureMpa"`
|
||||
MinimumMassKg float64 `json:"minimumMassKg"`
|
||||
MinimumObservedAt time.Time `json:"minimumObservedAt"`
|
||||
PreviousSample point `json:"previousSample"`
|
||||
HasPreviousSample bool `json:"hasPreviousSample"`
|
||||
AbnormalDropCount int `json:"abnormalDropCount"`
|
||||
InvalidSampleCount int `json:"invalidSampleCount"`
|
||||
SuspectedLeakCount int `json:"suspectedLeakCount"`
|
||||
SuspectedLeakMaxMpa float64 `json:"suspectedLeakMaxMpa"`
|
||||
}{
|
||||
SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg,
|
||||
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount,
|
||||
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount,
|
||||
QualifiedSegmentCount: stat.QualifiedSegmentCount,
|
||||
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg,
|
||||
MetadataCycleMinimumKg: stat.CycleMinimumMassKg,
|
||||
LastObservedMassKg: stat.LastObservation.MassKg,
|
||||
TankCapacityLiters: stat.LastObservation.TankCapacityLiter,
|
||||
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa,
|
||||
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC,
|
||||
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
LastFuelCellActive: stat.LastObservation.FuelCellActive,
|
||||
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown,
|
||||
Segment: segmentWindow{First: []any{}, Tail: []any{}},
|
||||
AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source,
|
||||
SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt,
|
||||
Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg},
|
||||
FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg,
|
||||
FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount,
|
||||
Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh,
|
||||
CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm,
|
||||
CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles,
|
||||
HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1,
|
||||
FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount,
|
||||
RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg,
|
||||
MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt,
|
||||
PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(),
|
||||
AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount,
|
||||
SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa,
|
||||
}
|
||||
return json.Marshal(state)
|
||||
}
|
||||
|
||||
func valueOrZero(value *float64) float64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -301,6 +301,26 @@ func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")).
|
||||
WithArgs("现代").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).
|
||||
AddRow("lb9a32a25r0ls1452").
|
||||
AddRow("invalid"))
|
||||
vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ")
|
||||
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
|
||||
t.Fatalf("vins=%v err=%v", vins, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -351,6 +371,37 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"}
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5,
|
||||
FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK",
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")).
|
||||
WithArgs(stat.Date, vins[0], vins[1]).
|
||||
WillReturnResult(sqlmock.NewResult(0, 2))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -396,7 +447,37 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
|
||||
if err := json.Unmarshal(encoded, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state["lastObservedMassKg"] != 9.4 || state["finalizedConsumptionKg"] != 2.6 || state["lastEventTime"] == "" {
|
||||
if state["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion {
|
||||
t.Fatalf("seed state=%s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a",
|
||||
QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条",
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
|
||||
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
|
||||
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
|
||||
WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason,
|
||||
0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
|
||||
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler {
|
||||
}
|
||||
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
setCachePolicy(w, r.URL.Path)
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func setCachePolicy(w http.ResponseWriter, path string) {
|
||||
if path == "/" || path == "/index.html" || path == "/app-config.js" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package static
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHandlerDisablesCachingForApplicationShell(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app-config.js"), []byte("config"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := Handler(dir, http.NotFoundHandler())
|
||||
for _, path := range []string{"/", "/index.html", "/app-config.js", "/users"} {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if got := response.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("%s Cache-Control=%q, want no-store", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCachesHashedAssetsImmutably(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assets := filepath.Join(dir, "assets")
|
||||
if err := os.Mkdir(assets, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assets, "app-1234.js"), []byte("app"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
Handler(dir, http.NotFoundHandler()).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app-1234.js", nil))
|
||||
if got := response.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
|
||||
t.Fatalf("asset Cache-Control=%q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user