Skip to content

Commit b532d65

Browse files
authored
Merge pull request #177 from prgrms-aibe-devcourse/fix/176-til-exp-text-length
[Bug] TIL 경험치가 본문 HTML 길이 기준으로 계산되어 예상치와 불일치
2 parents b7474bf + 843a9bc commit b532d65

5 files changed

Lines changed: 136 additions & 2 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ dependencies {
7070

7171
// Metrics Collection (Prometheus)
7272
implementation 'io.micrometer:micrometer-registry-prometheus'
73+
74+
// HTML 파싱 (TIL 본문 경험치 산정 시 태그 제외 후 순수 텍스트 길이 계산)
75+
implementation 'org.jsoup:jsoup:1.18.1'
7376
}
7477

7578
// ─── 테스트용 MySQL 컨테이너 생명주기 태스크 ─────────────────────────────────

src/main/java/com/Rootin/domain/til/service/TilService.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.Rootin.domain.til.entity.*;
1313
import com.Rootin.domain.til.repository.TagRepository;
1414
import com.Rootin.domain.til.repository.TilRepository;
15+
import com.Rootin.domain.til.util.TilContentLength;
1516
import com.Rootin.domain.user.entity.User;
1617
import com.Rootin.domain.user.repository.UserRepository;
1718
import com.Rootin.global.exception.CustomException;
@@ -58,8 +59,17 @@ public TilResponse create(Long userId, TilCreateRequest request) {
5859

5960
// TIL 저장 성공 직후, 글자 수 및 이력을 바탕으로 물주기 경험치/포인트/레벨업 비즈니스 로직을 구동합니다.
6061
// 이 호출이 Phase 2 경험치 시스템과 TIL 도메인을 연결하는 핵심 지점입니다.
61-
int contentLength = request.content() != null ? request.content().length() : 0;
62-
experienceService.applyWatering(userId, pot, contentLength, til.getId());
62+
// 경험치 산정 글자 수는 HTML 원문 길이가 아니라 태그·공백을 제외한 순수 텍스트 기준으로 계산합니다.
63+
// (서식만 추가해도 경험치가 늘어나는 문제 방지 + 에디터 예상 경험치와 기준 일치)
64+
int contentLength = TilContentLength.countVisibleCharacters(request.content());
65+
66+
// 가시 텍스트가 0자면 경험치도 0이므로 물주기를 수행하지 않습니다.
67+
// 0자에 대해 applyWatering을 호출하면 expGained=0인 WateringLog가 기록되고,
68+
// post_id unique 제약 + existsByPostId 중복 검사 탓에 해당 TIL은 이후로도 물주기 대상에서 영구 제외됩니다.
69+
// (서식 태그만 있는 본문은 @NotBlank를 통과하지만 가시 글자 수는 0이 될 수 있습니다.)
70+
if (contentLength > 0) {
71+
experienceService.applyWatering(userId, pot, contentLength, til.getId());
72+
}
6373

6474
return TilResponse.from(til);
6575
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.Rootin.domain.til.util;
2+
3+
import org.jsoup.Jsoup;
4+
5+
/**
6+
* TIL 본문(HTML)에서 경험치 산정에 사용할 글자 수를 계산하는 유틸리티입니다.
7+
*
8+
* 본문은 에디터의 getHTML() 결과라 <p>, <strong>, 콜아웃/코드블록 같은 태그와 속성이 포함됩니다.
9+
* 이를 그대로 세면 같은 분량이라도 서식을 넣을수록 글자 수가 부풀려져 경험치가 과다 지급됩니다.
10+
* 그래서 태그를 제거하고 보이는 텍스트만 추출한 뒤, 공백까지 제거하여 순수 글자 수를 셉니다.
11+
* (프론트 예상 경험치 계산의 editor.getText().replace(/\s/g, '') 기준과 동일하게 맞춥니다.)
12+
*/
13+
public final class TilContentLength {
14+
15+
private TilContentLength() {
16+
}
17+
18+
/**
19+
* HTML 본문에서 태그·공백을 제외한 순수 보이는 글자 수를 계산합니다.
20+
*
21+
* @param html TIL 본문 HTML (null 허용)
22+
* @return 태그 제거 + HTML 엔티티 디코딩 + 공백 제거 후의 글자 수 (입력이 비어 있으면 0)
23+
*/
24+
public static int countVisibleCharacters(String html) {
25+
if (html == null || html.isBlank()) {
26+
return 0;
27+
}
28+
// Jsoup.text()가 태그를 제거하고 HTML 엔티티(< 등)를 실제 문자로 디코딩한다.
29+
String visibleText = Jsoup.parse(html).text();
30+
return visibleText.replaceAll("\\s", "").length();
31+
}
32+
}

src/test/java/com/Rootin/domain/garden/service/ExperienceServiceTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,49 @@ void tilCreateTriggersWatering() {
257257
assertThat(logs.get(0).getPostId()).isEqualTo(response.tilId());
258258
}
259259

260+
@Test
261+
@DisplayName("TIL 본문에 서식(HTML 태그)이 있어도 경험치는 태그·공백을 제외한 순수 텍스트 글자 수로 산정된다")
262+
void tilCreateUsesVisibleTextLengthForExp() {
263+
// given: 가시 텍스트는 "오늘배운것"(5자)이지만 HTML 원문은 태그 때문에 훨씬 길다
264+
String html = "<p><strong>오늘</strong> <code>배운</code> 것</p>";
265+
TilCreateRequest request = new TilCreateRequest("서식 TIL", html, testPot.getId(), List.of("Java"));
266+
267+
// when
268+
TilResponse response = tilService.create(testUser.getId(), request);
269+
270+
em.flush();
271+
em.clear();
272+
273+
// then: WateringLog에는 HTML 원문 길이가 아니라 가시 글자 수(5)가 기록된다
274+
List<WateringLog> logs = wateringLogRepository.findByPotId(testPot.getId());
275+
assertThat(logs).hasSize(1);
276+
WateringLog log = logs.get(0);
277+
assertThat(log.getPostId()).isEqualTo(response.tilId());
278+
assertThat(log.getContentLength()).isEqualTo(5);
279+
assertThat(log.getContentLength()).isLessThan(html.length());
280+
// 경험치 = floor(min(5 * 0.2, 300) * 1.0) = 1 (첫 작성이라 스트릭 0일)
281+
assertThat(log.getExpGained()).isEqualTo(1);
282+
}
283+
284+
@Test
285+
@DisplayName("가시 텍스트가 없는 본문(서식 태그만 존재)은 경험치/물주기 이력을 만들지 않는다")
286+
void tilCreateWithNoVisibleTextSkipsWatering() {
287+
// given: @NotBlank는 통과하지만 가시 글자 수가 0인 본문
288+
TilCreateRequest request = new TilCreateRequest("빈 본문 TIL", "<p></p>", testPot.getId(), List.of());
289+
290+
// when
291+
TilResponse response = tilService.create(testUser.getId(), request);
292+
293+
em.flush();
294+
em.clear();
295+
296+
// then: TIL은 생성되지만 물주기 이력이 남지 않고(0-exp 로그가 post_id를 선점하지 않음) 화분 경험치도 그대로다
297+
assertThat(response.tilId()).isNotNull();
298+
assertThat(wateringLogRepository.findByPotId(testPot.getId())).isEmpty();
299+
Pot pot = potRepository.findById(testPot.getId()).orElseThrow();
300+
assertThat(pot.getTotalExp()).isZero();
301+
}
302+
260303
@Test
261304
@DisplayName("본인의 화분이 아닌 다른 사용자의 화분에 물주기를 수행하면 FORBIDDEN 에러가 발생한다")
262305
void applyWateringForbidden() {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.Rootin.domain.til.util;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.assertj.core.api.Assertions.assertThat;
7+
8+
@DisplayName("TilContentLength: TIL 본문 HTML에서 경험치 산정용 글자 수 계산")
9+
class TilContentLengthTest {
10+
11+
@Test
12+
@DisplayName("HTML 태그는 글자 수에서 제외하고 보이는 텍스트만 센다")
13+
void countsOnlyVisibleText() {
14+
assertThat(TilContentLength.countVisibleCharacters("<p>가나다</p>")).isEqualTo(3);
15+
}
16+
17+
@Test
18+
@DisplayName("공백·개행은 글자 수에서 제외한다 (FE getText().replace(/\\s/g,'') 기준과 일치)")
19+
void stripsWhitespace() {
20+
assertThat(TilContentLength.countVisibleCharacters("<p>가 나\n다</p>")).isEqualTo(3);
21+
}
22+
23+
@Test
24+
@DisplayName("HTML 엔티티는 디코딩한 실제 글자 1자로 센다")
25+
void decodesHtmlEntities() {
26+
// 화면에 보이는 글자는 "a<b&c" → 공백 제외 5자
27+
assertThat(TilContentLength.countVisibleCharacters("<code>a &lt; b &amp; c</code>")).isEqualTo(5);
28+
}
29+
30+
@Test
31+
@DisplayName("같은 텍스트면 굵게·콜아웃·코드블록 등 서식을 넣어도 글자 수가 같다")
32+
void formattingDoesNotInflateCount() {
33+
int plain = TilContentLength.countVisibleCharacters("<p>오늘배운것</p>");
34+
int formatted = TilContentLength.countVisibleCharacters(
35+
"<div data-type=\"callout\"><p><strong>오늘</strong><code>배운</code>것</p></div>");
36+
assertThat(formatted).isEqualTo(plain).isEqualTo(5);
37+
}
38+
39+
@Test
40+
@DisplayName("null 또는 빈 문자열은 0을 반환한다")
41+
void returnsZeroForEmpty() {
42+
assertThat(TilContentLength.countVisibleCharacters(null)).isZero();
43+
assertThat(TilContentLength.countVisibleCharacters("")).isZero();
44+
assertThat(TilContentLength.countVisibleCharacters(" ")).isZero();
45+
}
46+
}

0 commit comments

Comments
 (0)