From edff62f5c12ffcb9d40e6e6ffda818212629a365 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 23:33:22 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=ED=8C=8C=EC=9D=BC=20=EC=84=A4=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-06-08-agreement-versioned-files-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md diff --git a/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md b/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md new file mode 100644 index 00000000..1f483953 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-agreement-versioned-files-design.md @@ -0,0 +1,161 @@ +# Agreement Versioned Files Design + +## Context + +현재 약관 기능은 `GET /api/agreements/current`가 백엔드 리소스 Markdown과 `application.yaml`의 버전 설정을 읽어 현재 이용약관과 개인정보 처리방침을 반환한다. 사용자는 `users.tos_version`, `users.privacy_version`에 동의한 버전을 저장한다. + +문제는 원문 파일이 문서 타입별로 하나씩만 있어서 새 약관을 반영할 때 기존 파일을 덮어쓰게 된다는 점이다. 그러면 사용자가 과거에 동의한 버전 문자열은 DB에 남지만, 그 버전의 실제 원문은 백엔드 리소스에서 사라진다. + +## Goals + +- 약관 원문을 문서 타입별 버전 파일로 보존한다. +- `GET /api/agreements/current`는 계속 현재 버전 원문만 반환한다. +- API 응답 구조와 프론트엔드 호출 방식은 변경하지 않는다. +- 과거 원문은 백엔드 리소스에 남기되, 이번 범위에서는 과거 버전 조회 API를 추가하지 않는다. +- 현재 버전 설정과 실제 파일이 맞지 않으면 서버 설정 오류로 빠르게 드러나게 한다. + +## Non-Goals + +- 약관 원문을 DB에 저장하지 않는다. +- 약관 변경 이력 테이블을 만들지 않는다. +- `type + version`으로 과거 원문을 조회하는 공개 API를 추가하지 않는다. +- 로그인 사용자의 동의 버전 원문 조회 API를 추가하지 않는다. +- 고지 발송, 예약 발행, 시행일 자동 계산, 변경 사유 관리 기능은 포함하지 않는다. +- 운영정책, 저작권 정책은 이번 버전 파일 구조 변경 대상에 포함하지 않는다. + +## Resource Layout + +약관 리소스는 문서 타입별 디렉토리와 버전 파일로 관리한다. + +```text +src/main/resources/agreements/ +├── terms-of-service/ +│ └── 1.0.md +└── privacy-policy/ + └── 1.0.md +``` + +새 약관을 발행할 때는 기존 파일을 수정하지 않고 새 버전 파일을 추가한다. + +```text +src/main/resources/agreements/ +├── terms-of-service/ +│ ├── 1.0.md +│ └── 1.1.md +└── privacy-policy/ + ├── 1.0.md + └── 1.1.md +``` + +버전 문자열은 파일명으로 안전하게 사용할 수 있도록 숫자와 점으로만 구성한다. 허용 패턴은 `^[0-9]+(\\.[0-9]+)*$`이며, 예시는 `1.0`, `1.1`, `2.0.1`이다. + +## Configuration + +`application.yaml`은 현재 버전만 지정한다. + +```yaml +app: + agreements: + terms-of-service: + current-version: "1.0" + privacy-policy: + current-version: "1.0" +``` + +서비스는 문서 타입별 고정 경로와 `current-version`으로 리소스 위치를 계산한다. + +```text +classpath:agreements/terms-of-service/{currentVersion}.md +classpath:agreements/privacy-policy/{currentVersion}.md +``` + +현재 설정의 `resource` 필드는 제거한다. 리소스 경로를 설정에서 받지 않으면 `version=1.1`인데 `resource=1.0.md`를 가리키는 불일치를 만들 수 없다. + +## API Behavior + +`GET /api/agreements/current`는 기존과 같은 응답 구조를 유지한다. + +```json +{ + "items": [ + { + "type": "TERMS_OF_SERVICE", + "title": "이용약관", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 이용약관\n..." + }, + { + "type": "PRIVACY_POLICY", + "title": "개인정보 처리방침", + "version": "1.0", + "contentFormat": "MARKDOWN", + "content": "# 개인정보 처리방침\n..." + } + ] +} +``` + +`version`에는 각 문서의 `current-version` 값이 들어간다. `content`는 해당 버전 파일의 Markdown 원문이다. + +## Error Handling + +기존 `AGREEMENT_CONFIGURATION_INVALID`를 계속 사용한다. + +다음 경우는 서버 설정 오류로 처리한다. + +- `current-version`이 비어 있다. +- `current-version`이 허용된 버전 문자열 패턴과 맞지 않는다. +- 계산된 리소스 파일이 존재하지 않는다. +- 리소스 파일을 읽을 수 없다. + +이 오류는 클라이언트 입력 문제가 아니라 배포된 설정과 리소스의 불일치이므로 503 응답을 유지한다. + +## Components + +- `AgreementProperties` + - `AgreementDocument.version`을 `currentVersion`으로 바꾼다. + - `Resource resource` 설정은 제거한다. +- `AgreementService` + - 문서 타입별 title, API type, 리소스 디렉토리를 내부 매핑으로 둔다. + - 현재 버전 값을 검증한 뒤 `ClassPathResource`로 `{directory}/{version}.md`를 읽는다. + - `currentVersions()`, `needsReacceptance(User)`의 외부 동작은 유지한다. +- `AgreementController`와 응답 DTO + - API 계약을 바꾸지 않는다. + +## Manual Release Flow + +새 약관 버전을 발행할 때 운영자는 다음 순서로 변경한다. + +1. 기존 버전 파일은 수정하지 않는다. +2. 새 버전 파일을 추가한다. +3. `application.yaml`의 `current-version`을 새 버전으로 변경한다. +4. 테스트에서 현재 버전에 해당하는 파일이 읽히는지 확인한다. + +이 방식은 의도적으로 수동 발행이다. 지금 단계에서는 manifest나 배포 예약 기능 없이 파일 보존과 현재 버전 전환만 보장한다. + +## Documentation Updates + +구현 시 다음 문서를 함께 갱신한다. + +- `docs/ai/features.md`: 현재 약관 조회 설명을 버전별 리소스 파일과 `current-version` 설정 기준으로 수정한다. +- `docs/ai/erd.md`: 약관 원문 보존 방식 설명을 버전별 리소스 파일 기준으로 수정한다. +- `docs/policy/terms-of-service.md`, `docs/policy/privacy-policy.md`: 백엔드 리소스 경로 안내가 단일 파일을 가리키고 있으면 새 버전 경로로 수정한다. +- 기존 `docs/superpowers/specs/2026-06-08-backend-agreements-design.md`는 최초 약관 기능 설계 기록으로 남긴다. 후속 구현은 이 문서를 기준으로 단일 리소스 구조를 대체한다. + +## Testing + +- `AgreementServiceTest` + - 현재 버전 설정이 `1.0`이면 `agreements/{type}/1.0.md`를 읽는다. + - 현재 버전이 비어 있으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 현재 버전이 허용된 버전 문자열 패턴과 맞지 않으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 현재 버전에 해당하는 파일이 없으면 `AGREEMENT_CONFIGURATION_INVALID`를 던진다. + - 실제 약관 파일의 공개 정책 URL 검증은 새 경로 기준으로 유지한다. +- `AgreementControllerTest` + - 응답 구조가 바뀌지 않았음을 기존 테스트로 유지한다. + +최소 검증은 `./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest`와 `./gradlew compileJava`다. 커밋 전에는 프로젝트 규칙에 따라 `./gradlew checkstyleMain checkstyleTest`도 실행한다. + +## Follow-Up Scope + +- 과거 버전 원문 조회 API는 이번 범위에서 제외한다. 실제 서비스에서 사용자가 동의한 원문 표시나 감사 대응 화면이 필요해지면 인증된 사용자용 조회 API를 별도 설계한다. From d30fc2b2f35b56412096618e40c55d040d6368b8 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 23:43:20 +0900 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=ED=8C=8C=EC=9D=BC=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-08-agreement-versioned-files.md | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-agreement-versioned-files.md diff --git a/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md b/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md new file mode 100644 index 00000000..d1bbc20d --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-agreement-versioned-files.md @@ -0,0 +1,547 @@ +# Agreement Versioned Files Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve historical agreement Markdown files by version while keeping `GET /api/agreements/current` as the only current-agreement lookup API. + +**Architecture:** Agreement content remains in classpath resources. `application.yaml` stores only each agreement document's `current-version`; `AgreementService` validates that version, derives the resource path, reads the matching Markdown file, and returns the existing response shape. Historical versions stay in `src/main/resources/agreements//.md`. + +**Tech Stack:** Spring Boot 4.0.5, Java 21, Gradle, JUnit 5, AssertJ, Spring `ClassPathResource`, Markdown documentation. + +--- + +## File Structure + +Create and modify these files: + +- Modify: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` + - Keep the `app.agreements` binding. + - Replace per-document `version` and `resource` fields with `currentVersion`. +- Modify: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` + - Validate `currentVersion`. + - Derive `classpath:agreements/terms-of-service/{version}.md` and `classpath:agreements/privacy-policy/{version}.md`. + - Keep `getCurrentAgreements()`, `currentVersions()`, `validateAccepted(Boolean)`, and `needsReacceptance(User)` behavior. +- Modify: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` + - Update tests for `currentVersion`. + - Add invalid version format and missing derived resource coverage. +- Modify: `src/main/resources/application.yaml` + - Change `version` to `current-version`. + - Remove `resource`. +- Move: `src/main/resources/agreements/terms-of-service.md` to `src/main/resources/agreements/terms-of-service/1.0.md` +- Move: `src/main/resources/agreements/privacy-policy.md` to `src/main/resources/agreements/privacy-policy/1.0.md` +- Modify: `docs/ai/features.md` + - Update current agreement lookup description. +- Modify: `docs/ai/erd.md` + - Update agreement original-text storage note. +- Modify: `docs/policy/terms-of-service.md` + - Update backend resource pointer and setting pointer. +- Modify: `docs/policy/privacy-policy.md` + - Update backend resource pointer and setting pointer. + +Do not change these files unless compilation forces an import cleanup: + +- `src/main/java/com/howaboutus/backend/agreements/controller/AgreementController.java` +- `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementCurrentResponse.java` +- `src/main/java/com/howaboutus/backend/agreements/controller/dto/AgreementDocumentResponse.java` +- `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementDocumentResult.java` +- `src/main/java/com/howaboutus/backend/agreements/service/dto/AgreementVersions.java` + +--- + +### Task 1: Service Tests For Version-Derived Resources + +**Files:** +- Modify: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` + +- [ ] **Step 1: Replace the service test file with version-derived resource tests** + +Replace `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` with: + +```java +package com.howaboutus.backend.agreements.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; + +class AgreementServiceTest { + + @Test + @DisplayName("설정된 현재 버전의 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + void returnsCurrentAgreements() { + AgreementService service = new AgreementService(properties("1.0", "1.0")); + + var result = service.getCurrentAgreements(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).type()).isEqualTo("TERMS_OF_SERVICE"); + assertThat(result.get(0).title()).isEqualTo("이용약관"); + assertThat(result.get(0).version()).isEqualTo("1.0"); + assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); + assertThat(result.get(0).content()).startsWith("# 이용약관"); + assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); + assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); + assertThat(result.get(1).version()).isEqualTo("1.0"); + assertThat(result.get(1).content()).startsWith("# 개인정보 처리방침"); + } + + @Test + @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") + void termsOfServiceUsesPublicPolicyUrls() { + AgreementService service = new AgreementService(properties("1.0", "1.0")); + + var result = service.getCurrentAgreements(); + + assertThat(result.get(0).content()) + .doesNotContain("(privacy-policy.md)") + .doesNotContain("(operation-policy.md)") + .doesNotContain("(copyright-policy.md)") + .contains("(https://uttae.com/policies/privacy)") + .contains("(https://uttae.com/policies/operation)") + .contains("(https://uttae.com/policies/copyright)"); + } + + @Test + @DisplayName("현재 약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForBlankVersion() { + AgreementService service = new AgreementService(properties("", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + @Test + @DisplayName("현재 약관 버전 형식이 올바르지 않으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForInvalidVersionFormat() { + AgreementService service = new AgreementService(properties("../1.0", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + @Test + @DisplayName("현재 약관 버전에 해당하는 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); + + assertAgreementConfigurationInvalid(service); + } + + private AgreementProperties properties(String tosVersion, String privacyVersion) { + return new AgreementProperties( + new AgreementProperties.AgreementDocument(tosVersion), + new AgreementProperties.AgreementDocument(privacyVersion) + ); + } + + private void assertAgreementConfigurationInvalid(AgreementService service) { + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + } +} +``` + +- [ ] **Step 2: Run the focused service test to verify it fails** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: compilation fails because `AgreementProperties.AgreementDocument` still requires `(String version, Resource resource)` and the service still reads configured `resource`. + +- [ ] **Step 3: Keep the failing test uncommitted** + +```bash +git status --short +``` + +Expected: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` is modified. Do not commit this red state. + +--- + +### Task 2: Configuration And Service Implementation + +**Files:** +- Modify: `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` +- Modify: `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` +- Modify: `src/main/resources/application.yaml` + +- [ ] **Step 1: Update `AgreementProperties`** + +Replace `src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java` with: + +```java +package com.howaboutus.backend.agreements.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.agreements") +public record AgreementProperties( + AgreementDocument termsOfService, + AgreementDocument privacyPolicy +) { + + public record AgreementDocument( + String currentVersion + ) { + } +} +``` + +- [ ] **Step 2: Update `AgreementService`** + +Replace `src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java` with: + +```java +package com.howaboutus.backend.agreements.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.regex.Pattern; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import com.howaboutus.backend.agreements.config.AgreementProperties; +import com.howaboutus.backend.agreements.service.dto.AgreementDocumentResult; +import com.howaboutus.backend.agreements.service.dto.AgreementVersions; +import com.howaboutus.backend.common.error.CustomException; +import com.howaboutus.backend.common.error.ErrorCode; +import com.howaboutus.backend.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AgreementService { + + private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + private static final Pattern VERSION_PATTERN = Pattern.compile("^[0-9]+(\\.[0-9]+)*$"); + + private final AgreementProperties properties; + + public List getCurrentAgreements() { + return List.of( + document("TERMS_OF_SERVICE", "이용약관", "agreements/terms-of-service", + properties.termsOfService()), + document("PRIVACY_POLICY", "개인정보 처리방침", "agreements/privacy-policy", + properties.privacyPolicy()) + ); + } + + public AgreementVersions currentVersions() { + return new AgreementVersions( + requireCurrentVersion(properties.termsOfService()), + requireCurrentVersion(properties.privacyPolicy()) + ); + } + + public void validateAccepted(Boolean accepted) { + if (!Boolean.TRUE.equals(accepted)) { + throw new CustomException(ErrorCode.AGREEMENTS_NOT_ACCEPTED); + } + } + + public boolean needsReacceptance(User user) { + AgreementVersions versions = currentVersions(); + return !versions.tosVersion().equals(user.getTosVersion()) + || !versions.privacyVersion().equals(user.getPrivacyVersion()); + } + + private AgreementDocumentResult document(String type, String title, String directory, + AgreementProperties.AgreementDocument document) { + String version = requireCurrentVersion(document); + return new AgreementDocumentResult( + type, + title, + version, + CONTENT_FORMAT_MARKDOWN, + readContent(directory, version) + ); + } + + private String requireCurrentVersion(AgreementProperties.AgreementDocument document) { + if (document == null || document.currentVersion() == null + || document.currentVersion().isBlank() + || !VERSION_PATTERN.matcher(document.currentVersion()).matches()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return document.currentVersion(); + } + + private String readContent(String directory, String version) { + ClassPathResource resource = new ClassPathResource(directory + "/" + version + ".md"); + try { + if (!resource.exists()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return resource.getContentAsString(StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + } +} +``` + +- [ ] **Step 3: Update agreement configuration in `application.yaml`** + +In `src/main/resources/application.yaml`, replace: + +```yaml + agreements: + terms-of-service: + version: "1.0" + resource: "classpath:agreements/terms-of-service.md" + privacy-policy: + version: "1.0" + resource: "classpath:agreements/privacy-policy.md" +``` + +with: + +```yaml + agreements: + terms-of-service: + current-version: "1.0" + privacy-policy: + current-version: "1.0" +``` + +- [ ] **Step 4: Run the focused service test before moving resources** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest +``` + +Expected: test compiles but fails at runtime because `agreements/terms-of-service/1.0.md` and `agreements/privacy-policy/1.0.md` do not exist yet. + +- [ ] **Step 5: Keep service implementation uncommitted until resources move** + +```bash +git status --short +``` + +Expected: test, service, properties, and `application.yaml` changes are present. Do not commit until Task 3 makes the focused tests pass. + +--- + +### Task 3: Versioned Agreement Resource Files + +**Files:** +- Move: `src/main/resources/agreements/terms-of-service.md` to `src/main/resources/agreements/terms-of-service/1.0.md` +- Move: `src/main/resources/agreements/privacy-policy.md` to `src/main/resources/agreements/privacy-policy/1.0.md` +- Test: `src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java` +- Test: `src/test/java/com/howaboutus/backend/agreements/controller/AgreementControllerTest.java` + +- [ ] **Step 1: Move agreement Markdown files with Git** + +Run: + +```bash +mkdir -p src/main/resources/agreements/terms-of-service src/main/resources/agreements/privacy-policy +git mv src/main/resources/agreements/terms-of-service.md \ + src/main/resources/agreements/terms-of-service/1.0.md +git mv src/main/resources/agreements/privacy-policy.md \ + src/main/resources/agreements/privacy-policy/1.0.md +``` + +- [ ] **Step 2: Run focused agreement tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: both test classes pass. `AgreementControllerTest` should not require changes because the API response shape is unchanged. + +- [ ] **Step 3: Run compile verification** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Commit tests, implementation, and resource move together** + +```bash +git add src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java \ + src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java \ + src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java \ + src/main/resources/application.yaml \ + src/main/resources/agreements +git commit -m "feat: 약관 원문을 버전별 파일로 보존" +``` + +--- + +### Task 4: Domain And Policy Documentation + +**Files:** +- Modify: `docs/ai/features.md` +- Modify: `docs/ai/erd.md` +- Modify: `docs/policy/terms-of-service.md` +- Modify: `docs/policy/privacy-policy.md` + +- [ ] **Step 1: Update `docs/ai/features.md` current agreement row** + +In `docs/ai/features.md`, replace the current agreement lookup row: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +``` + +with: + +```markdown +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드의 문서 타입별 버전 리소스 파일에 보존하고, 현재 버전은 `application.yaml`의 `app.agreements.*.current-version`에서 관리한다 | - | +``` + +- [ ] **Step 2: Update `docs/ai/erd.md` agreement storage note** + +In `docs/ai/erd.md`, replace: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +with: + +```markdown +약관 원문은 DB에 저장하지 않고 백엔드의 문서 타입별 버전 리소스 파일로 보존한다. 현재 버전은 `application.yaml`의 `app.agreements.*.current-version` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +``` + +- [ ] **Step 3: Update `docs/policy/terms-of-service.md` pointer** + +Replace the file with: + +```markdown +# 이용약관 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 현재 리소스: `src/main/resources/agreements/terms-of-service/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.current-version` +``` + +- [ ] **Step 4: Update `docs/policy/privacy-policy.md` pointer** + +Replace the file with: + +```markdown +# 개인정보 처리방침 + +원문은 백엔드 약관 리소스로 이동했습니다. + +- 현재 리소스: `src/main/resources/agreements/privacy-policy/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.current-version` +``` + +- [ ] **Step 5: Run Markdown conflict checks** + +Run: + +```bash +rg -n '`docs/ai/[^`]*\.md`|`[A-Z][A-Z_]*\.md`' -g '*.md' +rg -n 'agreements/terms-of-service\.md|agreements/privacy-policy\.md|app\.agreements\..*\.version' AGENTS.md CONTRIBUTING.md docs src/main/resources src/test +``` + +Expected: + +- All referenced `docs/ai/*.md`, `AGENTS.md`, and `CONTRIBUTING.md` paths exist. +- No active documentation still says the current backend agreement resource is `src/main/resources/agreements/terms-of-service.md` or `src/main/resources/agreements/privacy-policy.md`. +- No active documentation still points to `app.agreements.terms-of-service.version` or `app.agreements.privacy-policy.version`. +- Historical spec and plan files may mention the old fields as prior context; do not rewrite historical design records unless they present themselves as current source of truth. + +- [ ] **Step 6: Commit documentation updates** + +```bash +git add docs/ai/features.md docs/ai/erd.md docs/policy/terms-of-service.md docs/policy/privacy-policy.md +git commit -m "docs: 약관 버전 파일 관리 방식 반영" +``` + +--- + +### Task 5: Final Verification + +**Files:** +- Verify all changed Java, resource, and Markdown files. + +- [ ] **Step 1: Run focused tests** + +Run: + +```bash +./gradlew test --tests com.howaboutus.backend.agreements.service.AgreementServiceTest \ + --tests com.howaboutus.backend.agreements.controller.AgreementControllerTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 2: Run Java compilation** + +Run: + +```bash +./gradlew compileJava +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 3: Run project checkstyle** + +Run: + +```bash +./gradlew checkstyleMain checkstyleTest +``` + +Expected: `BUILD SUCCESSFUL`. + +- [ ] **Step 4: Inspect final diff against the feature branch base** + +Run: + +```bash +git status --short +git log --oneline -5 +``` + +Expected: + +- `git status --short` prints no unstaged or uncommitted changes. +- Recent commits include: + - `feat: 약관 원문을 버전별 파일로 보존` + - `docs: 약관 버전 파일 관리 방식 반영` + +- [ ] **Step 5: Completion note** + +Report: + +```text +Implemented agreement versioned files. + +Verification: +- AgreementServiceTest and AgreementControllerTest passed. +- compileJava passed. +- checkstyleMain/checkstyleTest passed. + +Notes: +- /api/agreements/current response shape is unchanged. +- Historical agreement originals are preserved under versioned resource paths. +- Historical version lookup API remains out of scope. +``` From 30751b9d67a51233d571979274e41f22286d29b9 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 23:47:19 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EB=B2=84=EC=A0=84=EB=B3=84=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/AgreementProperties.java | 4 +- .../agreements/service/AgreementService.java | 34 +++++--- .../1.0.md} | 0 .../1.0.md} | 0 src/main/resources/application.yaml | 6 +- .../service/AgreementServiceTest.java | 87 +++++-------------- 6 files changed, 51 insertions(+), 80 deletions(-) rename src/main/resources/agreements/{privacy-policy.md => privacy-policy/1.0.md} (100%) rename src/main/resources/agreements/{terms-of-service.md => terms-of-service/1.0.md} (100%) diff --git a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java index 8a125f6c..4a366c9c 100644 --- a/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java +++ b/src/main/java/com/howaboutus/backend/agreements/config/AgreementProperties.java @@ -1,7 +1,6 @@ package com.howaboutus.backend.agreements.config; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.core.io.Resource; @ConfigurationProperties(prefix = "app.agreements") public record AgreementProperties( @@ -10,8 +9,7 @@ public record AgreementProperties( ) { public record AgreementDocument( - String version, - Resource resource + String currentVersion ) { } } diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java index 54c46b7f..19543944 100644 --- a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -3,7 +3,9 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.regex.Pattern; +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import com.howaboutus.backend.agreements.config.AgreementProperties; @@ -20,13 +22,18 @@ public class AgreementService { private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; + private static final String TERMS_OF_SERVICE_RESOURCE_DIR = "agreements/terms-of-service"; + private static final String PRIVACY_POLICY_RESOURCE_DIR = "agreements/privacy-policy"; + private static final Pattern VERSION_PATTERN = Pattern.compile("^[0-9]+(\\.[0-9]+)*$"); private final AgreementProperties properties; public List getCurrentAgreements() { return List.of( - document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService()), - document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy()) + document("TERMS_OF_SERVICE", "이용약관", properties.termsOfService(), + TERMS_OF_SERVICE_RESOURCE_DIR), + document("PRIVACY_POLICY", "개인정보 처리방침", properties.privacyPolicy(), + PRIVACY_POLICY_RESOURCE_DIR) ); } @@ -50,29 +57,36 @@ public boolean needsReacceptance(User user) { } private AgreementDocumentResult document(String type, String title, - AgreementProperties.AgreementDocument document) { + AgreementProperties.AgreementDocument document, String resourceDirectory) { + String version = requireVersion(document); return new AgreementDocumentResult( type, title, - requireVersion(document), + version, CONTENT_FORMAT_MARKDOWN, - readContent(document) + readContent(resourceDirectory, version) ); } private String requireVersion(AgreementProperties.AgreementDocument document) { - if (document == null || document.version() == null || document.version().isBlank()) { + if (document == null || document.currentVersion() == null + || document.currentVersion().isBlank()) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } - return document.version(); + String version = document.currentVersion(); + if (!VERSION_PATTERN.matcher(version).matches()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return version; } - private String readContent(AgreementProperties.AgreementDocument document) { - if (document == null || document.resource() == null) { + private String readContent(String resourceDirectory, String version) { + ClassPathResource resource = new ClassPathResource(resourceDirectory + "/" + version + ".md"); + if (!resource.exists()) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } try { - return document.resource().getContentAsString(StandardCharsets.UTF_8); + return resource.getContentAsString(StandardCharsets.UTF_8); } catch (IOException e) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } diff --git a/src/main/resources/agreements/privacy-policy.md b/src/main/resources/agreements/privacy-policy/1.0.md similarity index 100% rename from src/main/resources/agreements/privacy-policy.md rename to src/main/resources/agreements/privacy-policy/1.0.md diff --git a/src/main/resources/agreements/terms-of-service.md b/src/main/resources/agreements/terms-of-service/1.0.md similarity index 100% rename from src/main/resources/agreements/terms-of-service.md rename to src/main/resources/agreements/terms-of-service/1.0.md diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 7a519b6a..85039680 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -96,11 +96,9 @@ management: app: agreements: terms-of-service: - version: "1.0" - resource: "classpath:agreements/terms-of-service.md" + current-version: "1.0" privacy-policy: - version: "1.0" - resource: "classpath:agreements/privacy-policy.md" + current-version: "1.0" executor: ai: concurrency-limit: 4 diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java index 16a479f5..d08815ba 100644 --- a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -2,15 +2,8 @@ import static org.assertj.core.api.Assertions.*; -import java.io.IOException; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; - import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.core.io.ByteArrayResource; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; import com.howaboutus.backend.agreements.config.AgreementProperties; import com.howaboutus.backend.common.error.CustomException; @@ -19,12 +12,9 @@ class AgreementServiceTest { @Test - @DisplayName("설정된 현재 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") + @DisplayName("설정된 현재 버전의 이용약관과 개인정보 처리방침을 Markdown으로 반환한다") void returnsCurrentAgreements() { - AgreementService service = new AgreementService(properties( - "1.0", "# 이용약관", - "1.0", "# 개인정보 처리방침" - )); + AgreementService service = new AgreementService(properties("1.0", "1.0")); var result = service.getCurrentAgreements(); @@ -33,21 +23,17 @@ void returnsCurrentAgreements() { assertThat(result.get(0).title()).isEqualTo("이용약관"); assertThat(result.get(0).version()).isEqualTo("1.0"); assertThat(result.get(0).contentFormat()).isEqualTo("MARKDOWN"); - assertThat(result.get(0).content()).isEqualTo("# 이용약관"); + assertThat(result.get(0).content()).startsWith("# 이용약관"); assertThat(result.get(1).type()).isEqualTo("PRIVACY_POLICY"); assertThat(result.get(1).title()).isEqualTo("개인정보 처리방침"); assertThat(result.get(1).version()).isEqualTo("1.0"); + assertThat(result.get(1).content()).startsWith("# 개인정보 처리방침"); } @Test @DisplayName("현재 이용약관 원문은 프론트 공개 정책 URL을 사용한다") void termsOfServiceUsesPublicPolicyUrls() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument( - "1.0", new ClassPathResource("agreements/terms-of-service.md")), - new AgreementProperties.AgreementDocument( - "1.0", new ClassPathResource("agreements/privacy-policy.md")) - )); + AgreementService service = new AgreementService(properties("1.0", "1.0")); var result = service.getCurrentAgreements(); @@ -61,65 +47,40 @@ void termsOfServiceUsesPublicPolicyUrls() { } @Test - @DisplayName("약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") + @DisplayName("현재 약관 버전 설정이 비어 있으면 AGREEMENT_CONFIGURATION_INVALID 예외") void throwsForBlankVersion() { - AgreementService service = new AgreementService(properties( - "", "# 이용약관", - "1.0", "# 개인정보 처리방침" - )); + AgreementService service = new AgreementService(properties("", "1.0")); - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + assertAgreementConfigurationInvalid(service); } @Test - @DisplayName("약관 리소스 설정이 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") - void throwsForMissingResource() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument("1.0", null), - new AgreementProperties.AgreementDocument( - "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) - )); + @DisplayName("현재 약관 버전 형식이 올바르지 않으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForInvalidVersionFormat() { + AgreementService service = new AgreementService(properties("../1.0", "1.0")); - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + assertAgreementConfigurationInvalid(service); } @Test - @DisplayName("약관 리소스 읽기에 실패하면 AGREEMENT_CONFIGURATION_INVALID 예외") - void throwsForUnreadableResource() { - AgreementService service = new AgreementService(new AgreementProperties( - new AgreementProperties.AgreementDocument("1.0", unreadableResource()), - new AgreementProperties.AgreementDocument( - "1.0", new ByteArrayResource("# 개인정보 처리방침".getBytes(StandardCharsets.UTF_8))) - )); + @DisplayName("현재 약관 버전에 해당하는 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void throwsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); - assertThatThrownBy(service::getCurrentAgreements) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) - .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); + assertAgreementConfigurationInvalid(service); } - private AgreementProperties properties(String tosVersion, String tosContent, - String privacyVersion, String privacyContent) { + private AgreementProperties properties(String tosVersion, String privacyVersion) { return new AgreementProperties( - new AgreementProperties.AgreementDocument( - tosVersion, new ByteArrayResource(tosContent.getBytes(StandardCharsets.UTF_8))), - new AgreementProperties.AgreementDocument( - privacyVersion, new ByteArrayResource(privacyContent.getBytes(StandardCharsets.UTF_8))) + new AgreementProperties.AgreementDocument(tosVersion), + new AgreementProperties.AgreementDocument(privacyVersion) ); } - private Resource unreadableResource() { - return new ByteArrayResource("".getBytes(StandardCharsets.UTF_8)) { - @Override - public String getContentAsString(Charset charset) throws IOException { - throw new IOException("read failed"); - } - }; + private void assertAgreementConfigurationInvalid(AgreementService service) { + assertThatThrownBy(service::getCurrentAgreements) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) + .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); } } From e7dc2e2d8f83eb66fc1af513c093eb578e6d5ecd Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 23:53:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EC=95=BD=EA=B4=80=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EB=B2=84=EC=A0=84=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agreements/service/AgreementService.java | 24 ++++++++++++++----- .../service/AgreementServiceTest.java | 14 ++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java index 19543944..42408b85 100644 --- a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -39,8 +39,8 @@ public List getCurrentAgreements() { public AgreementVersions currentVersions() { return new AgreementVersions( - requireVersion(properties.termsOfService()), - requireVersion(properties.privacyPolicy()) + requireVersionWithResource(properties.termsOfService(), TERMS_OF_SERVICE_RESOURCE_DIR), + requireVersionWithResource(properties.privacyPolicy(), PRIVACY_POLICY_RESOURCE_DIR) ); } @@ -80,15 +80,27 @@ private String requireVersion(AgreementProperties.AgreementDocument document) { return version; } + private String requireVersionWithResource(AgreementProperties.AgreementDocument document, + String resourceDirectory) { + String version = requireVersion(document); + requireExistingResource(resourceDirectory, version); + return version; + } + private String readContent(String resourceDirectory, String version) { - ClassPathResource resource = new ClassPathResource(resourceDirectory + "/" + version + ".md"); - if (!resource.exists()) { - throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); - } + ClassPathResource resource = requireExistingResource(resourceDirectory, version); try { return resource.getContentAsString(StandardCharsets.UTF_8); } catch (IOException e) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } } + + private ClassPathResource requireExistingResource(String resourceDirectory, String version) { + ClassPathResource resource = new ClassPathResource(resourceDirectory + "/" + version + ".md"); + if (!resource.exists()) { + throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); + } + return resource; + } } diff --git a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java index d08815ba..1df15ab1 100644 --- a/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java +++ b/src/test/java/com/howaboutus/backend/agreements/service/AgreementServiceTest.java @@ -70,6 +70,14 @@ void throwsForMissingVersionResource() { assertAgreementConfigurationInvalid(service); } + @Test + @DisplayName("현재 약관 버전 조회 시 리소스가 없으면 AGREEMENT_CONFIGURATION_INVALID 예외") + void currentVersionsThrowsForMissingVersionResource() { + AgreementService service = new AgreementService(properties("9.9", "1.0")); + + assertAgreementConfigurationInvalid(service::currentVersions); + } + private AgreementProperties properties(String tosVersion, String privacyVersion) { return new AgreementProperties( new AgreementProperties.AgreementDocument(tosVersion), @@ -78,7 +86,11 @@ private AgreementProperties properties(String tosVersion, String privacyVersion) } private void assertAgreementConfigurationInvalid(AgreementService service) { - assertThatThrownBy(service::getCurrentAgreements) + assertAgreementConfigurationInvalid(service::getCurrentAgreements); + } + + private void assertAgreementConfigurationInvalid(Runnable action) { + assertThatThrownBy(action::run) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException)e).getErrorCode()) .isEqualTo(ErrorCode.AGREEMENT_CONFIGURATION_INVALID)); From b9e30ae5982b587a6a7fd76df43901e137a8a51c Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Mon, 8 Jun 2026 23:58:54 +0900 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=ED=8C=8C=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai/erd.md | 2 +- docs/ai/features.md | 2 +- docs/policy/privacy-policy.md | 4 ++-- docs/policy/terms-of-service.md | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/ai/erd.md b/docs/ai/erd.md index 69a1eafe..0aa23057 100644 --- a/docs/ai/erd.md +++ b/docs/ai/erd.md @@ -36,7 +36,7 @@ Google OAuth 기반 사용자 정보 > 활성 회원은 email/nickname/provider/provider_id가 NOT NULL이며 이메일과 (provider, provider_id) 조합이 unique. 탈퇴 회원은 모두 NULL 가능하며 unique 검사 대상에서 제외되어 동일 OAuth 계정으로 재가입할 수 있다. -약관 원문은 DB에 저장하지 않고 백엔드 리소스 파일로 관리한다. 현재 버전은 `application.yaml`의 `app.agreements` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. +약관 원문은 DB에 저장하지 않고 백엔드의 문서 타입별 버전 리소스 파일로 보존한다. 현재 버전은 `application.yaml`의 `app.agreements.*.current-version` 설정을 기준으로 하며, 프론트엔드는 버전 문자열을 전송하지 않는다. --- diff --git a/docs/ai/features.md b/docs/ai/features.md index 95679066..4c338fa2 100644 --- a/docs/ai/features.md +++ b/docs/ai/features.md @@ -51,7 +51,7 @@ Caddy는 `caddy-ratelimit` 기반 IP 제한으로 엣지에서 넓고 거친 폭 | 상태 | 기능 | 설명 | ERD 연관 | |------|------|------|----------| | `[x]` | 구글 OAuth 로그인 | Google 계정으로 소셜 로그인 | users | -| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드 리소스 파일에 있고 현재 버전은 `application.yaml`에서 관리한다 | - | +| `[x]` | 현재 약관 조회 | `GET /api/agreements/current`로 현재 이용약관과 개인정보 처리방침 원문 Markdown, 버전, 문서 타입을 비인증 상태에서 조회한다. 약관 원문은 백엔드의 문서 타입별 버전 리소스 파일에 보존하고, 현재 버전은 `application.yaml`의 `app.agreements.*.current-version`에서 관리한다 | - | | `[x]` | 가입 약관 동의 기록 | `POST /auth/google/login`에서 프론트는 `agreementsAccepted`만 전송한다. 신규 사용자는 값이 `true`일 때만 생성되며, 백엔드 현재 약관 버전과 서버 시간을 `users`에 저장한다 | users | | `[x]` | 약관 재동의 | 기존 사용자의 저장 약관 버전이 현재 서버 버전과 다르면 로그인 시 재동의가 필요하다. 로그인 요청에서 `agreementsAccepted=true`이면 서버 현재 버전으로 갱신 후 토큰을 발급하고, 이미 로그인된 사용자는 `POST /api/users/me/agreements`로 현재 약관에 재동의한다 | users | | `[x]` | 토큰 재발급 (Refresh) | Refresh Token Rotation: UUID 기반 HTTP-only 쿠키(path=/auth/refresh), Redis `refresh:token:{uuid}`→userId(TTL 14일) / `refresh:user:{userId}`→Set\. Replay Detection 으로 탈취 시 전체 무효화 | Redis | diff --git a/docs/policy/privacy-policy.md b/docs/policy/privacy-policy.md index 0b60b45a..61857782 100644 --- a/docs/policy/privacy-policy.md +++ b/docs/policy/privacy-policy.md @@ -2,5 +2,5 @@ 원문은 백엔드 약관 리소스로 이동했습니다. -- 리소스: `src/main/resources/agreements/privacy-policy.md` -- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.version` +- 현재 리소스: `src/main/resources/agreements/privacy-policy/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.privacy-policy.current-version` diff --git a/docs/policy/terms-of-service.md b/docs/policy/terms-of-service.md index 166bcde7..bbc36afe 100644 --- a/docs/policy/terms-of-service.md +++ b/docs/policy/terms-of-service.md @@ -2,5 +2,5 @@ 원문은 백엔드 약관 리소스로 이동했습니다. -- 리소스: `src/main/resources/agreements/terms-of-service.md` -- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.version` +- 현재 리소스: `src/main/resources/agreements/terms-of-service/1.0.md` +- 현재 버전 설정: `src/main/resources/application.yaml`의 `app.agreements.terms-of-service.current-version` From 6911f24027385f015a7726b2a215499038e0ecd8 Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Tue, 9 Jun 2026 00:24:46 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=EC=A0=95=EC=B1=85=20=EC=8B=9C?= =?UTF-8?q?=ED=96=89=EC=9D=BC=EA=B3=BC=20=EC=9A=B4=EC=98=81=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/policy/copyright-policy.md | 4 ++-- docs/policy/operation-policy.md | 10 +++++----- src/main/resources/agreements/privacy-policy/1.0.md | 4 ++-- src/main/resources/agreements/terms-of-service/1.0.md | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/policy/copyright-policy.md b/docs/policy/copyright-policy.md index 606c810e..87ec3cdb 100644 --- a/docs/policy/copyright-policy.md +++ b/docs/policy/copyright-policy.md @@ -1,6 +1,6 @@ # 저작권 정책 -**시행일: 2026년 6월 6일** +**시행일: 2026년 6월 9일** --- @@ -108,4 +108,4 @@ Google 등 외부 서비스가 제공하는 장소 정보, 사진, 지도, 경 | 버전 | 시행일 | |------|--------| -| 1.0 | 2026년 6월 6일 | +| 1.0 | 2026년 6월 9일 | diff --git a/docs/policy/operation-policy.md b/docs/policy/operation-policy.md index a2483575..ca63e91b 100644 --- a/docs/policy/operation-policy.md +++ b/docs/policy/operation-policy.md @@ -1,6 +1,6 @@ # 운영정책 -**시행일: 2026년 6월 6일** +**시행일: 2026년 6월 9일** --- @@ -71,10 +71,10 @@ 이용 제한은 위반 행위의 내용, 반복 여부, 피해 규모, 고의성, 긴급성, 소명 여부를 고려하여 다음 범위에서 적용됩니다. -1. 콘텐츠 제한: 게시물 숨김, 삭제, 검색·노출 제한 +1. 콘텐츠 제한: 게시물 삭제 요청, 접근 제한 등 운영 가능한 범위의 조치 2. 기능 제한: 채팅, AI 요청, 방 생성, 초대, 장소·경로 검색 등 일부 기능의 일시 제한 -3. 접근 제한: 특정 여행 방 접근 제한 또는 멤버 제외 -4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 +3. 접근 제한: 특정 여행 방 접근 제한, 멤버 제외 요청 등 운영 가능한 범위의 조치 +4. 계정 제한: 경고, 일시 정지, 영구 정지, 계정 삭제 요청·처리 등 운영 가능한 범위의 조치 5. 기술적 제한: 비정상 요청 차단, Rate Limit 적용, 보안상 필요한 접속 제한 위반이 중대하거나 긴급한 피해 방지가 필요한 경우 운영자는 사전 통지 없이 즉시 제한 조치를 할 수 있습니다. @@ -115,4 +115,4 @@ ## 부칙 -이 정책은 2026년 6월 6일부터 시행합니다. +이 정책은 2026년 6월 9일부터 시행합니다. diff --git a/src/main/resources/agreements/privacy-policy/1.0.md b/src/main/resources/agreements/privacy-policy/1.0.md index d53f0637..bb2e3bb8 100644 --- a/src/main/resources/agreements/privacy-policy/1.0.md +++ b/src/main/resources/agreements/privacy-policy/1.0.md @@ -2,7 +2,7 @@ 우때(이하 "서비스")는 이용자의 개인정보를 소중히 여기며, 「개인정보 보호법」 등 관련 법령을 준수합니다. 이 처리방침은 서비스가 어떤 개인정보를 어떤 목적으로 처리하는지, 이용자가 어떤 권리를 행사할 수 있는지 안내합니다. -**시행일: 2026년 6월 7일** +**시행일: 2026년 6월 9일** --- @@ -208,4 +208,4 @@ Google Analytics 수집만 선택적으로 차단하려면 [Google Analytics 수 | 버전 | 시행일 | |------|--------| -| 1.0 | 2026년 6월 7일 | +| 1.0 | 2026년 6월 9일 | diff --git a/src/main/resources/agreements/terms-of-service/1.0.md b/src/main/resources/agreements/terms-of-service/1.0.md index b45d128a..f1277dc4 100644 --- a/src/main/resources/agreements/terms-of-service/1.0.md +++ b/src/main/resources/agreements/terms-of-service/1.0.md @@ -1,6 +1,6 @@ # 이용약관 -**시행일: 2026년 6월 8일** +**시행일: 2026년 6월 9일** --- @@ -104,8 +104,8 @@ ## 제10조 (서비스 이용 제한) -1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스 이용을 제한하거나 계정을 삭제할 수 있습니다. -2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제, 채팅 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 조치를 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. +1. 운영자는 이용자가 제8조 또는 제9조를 위반한 경우 사전 통보 없이 서비스가 제공하는 기능 또는 운영 가능한 절차의 범위에서 서비스 이용을 제한하거나 계정 삭제를 요청·처리할 수 있습니다. +2. 운영자는 위반 행위의 내용, 반복 여부, 피해 규모, 긴급성을 고려하여 게시물 삭제 요청, 채팅 이용 제한, 여행 방 접근 제한, 계정 정지 또는 계정 삭제 등 필요한 조치를 운영 가능한 범위에서 할 수 있습니다. 세부 기준은 [운영정책](https://uttae.com/policies/operation)에 따릅니다. 3. 이용 제한 처분에 이의가 있는 경우 team.uttae@gmail.com으로 이의를 신청할 수 있으며, 운영자는 7일 이내에 처리 결과를 안내합니다. --- @@ -174,4 +174,4 @@ ## 부칙 -이 약관은 2026년 6월 8일부터 시행합니다. +이 약관은 2026년 6월 9일부터 시행합니다. From 5fda2a2a06a01a36a1fbfa3de1a5fdbb19bd54aa Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Tue, 9 Jun 2026 00:26:05 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore=20:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=82=AD=EC=A0=9C,=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EC=83=81=EC=97=90=EC=84=9C=20=ED=95=84=EC=9A=94=ED=95=98?= =?UTF-8?q?=EC=A7=80=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/legal/policy-preparation.md | 154 ------------------------------- docs/policy/operator-info.md | 16 ---- 2 files changed, 170 deletions(-) delete mode 100644 docs/legal/policy-preparation.md delete mode 100644 docs/policy/operator-info.md diff --git a/docs/legal/policy-preparation.md b/docs/legal/policy-preparation.md deleted file mode 100644 index 99c7a724..00000000 --- a/docs/legal/policy-preparation.md +++ /dev/null @@ -1,154 +0,0 @@ -# 약관·개인정보 처리 준비 체크리스트 - -> 배포 전에 작성·고지해야 하는 약관 및 개인정보 관련 정책을 정리한다. -> 현재 단계: `초안`. 실제 약관 본문은 별도 문서로 분리해 작성한다. - -## 문서 목적 - -- 배포 전 법적으로 갖춰야 할 약관/정책 항목을 식별한다. -- 우리 서비스(여행 협업 + AI 어시스턴트)에서 어떤 조항이 핵심인지 정리한다. -- 약관에 적은 절차를 실제로 이행하려면 코드/기능 상 무엇을 추가해야 하는지 추적한다. - -## 서비스 특성 요약 - -- **수집 정보**: Google OAuth 프로필(이메일, 이름, 프로필 이미지), 채팅 메시지, 여행 일정/북마크, 접속 로그, 쿠키 기반 인증 토큰 -- **외부 전송**: Google Places/Routes API, 자체 AI 서버 -- **저장소**: PostgreSQL, MongoDB(채팅), Redis(세션·캐시) -- **회원 기능**: 로그인/로그아웃은 구현됨. **회원 탈퇴, 신고, 운영자 제재 절차는 미구현** (`docs/ai/features.md` 기준) - ---- - -## 1. 법적으로 반드시 필요한 문서 - -| 문서 | 근거 법령 | 비고 | -|------|-----------|------| -| 개인정보 처리방침 | 개인정보보호법 §30 | 홈페이지 첫 화면에서 쉽게 접근 가능해야 함 | -| 이용약관 | 약관규제법, 전기통신사업법 | 가입 전 동의 절차 필요 | -| 운영정책 | 이용약관 하위 정책 | 채팅·게시물·AI·초대코드·이용제한·신고/이의제기 세부 기준 | -| 운영자 정보 표시 | 정보통신망법 §10 | 상호, 대표자, 주소, 사업자번호, 연락처, 개인정보 보호책임자 | -| 저작권 정책 | 저작권법 §102, §103 | 이용자 콘텐츠의 권리 침해 신고, 게시중단, 재게시 요청 절차 안내 | - -> 위치정보법은 "단말기 위치"를 수집하지 않고 사용자가 검색한 장소 좌표만 다루면 일반적으로 적용 범위 밖이다. 추후 "내 위치 기반 추천" 기능 도입 시 **위치기반서비스 이용약관**과 방통위 신고가 추가로 필요하다. - ---- - -## 2. 개인정보 처리방침에 들어가야 할 항목 - -> 개인정보보호법 시행령 §31 기준. 각 항목을 우리 서비스 맥락으로 채워야 한다. - -### 2-1. 수집 항목 / 수집 목적 - -- **필수 수집**: Google OAuth `sub`(고유 ID), 이메일, 이름, 프로필 이미지 URL -- **자동 수집**: IP, User-Agent, 접속 일시, 쿠키(`access_token`, `refresh_token`), Redis presence 정보 -- **사용자 입력**: 방 제목·여행지·날짜, 채팅 메시지, 북마크/일정 메모 - -### 2-2. 보유·이용 기간 - -- 회원 정보: 탈퇴 시까지. 탈퇴 시 계정 식별 정보는 삭제 또는 익명화하고 인증 정보는 무효화 -- 여행 방 단위 데이터(방 제목, 채팅, 일정, 북마크, 메모): 방 삭제 시까지. 단, 탈퇴 처리와 함께 방이 삭제되는 경우 함께 삭제 -- **법령상 의무 보관 (검토 필요)**: - - 통신비밀보호법: 접속 로그 3개월 - - 전자상거래법: 소비자 불만/분쟁 처리 기록 3년 — 결제 기능이 없으면 적용 범위가 좁음 - -### 2-3. 제3자 제공 · 국외 이전 · 처리위탁 - -> 외부 호출이 많아 이 항목이 가장 큰 리스크다. 누가 어떤 데이터를 받는지 명확히 적어야 한다. - -- **제3자 제공**: 사전 동의 또는 법령상 근거가 있는 예외를 제외하고 제공하지 않는 것으로 정리 -- **Google (미국)**: OAuth 인증, Places/Routes API, Google Analytics → 처리 위탁 및 국외 이전 고지 필요 -- **OpenAI (미국)**: AI 응답 생성, 대화 요약 생성 → 처리 위탁 및 국외 이전 고지 필요 -- **클라우드 인프라(AWS Lightsail 등)**: 처리 위탁 및 국외 이전 고지 필요 -- 채팅 메시지에 포함된 **다른 사용자의 메시지/장소 스냅샷**이 AI 서버로 함께 전송되는 흐름을 명확히 적어야 한다. - -### 2-4. 정보주체 권리 - -- 열람, 정정, 삭제, 처리정지, 동의 철회권 안내 -- 권리 행사 방법 (이메일 또는 서비스 내 메뉴 등) - -### 2-5. 자동 수집 장치 (쿠키 등) - -- HTTP-only 쿠키(`access_token`, `refresh_token`) 사용 사실 -- 쿠키 거부 방법과 거부 시 영향 (로그인 불가) - -### 2-6. 개인정보 보호책임자 - -- 이름, 직책, 연락처 (이메일 가능) - -### 2-7. 만 14세 미만 처리 - -- 만 14세 미만 가입을 불허할지, 법정대리인 동의를 받을지 정책 결정 — `미결` -- 통상 스타트업은 "만 14세 미만 가입 불가" 정책으로 단순화한다. - -### 2-8. 변경 절차 - -- 처리방침 변경 시 사전 고지 기간과 통지 방법 - ---- - -## 3. 이용약관 핵심 조항 - -| 조항 | 우리 서비스에서 다뤄야 할 내용 | -|------|--------------------------------| -| 서비스 내용 | "여행 계획 협업 + AI 어시스턴트 보조" 명시 | -| 회원 가입/자격 | Google 계정 보유자, 만 14세 이상 | -| 계정·탈퇴 절차 | **현재 미구현** — 탈퇴 API/UI 마련 필요 | -| 이용제한·정지 | 사유, 기간, 사전·사후 통지 절차, 이의제기 절차 | -| 금지 행위 | 도배, 음란/혐오/명예훼손, 타인 사칭, 크롤링, 부정 이용 | -| 운영정책 | 여행 방, 채팅, AI 어시스턴트, 초대코드, 신고 및 이용 제한 세부 기준 | -| 게시물 정책 | 채팅·북마크·일정 메모의 권리 귀속(사용자 보유), 서비스 운영 목적 사용 동의 범위 | -| 저작권 신고/게시중단 | 권리자 신고, 작성자 통지, 재게시 요청, 반복 침해자 제한 | -| AI 응답 면책 | AI 추천은 참고용이며 정확성·안전성 보장하지 않음 | -| 외부 데이터 면책 | Google 장소 정보(영업시간·평점 등)는 Google 제공 자료이며 실제와 다를 수 있음 | -| 서비스 변경/중단 | 사전 공지 기간, 무료 서비스 한정 면책 | -| 손해배상/면책 | 무료 서비스 특성 반영 | -| 분쟁 해결 | 준거법(대한민국), 관할 법원, 사전 협의 절차 | - ---- - -## 4. 코드/기능 측에서 추가로 준비할 작업 - -> 약관 본문만 작성해도 실제 절차를 이행할 수 없으면 의미가 없다. 다음 항목이 `docs/ai/features.md`에 없거나 부족하다. - -| # | 항목 | 비고 | -|---|------|------| -| 1 | 회원 탈퇴 기능 | 현재 worktree의 인증 섹션에는 없음. `feature/user-withdrawal` 기준 정책은 계정 식별 정보 익명화, 참여 정보 삭제, 방 단위 협업 데이터는 방 삭제 시까지 유지 | -| 2 | 계정 정지/차단 운영자 도구 | 방장 추방은 있으나 서비스 차원의 제재 수단 없음 | -| 3 | 신고 기능 | 다른 사용자/메시지 신고 API | -| 4 | 저작권 게시중단 처리 절차 | 이메일 접수로 시작 가능하나 운영자 검토/작성자 통지/재게시 요청 기록 필요 | -| 5 | 약관 동의 이력 저장 | 가입 시 어떤 버전에 동의했는지, 변경 시 재동의 처리 | -| 6 | 개인정보 파기 절차 | 탈퇴 후 보관 기간, 자동 파기 배치 | -| 7 | 로그 보관 기간 정책 | 접속 로그는 통신비밀보호법 시행령 기준 3개월로 정리. 채팅 로그는 방 삭제 시까지 보관하는 기준과 구현 일치 필요 | -| 8 | 데이터 열람/내보내기 (선택) | 정보주체의 열람권 대응. 이메일 송부로도 대체 가능 | - ---- - -## 5. 진행 순서 (제안) - -1. **정책 결정** (코드 작업보다 먼저) - - 탈퇴 시 계정 식별 정보 익명화와 방 단위 협업 데이터 보관 정책 반영 - - 만 14세 미만 정책 (가입 차단으로 단순화 권장) - - 신고 처리 SLA - - 저작권 게시중단 및 재게시 요청 처리 방식 - - 채팅 보관 기간과 방 삭제 시 처리 방식 -2. **운영자 정보 확정** - - 사업자등록 여부, 대표자, 주소, 연락처 - - 개인정보 보호책임자 지정 -3. **회원 탈퇴/신고 기능 구현** - - 약관에 적을 절차가 실제로 동작하도록 코드 추가 -4. **약관·처리방침·운영정책·저작권 정책 초안 작성** - - 한국인터넷진흥원(KISA) 표준 처리방침 양식 활용 가능 -5. **가입 동의 UI / 약관 버전 관리 구현** -6. **법률 검토** - - 가능하면 변호사 검토. 특히 AI 데이터 처리, 국외 이전 부분이 중요하다. - ---- - -## 미결 사항 - -| # | 항목 | 비고 | -|---|------|------| -| 1 | 탈퇴 시 채팅 메시지 처리 | `feature/user-withdrawal` 기준: 방 단위 협업 데이터로 방 삭제 시까지 유지. 탈퇴자의 계정 식별 정보와 참여 정보는 삭제 또는 익명화 | -| 2 | 만 14세 미만 정책 | 가입 차단 vs 법정대리인 동의 | -| 3 | 채팅 메시지 AI 전송 고지 방식 | 처리 위탁 및 국외 이전 고지로 정리. 실제 가입/AI 호출 UI 고지 방식 결정 필요 | -| 4 | 회원 정보 탈퇴 후 보관 기간 | 즉시 파기 vs N일 유예 | -| 5 | 사업자등록 여부 | 등록 시점, 상호, 주소 | diff --git a/docs/policy/operator-info.md b/docs/policy/operator-info.md deleted file mode 100644 index e910cfca..00000000 --- a/docs/policy/operator-info.md +++ /dev/null @@ -1,16 +0,0 @@ -# 운영자 정보 - -정보통신망 이용촉진 및 정보보호 등에 관한 법률 제10조에 따라 다음과 같이 운영자 정보를 공개합니다. - ---- - -| 항목 | 내용 | -|------|------| -| 서비스명 | 우때 | -| 운영자 | 박주영 (개인 운영) | -| 이메일 | team.uttae@gmail.com | -| 호스팅 사업자 | Amazon Web Services (AWS) | - ---- - -> 사업자 미등록 개인 운영으로, 사업자등록번호 및 사업장 주소는 별도 공개하지 않습니다. 운영자에 대한 문의는 위 이메일로 연락주시기 바랍니다. From a0b83717f3f01bda9eb8f8a402a40b743f8c2e0d Mon Sep 17 00:00:00 2001 From: parkjuyeong0312 Date: Tue, 9 Jun 2026 00:36:59 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=95=BD=EA=B4=80=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EA=B2=80=EC=A6=9D=20=EC=A0=95=EA=B7=9C=EC=8B=9D=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=A0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agreements/service/AgreementService.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java index 42408b85..c6c62da4 100644 --- a/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java +++ b/src/main/java/com/howaboutus/backend/agreements/service/AgreementService.java @@ -3,7 +3,6 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; -import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; @@ -24,7 +23,6 @@ public class AgreementService { private static final String CONTENT_FORMAT_MARKDOWN = "MARKDOWN"; private static final String TERMS_OF_SERVICE_RESOURCE_DIR = "agreements/terms-of-service"; private static final String PRIVACY_POLICY_RESOURCE_DIR = "agreements/privacy-policy"; - private static final Pattern VERSION_PATTERN = Pattern.compile("^[0-9]+(\\.[0-9]+)*$"); private final AgreementProperties properties; @@ -74,7 +72,7 @@ private String requireVersion(AgreementProperties.AgreementDocument document) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } String version = document.currentVersion(); - if (!VERSION_PATTERN.matcher(version).matches()) { + if (!isValidVersion(version)) { throw new CustomException(ErrorCode.AGREEMENT_CONFIGURATION_INVALID); } return version; @@ -103,4 +101,21 @@ private ClassPathResource requireExistingResource(String resourceDirectory, Stri } return resource; } + + private boolean isValidVersion(String version) { + boolean requiresDigit = true; + for (int i = 0; i < version.length(); i++) { + char current = version.charAt(i); + if (Character.isDigit(current)) { + requiresDigit = false; + continue; + } + if (current == '.' && !requiresDigit) { + requiresDigit = true; + continue; + } + return false; + } + return !requiresDigit; + } }