From ed4a5b3d496cb5fcd6af70870e0d3632a0664878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sat, 7 Nov 2026 19:43:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=B2=84=EC=A0=84=C2=B7=EB=8F=99=EC=9D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EA=B3=BC=20V8=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/mysql84-test.py | 2 +- .../backend/domain/legal/LegalConsent.java | 18 +++++++++++ .../domain/legal/LegalConsentRepository.java | 5 +++ .../backend/domain/legal/LegalDocument.java | 28 ++++++++++++++++ .../domain/legal/LegalDocumentRepository.java | 14 ++++++++ .../backend/domain/legal/LegalError.java | 14 ++++++++ .../backend/domain/legal/LegalProperties.java | 25 +++++++++++++++ .../backend/domain/user/entity/User.java | 7 ++++ src/main/resources/application.yaml | 5 +++ .../db/migration/V8__legal_consents.sql | 29 +++++++++++++++++ .../migration/Mysql84MigrationTest.java | 31 +++++++++++++++--- .../TestCreditMysql84IntegrationTest.java | 1 + .../domain/InitialSchemaValidationTest.java | 2 +- .../AiGenerationLoggingIntegrationTest.java | 2 +- .../domain/legal/LegalPropertiesTest.java | 32 +++++++++++++++++++ .../domain/payment/PaymentMigrationTest.java | 2 +- .../TestCreditContextIntegrationTest.java | 2 +- .../payment/PaymentInspectorContract.java | 2 +- .../payment/PaymentReclaimerContract.java | 2 +- 19 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalConsent.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalConsentRepository.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalDocument.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalDocumentRepository.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalError.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalProperties.java create mode 100644 src/main/resources/db/migration/V8__legal_consents.sql create mode 100644 src/test/java/com/mongle/backend/domain/legal/LegalPropertiesTest.java diff --git a/scripts/mysql84-test.py b/scripts/mysql84-test.py index 27fa31a..4b96085 100644 --- a/scripts/mysql84-test.py +++ b/scripts/mysql84-test.py @@ -94,7 +94,7 @@ def stop_on_signal(signum, frame): migration_password = secrets.token_hex(24) sql(f"CREATE USER 'mongle_migrator'@'127.0.0.1' IDENTIFIED BY '{migration_password}' REQUIRE SSL;") migration_databases = ("flyway_fresh", "flyway_existing", "flyway_drift", "flyway_failed", - "flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation") + "flyway_checksum", "flyway_privilege", "flyway_jpa", "flyway_v1", "flyway_cli", "flyway_check", "flyway_summary", "flyway_reclamation", "flyway_legal") schema = (repo / "src/main/resources/db/schema-initial.sql").read_text() for database in migration_databases: sql(f"CREATE DATABASE {database} CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;") diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalConsent.java b/src/main/java/com/mongle/backend/domain/legal/LegalConsent.java new file mode 100644 index 0000000..ddbea79 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalConsent.java @@ -0,0 +1,18 @@ +package com.mongle.backend.domain.legal; +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; +@Entity @Getter @NoArgsConstructor(access=AccessLevel.PROTECTED) +@Table(name="legal_consents",uniqueConstraints=@UniqueConstraint(name="uk_legal_consent_version",columnNames={"user_id","document_type","document_version"})) +public class LegalConsent { + @Id @GeneratedValue(strategy=GenerationType.IDENTITY) private Long id; + @Column(name="user_id",nullable=false) private Long userId; + @Column(name="document_type",nullable=false,length=20) private String documentType; + @Column(name="document_version",nullable=false,length=40) private String documentVersion; + @Column(name="document_digest",nullable=false,length=64) private String documentDigest; + @Column(name="accepted_at",nullable=false) private LocalDateTime acceptedAt; + @Column(name="age14_confirmed",nullable=false) private boolean age14Confirmed; + static LegalConsent create(Long user,String type,String version,String digest,LocalDateTime at) { + var c=new LegalConsent(); c.userId=user;c.documentType=type;c.documentVersion=version;c.documentDigest=digest;c.acceptedAt=at;c.age14Confirmed=true;return c; + } +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalConsentRepository.java b/src/main/java/com/mongle/backend/domain/legal/LegalConsentRepository.java new file mode 100644 index 0000000..7fcbd81 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalConsentRepository.java @@ -0,0 +1,5 @@ +package com.mongle.backend.domain.legal; +import org.springframework.data.jpa.repository.JpaRepository; +public interface LegalConsentRepository extends JpaRepository { + boolean existsByUserIdAndDocumentTypeAndDocumentVersion(Long userId,String documentType,String documentVersion); +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalDocument.java b/src/main/java/com/mongle/backend/domain/legal/LegalDocument.java new file mode 100644 index 0000000..3141b79 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalDocument.java @@ -0,0 +1,28 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.global.error.BusinessException; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** No personal information. One immutable content digest per document version. */ +@Entity +@Getter +@Table(name = "legal_document_versions") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class LegalDocument { + @Id @Column(length = 64) private String id; + @Column(name = "document_type", nullable = false, length = 20) private String documentType; + @Column(name = "document_version", nullable = false, length = 40) private String documentVersion; + @Column(name = "document_digest", length = 64) private String documentDigest; + + public static String key(String type, String version) { return type + ":" + version; } + + void bindDigest(String digest) { + if (documentDigest != null && !documentDigest.equals(digest)) { + throw new BusinessException(LegalError.DOCUMENT_CONFLICT); + } + documentDigest = digest; + } +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalDocumentRepository.java b/src/main/java/com/mongle/backend/domain/legal/LegalDocumentRepository.java new file mode 100644 index 0000000..b386772 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalDocumentRepository.java @@ -0,0 +1,14 @@ +package com.mongle.backend.domain.legal; + +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.Optional; + +public interface LegalDocumentRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select d from LegalDocument d where d.id=:id") + Optional lock(@Param("id") String id); +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalError.java b/src/main/java/com/mongle/backend/domain/legal/LegalError.java new file mode 100644 index 0000000..34a9ce5 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalError.java @@ -0,0 +1,14 @@ +package com.mongle.backend.domain.legal; +import com.mongle.backend.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +@Getter @RequiredArgsConstructor +public enum LegalError implements ErrorCode { + INVALID(HttpStatus.BAD_REQUEST,"LEGAL_400_1","필수 동의 두 항목과 만 14세 이상 여부를 확인해주세요."), + REQUIRED(HttpStatus.FORBIDDEN,"LEGAL_403_1","최신 이용약관과 개인정보 수집·이용에 동의해주세요."), + VERSION(HttpStatus.CONFLICT,"LEGAL_409_1","문서가 변경되었습니다. 최신 문서를 확인해주세요."), + UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE,"LEGAL_503_1","동의 문서가 준비되지 않았습니다."), + DOCUMENT_CONFLICT(HttpStatus.SERVICE_UNAVAILABLE,"LEGAL_503_2","동의 문서 버전과 내용이 일치하지 않습니다. 잠시 후 다시 확인해주세요."); + private final HttpStatus httpStatus; private final String code; private final String message; +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalProperties.java b/src/main/java/com/mongle/backend/domain/legal/LegalProperties.java new file mode 100644 index 0000000..9bc277e --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalProperties.java @@ -0,0 +1,25 @@ +package com.mongle.backend.domain.legal; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +@ConfigurationProperties("mongle.legal") +public record LegalProperties( + @DefaultValue("false") boolean published, + @DefaultValue("false") boolean enforce, + @DefaultValue("") String termsDigest, + @DefaultValue("") String privacyDigest) { + + public LegalProperties { + if (enforce && !published) { + throw new IllegalArgumentException("약관 공개 후 동의 필수를 활성화해주세요."); + } + if (published && (!validDigest(termsDigest) || !validDigest(privacyDigest))) { + throw new IllegalArgumentException("공개한 두 문서의 SHA-256을 설정해주세요."); + } + } + + private static boolean validDigest(String value) { + return value != null && value.matches("[0-9a-f]{64}"); + } +} diff --git a/src/main/java/com/mongle/backend/domain/user/entity/User.java b/src/main/java/com/mongle/backend/domain/user/entity/User.java index a635abc..2e587c0 100644 --- a/src/main/java/com/mongle/backend/domain/user/entity/User.java +++ b/src/main/java/com/mongle/backend/domain/user/entity/User.java @@ -39,6 +39,13 @@ public class User extends BaseEntity { @Column(name = "retention_until") private LocalDateTime retentionUntil; + @Column(name="terms_version",length=40) private String termsVersion; + @Column(name="terms_digest",length=64) private String termsDigest; + @Column(name="privacy_version",length=40) private String privacyVersion; + @Column(name="privacy_digest",length=64) private String privacyDigest; + public void acceptLegal(String terms,String termsHash,String privacy,String privacyHash) { + termsVersion=terms; termsDigest=termsHash; privacyVersion=privacy; privacyDigest=privacyHash; + } public boolean isWithdrawn() { return withdrawnAt != null; } public void withdraw(LocalDateTime now, LocalDateTime retainUntil) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 319c01a..1981612 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,6 +39,11 @@ logging: mongle: + legal: + published: ${MONGLE_LEGAL_PUBLISHED:false} + enforce: ${MONGLE_LEGAL_ENFORCE:false} + terms-digest: ${MONGLE_LEGAL_TERMS_DIGEST:} + privacy-digest: ${MONGLE_LEGAL_PRIVACY_DIGEST:} oauth: web: success-url: ${MONGLE_OAUTH_SUCCESS_URL:} diff --git a/src/main/resources/db/migration/V8__legal_consents.sql b/src/main/resources/db/migration/V8__legal_consents.sql new file mode 100644 index 0000000..f1061fe --- /dev/null +++ b/src/main/resources/db/migration/V8__legal_consents.sql @@ -0,0 +1,29 @@ +ALTER TABLE users ADD COLUMN terms_version VARCHAR(40) NULL; +ALTER TABLE users ADD COLUMN terms_digest VARCHAR(64) NULL; +ALTER TABLE users ADD COLUMN privacy_version VARCHAR(40) NULL; +ALTER TABLE users ADD COLUMN privacy_digest VARCHAR(64) NULL; +-- The initial digest is bound atomically on the first accepted consent. +-- Once bound, changing content requires a new version rather than replacing the digest. +CREATE TABLE legal_document_versions ( + id VARCHAR(64) NOT NULL PRIMARY KEY, + document_type VARCHAR(20) NOT NULL, + document_version VARCHAR(40) NOT NULL, + document_digest VARCHAR(64) NULL, + CONSTRAINT uk_legal_document_version UNIQUE(document_type,document_version) +); +INSERT INTO legal_document_versions(id,document_type,document_version) + VALUES ('TERMS:2026-11-07-v1','TERMS','2026-11-07-v1'), + ('PRIVACY:2026-11-07-v1','PRIVACY','2026-11-07-v1'); +CREATE TABLE legal_consents ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + document_type VARCHAR(20) NOT NULL, + document_version VARCHAR(40) NOT NULL, + document_digest VARCHAR(64) NOT NULL, + accepted_at DATETIME(6) NOT NULL, + age14_confirmed BOOLEAN NOT NULL, + CONSTRAINT uk_legal_consent_version UNIQUE(user_id,document_type,document_version), + CONSTRAINT fk_legal_consent_user FOREIGN KEY(user_id) REFERENCES users(id), + CONSTRAINT fk_legal_consent_document FOREIGN KEY(document_type,document_version) + REFERENCES legal_document_versions(document_type,document_version) +); diff --git a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java index 087d787..936a338 100644 --- a/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java +++ b/src/mysqlTest/java/com/mongle/backend/migration/Mysql84MigrationTest.java @@ -83,7 +83,7 @@ String location() throws Exception { assertThat(new MigrationEngine(source, "flyway_existing", "filesystem:" + priorRelease).execute("migrate")) .containsEntry("targetVersion", "3"); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='recent_interpretations'", Integer.class)).isZero(); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 4).containsEntry("targetVersion", "7"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 5).containsEntry("targetVersion", "8"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted", 0); assertThatThrownBy(() -> engine.execute("baseline")).hasMessage("HISTORY_ALREADY_EXISTS"); assertThat(jdbc.queryForObject("SELECT nickname FROM users WHERE email='preserved@test.invalid'", String.class)).isEqualTo("keep"); @@ -102,7 +102,7 @@ String location() throws Exception { jdbc.update("INSERT INTO users(email,nickname,created_at,updated_at) VALUES('summary-preserved@test.invalid','keep-summary',NOW(),NOW())"); var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var engine=new MigrationEngine(source,"flyway_summary"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",3).containsEntry("targetVersion","7"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",4).containsEntry("targetVersion","8"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version in ('1','3','4') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForObject("select nickname from users where email='summary-preserved@test.invalid'",String.class)).isEqualTo("keep-summary"); @@ -124,9 +124,9 @@ String location() throws Exception { var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); var accounts=jdbc.queryForList("select * from credit_accounts");var grants=jdbc.queryForList("select * from credit_grants"); var engine=new MigrationEngine(ds,"flyway_reclamation"); - assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",2).containsEntry("targetVersion","7"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",3).containsEntry("targetVersion","8"); assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); - assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7') order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version not in ('6','7','8') order by installed_rank")).isEqualTo(history); assertThat(jdbc.queryForList("select * from credit_accounts")).isEqualTo(accounts); var upgradedGrants=jdbc.queryForList("select * from credit_grants"); upgradedGrants.forEach(row -> assertThat(row.remove("forfeited")).isEqualTo(0L)); @@ -134,6 +134,29 @@ String location() throws Exception { assertThat(jdbc.queryForObject("select count(*) from payment_credit_reclamations",Long.class)).isZero(); } + @Test void v8UpgradesV7WithoutFabricatingConsentOrChangingAppliedChecksums() throws Exception { + var prior=Files.createDirectory(directory.resolve("prior-legal-release")); + for(String name:java.util.List.of("V1__mongle_baseline.sql","V3__image_regeneration_policy.sql", + "V4__recent_interpretations.sql","V5__world_summaries.sql","V6__payment_credit_reclamations.sql","V7__user_withdrawal.sql")) { + try(var sql=new ClassPathResource("db/migration/"+name).getInputStream()) { Files.copy(sql,prior.resolve(name)); } + } + var ds=source("flyway_legal"); var jdbc=new JdbcTemplate(ds); + assertThat(new MigrationEngine(ds,"flyway_legal","filesystem:"+prior).execute("migrate")).containsEntry("targetVersion","7"); + jdbc.update("insert into users(email,nickname,created_at,updated_at) values('legal-existing@test.invalid','keep',NOW(),NOW())"); + var before=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); + var history=jdbc.queryForList("select version,checksum from flyway_schema_history order by installed_rank"); + var engine=new MigrationEngine(ds,"flyway_legal"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",1).containsEntry("targetVersion","8"); + assertThat(engine.execute("migrate")).containsEntry("migrationsExecuted",0); + var after=jdbc.queryForMap("select * from users where email='legal-existing@test.invalid'"); + for(String column:java.util.List.of("terms_version","terms_digest","privacy_version","privacy_digest")) + assertThat(after.remove(column)).isNull(); + assertThat(after).isEqualTo(before); + assertThat(jdbc.queryForList("select version,checksum from flyway_schema_history where version<>'8' order by installed_rank")).isEqualTo(history); + assertThat(jdbc.queryForObject("select count(*) from legal_consents",Long.class)).isZero(); + assertThat(jdbc.queryForObject("select count(*) from legal_document_versions where document_digest is null",Long.class)).isEqualTo(2); + } + @Test void baselineRejectsColumnIndexAndForeignKeyDriftEvenWith33Tables() throws Exception { var source = source("flyway_drift"); var jdbc = new JdbcTemplate(source); var engine = new MigrationEngine(source, "flyway_drift"); diff --git a/src/mysqlTest/java/com/mongle/backend/operations/credit/TestCreditMysql84IntegrationTest.java b/src/mysqlTest/java/com/mongle/backend/operations/credit/TestCreditMysql84IntegrationTest.java index b3d613a..f531743 100644 --- a/src/mysqlTest/java/com/mongle/backend/operations/credit/TestCreditMysql84IntegrationTest.java +++ b/src/mysqlTest/java/com/mongle/backend/operations/credit/TestCreditMysql84IntegrationTest.java @@ -31,6 +31,7 @@ private Map fixture() { @Override DataSource dataSource() { + com.mongle.backend.support.NativeMysqlSupport.register((name,value) -> {}, "credit_test"); return TestCreditSettings.fromEnvironment(fixture()).dataSource(); } diff --git a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java index 32f67cf..7015503 100644 --- a/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java +++ b/src/test/java/com/mongle/backend/domain/InitialSchemaValidationTest.java @@ -11,7 +11,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql", "spring.jpa.hibernate.ddl-auto=validate" }) @ActiveProfiles("test") diff --git a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java index 6e7b518..976f7b3 100644 --- a/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/ai/liner/AiGenerationLoggingIntegrationTest.java @@ -47,7 +47,7 @@ @SpringBootTest(properties = { "spring.datasource.url=jdbc:h2:mem:mongle-ai-logging;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "spring.sql.init.mode=always", - "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql", + "spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql", "spring.jpa.hibernate.ddl-auto=validate", "mongle.ai.liner.api-key=logging-test-key", "mongle.ai.liner.connect-timeout=300ms", diff --git a/src/test/java/com/mongle/backend/domain/legal/LegalPropertiesTest.java b/src/test/java/com/mongle/backend/domain/legal/LegalPropertiesTest.java new file mode 100644 index 0000000..3c381cc --- /dev/null +++ b/src/test/java/com/mongle/backend/domain/legal/LegalPropertiesTest.java @@ -0,0 +1,32 @@ +package com.mongle.backend.domain.legal; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import static org.assertj.core.api.Assertions.*; + +class LegalPropertiesTest { + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(LegalProperties.class) + static class Config {} + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(Config.class); + + @Test void defaultsDoNotPublishOrEnforceAndIncompleteActivationFailsStartup() { + runner.run(context -> { + var properties = context.getBean(LegalProperties.class); + assertThat(properties.published()).isFalse(); + assertThat(properties.enforce()).isFalse(); + }); + runner.withPropertyValues("mongle.legal.enforce=true") + .run(context -> assertThat(context).hasFailed()); + runner.withPropertyValues("mongle.legal.published=true") + .run(context -> assertThat(context).hasFailed()); + runner.withPropertyValues("mongle.legal.published=true", "mongle.legal.enforce=true", + "mongle.legal.terms-digest=" + "a".repeat(64), + "mongle.legal.privacy-digest=" + "b".repeat(64)) + .run(context -> assertThat(context).hasNotFailed()); + } +} diff --git a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java index cde4510..58b3fd3 100644 --- a/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java +++ b/src/test/java/com/mongle/backend/domain/payment/PaymentMigrationTest.java @@ -6,7 +6,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.jdbc.core.JdbcTemplate; @SpringBootTest(properties={"spring.datasource.url=jdbc:h2:mem:payment-schema;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", - "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql","spring.jpa.hibernate.ddl-auto=validate"}) + "spring.sql.init.mode=always","spring.sql.init.schema-locations=classpath:db/schema-initial.sql,classpath:db/migration/V3__image_regeneration_policy.sql,classpath:db/migration/V4__recent_interpretations.sql,classpath:db/migration/V5__world_summaries.sql,classpath:db/migration/V7__user_withdrawal.sql,classpath:db/migration/V8__legal_consents.sql","spring.jpa.hibernate.ddl-auto=validate"}) @ActiveProfiles("test") class PaymentMigrationTest { @Autowired JdbcTemplate jdbc; diff --git a/src/test/java/com/mongle/backend/operations/credit/TestCreditContextIntegrationTest.java b/src/test/java/com/mongle/backend/operations/credit/TestCreditContextIntegrationTest.java index 0fbc852..c380755 100644 --- a/src/test/java/com/mongle/backend/operations/credit/TestCreditContextIntegrationTest.java +++ b/src/test/java/com/mongle/backend/operations/credit/TestCreditContextIntegrationTest.java @@ -35,7 +35,7 @@ javax.sql.DataSource dataSource() { "jdbc:h2:mem:operator-credits;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); - new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql")) + new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql")) .execute(source); return source; } diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java index a7b2806..ac4c455 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentInspectorContract.java @@ -41,7 +41,7 @@ DataSource dataSource() { "jdbc:h2:mem:payment-inspect;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", "sa", ""); - new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql")).execute(ds); + new ResourceDatabasePopulator(new ClassPathResource("db/schema-initial.sql"),new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql")).execute(ds); return ds; } diff --git a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java index a92c9af..5572bdb 100644 --- a/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java +++ b/src/test/java/com/mongle/backend/operations/payment/PaymentReclaimerContract.java @@ -53,7 +53,7 @@ DataSource dataSource() { ""); new ResourceDatabasePopulator( new ClassPathResource("db/schema-initial.sql"), - new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql")) + new ClassPathResource("db/migration/V6__payment_credit_reclamations.sql"), new ClassPathResource("db/migration/V7__user_withdrawal.sql"), new ClassPathResource("db/migration/V8__legal_consents.sql")) .execute(ds); return ds; } From 84f5561efe5f13956a6dbe48c6b22f476025e52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sat, 7 Nov 2026 19:44:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=95=BD=EA=B4=80=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20API=EC=99=80=20=EC=B5=9C=EC=8B=A0=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=20=EB=8F=99=EC=9D=98=20=EC=83=81=ED=83=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/SocialLoginService.java | 11 +- .../domain/auth/service/TokenService.java | 3 +- .../mongle/backend/domain/legal/LegalApi.java | 36 ++++ .../backend/domain/legal/LegalController.java | 33 ++++ .../backend/domain/legal/LegalService.java | 134 +++++++++++++ .../backend/domain/user/dto/UserResponse.java | 8 +- .../domain/user/service/UserService.java | 8 +- .../user/withdrawal/WithdrawalData.java | 1 + .../backend/global/config/SecurityConfig.java | 15 +- .../MongleJwtAuthenticationConverter.java | 2 + .../Mysql84LegalConsentIntegrationTest.java | 13 ++ .../domain/auth/OAuthLoginHandlersTest.java | 4 +- .../domain/auth/SocialLoginRecoveryTest.java | 5 +- .../LegalConsentDisabledIntegrationTest.java | 32 ++++ .../legal/LegalConsentIntegrationTest.java | 179 ++++++++++++++++++ 15 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalApi.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalController.java create mode 100644 src/main/java/com/mongle/backend/domain/legal/LegalService.java create mode 100644 src/mysqlTest/java/com/mongle/backend/domain/legal/Mysql84LegalConsentIntegrationTest.java create mode 100644 src/test/java/com/mongle/backend/domain/legal/LegalConsentDisabledIntegrationTest.java create mode 100644 src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java diff --git a/src/main/java/com/mongle/backend/domain/auth/service/SocialLoginService.java b/src/main/java/com/mongle/backend/domain/auth/service/SocialLoginService.java index 686a030..3d998f0 100644 --- a/src/main/java/com/mongle/backend/domain/auth/service/SocialLoginService.java +++ b/src/main/java/com/mongle/backend/domain/auth/service/SocialLoginService.java @@ -22,12 +22,15 @@ public class SocialLoginService { private final SocialAccountRepository accounts; private final UserRepository users; private final TransactionTemplate read; + private final com.mongle.backend.domain.legal.LegalService legal; private final TransactionTemplate write; private final com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities; public SocialLoginService(SocialAccountRepository accounts, UserRepository users, PlatformTransactionManager manager, - com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities) { + com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities, + com.mongle.backend.domain.legal.LegalService legal) { + this.legal = legal; this.accounts = accounts; this.users = users; this.withdrawalIdentities = withdrawalIdentities; @@ -51,14 +54,14 @@ public UserResponse login(SocialIdentity identity) { Optional account = accounts.findByProviderAndProviderUserId( identity.provider(), identity.providerUserId()); if (account.isPresent()) { - return UserResponse.from(account.get().getUser()); + return legal.response(account.get().getUser()); } User user = users.save(User.register(identity.email())); accounts.saveAndFlush(SocialAccount.link(user, identity.provider(), identity.providerUserId())); Long userId = user.getId(); // 이메일·소셜 제공자 사용자 ID 대신 내부 PK와 제공사만 남긴다. CommittedLog.afterCommit(() -> log.info("소셜 회원 가입 완료: userId={}, provider={}", userId, identity.provider())); - return UserResponse.from(user); + return legal.response(user); }); } catch (DataIntegrityViolationException duplicate) { // 중복 가입으로 실패한 트랜잭션은 사용자 저장까지 모두 롤백된 상태다. @@ -70,6 +73,6 @@ public UserResponse login(SocialIdentity identity) { private Optional find(SocialIdentity identity) { return read.execute(status -> accounts.findByProviderAndProviderUserId( identity.provider(), identity.providerUserId()) - .map(account -> UserResponse.from(account.getUser()))); + .map(account -> legal.response(account.getUser()))); } } diff --git a/src/main/java/com/mongle/backend/domain/auth/service/TokenService.java b/src/main/java/com/mongle/backend/domain/auth/service/TokenService.java index 00e9c56..7835b5a 100644 --- a/src/main/java/com/mongle/backend/domain/auth/service/TokenService.java +++ b/src/main/java/com/mongle/backend/domain/auth/service/TokenService.java @@ -40,6 +40,7 @@ public class TokenService { private final JwtEncoder encoder; private final AuthProperties properties; private final Clock authClock; + private final com.mongle.backend.domain.legal.LegalService legal; // 쿠키에 담을 원문 토큰은 응답 DTO에 포함하지 않는다. public record IssuedTokens(TokenResponse response, String refreshToken, Instant refreshExpiresAt) { } @@ -127,7 +128,7 @@ private IssuedTokens issue(RefreshSession session, String refresh, Instant now) String access = encoder.encode(JwtEncoderParameters.from( JwsHeader.with(MacAlgorithm.HS256).type("JWT").build(), claims)).getTokenValue(); var response = new TokenResponse(access, "Bearer", accessExpiry.getEpochSecond() - now.getEpochSecond(), - UserResponse.from(session.getUser())); + legal.response(session.getUser())); return new IssuedTokens(response, refresh, sessionExpiry); } diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalApi.java b/src/main/java/com/mongle/backend/domain/legal/LegalApi.java new file mode 100644 index 0000000..d9d6fe3 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalApi.java @@ -0,0 +1,36 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.domain.user.dto.UserResponse; +import com.mongle.backend.global.config.SwaggerConfig; +import com.mongle.backend.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.jwt.Jwt; + +@Tag(name = "Legal", description = "버전별 필수 문서 동의") +public interface LegalApi { + @Operation(summary = "필수 동의 문서 버전·해시 조회", + description = "공개 조회입니다. published=false이면 동의를 접수하지 않습니다. 프론트는 published=true인 현재 버전·해시와 동일한 문서를 각각 표시해야 합니다. 본문을 반환하는 API는 아니며 PRIVACY는 필수 개인정보 수집·이용 고지입니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "문서 메타데이터", useReturnTypeSchema = true), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "LEGAL_503_1 문서 미준비, LEGAL_503_2 동일 버전의 해시 충돌") + }) + ResponseEntity> documents(); + + @Operation(summary = "이용약관·개인정보 필수 수집·이용 동의", + description = "온보딩 전/기존 회원 모두 가능합니다. 두 필수 항목·만14세 이상 확인·현재 문서 버전과 SHA-256이 필요합니다. 시각은 서버 UTC로 기록하며 동일 버전 재요청은 최초 시각을 보존합니다. 회원 ID나 동의 시각을 요청에 넣지 않습니다.") + @SecurityRequirement(name = SwaggerConfig.BEARER_AUTH) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "갱신된 회원 상태", useReturnTypeSchema = true), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "LEGAL_400_1 필수 동의/연령 확인 누락 또는 거절"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "LEGAL_409_1 요청한 문서 버전·해시가 현재와 다름"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "503", description = "LEGAL_503_1 공개 전/문서 미준비, LEGAL_503_2 동일 버전의 해시 충돌") + }) + ResponseEntity> accept( + @Parameter(hidden = true) Jwt jwt, LegalService.Accept request); +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalController.java b/src/main/java/com/mongle/backend/domain/legal/LegalController.java new file mode 100644 index 0000000..d9adc63 --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalController.java @@ -0,0 +1,33 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.domain.user.dto.UserResponse; +import com.mongle.backend.global.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping(produces = MediaType.APPLICATION_JSON_VALUE) +public class LegalController implements LegalApi { + private final LegalService legal; + + @Override + @GetMapping("/api/v1/legal/documents") + public ResponseEntity> documents() { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(ApiResponse.success(legal.catalog())); + } + + @Override + @PostMapping(value = "/api/v1/users/me/agreements", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> accept( + @AuthenticationPrincipal Jwt jwt, @RequestBody LegalService.Accept request) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(ApiResponse.success(legal.accept(Long.valueOf(jwt.getSubject()), request))); + } +} diff --git a/src/main/java/com/mongle/backend/domain/legal/LegalService.java b/src/main/java/com/mongle/backend/domain/legal/LegalService.java new file mode 100644 index 0000000..540745f --- /dev/null +++ b/src/main/java/com/mongle/backend/domain/legal/LegalService.java @@ -0,0 +1,134 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.domain.user.dto.UserResponse; +import com.mongle.backend.domain.user.entity.User; +import com.mongle.backend.domain.user.exception.UserErrorCode; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.global.error.BusinessException; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +@Service +@RequiredArgsConstructor +@EnableConfigurationProperties(LegalProperties.class) +public class LegalService { + // Content changes require new versions, registry rows, hashes and matching frontend documents. + public static final String TERMS_VERSION = "2026-11-07-v1"; + public static final String PRIVACY_VERSION = "2026-11-07-v1"; + + private final LegalProperties properties; + private final LegalDocumentRepository documents; + private final LegalConsentRepository consents; + private final UserRepository users; + private final Clock authClock; + + @Schema(name = "LegalDocumentMetadata") + public record Document(String type, String version, String digest, boolean required) {} + + @Schema(name = "LegalDocumentCatalog") + public record Catalog(boolean published, List documents) {} + + @Schema(name = "LegalConsentRequest") + public record Accept( + @Schema(requiredMode = REQUIRED, example = "2026-11-07-v1") String termsVersion, + @Schema(requiredMode = REQUIRED, pattern = "[0-9a-f]{64}") String termsDigest, + @Schema(requiredMode = REQUIRED, description = "이용약관에 실제 동의한 경우만 true") Boolean termsAccepted, + @Schema(requiredMode = REQUIRED, example = "2026-11-07-v1") String privacyVersion, + @Schema(requiredMode = REQUIRED, pattern = "[0-9a-f]{64}") String privacyDigest, + @Schema(requiredMode = REQUIRED, description = "필수 개인정보 수집·이용 고지에 실제 동의한 경우만 true") Boolean privacyAccepted, + @Schema(requiredMode = REQUIRED, description = "만14세 이상임을 실제 확인한 경우만 true. 생년월일을 수집하지 않는 자기 확인입니다.") Boolean age14OrOlder) {} + + @Transactional(readOnly = true) + public Catalog catalog() { + if (properties.published()) { + verifyDocument("TERMS", TERMS_VERSION, properties.termsDigest()); + verifyDocument("PRIVACY", PRIVACY_VERSION, properties.privacyDigest()); + } + return new Catalog(properties.published(), List.of( + new Document("TERMS", TERMS_VERSION, properties.termsDigest(), true), + new Document("PRIVACY", PRIVACY_VERSION, properties.privacyDigest(), true))); + } + + private void verifyDocument(String type, String version, String digest) { + var document = documents.findById(LegalDocument.key(type, version)) + .orElseThrow(() -> new BusinessException(LegalError.UNAVAILABLE)); + if (document.getDocumentDigest() != null && !document.getDocumentDigest().equals(digest)) { + throw new BusinessException(LegalError.DOCUMENT_CONFLICT); + } + } + + public boolean current(User user) { + return !user.isWithdrawn() && properties.published() + && TERMS_VERSION.equals(user.getTermsVersion()) + && PRIVACY_VERSION.equals(user.getPrivacyVersion()) + && properties.termsDigest().equals(user.getTermsDigest()) + && properties.privacyDigest().equals(user.getPrivacyDigest()); + } + + public UserResponse response(User user) { + return UserResponse.from(user, current(user)); + } + + public boolean accessAllowed(User user) { + return !properties.enforce() || current(user); + } + + public void requireAccepted(User user) { + if (properties.enforce() && !current(user)) { + throw new BusinessException(LegalError.REQUIRED); + } + } + + @Transactional + public UserResponse accept(Long id, Accept request) { + requireValidRequest(request); + // Keep the user-first order used by onboarding and withdrawal, then lock + // global document rows in a fixed order across members and instances. + var user = users.findByIdForUpdate(id).filter(account -> !account.isWithdrawn()) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + bindDocument("TERMS", TERMS_VERSION, properties.termsDigest()); + bindDocument("PRIVACY", PRIVACY_VERSION, properties.privacyDigest()); + var at = LocalDateTime.ofInstant(authClock.instant(), ZoneOffset.UTC); + saveOnce(id, "TERMS", TERMS_VERSION, properties.termsDigest(), at); + saveOnce(id, "PRIVACY", PRIVACY_VERSION, properties.privacyDigest(), at); + user.acceptLegal(TERMS_VERSION, properties.termsDigest(), PRIVACY_VERSION, properties.privacyDigest()); + consents.flush(); + return response(user); + } + + private void requireValidRequest(Accept request) { + if (!properties.published()) { + throw new BusinessException(LegalError.UNAVAILABLE); + } + if (request == null || !Boolean.TRUE.equals(request.termsAccepted()) + || !Boolean.TRUE.equals(request.privacyAccepted()) || !Boolean.TRUE.equals(request.age14OrOlder())) { + throw new BusinessException(LegalError.INVALID); + } + if (!TERMS_VERSION.equals(request.termsVersion()) || !PRIVACY_VERSION.equals(request.privacyVersion()) + || !properties.termsDigest().equals(request.termsDigest()) + || !properties.privacyDigest().equals(request.privacyDigest())) { + throw new BusinessException(LegalError.VERSION); + } + } + + private void bindDocument(String type, String version, String digest) { + documents.lock(LegalDocument.key(type, version)) + .orElseThrow(() -> new BusinessException(LegalError.UNAVAILABLE)).bindDigest(digest); + } + + private void saveOnce(Long user, String type, String version, String digest, LocalDateTime at) { + if (!consents.existsByUserIdAndDocumentTypeAndDocumentVersion(user, type, version)) { + consents.save(LegalConsent.create(user, type, version, digest, at)); + } + } +} diff --git a/src/main/java/com/mongle/backend/domain/user/dto/UserResponse.java b/src/main/java/com/mongle/backend/domain/user/dto/UserResponse.java index 621f705..e117736 100644 --- a/src/main/java/com/mongle/backend/domain/user/dto/UserResponse.java +++ b/src/main/java/com/mongle/backend/domain/user/dto/UserResponse.java @@ -2,10 +2,12 @@ import com.mongle.backend.domain.user.entity.User; -public record UserResponse(Long userId, String email, String nickname, boolean onboardingCompleted) { +public record UserResponse(Long userId, String email, String nickname, boolean onboardingCompleted, + @io.swagger.v3.oas.annotations.media.Schema(description="현재 공개된 이용약관과 개인정보 수집·이용 필수 동의를 모두 완료했는지 여부. 버전과 문서 해시를 서버 설정과 대조합니다.") + boolean termsAccepted) { - public static UserResponse from(User user) { + public static UserResponse from(User user,boolean termsAccepted) { return new UserResponse(user.getId(), user.getEmail(), user.getNickname(), - user.isOnboardingCompleted()); + user.isOnboardingCompleted(), termsAccepted); } } diff --git a/src/main/java/com/mongle/backend/domain/user/service/UserService.java b/src/main/java/com/mongle/backend/domain/user/service/UserService.java index 1297952..bae7c13 100644 --- a/src/main/java/com/mongle/backend/domain/user/service/UserService.java +++ b/src/main/java/com/mongle/backend/domain/user/service/UserService.java @@ -22,11 +22,12 @@ public class UserService { private final UserRepository userRepository; private final CreditService credits; + private final com.mongle.backend.domain.legal.LegalService legal; private final CreditProperties creditProperties; private final com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService withdrawalIdentities; public UserResponse getMe(Long authenticatedUserId) { - return UserResponse.from(userRepository.findById(authenticatedUserId) + return legal.response(userRepository.findById(authenticatedUserId) .filter(user -> !user.isWithdrawn()) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND))); } @@ -39,7 +40,7 @@ public UserResponse updateNickname(Long authenticatedUserId, String nickname) { if (user.changeNickname(nickname)) { CommittedLog.afterCommit(() -> log.info("사용자 닉네임 변경 완료: userId={}", authenticatedUserId)); } - return UserResponse.from(user); + return legal.response(user); } @Transactional @@ -49,6 +50,7 @@ public UserResponse completeOnboarding(Long authenticatedUserId, String nickname User user = userRepository.findByIdForUpdate(authenticatedUserId) .filter(account -> !account.isWithdrawn()) .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + legal.requireAccepted(user); boolean alreadyCompleted = user.isOnboardingCompleted(); user.completeOnboarding(nickname); // 같은 닉네임의 멱등 재요청은 새 상태 변경이 아니다. 최초 커밋에만 성공 로그를 남긴다. @@ -58,6 +60,6 @@ public UserResponse completeOnboarding(Long authenticatedUserId, String nickname creditProperties.signupGrantAmount()); CommittedLog.afterCommit(() -> log.info("사용자 온보딩 완료: userId={}", authenticatedUserId)); } - return UserResponse.from(user); + return legal.response(user); } } diff --git a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java index 54f0ef1..cbe2770 100644 --- a/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java +++ b/src/main/java/com/mongle/backend/domain/user/withdrawal/WithdrawalData.java @@ -134,6 +134,7 @@ insert into credit_ledger(user_id,entry_key,kind,source_type,grant_id,available_ public void deleteAccounting(Long user, LocalDateTime now) { em.flush(); for(String sql:List.of( + "delete from legal_consents where user_id=:user", "delete from payment_credit_reclamations where user_id=:user", "delete from credit_payment_holds where user_id=:user", "delete from payment_notifications where order_id in (select id from payment_orders where user_id=:user)", diff --git a/src/main/java/com/mongle/backend/global/config/SecurityConfig.java b/src/main/java/com/mongle/backend/global/config/SecurityConfig.java index 7e29bc6..36dfd37 100644 --- a/src/main/java/com/mongle/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/mongle/backend/global/config/SecurityConfig.java @@ -54,7 +54,9 @@ SecurityFilterChain securityFilterChain(HttpSecurity http, Environment environme boolean pending = !(exception instanceof CsrfException) && current != null && current.getPrincipal() instanceof Jwt && current.getAuthorities().stream().noneMatch(a -> a.getAuthority().equals("ROLE_ONBOARDED")); - responses.failure(response, pending ? UserErrorCode.ONBOARDING_REQUIRED : CommonErrorCode.FORBIDDEN); + boolean agreement=!(exception instanceof CsrfException) && current!=null && current.getPrincipal() instanceof Jwt + && current.getAuthorities().stream().noneMatch(a->a.getAuthority().equals("ROLE_AGREED")); + responses.failure(response, pending ? UserErrorCode.ONBOARDING_REQUIRED : agreement ? com.mongle.backend.domain.legal.LegalError.REQUIRED : CommonErrorCode.FORBIDDEN); }; var corsFilter = new CorsFilter(corsSource); corsFilter.setCorsProcessor(new DefaultCorsProcessor() { @@ -105,10 +107,17 @@ protected void rejectRequest(ServerHttpResponse response) throws IOException { auth.requestMatchers(HttpMethod.GET, "/api/v1/auth/csrf").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/auth/refresh", "/api/v1/auth/logout").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/payments/payapp/feedback").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/legal/documents").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/users/me").hasRole("USER") .requestMatchers(HttpMethod.DELETE, "/api/v1/users/me").hasRole("USER") - .requestMatchers(HttpMethod.POST, "/api/v1/users/me/onboarding").hasRole("USER") - .anyRequest().hasRole("ONBOARDED"); + .requestMatchers(HttpMethod.POST, "/api/v1/users/me/agreements", "/api/v1/users/me/onboarding").hasRole("USER") + // Rights-related endpoints must remain usable while re-consent is pending. + .requestMatchers(HttpMethod.POST, "/api/v1/payments/refunds").hasRole("ONBOARDED") + .requestMatchers(HttpMethod.GET, "/api/v1/payments/refunds", "/api/v1/payments/refunds/**").hasRole("ONBOARDED") + .requestMatchers(HttpMethod.GET, "/api/v1/payments/orders", "/api/v1/payments/orders/**", "/api/v1/credits/balance", "/api/v1/credits/transactions").hasRole("ONBOARDED") + .anyRequest().access(org.springframework.security.authorization.AuthorizationManagers.allOf( + org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole("ONBOARDED"), + org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole("AGREED"))); }); if (local) { http.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) diff --git a/src/main/java/com/mongle/backend/global/security/MongleJwtAuthenticationConverter.java b/src/main/java/com/mongle/backend/global/security/MongleJwtAuthenticationConverter.java index 4c3e08e..dd5902f 100644 --- a/src/main/java/com/mongle/backend/global/security/MongleJwtAuthenticationConverter.java +++ b/src/main/java/com/mongle/backend/global/security/MongleJwtAuthenticationConverter.java @@ -21,6 +21,7 @@ public class MongleJwtAuthenticationConverter implements Converter { private final RefreshSessionRepository sessions; private final Clock authClock; + private final com.mongle.backend.domain.legal.LegalService legal; @Override @Transactional(readOnly = true) @@ -32,6 +33,7 @@ public AbstractAuthenticationToken convert(Jwt jwt) { .orElseThrow(() -> new InvalidBearerTokenException("로그인이 만료되었거나 로그아웃한 계정입니다.")); var authorities = new ArrayList(); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); + if(legal.accessAllowed(user)) authorities.add(new SimpleGrantedAuthority("ROLE_AGREED")); if (user.isOnboardingCompleted()) { authorities.add(new SimpleGrantedAuthority("ROLE_ONBOARDED")); } diff --git a/src/mysqlTest/java/com/mongle/backend/domain/legal/Mysql84LegalConsentIntegrationTest.java b/src/mysqlTest/java/com/mongle/backend/domain/legal/Mysql84LegalConsentIntegrationTest.java new file mode 100644 index 0000000..e942188 --- /dev/null +++ b/src/mysqlTest/java/com/mongle/backend/domain/legal/Mysql84LegalConsentIntegrationTest.java @@ -0,0 +1,13 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.support.NativeMysqlSupport; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +/** The same consent/HTTP/ownership/concurrency contract against the migrated disposable fixture. */ +class Mysql84LegalConsentIntegrationTest extends LegalConsentIntegrationTest { + @DynamicPropertySource + static void mysql(DynamicPropertyRegistry registry) { + NativeMysqlSupport.register(registry, "credit_test"); + } +} diff --git a/src/test/java/com/mongle/backend/domain/auth/OAuthLoginHandlersTest.java b/src/test/java/com/mongle/backend/domain/auth/OAuthLoginHandlersTest.java index 4aa2880..48d8f86 100644 --- a/src/test/java/com/mongle/backend/domain/auth/OAuthLoginHandlersTest.java +++ b/src/test/java/com/mongle/backend/domain/auth/OAuthLoginHandlersTest.java @@ -33,7 +33,7 @@ void issuesMongleTokensWithoutExposingProviderTokensAndClearsOAuthSession() thro var request = new MockHttpServletRequest(); request.getSession().setAttribute("oauth-secret", "provider-token"); var response = new MockHttpServletResponse(); - var principal = new MongleOAuth2User(new UserResponse(7L, "user@example.com", null, false)); + var principal = new MongleOAuth2User(new UserResponse(7L, "user@example.com", null, false, false)); var authentication = new UsernamePasswordAuthenticationToken( principal, null, principal.getAuthorities()); @@ -76,7 +76,7 @@ void hidesProviderDescriptionsAndClearsFailedLoginSession() throws Exception { @Test void sessionIssueFailuresClearRefreshAndReturnOnlyServiceErrors() throws Exception { - var principal = new MongleOAuth2User(new UserResponse(7L, "user@example.com", null, false)); + var principal = new MongleOAuth2User(new UserResponse(7L, "user@example.com", null, false, false)); var authentication = new UsernamePasswordAuthenticationToken( principal, null, principal.getAuthorities()); diff --git a/src/test/java/com/mongle/backend/domain/auth/SocialLoginRecoveryTest.java b/src/test/java/com/mongle/backend/domain/auth/SocialLoginRecoveryTest.java index f712f6a..bc2d871 100644 --- a/src/test/java/com/mongle/backend/domain/auth/SocialLoginRecoveryTest.java +++ b/src/test/java/com/mongle/backend/domain/auth/SocialLoginRecoveryTest.java @@ -36,8 +36,11 @@ void resolvesWinnerAfterRollingBackDuplicateAndDoesNotHideOtherIntegrityFailures when(users.save(any())).thenAnswer(call -> call.getArgument(0)); var conflict = new DataIntegrityViolationException("소셜 계정이 중복되었습니다."); when(accounts.saveAndFlush(any())).thenThrow(conflict); + var legal = mock(com.mongle.backend.domain.legal.LegalService.class); + when(legal.response(any(User.class))).thenAnswer(call -> + com.mongle.backend.domain.user.dto.UserResponse.from(call.getArgument(0),false)); var service = new SocialLoginService(accounts, users, manager, - org.mockito.Mockito.mock(com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService.class)); + org.mockito.Mockito.mock(com.mongle.backend.domain.user.withdrawal.WithdrawalIdentityService.class),legal); assertThat(service.login(identity).nickname()).isEqualTo("몽글이"); var order = inOrder(manager, accounts); diff --git a/src/test/java/com/mongle/backend/domain/legal/LegalConsentDisabledIntegrationTest.java b/src/test/java/com/mongle/backend/domain/legal/LegalConsentDisabledIntegrationTest.java new file mode 100644 index 0000000..2873999 --- /dev/null +++ b/src/test/java/com/mongle/backend/domain/legal/LegalConsentDisabledIntegrationTest.java @@ -0,0 +1,32 @@ +package com.mongle.backend.domain.legal; + +import com.mongle.backend.domain.user.entity.User; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.global.error.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:legal-disabled;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE" +}) +@ActiveProfiles("test") +class LegalConsentDisabledIntegrationTest { + @Autowired LegalService legal; + @Autowired UserRepository users; + @Autowired LegalConsentRepository consents; + + @Test void unpublishedDocumentsDoNotFabricateConsentOrBlockExistingMembers() { + var user = users.saveAndFlush(User.create(UUID.randomUUID() + "@legal.test", "기존회원")); + assertThat(legal.catalog().published()).isFalse(); + assertThat(legal.accessAllowed(user)).isTrue(); + assertThat(legal.response(user).termsAccepted()).isFalse(); + assertThatThrownBy(() -> legal.accept(user.getId(), null)) + .isInstanceOfSatisfying(BusinessException.class, + error -> assertThat(error.getErrorCode()).isEqualTo(LegalError.UNAVAILABLE)); + assertThat(consents.count()).isZero(); + } +} diff --git a/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java b/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java new file mode 100644 index 0000000..b970a71 --- /dev/null +++ b/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java @@ -0,0 +1,179 @@ +package com.mongle.backend.domain.legal; +import com.mongle.backend.domain.user.entity.User; +import com.mongle.backend.domain.user.repository.UserRepository; +import com.mongle.backend.domain.user.service.UserService; +import com.mongle.backend.domain.auth.service.TokenService; +import com.mongle.backend.domain.credit.CreditService; +import com.mongle.backend.global.error.BusinessException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.*; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.jdbc.core.JdbcTemplate; +import java.net.*; +import java.net.http.*; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT,properties={ + "spring.datasource.url=jdbc:h2:mem:legal-consents;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE", + "mongle.legal.published=true","mongle.legal.enforce=true", + "mongle.legal.terms-digest=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mongle.legal.privacy-digest=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mongle.credit.enabled=true"}) +@ActiveProfiles("test") +public class LegalConsentIntegrationTest { + @Autowired org.springframework.transaction.support.TransactionTemplate transaction; + @Autowired LegalDocumentRepository documents; + @Autowired java.time.Clock authClock; + @Autowired LegalService legal; + @org.springframework.test.context.bean.override.mockito.MockitoSpyBean LegalConsentRepository consents; + @org.springframework.test.context.bean.override.mockito.MockitoSpyBean com.mongle.backend.domain.user.withdrawal.WithdrawalProperties withdrawalProperties; + @Autowired com.mongle.backend.domain.user.withdrawal.WithdrawalService withdrawals; + @Autowired com.mongle.backend.domain.user.withdrawal.WithdrawalRetention retention; + @Autowired UserRepository users; @Autowired UserService service; @Autowired TokenService tokens; + @Autowired CreditService credits; @Autowired JdbcTemplate jdbc; + @Value("${local.server.port}") int port; + @org.junit.jupiter.api.BeforeAll + static void seedDocumentRegistry(@Autowired JdbcTemplate jdbc) { + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + try(var tables=connection.getMetaData().getTables(connection.getCatalog(),null,"payment_credit_reclamations",null)) { + if(!tables.next()) new org.springframework.jdbc.datasource.init.ResourceDatabasePopulator( + new org.springframework.core.io.ClassPathResource("db/migration/V6__payment_credit_reclamations.sql")).populate(connection); + } + return null; + }); + for(var type:List.of("TERMS","PRIVACY")) { + String key=LegalDocument.key(type,"2026-11-07-v1"); + if(jdbc.queryForObject("select count(*) from legal_document_versions where id=?",Long.class,key)==0) + jdbc.update("insert into legal_document_versions(id,document_type,document_version) values(?,?,?)",key,type,"2026-11-07-v1"); + } + } + LegalService.Accept request(boolean terms,boolean privacy,boolean age) { + return new LegalService.Accept(LegalService.TERMS_VERSION,"a".repeat(64),terms, + LegalService.PRIVACY_VERSION,"b".repeat(64),privacy,age); + } + @Test void separateRequiredItemsAndExactVersionsAreStoredOnceUnderConcurrentRequests() throws Exception { + Long user=users.saveAndFlush(User.register(UUID.randomUUID()+"@legal.test")).getId(); + for(var invalid:List.of(request(false,true,true),request(true,false,true),request(true,true,false))) + assertThatThrownBy(()->legal.accept(user,invalid)).isInstanceOf(BusinessException.class); + var stale=new LegalService.Accept("old","a".repeat(64),true,LegalService.PRIVACY_VERSION,"b".repeat(64),true,true); + assertThatThrownBy(()->legal.accept(user,stale)).isInstanceOf(BusinessException.class); + assertThat(jdbc.queryForObject("select count(*) from legal_consents where user_id=?",Long.class,user)).isZero(); + try(var pool=Executors.newFixedThreadPool(3)) { + var calls=new ArrayList>(); + for(int i=0;i<3;i++) calls.add(pool.submit(()->legal.accept(user,request(true,true,true)))); + for(var call:calls) call.get(10,TimeUnit.SECONDS); + } + var before=jdbc.queryForList("select document_type,document_digest,accepted_at from legal_consents where user_id=? order by id",user); + legal.accept(user,request(true,true,true)); + assertThat(jdbc.queryForList("select document_type,document_digest,accepted_at from legal_consents where user_id=? order by id",user)).isEqualTo(before); + assertThat(before).hasSize(2); assertThat(service.getMe(user).termsAccepted()).isTrue(); + assertThat(service.completeOnboarding(user,"법적동의").termsAccepted()).isTrue(); + assertThat(credits.balance(user).total()).isEqualTo(3); + } + @Test void aSecondInstanceCannotRebindTextToAnAlreadyAcceptedVersion() { + Long first=users.saveAndFlush(User.register(UUID.randomUUID()+"@legal.test")).getId(); + legal.accept(first,request(true,true,true)); + Long second=users.saveAndFlush(User.register(UUID.randomUUID()+"@legal.test")).getId(); + var changed=new LegalService(new LegalProperties(true,true,"c".repeat(64),"b".repeat(64)),documents,consents,users,authClock); + var other=new LegalService.Accept(LegalService.TERMS_VERSION,"c".repeat(64),true, + LegalService.PRIVACY_VERSION,"b".repeat(64),true,true); + assertThatThrownBy(()->transaction.execute(status->changed.accept(second,other))) + .isInstanceOfSatisfying(BusinessException.class,e->assertThat(e.getErrorCode()).isEqualTo(LegalError.DOCUMENT_CONFLICT)); + assertThat(consents.existsByUserIdAndDocumentTypeAndDocumentVersion(second,"TERMS",LegalService.TERMS_VERSION)).isFalse(); + assertThat(documents.findById(LegalDocument.key("TERMS",LegalService.TERMS_VERSION)).orElseThrow().getDocumentDigest()).isEqualTo("a".repeat(64)); + assertThat(changed.current(users.findById(first).orElseThrow())).isFalse(); + assertThat(changed.response(users.findById(first).orElseThrow()).termsAccepted()).isFalse(); + } + @Test void failureRollsBackBothConsentsCachedStateAndDocumentBinding() { + Long user=users.saveAndFlush(User.register(UUID.randomUUID()+"@legal.test")).getId(); + jdbc.update("update legal_document_versions set document_digest=null"); + org.mockito.Mockito.doThrow(new IllegalStateException("simulated-storage-failure")) + .when(consents).save(org.mockito.ArgumentMatchers.any(LegalConsent.class)); + assertThatThrownBy(()->legal.accept(user,request(true,true,true))).isInstanceOf(IllegalStateException.class); + assertThat(jdbc.queryForObject("select count(*) from legal_consents where user_id=?",Long.class,user)).isZero(); + assertThat(users.findById(user).orElseThrow().getTermsVersion()).isNull(); + assertThat(jdbc.queryForObject("select count(*) from legal_document_versions where document_digest is not null",Long.class)).isZero(); + org.mockito.Mockito.reset(consents); + legal.accept(user,request(true,true,true)); + assertThat(service.getMe(user).termsAccepted()).isTrue(); + } + @Test void documentReadIsPublicAndReadOnlyWhileConsentPostRequiresAuthentication() throws Exception { + var before=jdbc.queryForList("select * from legal_document_versions order by id"); + try(var client=HttpClient.newHttpClient()) { + var metadata=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/legal/documents")).GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(metadata.statusCode()).isEqualTo(200); + assertThat(metadata.headers().firstValue("Cache-Control")).contains("no-store"); + var anonymous=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/users/me/agreements")) + .header("Content-Type","application/json").POST(HttpRequest.BodyPublishers.ofString("{}")).build(),HttpResponse.BodyHandlers.ofString()); + assertThat(anonymous.statusCode()).isEqualTo(401); + var spec=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/v3/api-docs")).GET().build(),HttpResponse.BodyHandlers.ofString()); + var paths=tools.jackson.databind.json.JsonMapper.builder().build().readTree(spec.body()).get("paths"); + var get=paths.get("/api/v1/legal/documents").get("get"); + assertThat(get.get("security")==null || get.get("security").isEmpty()).isTrue(); + assertThat(paths.get("/api/v1/users/me/agreements").get("post").get("security").toString()).contains("bearerAuth"); + assertThat(spec.body()).contains("LegalDocumentCatalog","LegalDocumentMetadata","LegalConsentRequest"); + } + assertThat(jdbc.queryForList("select * from legal_document_versions order by id")).isEqualTo(before); + } + @Test void unpaidWithdrawalDeletesConsentAndFinancialTombstoneRetainsUntilPurge() { + org.mockito.Mockito.doReturn(true).when(withdrawalProperties).enabled(); + Long unpaid=users.saveAndFlush(User.create(UUID.randomUUID()+"@legal.test","일반회원")).getId(); + legal.accept(unpaid,request(true,true,true)); + withdrawals.withdraw(unpaid); + assertThat(jdbc.queryForObject("select count(*) from legal_consents where user_id=?",Long.class,unpaid)).isZero(); + assertThat(legal.current(users.findById(unpaid).orElseThrow())).isFalse(); + Long financial=users.saveAndFlush(User.create(UUID.randomUUID()+"@legal.test","거래회원")).getId(); + legal.accept(financial,request(true,true,true)); + jdbc.update(""" + insert into payment_orders(public_id,user_id,request_id,input_hash,notification_token,product_id,product_name, + price,currency,credits,state,request_deadline,version,created_at,updated_at) + values(?,?,?,REPEAT('a',64),?,'test-pack','test pack',1100,'KRW',10,'CANCELLED',CURRENT_TIMESTAMP,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """,UUID.randomUUID().toString(),financial,UUID.randomUUID().toString(),UUID.randomUUID().toString()); + withdrawals.withdraw(financial); + assertThat(jdbc.queryForObject("select count(*) from legal_consents where user_id=?",Long.class,financial)).isEqualTo(2); + assertThat(retention.purge(financial)).isFalse(); + jdbc.update("update payment_orders set updated_at=? where user_id=?",java.time.LocalDateTime.now().minusYears(6),financial); + jdbc.update("update users set retention_until=? where id=?",java.time.LocalDateTime.now().minusDays(1),financial); + assertThat(retention.purge(financial)).isTrue(); + assertThat(jdbc.queryForObject("select count(*) from legal_consents where user_id=?",Long.class,financial)).isZero(); + } + @Test void existingMemberMustConsentButCanReadBalanceAndHistoryWithoutIt() throws Exception { + Long user=users.saveAndFlush(User.create(UUID.randomUUID()+"@legal.test","기존회원")).getId(); + assertThat(tokens.login(user).response().user().termsAccepted()).isFalse(); + try(var client=HttpClient.newHttpClient()) { + String token=tokens.login(user).response().accessToken(); + var blocked=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/archives")) + .header("Authorization","Bearer "+token).GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(blocked.statusCode()).isEqualTo(403); assertThat(blocked.body()).contains("LEGAL_403_1"); + var paidRights=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/payments/orders")) + .header("Authorization","Bearer "+token).GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(paidRights.statusCode()).isEqualTo(200); + var withdrawal=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/users/me")) + .header("Authorization","Bearer "+token).DELETE().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(withdrawal.statusCode()).isEqualTo(503); // feature flag, not missing agreement + assertThat(withdrawal.body()).contains("USER_503_1"); + var balance=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/credits/balance")) + .header("Authorization","Bearer "+token).GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(balance.statusCode()).isEqualTo(200); + var documents=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/legal/documents")) + .GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(documents.statusCode()).isEqualTo(200); assertThat(documents.body()).contains("TERMS","PRIVACY"); + var body=""" + {"termsVersion":"2026-11-07-v1","termsDigest":"%s","termsAccepted":true, + "privacyVersion":"2026-11-07-v1","privacyDigest":"%s","privacyAccepted":true,"age14OrOlder":true} + """.formatted("a".repeat(64),"b".repeat(64)); + var accepted=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/users/me/agreements")) + .header("Authorization","Bearer "+token).header("Content-Type","application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + assertThat(accepted.statusCode()).isEqualTo(200); assertThat(accepted.body()).contains("\"termsAccepted\":true"); + var productResponse=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/payments/products")) + .header("Authorization","Bearer "+token).GET().build(),HttpResponse.BodyHandlers.ofString()); + assertThat(productResponse.statusCode()).isEqualTo(200); + assertThat(tokens.refresh(tokens.login(user).refreshToken()).response().user().termsAccepted()).isTrue(); + } + assertThat(credits.balance(user).total()).isZero(); + } +} From 613190b63757553f53665282a823d1429f1f90eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=ED=98=84=EC=84=B1?= Date: Sat, 7 Nov 2026 19:44:25 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EC=95=BD=EA=B4=80=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=EA=B3=84=EC=95=BD=EA=B3=BC=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=A0=81=20=EA=B3=B5=EA=B0=9C=C2=B7=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=A0=88=EC=B0=A8=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + docs/frontend-integration.md | 11 ++ docs/legal-consents.md | 130 ++++++++++++++++++ docs/user-withdrawal.md | 7 + .../legal/LegalConsentIntegrationTest.java | 2 +- 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 docs/legal-consents.md diff --git a/README.md b/README.md index 46d2f83..700fba7 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,5 @@ MySQL 8.4 / RDS TLS, existing-schema upgrade and native regression instructions: [프론트 연동 전달본](docs/frontend-integration.md)에 현재 API·인증·상태 처리와 미구현 범위를 구분했다. [회원탈퇴 문서](docs/user-withdrawal.md)에 탈퇴 API, 유료·무료 잔액 구분, 카카오/이미지 후속 작업, 거래·가입 보상 기록 보존 및 활성화 조건을 정리했다. + +[필수 문서 동의](docs/legal-consents.md)에 API·기존 회원 재동의·버전/해시·단계적 활성화 방법을 정리했습니다. diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index c82f4a9..157ab58 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -252,6 +252,17 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. - 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 법정 환불 접수·운영 취소 처리 경로는 별도 준비 중이며 일괄 환불 불가를 확정한 것은 아니다. +## 필수 문서 동의 (#128) + +GET /api/v1/legal/documents는 공개 상태와 필수 문서 두 항목의 버전·해시를 반환합니다. +POST /api/v1/users/me/agreements는 온보딩 전/기존 회원 모두 Bearer 인증으로 접수할 수 있습니다. +로그인·Refresh·GET /users/me 응답의 termsAccepted는 최신 두 문서의 버전·해시를 모두 동의했는지 나타냅니다. +onboardingCompleted는 기존 닉네임 온보딩 상태로 유지합니다. 기존 회원의 동의를 자동으로 채우지 않습니다. + +현재 코드의 공개/필수 적용은 모두 기본 false입니다. 문안과 프론트 버전·해시가 맞기 전에 활성화하지 않습니다. +미동의 재안내·요청 본문·오류·거래/탈퇴 예외·배포 순서는 [필수 문서 동의 계약](legal-consents.md)을 따릅니다. +이 PR은 실제 문안 게시, 환불 API, 무료 우선 사용 변경을 포함하지 않습니다. + ## 회원탈퇴 (#126) `DELETE /api/v1/users/me`는 Bearer 인증으로 호출하며 닉네임 온보딩 전에도 가능합니다. diff --git a/docs/legal-consents.md b/docs/legal-consents.md new file mode 100644 index 0000000..03cb89a --- /dev/null +++ b/docs/legal-consents.md @@ -0,0 +1,130 @@ +# 필수 약관 동의 및 기존 회원 확인 (#128) + +## 범위 +이용약관(TERMS)과 필수 개인정보 수집·이용 고지(PRIVACY)의 버전·SHA-256·동의 시각을 각각 저장한다. +개인정보 처리방침 전체에 대한 포괄 동의나 선택 마케팅 동의를 추가한 기능은 아니다. +문서 본문 작성·사업자 정보 확정·프론트 화면 배포는 협업 범위다. 이 API는 본문을 제공하지 않는다. +결제/환불·무료 우선 사용·회원탈퇴 자체의 정책 변경은 이 PR에 포함하지 않는다. + +## API +모든 응답은 기존 ApiResponse 봉투와 Cache-Control:no-store를 사용한다. + +| 메서드·경로 | 인증 | 역할 | +| --- | --- | --- | +| GET /api/v1/legal/documents | 공개 | 공개 상태와 필수 문서 2개의 버전·해시 | +| POST /api/v1/users/me/agreements | Bearer, 온보딩 전에도 가능 | 인증된 본인의 두 필수 동의 접수 | + +문서 조회 예시(data 내부): +```json +{ + "published": true, + "documents": [ + {"type":"TERMS","version":"2026-11-07-v1","digest":"<실제 문서 SHA-256 64자리>","required":true}, + {"type":"PRIVACY","version":"2026-11-07-v1","digest":"<실제 문서 SHA-256 64자리>","required":true} + ] +} +``` +published=false이면 동의 기능이 공개되지 않은 상태다. 해시는 미설정으로 빈 문자열일 수 있다. +조회는 문서·회원·동의 기록을 만들거나 변경하지 않는다. + +동의 요청 예시: +```json +{ + "termsVersion": "2026-11-07-v1", + "termsDigest": "<화면에 표시한 이용약관의 실제 SHA-256>", + "termsAccepted": true, + "privacyVersion": "2026-11-07-v1", + "privacyDigest": "<화면에 표시한 필수 수집·이용 고지의 실제 SHA-256>", + "privacyAccepted": true, + "age14OrOlder": true +} +``` +각 hash는 소문자 16진수 64자리다. 예시의 꺾쇠 문구를 그대로 전송하지 않는다. +회원 ID·동의 시각을 클라이언트에서 받지 않는다. JWT의 본인 ID와 서버 UTC 시각을 사용한다. +두 필수 동의와 연령 확인은 실제 화면 확인 후에만 true로 보낸다. 초기값을 true로 고정하지 않는다. +기존 필수 체크 두 항목을 유지하려면 이용약관 확인 문구에 만14세 이상 확인을 명시하는 등 실제 확인 흐름과 맞춘다. +age14OrOlder는 자기 확인 기록이며 생년월일 수집이나 본인인증 결과를 뜻하지 않는다. + +성공 data는 UserResponse다. 기존 userId/email/nickname/onboardingCompleted에 termsAccepted를 추가한다. +여기서 응답 termsAccepted는 현재 공개된 두 문서의 버전·해시를 모두 확인한 결과다. +로그인·Refresh·내 정보·닉네임·온보딩·동의 응답에서 동일하게 계산한다. +온보딩 여부를 동의 여부로 대체하거나 기존 회원을 임의로 동의 완료 처리하지 않는다. + +## 프론트 흐름 +1. 실제 화면에 표시할 두 문서를 버전별 UTF-8 원본 파일로 고정하고 해시를 계산한다. +2. GET /legal/documents의 공개 상태를 확인한다. published=false에서 새 동의 API가 준비됐다고 가정하지 않는다. +3. 백엔드 메타데이터와 프론트에 포함한 원본 문서 버전·해시가 일치할 때만 그 문서의 동의를 접수한다. + 기존 문구를 표시하면서 서버가 준 해시만 복사해 전송하면 안 된다. +4. 카카오 복귀 후 Refresh 또는 GET /users/me의 termsAccepted를 확인한다. + 미동의면 동의 화면, 동의 후 onboardingCompleted=false이면 기존 닉네임 온보딩으로 이동한다. + 기존 회원은 동의만 접수하고 닉네임/가입 보상 처리를 다시 실행하지 않는다. +5. POST 성공 응답으로 회원 상태를 갱신한다. 같은 Access Token도 다음 요청부터 현재 DB 동의 상태가 반영된다. +6. 409이면 최신 문서를 다시 확인한다. 503이면 반복 동의/자동 true 처리를 하지 않고 준비 중 안내로 전환한다. + +| 오류 | 의미 | +| --- | --- | +| LEGAL_400_1 | 두 필수 동의 또는 만14세 이상 확인이 누락/false | +| LEGAL_403_1 | 동의 필수 적용 상태에서 일반 기능·온보딩 사용 전 최신 동의 필요 | +| LEGAL_409_1 | 요청 버전·해시가 현재 공개 설정과 다름 | +| LEGAL_503_1 | 문서 공개 전 또는 필요한 문서 버전 행이 준비되지 않음 | +| LEGAL_503_2 | 같은 문서 버전에 다른 해시를 적용하려는 운영 설정 충돌 | + +잘못된 JSON/인증 오류에는 기존 공통 오류 계약이 적용된다. +공개 GET은 인증 없이 호출할 수 있으며, 잘못된 Bearer 헤더를 일부러 보내면 기존 인증 필터가 거절할 수 있다. + +## 접근 권한 +MONGLE_LEGAL_ENFORCE=true일 때 현재 동의가 없는 회원은 일반 ONBOARDED 기능을 사용할 수 없다. +신규 온보딩도 같은 조건을 서버에서 검증한다. +내 정보·동의 접수·회원탈퇴는 USER 권한으로 계속 접근할 수 있다. +주문 내역/상태·잔액/원장 확인과 후속 환불 접수/조회 경로는 동의 미완료만으로 막지 않는다. +환불 경로의 권한 예외는 향후 구현을 위한 계약이며 환불 API가 이 PR에서 구현됐다는 의미는 아니다. +외부 결제 통보의 별도 인증과 공개 범위는 유지한다. +기본 enforce=false에서는 기존 회원/온보딩 흐름을 차단하지 않는다. + +## 저장·동시성·보존 +V8__legal_consents.sql은 기존 사용자 열을 nullable로 확장하고 두 테이블을 추가한다. +- legal_document_versions: 회원 식별정보를 저장하지 않는 문서 종류·버전·내용 해시. +- legal_consents: 내부 회원 ID, 문서 종류·버전·해시, 서버 UTC 동의 시각, 만14세 이상 확인. + +이전 회원의 동의 값을 SQL로 채우지 않으며 기존 잔액·콘텐츠·온보딩은 보존한다. +문서 버전 행은 마이그레이션으로 준비하고 최초 동의 트랜잭션이 해시를 결합한다. +일단 결합한 버전의 해시는 바꾸지 않는다. 다른 해시의 동시 요청은 하나의 문서 버전에 섞여 저장되지 않는다. +회원 행 → TERMS 문서 행 → PRIVACY 문서 행 순서로 잠그고 두 동의와 회원 요약 상태를 같은 트랜잭션으로 저장한다. +같은 회원/문서/버전은 UNIQUE로 보호하며 재요청은 최초 시각을 유지한다. +문서 결합·동의 한 항목·회원 상태 중 저장 실패가 나면 전체를 롤백한다. + +거래 기록이 없는 탈퇴 계정은 동의 기록을 삭제한다. +거래 기록 보존이 필요한 탈퇴 계정은 기존 정산 표식과 함께 별도 보존하고 만료 파기에서 삭제한다. +문서 버전 해시는 회원별 동의 기록과 구분하며 회원 탈퇴와 함께 덮어쓰거나 지우지 않는다. +구체적 접근/보관·파기 절차는 기존 회원탈퇴·거래 보존 문서와 처리방침에 맞춘다. + +## 설정·배포 순서 +| 환경변수 | 기본값 | 설명 | +| --- | --- | --- | +| MONGLE_LEGAL_PUBLISHED | false | 두 필수 문서의 공개/동의 접수 준비 상태 | +| MONGLE_LEGAL_ENFORCE | false | 일반 기능·온보딩의 최신 동의 필수 적용 | +| MONGLE_LEGAL_TERMS_DIGEST | 빈 값 | 확정 이용약관 UTF-8 원본의 SHA-256 | +| MONGLE_LEGAL_PRIVACY_DIGEST | 빈 값 | 확정 필수 수집·이용 고지 UTF-8 원본의 SHA-256 | + +enforce=true/published=false 또는 공개 상태에서 잘못된 해시 설정은 시작 시 거절한다. +문서 해시는 공개 본문 식별값이며 결제/JWT 비밀키가 아니다. + +1. 기존 CD/Flyway 절차로 V8을 적용하고 기본 두 플래그 false로 배포한다. V1~V7/이력은 수정하거나 재실행하지 않는다. +2. 실제 사업자·문의 정보와 정책에 맞는 두 고지 원본을 확정한다. 서로 다른 문서를 같은 내용으로 임의 합치지 않는다. +3. 프론트가 같은 원본/버전/해시를 사용하도록 배포한다. 예시 해시를 운영에 넣지 않는다. +4. 두 실제 해시와 published=true, enforce=false로 동의 저장/신규·기존 회원 흐름을 검증한다. +5. 프론트 연동이 확인된 뒤 enforce=true를 적용하고 미동의 접근·거래/탈퇴 예외를 검증한다. +6. 프론트 연동 장애 시 enforce=false로 되돌릴 수 있다. 기존 동의 기록을 지우거나 임의 동의로 채우지 않는다. + +원본은 줄바꿈 LF·UTF-8 등 정한 형태로 고정한 뒤, 예를 들어 shasum -a 256 명령으로 계산한다. +화면용 HTML/JSX 전체를 임의로 해싱하거나 원본을 재입력해 해시를 바꾸지 않는다. +문서 내용 변경은 코드의 버전·신규 문서 행 마이그레이션·해시·프론트 문구를 함께 갱신한다. +기존 행/해시를 UPDATE하거나 이미 적용한 V8을 고쳐 해결하지 않는다. +동의 기록이 생긴 뒤 구버전 JAR로 수동 롤백하면 그 버전의 탈퇴·파기 경로가 새 동의 테이블을 처리하지 못할 수 있다. +프론트 연동 문제는 우선 enforce=false로 완화하고, 저장을 시작한 이후의 앱 롤백은 동의 기록 정리 호환성을 확인한다. +이 작업은 운영 환경 파일·문서 게시·실제 회원 계정 변경을 직접 실행하지 않는다. + +## 검증 +H2와 임시 MySQL 8.4에서 동일한 동의·권한·동시성·롤백·탈퇴 보존 계약을 실행한다. +V7→V8 업그레이드, 이전 체크섬·회원 데이터 보존, 기존 회원 미동의와 새 테이블 초기 상태를 별도 검증한다. +프론트 실제 문구·사업자 정보·운영 설정 적용은 코드 테스트와 별도다. diff --git a/docs/user-withdrawal.md b/docs/user-withdrawal.md index 64105c7..a8e3f91 100644 --- a/docs/user-withdrawal.md +++ b/docs/user-withdrawal.md @@ -102,3 +102,10 @@ WithdrawalRetention은 만료 후보를 소량 조회한 뒤 사용자별 잠금 비교 기록을 일괄 파기한다. 일반 HTTP API로 노출하지 않으며 새 혜택으로 임의 재사용하지 않는다. 가입 보상 식별 기록은 법정 거래 보존과 별개다. 목적·항목·기간·근거/필요성을 개인정보 처리방침에 반영하고, HMAC을 익명정보로 표시하지 않는다. + +## 동의 기록 연동 (#128) + +V8에서 문서별 동의 기록과 회원의 요약 상태를 추가합니다. 거래기록이 없는 탈퇴 계정의 동의는 삭제하고, +거래 관련 보존이 필요한 경우 정산 기록과 함께 보관한 후 WithdrawalRetention 만료 파기에서 제거합니다. +문서 버전 레지스트리는 개인정보 없는 본문 해시 자료이며 개인 동의 기록과 구분합니다. +동의 미완료만으로 탈퇴·금융 확인 경로를 막지 않습니다. [동의 저장·배포 계약](legal-consents.md)을 참고합니다. diff --git a/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java b/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java index b970a71..87f0a13 100644 --- a/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/legal/LegalConsentIntegrationTest.java @@ -161,7 +161,7 @@ insert into payment_orders(public_id,user_id,request_id,input_hash,notification_ var documents=client.send(HttpRequest.newBuilder(URI.create("http://localhost:"+port+"/api/v1/legal/documents")) .GET().build(),HttpResponse.BodyHandlers.ofString()); assertThat(documents.statusCode()).isEqualTo(200); assertThat(documents.body()).contains("TERMS","PRIVACY"); - var body=""" + var body=""" {"termsVersion":"2026-11-07-v1","termsDigest":"%s","termsAccepted":true, "privacyVersion":"2026-11-07-v1","privacyDigest":"%s","privacyAccepted":true,"age14OrOlder":true} """.formatted("a".repeat(64),"b".repeat(64));