Use speed_enhanced, speed or elevation_enhanced and elevation
This commit is contained in:
parent
cfea7abff3
commit
df3fdad43c
4 changed files with 201 additions and 7 deletions
|
|
@ -17,10 +17,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -43,6 +46,24 @@ public class SecurityConfig {
|
|||
*/
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
// One-shot migration carve-out: allow POST /api/debug/reprocess-fit-elevation
|
||||
// ONLY when the request comes from a loopback address. The intended workflow
|
||||
// is "open a shell on the prod container, curl localhost:8080" — anyone outside
|
||||
// the host network is rejected by the trailing denyAll() on /api/debug/**.
|
||||
// Remove this matcher (and the corresponding permitAll line below) once the
|
||||
// FIT elevation backfill is complete.
|
||||
final RequestMatcher loopbackFitElevationMatcher = request -> {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod())
|
||||
|| !"/api/debug/reprocess-fit-elevation".equals(request.getRequestURI())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return InetAddress.getByName(request.getRemoteAddr()).isLoopbackAddress();
|
||||
} catch (UnknownHostException e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
http
|
||||
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
|
|
@ -99,7 +120,10 @@ public class SecurityConfig {
|
|||
// Public endpoints - User's public activities
|
||||
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
|
||||
|
||||
// Debug endpoints (dev only)
|
||||
// Debug endpoints — denied by default. Specific endpoints can be
|
||||
// carved out above this line for one-off backfills / migrations.
|
||||
// Re-lock by removing the carve-outs once the migration is done.
|
||||
.requestMatchers(loopbackFitElevationMatcher).permitAll()
|
||||
.requestMatchers("/api/debug/**").denyAll()
|
||||
|
||||
// Public endpoints - Likes and Comments (GET only)
|
||||
|
|
|
|||
|
|
@ -164,4 +164,63 @@ public class DebugController {
|
|||
|
||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
|
||||
}
|
||||
|
||||
private final java.util.concurrent.atomic.AtomicBoolean fitElevationReprocessRunning =
|
||||
new java.util.concurrent.atomic.AtomicBoolean(false);
|
||||
|
||||
/**
|
||||
* Backfill the per-track-point elevation profile for FIT activities that
|
||||
* currently have none. Re-parses each FIT activity's stored raw bytes through
|
||||
* the current FitParser (which reads {@code enhanced_altitude} from modern
|
||||
* Garmin devices in addition to the legacy {@code altitude} field) and
|
||||
* replaces the activity's track points JSON.
|
||||
*
|
||||
* <p>The job is idempotent: activities that already have at least one non-null
|
||||
* elevation value on a track point are skipped, so re-running the backfill is
|
||||
* safe and only touches the still-broken activities.
|
||||
*
|
||||
* <p>Runs on a single background thread, with progress logged every 100 rows.
|
||||
* A second concurrent invocation returns {@code "already running"} immediately.
|
||||
*/
|
||||
@org.springframework.web.bind.annotation.PostMapping("/reprocess-fit-elevation")
|
||||
public org.springframework.http.ResponseEntity<Map<String, String>> reprocessFitElevation() {
|
||||
if (!fitElevationReprocessRunning.compareAndSet(false, true)) {
|
||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running"));
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
var ids = activityRepository.findAllIds();
|
||||
log.info("Starting FIT elevation reprocessing for {} activities", ids.size());
|
||||
|
||||
int updated = 0, skipped = 0, failed = 0;
|
||||
for (int i = 0; i < ids.size(); i++) {
|
||||
try {
|
||||
var activity = activityRepository.findById(ids.get(i)).orElse(null);
|
||||
if (activity == null || !"FIT".equals(activity.getSourceFileFormat())) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (activityFileService.reprocessFitElevation(activity)) {
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
log.warn("Failed to reprocess FIT elevation for {}: {}", ids.get(i), e.getMessage());
|
||||
}
|
||||
if ((i + 1) % 100 == 0) {
|
||||
log.info("FIT elevation reprocess progress: {} / {} (updated={}, skipped={}, failed={})",
|
||||
i + 1, ids.size(), updated, skipped, failed);
|
||||
}
|
||||
}
|
||||
log.info("FIT elevation reprocessing complete: updated={}, skipped={}, failed={}", updated, skipped, failed);
|
||||
} finally {
|
||||
fitElevationReprocessRunning.set(false);
|
||||
}
|
||||
}, "fit-elevation-reprocess").start();
|
||||
|
||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,6 +232,102 @@ public class ActivityFileService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reprocess elevation for a FIT activity that currently has no per-point
|
||||
* elevation profile. Re-parses the stored raw FIT bytes with the current parser
|
||||
* (which reads {@code enhanced_altitude} in addition to the legacy {@code altitude}
|
||||
* field) and replaces the activity's track-points JSON. Also fills in
|
||||
* {@code elevationGain}/{@code elevationLoss} if they were missing.
|
||||
*
|
||||
* <p>Returns {@code false} (and does not touch the activity) if the activity:
|
||||
* <ul>
|
||||
* <li>is not a FIT file,</li>
|
||||
* <li>has no raw file stored (e.g. uploaded before raw file persistence was added),</li>
|
||||
* <li>already has a non-null elevation value on at least one track point,</li>
|
||||
* <li>or fails to re-parse for any reason.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param activity the activity to reprocess
|
||||
* @return true if the track points JSON was updated
|
||||
*/
|
||||
@Transactional
|
||||
public boolean reprocessFitElevation(Activity activity) {
|
||||
if (!"FIT".equals(activity.getSourceFileFormat())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] rawFile = activity.getRawActivityFile();
|
||||
if (rawFile == null || rawFile.length == 0) {
|
||||
log.debug("No raw file stored for FIT activity {}, skipping", activity.getId());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip activities that already have per-point elevation. This makes the
|
||||
// backfill idempotent — running it twice in a row is safe and the second
|
||||
// run is a no-op for already-fixed activities.
|
||||
if (trackJsonAlreadyHasElevation(activity.getTrackPointsJson())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
ParsedActivityData parsedData = fitParser.parse(rawFile);
|
||||
|
||||
// Replace the entire track points blob. The new JSON will contain
|
||||
// elevation values from enhanced_altitude (or legacy altitude as
|
||||
// fallback) thanks to the FitParser fix.
|
||||
String newJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||
activity.setTrackPointsJson(newJson);
|
||||
|
||||
// Backfill session totals if they were missing. We don't overwrite
|
||||
// existing values — those came from session.getTotalAscent() at upload
|
||||
// time and the device's own calculation is likely more accurate than
|
||||
// anything we'd recompute from the track points.
|
||||
if (activity.getElevationGain() == null && parsedData.getElevationGain() != null) {
|
||||
activity.setElevationGain(parsedData.getElevationGain());
|
||||
}
|
||||
if (activity.getElevationLoss() == null && parsedData.getElevationLoss() != null) {
|
||||
activity.setElevationLoss(parsedData.getElevationLoss());
|
||||
}
|
||||
|
||||
activityRepository.save(activity);
|
||||
log.info("Reprocessed FIT elevation profile for activity {} ({} track points)",
|
||||
activity.getId(), parsedData.getTrackPoints().size());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to reprocess FIT elevation for activity {}: {}", activity.getId(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given track points JSON already contains at least one
|
||||
* non-null {@code elevation} value. Used by the FIT elevation backfill to
|
||||
* skip activities that don't need to be touched.
|
||||
*/
|
||||
private boolean trackJsonAlreadyHasElevation(String trackPointsJson) {
|
||||
if (trackPointsJson == null || trackPointsJson.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(trackPointsJson);
|
||||
if (!root.isArray()) {
|
||||
return false;
|
||||
}
|
||||
for (com.fasterxml.jackson.databind.JsonNode point : root) {
|
||||
com.fasterxml.jackson.databind.JsonNode elevation = point.get("elevation");
|
||||
if (elevation != null && !elevation.isNull()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
// Malformed JSON shouldn't happen for stored activities, but if it does
|
||||
// err on the side of "no elevation" so the backfill picks it up.
|
||||
log.warn("Could not parse track points JSON to check for elevation: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects file format from content and filename.
|
||||
* Priority: magic bytes > XML header > file extension
|
||||
|
|
|
|||
|
|
@ -156,9 +156,18 @@ public class FitParser {
|
|||
point.setTimestamp(convertDateTime(record.getTimestamp()));
|
||||
}
|
||||
|
||||
// Extract elevation
|
||||
if (record.getAltitude() != null) {
|
||||
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
|
||||
// Extract elevation. Prefer enhanced_altitude (4-byte high-resolution barometric
|
||||
// value used by modern Garmin devices) and fall back to the legacy altitude field
|
||||
// if it's not present. Older devices write only the legacy field; newer Edge /
|
||||
// Fenix / Forerunner units write only the enhanced one. Reading just one of them
|
||||
// produces empty elevation profiles for everything recorded on the other class
|
||||
// of device.
|
||||
Float elevationValue = record.getEnhancedAltitude();
|
||||
if (elevationValue == null) {
|
||||
elevationValue = record.getAltitude();
|
||||
}
|
||||
if (elevationValue != null) {
|
||||
point.setElevation(BigDecimal.valueOf(elevationValue).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract heart rate
|
||||
|
|
@ -176,9 +185,15 @@ public class FitParser {
|
|||
point.setPower(record.getPower());
|
||||
}
|
||||
|
||||
// Extract speed (convert m/s to km/h)
|
||||
if (record.getSpeed() != null) {
|
||||
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
|
||||
// Extract speed (convert m/s to km/h). Same enhanced-vs-legacy split as altitude:
|
||||
// modern Garmin devices write enhanced_speed (higher resolution) and may leave the
|
||||
// legacy speed field null. Read both, preferring enhanced.
|
||||
Float speedValue = record.getEnhancedSpeed();
|
||||
if (speedValue == null) {
|
||||
speedValue = record.getSpeed();
|
||||
}
|
||||
if (speedValue != null) {
|
||||
point.setSpeed(BigDecimal.valueOf(speedValue * MPS_TO_KPH)
|
||||
.setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue