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 @@ -30,7 +30,7 @@ public record ConfirmClub(
String topic,

@NotBlank
@Size(max = 30)
@Size(max = 100)
String description,

@NotNull
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 @@ -40,7 +44,7 @@ public class AdminWebsiteClubSheetImportService {
private static final int DEFAULT_HEADER_INDEX = 3;
private static final int NAME_MAX_LENGTH = 50;
private static final int TOPIC_MAX_LENGTH = 20;
private static final int DESCRIPTION_MAX_LENGTH = 30;
private static final int DESCRIPTION_MAX_LENGTH = 100;
private static final int CATEGORY_EMOJI_MAX_LENGTH = 255;
private static final String EMPTY_INTRODUCE = "";
private static final int NAME_COLUMN_INDEX = 0;
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);
}

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);
}

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
Expand Up @@ -32,12 +32,12 @@ public record ClubInformationUpdateRequestDto(
String clubTopic,

@Schema(
description = "ํ•œ ์ค„ ์†Œ๊ฐœ (์ตœ๋Œ€ 30์ž)",
description = "ํ•œ ์ค„ ์†Œ๊ฐœ (์ตœ๋Œ€ 100์ž)",
example = "์ฝ”๋”ฉ ๋™์•„๋ฆฌ์ž…๋‹ˆ๋‹ค.",
requiredMode = REQUIRED
)
@NotBlank(message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.")
@Size(max = 30, message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ์ตœ๋Œ€ 30์ž์ž…๋‹ˆ๋‹ค.")
@Size(max = 100, message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ์ตœ๋Œ€ 100์ž์ž…๋‹ˆ๋‹ค.")
String shortDescription,

@Schema(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ public record ClubRegistrationRequestDto(
@Size(max = 10, message = "๋™์•„๋ฆฌ ์ด๋ชจ์ง€๋Š” ์ตœ๋Œ€ 10์ž์ž…๋‹ˆ๋‹ค.")
String clubEmoji,

@Schema(description = "ํ•œ ์ค„ ์†Œ๊ฐœ (์ตœ๋Œ€ 30์ž)", example = "์ฝ”๋”ฉ ๋™์•„๋ฆฌ์ž…๋‹ˆ๋‹ค.", requiredMode = REQUIRED)
@Schema(description = "ํ•œ ์ค„ ์†Œ๊ฐœ (์ตœ๋Œ€ 100์ž)", example = "์ฝ”๋”ฉ ๋™์•„๋ฆฌ์ž…๋‹ˆ๋‹ค.", requiredMode = REQUIRED)
@NotBlank(message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.")
@Size(max = 30, message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ์ตœ๋Œ€ 30์ž์ž…๋‹ˆ๋‹ค.")
@Size(max = 100, message = "ํ•œ ์ค„ ์†Œ๊ฐœ๋Š” ์ตœ๋Œ€ 100์ž์ž…๋‹ˆ๋‹ค.")
String shortDescription,

@Schema(description = "๋™์•„๋ฆฌ ์†Œ๊ฐœ (์ตœ๋Œ€ 2000์ž)", example = "์ƒ์„ธํ•œ ๋™์•„๋ฆฌ ์†Œ๊ฐœ ๋‚ด์šฉ...", requiredMode = REQUIRED)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public class ClubInformationUpdateRequest extends BaseEntity {
private String clubTopic;

@NotNull
@Column(name = "short_description", length = 30, nullable = false)
@Column(name = "short_description", length = 100, nullable = false)
private String shortDescription;

@NotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public class ClubRegistrationRequest extends BaseEntity {
private String clubEmoji;

@NotNull
@Column(name = "short_description", length = 30, nullable = false)
@Column(name = "short_description", length = 100, nullable = false)
private String shortDescription;

@NotNull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public class WebClub extends BaseEntity {
@Column(name = "topic", length = 20, nullable = false)
private String topic;

@Column(name = "description", length = 30, nullable = false)
@Column(name = "description", length = 100, nullable = false)
private String description;

@Column(name = "introduce", columnDefinition = "TEXT", nullable = false)
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));
}

// 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);
}

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ALTER TABLE web_club
MODIFY COLUMN description VARCHAR(100) NOT NULL;

ALTER TABLE club_registration_request
MODIFY COLUMN short_description VARCHAR(100) NOT NULL;

ALTER TABLE club_information_update_request
MODIFY COLUMN short_description VARCHAR(100) NOT NULL;
Loading
Loading