Skip to content

Commit 6d55e7c

Browse files
Merge pull request #77 from ssafy-salman/phase/5-property-safety-score-batch
[Phase 5] feat(safety): 매물 안전 점수 계산 배치 구현
2 parents ca479ef + e05a637 commit 6d55e7c

13 files changed

Lines changed: 633 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.ssafy.salmanhae.batch;
2+
3+
import java.util.List;
4+
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
11+
import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult;
12+
import com.ssafy.salmanhae.service.safety.PropertySafetyScoreService;
13+
14+
@Component
15+
@ConditionalOnProperty(
16+
prefix = "safety.score.scheduler",
17+
name = "enabled",
18+
havingValue = "true"
19+
)
20+
public class PropertySafetyScoreScheduler {
21+
22+
private static final Logger log = LoggerFactory.getLogger(PropertySafetyScoreScheduler.class);
23+
24+
private final PropertySafetyScoreService propertySafetyScoreService;
25+
26+
public PropertySafetyScoreScheduler(PropertySafetyScoreService propertySafetyScoreService) {
27+
this.propertySafetyScoreService = propertySafetyScoreService;
28+
}
29+
30+
@Scheduled(
31+
cron = "${safety.score.scheduler.cron:0 30 3 1 * *}",
32+
zone = "${safety.score.scheduler.zone:Asia/Seoul}"
33+
)
34+
public void runMonthlyRecalculation() {
35+
List<PropertySafetyScoreResult> results = propertySafetyScoreService.recalculateAll();
36+
log.info("Monthly property safety score recalculation completed: properties={}", results.size());
37+
}
38+
}

backend/src/main/java/com/ssafy/salmanhae/model/dao/property/JdbcPropertyDao.java

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
import java.math.BigDecimal;
66
import java.util.HashMap;
77
import java.util.List;
8+
import java.util.Locale;
89
import java.util.Map;
910
import java.util.Optional;
1011

1112
import org.springframework.jdbc.core.RowMapper;
1213
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
1314
import org.springframework.stereotype.Repository;
15+
import org.springframework.transaction.annotation.Transactional;
1416

1517
import com.ssafy.salmanhae.model.dto.map.MapViewportItemType;
1618
import com.ssafy.salmanhae.model.dto.map.PropertyClusterViewportItem;
@@ -23,6 +25,7 @@
2325
import com.ssafy.salmanhae.model.dto.property.PropertyType;
2426
import com.ssafy.salmanhae.model.dto.property.RegionPriceStatResponse;
2527
import com.ssafy.salmanhae.model.dto.property.TransactionType;
28+
import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult;
2629

2730
@Repository
2831
public class JdbcPropertyDao implements PropertyDao {
@@ -35,6 +38,7 @@ public class JdbcPropertyDao implements PropertyDao {
3538
""";
3639

3740
private final NamedParameterJdbcTemplate jdbcTemplate;
41+
private volatile Boolean onConflictSupported;
3842

3943
public JdbcPropertyDao(NamedParameterJdbcTemplate jdbcTemplate) {
4044
this.jdbcTemplate = jdbcTemplate;
@@ -311,6 +315,19 @@ public Optional<PropertyRow> findActiveById(Long propertyId) {
311315
return rows.stream().findFirst();
312316
}
313317

318+
@Override
319+
public List<PropertyRow> findActivePropertiesForSafetyScoring() {
320+
String sql = """
321+
SELECT %s
322+
FROM properties
323+
WHERE is_active = true
324+
AND latitude IS NOT NULL
325+
AND longitude IS NOT NULL
326+
ORDER BY id ASC
327+
""".formatted(PROPERTY_COLUMNS);
328+
return jdbcTemplate.query(sql, Map.of(), propertyRowMapper());
329+
}
330+
314331
@Override
315332
public List<PropertyTransactionResponse> findComparableTransactions(PropertyRow property, String minContractYearMonth) {
316333
String sql = """
@@ -368,6 +385,96 @@ public Optional<PropertySafetySummaryResponse> findSafetySummary(Long propertyId
368385
return rows.stream().findFirst();
369386
}
370387

388+
@Override
389+
@Transactional
390+
public int upsertSafetyScoreStats(List<PropertySafetyScoreResult> results) {
391+
if (results == null || results.isEmpty()) {
392+
return 0;
393+
}
394+
return results.stream()
395+
.mapToInt(this::upsertSafetyScoreStat)
396+
.sum();
397+
}
398+
399+
private int upsertSafetyScoreStat(PropertySafetyScoreResult result) {
400+
Map<String, Object> params = new HashMap<>();
401+
params.put("propertyId", result.propertyId());
402+
params.put("safetyScore", result.safetyScore());
403+
params.put("cctvCount300m", result.cctvCount300m());
404+
params.put("bellCount300m", result.bellCount300m());
405+
params.put("lightCount300m", result.lightCount300m());
406+
params.put("policeCount500m", result.policeCount500m());
407+
if (supportsOnConflict()) {
408+
return upsertSafetyScoreStatWithOnConflict(params);
409+
}
410+
return upsertSafetyScoreStatWithUpdateInsert(params);
411+
}
412+
413+
private int upsertSafetyScoreStatWithOnConflict(Map<String, Object> params) {
414+
return jdbcTemplate.update("""
415+
INSERT INTO property_score_stat (
416+
property_id, safety_score, cctv_count_300m, bell_count_300m,
417+
light_count_300m, police_count_500m, created_at, updated_at
418+
) VALUES (
419+
:propertyId, :safetyScore, :cctvCount300m, :bellCount300m,
420+
:lightCount300m, :policeCount500m, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
421+
)
422+
ON CONFLICT (property_id) DO UPDATE
423+
SET safety_score = EXCLUDED.safety_score,
424+
cctv_count_300m = EXCLUDED.cctv_count_300m,
425+
bell_count_300m = EXCLUDED.bell_count_300m,
426+
light_count_300m = EXCLUDED.light_count_300m,
427+
police_count_500m = EXCLUDED.police_count_500m,
428+
updated_at = CURRENT_TIMESTAMP
429+
""", params);
430+
}
431+
432+
private int upsertSafetyScoreStatWithUpdateInsert(Map<String, Object> params) {
433+
int updated = jdbcTemplate.update("""
434+
UPDATE property_score_stat
435+
SET safety_score = :safetyScore,
436+
cctv_count_300m = :cctvCount300m,
437+
bell_count_300m = :bellCount300m,
438+
light_count_300m = :lightCount300m,
439+
police_count_500m = :policeCount500m,
440+
updated_at = CURRENT_TIMESTAMP
441+
WHERE property_id = :propertyId
442+
""", params);
443+
if (updated > 0) {
444+
return updated;
445+
}
446+
return jdbcTemplate.update("""
447+
INSERT INTO property_score_stat (
448+
property_id, safety_score, cctv_count_300m, bell_count_300m,
449+
light_count_300m, police_count_500m, created_at, updated_at
450+
) VALUES (
451+
:propertyId, :safetyScore, :cctvCount300m, :bellCount300m,
452+
:lightCount300m, :policeCount500m, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
453+
)
454+
""", params);
455+
}
456+
457+
private boolean supportsOnConflict() {
458+
Boolean cached = onConflictSupported;
459+
if (cached != null) {
460+
return cached;
461+
}
462+
onConflictSupported = detectOnConflictSupport();
463+
return onConflictSupported;
464+
}
465+
466+
private boolean detectOnConflictSupport() {
467+
if (jdbcTemplate.getJdbcTemplate().getDataSource() == null) {
468+
return true;
469+
}
470+
try (var connection = jdbcTemplate.getJdbcTemplate().getDataSource().getConnection()) {
471+
String productName = connection.getMetaData().getDatabaseProductName();
472+
return productName == null || !productName.toLowerCase(Locale.ROOT).contains("h2");
473+
} catch (SQLException exception) {
474+
return true;
475+
}
476+
}
477+
371478
@Override
372479
public List<RegionPriceStatResponse> findRegionPriceStats(
373480
String legalDongCode,

backend/src/main/java/com/ssafy/salmanhae/model/dao/property/PropertyDao.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.ssafy.salmanhae.model.dto.property.PropertyRow;
1515
import com.ssafy.salmanhae.model.dto.property.PropertySearchCriteria;
1616
import com.ssafy.salmanhae.model.dto.property.TransactionType;
17+
import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult;
1718

1819
public interface PropertyDao {
1920

@@ -35,10 +36,14 @@ List<PropertyClusterViewportItem> findPropertyClusters(
3536

3637
Optional<PropertyRow> findActiveById(Long propertyId);
3738

39+
List<PropertyRow> findActivePropertiesForSafetyScoring();
40+
3841
List<PropertyTransactionResponse> findComparableTransactions(PropertyRow property, String minContractYearMonth);
3942

4043
Optional<PropertySafetySummaryResponse> findSafetySummary(Long propertyId, Integer radius);
4144

45+
int upsertSafetyScoreStats(List<PropertySafetyScoreResult> results);
46+
4247
List<RegionPriceStatResponse> findRegionPriceStats(
4348
String legalDongCode,
4449
PropertyType propertyType,
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.ssafy.salmanhae.model.dto.safety;
2+
3+
public record PropertySafetyScoreInput(
4+
Long propertyId,
5+
int cctvCount300m,
6+
int bellCount300m,
7+
int lightCount300m,
8+
int policeCount500m
9+
) {
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.ssafy.salmanhae.model.dto.safety;
2+
3+
public record PropertySafetyScoreResult(
4+
Long propertyId,
5+
int safetyScore,
6+
int cctvCount300m,
7+
int bellCount300m,
8+
int lightCount300m,
9+
int policeCount500m
10+
) {
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.ssafy.salmanhae.service.safety;
2+
3+
import java.util.List;
4+
5+
import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreInput;
6+
import com.ssafy.salmanhae.model.dto.safety.PropertySafetyScoreResult;
7+
8+
public interface PropertySafetyScoreService {
9+
10+
List<PropertySafetyScoreResult> recalculateAll();
11+
12+
PropertySafetyScoreResult calculateScore(PropertySafetyScoreInput input);
13+
}

0 commit comments

Comments
 (0)