Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'com.github.ben-manes.caffeine:caffeine'

// db
implementation 'com.mysql:mysql-connector-j'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import com.google.api.services.sheets.v4.Sheets;
import com.google.api.services.sheets.v4.model.ValueRange;
Expand All @@ -24,6 +27,7 @@
import gg.agit.konect.domain.website.model.WebUniversity;
import gg.agit.konect.domain.website.repository.WebClubRepository;
import gg.agit.konect.domain.website.repository.WebUniversityRepository;
import gg.agit.konect.domain.website.service.WebsiteClubStatsReader;
import gg.agit.konect.global.code.ApiResponseCode;
import gg.agit.konect.global.exception.CustomException;
import lombok.RequiredArgsConstructor;
Expand All @@ -49,10 +53,26 @@ public class AdminWebsiteClubSheetImportService {
private static final int TOPIC_COLUMN_INDEX = 3;
private static final int CATEGORY_EMOJI_COLUMN_INDEX = 4;
private static final int DESCRIPTION_COLUMN_INDEX = 5;
private static final Set<String> PLACEHOLDER_TEXTS = Set.of(
"-",
"없음",
"미정",
"미확인",
"확인필요",
"확인 필요",
"조사필요",
"조사 필요",
"미분류"
);
private static final Pattern URL_OR_SNS_PATTERN = Pattern.compile(
"(?i).*(https?://|www\\.|instagram\\.com|open\\.kakao|kakao\\.com|@\\w+).*"
);
private static final Pattern PHONE_NUMBER_PATTERN = Pattern.compile(".*\\d{2,3}[- .]?\\d{3,4}[- .]?\\d{4}.*");

private final Sheets googleSheetsService;
private final WebUniversityRepository webUniversityRepository;
private final WebClubRepository webClubRepository;
private final WebsiteClubStatsReader websiteClubStatsReader;

public AdminWebsiteClubSheetImportPreviewResponse previewClubs(
Integer universityId,
Expand Down Expand Up @@ -113,6 +133,16 @@ public AdminWebsiteClubSheetImportResponse confirmImport(
warnings.add(String.format("%d행: 이미 등록된 동아리명 '%s'을 제외했습니다.", club.rowNumber(), name));
continue;
}
List<String> contentWarnings = validateClubContent(
club.rowNumber(),
name,
club.topic(),
club.description()
);
if (!contentWarnings.isEmpty()) {
warnings.addAll(contentWarnings);
continue;
}

clubsToSave.add(WebClub.builder()
.university(university)
Expand All @@ -131,6 +161,9 @@ public AdminWebsiteClubSheetImportResponse confirmImport(
List<WebClub> savedClubs = clubsToSave.isEmpty()
? List.of()
: webClubRepository.saveAll(clubsToSave);
if (!savedClubs.isEmpty()) {
invalidateWebsiteStatsAfterCommit(universityId);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return AdminWebsiteClubSheetImportResponse.of(
savedClubs.size(),
Expand Down Expand Up @@ -159,8 +192,15 @@ private SheetClubImportPlan buildImportPlan(List<RawClubRow> rows) {
requiredText(row.description(), name + " 동아리입니다."),
DESCRIPTION_MAX_LENGTH
);
List<String> contentWarnings = validateClubContent(
row.rowNumber(),
row.name(),
row.topic(),
row.description()
);

addWarnings(row, category, topic, categoryEmoji, description, warnings);
warnings.addAll(contentWarnings);
clubs.add(new AdminWebsiteClubSheetImportPreviewResponse.PreviewClub(
row.rowNumber(),
name,
Expand All @@ -169,7 +209,7 @@ private SheetClubImportPlan buildImportPlan(List<RawClubRow> rows) {
description,
EMPTY_INTRODUCE,
categoryEmoji,
true
contentWarnings.isEmpty()
));
}

Expand Down Expand Up @@ -204,6 +244,73 @@ private void addWarnings(
}
}

private void invalidateWebsiteStatsAfterCommit(Integer universityId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
websiteClubStatsReader.invalidateUniversity(universityId);
return;
}

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
websiteClubStatsReader.invalidateUniversity(universityId);
}
});
}

private List<String> validateClubContent(
int rowNumber,
String name,
String topic,
String description
) {
List<String> warnings = new ArrayList<>();
String normalizedName = optionalText(name);
if (isSuspiciousName(normalizedName)) {
warnings.add(String.format("%d행: 동아리명이 소개 문장 또는 시트 헤더처럼 보여 제외했습니다.", rowNumber));
}
if (isSuspiciousShortText(topic)) {
warnings.add(String.format("%d행: 동아리 주제에 미확인/연락처성 문구가 있어 제외했습니다.", rowNumber));
}
if (isSuspiciousShortText(description)) {
warnings.add(String.format("%d행: 한 줄 소개에 미확인/연락처성 문구가 있어 제외했습니다.", rowNumber));
}
return warnings;
}

private boolean isSuspiciousName(String name) {
if (name.isBlank()) {
return false;
}
String normalized = name.trim();
// Header/label fragments mean a sheet row or intro column leaked into the name field.
return HEADER_NAME.equals(normalized)
|| normalized.contains("한 줄 소개")
|| normalized.contains("상세소개")
// Sentence-like names usually came from one-line introductions rather than club names.
|| normalized.contains("동아리입니다")
|| normalized.endsWith("입니다.")
|| normalized.endsWith("합니다.")
// Contact handles and placeholders are not stable display names.
|| URL_OR_SNS_PATTERN.matcher(normalized).matches()
|| PHONE_NUMBER_PATTERN.matcher(normalized).matches()
|| isPlaceholder(normalized);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private boolean isSuspiciousShortText(String value) {
String normalized = optionalText(value);
if (normalized.isBlank()) {
return false;
}
return isPlaceholder(normalized)
|| URL_OR_SNS_PATTERN.matcher(normalized).matches()
|| PHONE_NUMBER_PATTERN.matcher(normalized).matches();
}

private boolean isPlaceholder(String value) {
return PLACEHOLDER_TEXTS.contains(value.trim().toLowerCase(Locale.ROOT));
}

private List<RawClubRow> readClubRows(String spreadsheetId) {
try {
ValueRange response = googleSheetsService.spreadsheets().values()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package gg.agit.konect.domain.website.service;

import java.time.Duration;
import java.util.Locale;
import java.util.Map;

import org.springframework.stereotype.Component;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

import gg.agit.konect.domain.club.enums.ClubCategory;
import gg.agit.konect.domain.website.repository.WebsiteQueryRepository;
import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class WebsiteClubStatsReader {

private static final long UNIVERSITY_CLUB_COUNT_CACHE_MAX_SIZE = 500;
private static final long CATEGORY_COUNT_CACHE_MAX_SIZE = 10_000;
private static final Duration CACHE_TTL = Duration.ofMinutes(10);

private final WebsiteQueryRepository websiteQueryRepository;
private final Cache<Integer, Long> universityClubCountCache = Caffeine.newBuilder()
.maximumSize(UNIVERSITY_CLUB_COUNT_CACHE_MAX_SIZE)
.expireAfterWrite(CACHE_TTL)
.build();
private final Cache<CategoryCountCacheKey, Map<ClubCategory, Long>> categoryCountCache = Caffeine.newBuilder()
.maximumSize(CATEGORY_COUNT_CACHE_MAX_SIZE)
.expireAfterWrite(CACHE_TTL)
.build();

public Long getUniversityClubCount(Integer universityId) {
return universityClubCountCache.get(
universityId,
websiteQueryRepository::countClubsByUniversityId
);
}

public Map<ClubCategory, Long> getCategoryCounts(Integer universityId, String query) {
CategoryCountCacheKey key = new CategoryCountCacheKey(universityId, normalizeQuery(query));
return categoryCountCache.get(
key,
cacheKey -> Map.copyOf(websiteQueryRepository.countClubCategories(
cacheKey.universityId(),
cacheKey.query()
))
);
}

public void invalidateUniversity(Integer universityId) {
universityClubCountCache.invalidate(universityId);
categoryCountCache.asMap().keySet().removeIf(key -> key.universityId().equals(universityId));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// null/blank queries share one "no search" cache key; trim and Locale.ROOT keep query keys stable.
private String normalizeQuery(String query) {
if (query == null || query.isBlank()) {
return null;
}
return query.trim().toLowerCase(Locale.ROOT);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private record CategoryCountCacheKey(
Integer universityId,
String query
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class WebsiteService {
private final WebsiteQueryRepository websiteQueryRepository;
private final UniversitySearchMatcher universitySearchMatcher;
private final UniversitySearchKeywordReader universitySearchKeywordReader;
private final WebsiteClubStatsReader websiteClubStatsReader;

public WebsiteHomeResponse getHome(String query, UniversityRegion region) {
List<WebsiteUniversitySummary> summaries = websiteQueryRepository.findUniversitySummaries(null, region)
Expand Down Expand Up @@ -71,15 +72,15 @@ public WebsiteClubsResponse getUniversityClubs(Integer universityId, WebsiteClub
return WebsiteClubsResponse.of(
university,
clubs,
websiteQueryRepository.countClubsByUniversityId(universityId),
websiteQueryRepository.countClubCategories(universityId, condition.query())
websiteClubStatsReader.getUniversityClubCount(universityId),
websiteClubStatsReader.getCategoryCounts(universityId, condition.query())
);
}

public WebsiteClubDetailResponse getClubDetail(Integer clubId) {
WebClub club = websiteQueryRepository.findClub(clubId)
.orElseThrow(() -> CustomException.of(NOT_FOUND_CLUB));
Long universityClubCount = websiteQueryRepository.countClubsByUniversityId(club.getUniversity().getId());
Long universityClubCount = websiteClubStatsReader.getUniversityClubCount(club.getUniversity().getId());

return WebsiteClubDetailResponse.of(club, universityClubCount);
}
Expand Down
Loading
Loading