fix(content): 성인 콘텐츠 설정 동기화와 국가별 인증 분기를 적용한다

This commit is contained in:
Yu Sung
2026-03-27 17:34:02 +09:00
parent 44daabdcae
commit 1d120b58bd
24 changed files with 1029 additions and 133 deletions

View File

@@ -74,6 +74,7 @@ class AppState: ObservableObject {
@Published var isShowErrorPopup = false
@Published var errorMessage = ""
@Published private var pendingContentSettingsGuideMessage: String? = nil
@Published var liveDetailSheet: LiveDetailSheetState? = nil
private func syncStepWithNavigationPath() {
@@ -181,6 +182,16 @@ class AppState: ObservableObject {
pendingCommunityCommentPostId = 0
}
func setPendingContentSettingsGuideMessage(_ message: String) {
pendingContentSettingsGuideMessage = message
}
func consumePendingContentSettingsGuideMessage() -> String? {
let message = pendingContentSettingsGuideMessage
pendingContentSettingsGuideMessage = nil
return message
}
// ( -> ) UI
func softRestart() {
isRestartApp = true

View File

@@ -75,6 +75,9 @@ final class AppViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true
}

View File

@@ -41,7 +41,14 @@ struct ChatTabView: View {
AppState.shared.setAppStep(step: .login)
return
}
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = {
AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId))
@@ -49,9 +56,21 @@ struct ChatTabView: View {
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
moveToContentSettingsWithGuideToast()
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
}
private func moveToContentSettingsWithGuideToast() {
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
AppState.shared.setAppStep(step: .contentViewSettings)
}
private func handleCharacterSelection() {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {

View File

@@ -25,6 +25,7 @@ enum UserDefaultsKey: String, CaseIterable {
case notShowingEventPopupId
case isAdultContentVisible
case contentPreference
case countryCode
case isAuditionNotification
case searchChannel
case marketingPid
@@ -73,7 +74,7 @@ extension UserDefaults {
static func isAdultContentVisible() -> Bool {
let key = UserDefaultsKey.isAdultContentVisible.rawValue
return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : true
return UserDefaults.standard.object(forKey: key) != nil ? bool(forKey: .isAdultContentVisible) : false
}
static func reset() {

View File

@@ -44,7 +44,14 @@ struct HomeTabView: View {
AppState.shared.setAppStep(step: .login)
return
}
if auth == false {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = {
AppState.shared
.setAppStep(step: .characterDetail(characterId: characterId))
@@ -52,6 +59,15 @@ struct HomeTabView: View {
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
AppState.shared.setAppStep(step: .characterDetail(characterId: characterId))
}

View File

@@ -408,6 +408,30 @@ enum I18n {
//
static var alertTitle: String { pick(ko: "알림", en: "Notice", ja: "お知らせ") }
static var adultContentAgeCheckTitle: String {
pick(
ko: "당신은 18세 이상입니까?",
en: "Are you over 18 years old?",
ja: "あなたは18歳以上ですか"
)
}
static var adultContentAgeCheckDesc: String {
pick(
ko: "해당 콘텐츠는 18세 이상만 이용이 가능합니다!",
en: "This content is available only to users aged 18 and over!",
ja: "このコンテンツは18歳以上のみ利用可能です"
)
}
static var adultContentEnableGuide: String {
pick(
ko: "민감한 콘텐츠를 보려면 콘텐츠 보기 설정에서 민감한 콘텐츠 보기를 켜주세요.",
en: "To view sensitive content, turn on Sensitive Content in Content View Settings.",
ja: "センシティブなコンテンツを表示するには、コンテンツ表示設定でセンシティブなコンテンツ表示をオンにしてください。"
)
}
//
static var logoutQuestion: String {
pick(

View File

@@ -169,11 +169,29 @@ struct LiveNowAllView: View {
AppState.shared.setAppStep(step: .login)
return
}
if isAdult && auth == false {
if isAdult {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
}
openLiveDetail(roomId: roomId)
}

View File

@@ -64,7 +64,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
@Published var isShowUserProfilePopup = false
@Published var changeIsAdult = false {
didSet {
if changeIsAdult && !UserDefaults.bool(forKey: .auth) {
if changeIsAdult && requiresAdultAuthenticationByCountry() {
agora.speakerMute(true)
}
}
@@ -653,7 +653,7 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
getTotalDonationCan()
getTotalHeartCount()
if data.isAdult && !UserDefaults.bool(forKey: .auth) {
if data.isAdult && requiresAdultAuthenticationByCountry() {
changeIsAdult = true
}
@@ -697,6 +697,16 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
}
}
private func requiresAdultAuthenticationByCountry() -> Bool {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
return isKoreanCountry && !UserDefaults.bool(forKey: .auth)
}
func toggleMute() {
setMute(!isMute)
}

View File

@@ -472,11 +472,29 @@ struct HomeView: View {
AppState.shared.setAppStep(step: .login)
return
}
if isAdult && auth == false {
if isAdult {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
if isKoreanCountry && auth == false {
pendingAction = { openLiveDetail(roomId: roomId) }
isShowAuthConfirmView = true
return
}
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
return
}
}
openLiveDetail(roomId: roomId)
}

View File

@@ -59,6 +59,9 @@ final class HomeViewModel: ObservableObject {
UserDefaults.set(data.isAuth, forKey: .auth)
UserDefaults.set(data.role.rawValue, forKey: .role)
UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification)
UserDefaults.set(data.countryCode, forKey: .countryCode)
UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(data.contentType, forKey: .contentPreference)
if data.followingChannelLiveNotice == nil && data.followingChannelUploadContentNotice == nil && data.messageNotice == nil {
AppState.shared.isShowNotificationSettingsDialog = true
}

View File

@@ -21,6 +21,12 @@ struct MyPageView: View {
@AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token)
var body: some View {
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"
BaseView(isLoading: $viewModel.isLoading) {
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
viewModel.isShowAuthView {
@@ -114,6 +120,7 @@ struct MyPageView: View {
CategoryButtonsView(
isShowAuthView: $viewModel.isShowAuthView,
isAuthenticated: viewModel.isAuth,
isKoreanCountry: isKoreanCountry,
showMessage: {
viewModel.errorMessage = $0
viewModel.isShowPopup = true
@@ -381,6 +388,7 @@ struct CategoryButtonsView: View {
@Binding var isShowAuthView: Bool
let isAuthenticated: Bool
let isKoreanCountry: Bool
let showMessage: (String) -> Void
let refresh: () -> Void
@@ -420,6 +428,7 @@ struct CategoryButtonsView: View {
AppState.shared.setAppStep(step: .serviceCenter)
}
if isKoreanCountry {
CategoryButtonItem(
icon: "ic_my_auth",
title: isAuthenticated ? "인증완료" : "본인인증"
@@ -430,6 +439,7 @@ struct CategoryButtonsView: View {
}
}
}
}
}
struct CategoryButtonItem: View {

View File

@@ -10,8 +10,10 @@ import SwiftUI
struct ContentSettingsView: View {
@StateObject var viewModel = ContentSettingsViewModel()
@ObservedObject private var appState = AppState.shared
var body: some View {
BaseView(isLoading: $viewModel.isLoading) {
ZStack {
Color.black.ignoresSafeArea()
@@ -37,7 +39,7 @@ struct ContentSettingsView: View {
.resizable()
.frame(width: 44, height: 27)
.onTapGesture {
viewModel.isAdultContentVisible.toggle()
viewModel.handleAdultContentToggleTap()
}
}
.frame(height: 50)
@@ -118,6 +120,29 @@ struct ContentSettingsView: View {
.padding(.horizontal, 13.3)
}
}
if viewModel.isShowAdultContentAgeCheckDialog {
SodaDialog(
title: I18n.Settings.adultContentAgeCheckTitle,
desc: I18n.Settings.adultContentAgeCheckDesc,
confirmButtonTitle: I18n.Common.yes,
confirmButtonAction: {
viewModel.confirmAdultContentAgeCheck()
},
cancelButtonTitle: I18n.Common.no,
cancelButtonAction: {
viewModel.cancelAdultContentAgeCheck()
}
)
}
}
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
.onAppear {
if let pendingGuideMessage = appState.consumePendingContentSettingsGuideMessage() {
viewModel.errorMessage = pendingGuideMessage
viewModel.isShowPopup = true
}
}
}
}

View File

@@ -6,28 +6,183 @@
//
import Foundation
import Combine
final class ContentSettingsViewModel: ObservableObject {
@Published var isAdultContentVisible = UserDefaults.isAdultContentVisible() {
didSet {
if oldValue != isAdultContentVisible {
UserDefaults.set(isAdultContentVisible, forKey: .isAdultContentVisible)
AppState.shared.isRestartApp = true
private let userRepository = UserRepository()
private var subscription = Set<AnyCancellable>()
private let contentPreferenceSubject = PassthroughSubject<ContentPreferenceState, Never>()
private var latestRequestToken = UUID()
private var isApplyingServerState = false
private var lastSyncedState: ContentPreferenceState
if !isAdultContentVisible {
adultContentPreference = .ALL
UserDefaults.set(ContentType.ALL.rawValue, forKey: .contentPreference)
@Published var isLoading = false
@Published var errorMessage = ""
@Published var isShowPopup = false
@Published var isShowAdultContentAgeCheckDialog = false
@Published var isAdultContentVisible: Bool
@Published var adultContentPreference: ContentType
init() {
let isAdultContentVisible = UserDefaults.isAdultContentVisible()
let contentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? .ALL
let initialState = ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? contentPreference : .ALL
)
_isAdultContentVisible = Published(initialValue: isAdultContentVisible)
_adultContentPreference = Published(initialValue: isAdultContentVisible ? contentPreference : .ALL)
lastSyncedState = initialState
bindContentPreference()
}
private func bindContentPreference() {
$isAdultContentVisible
.dropFirst()
.removeDuplicates()
.sink { [weak self] isAdultContentVisible in
guard let self = self else { return }
if self.isApplyingServerState { return }
if !isAdultContentVisible && self.adultContentPreference != .ALL {
self.adultContentPreference = .ALL
}
}
.store(in: &subscription)
Publishers.CombineLatest($isAdultContentVisible, $adultContentPreference)
.map { isAdultContentVisible, adultContentPreference in
ContentPreferenceState(
isAdultContentVisible: isAdultContentVisible,
contentType: isAdultContentVisible ? adultContentPreference : .ALL
)
}
.removeDuplicates()
.dropFirst()
.sink { [weak self] state in
guard let self = self else { return }
if self.isApplyingServerState { return }
self.applyLocalState(state)
self.contentPreferenceSubject.send(state)
}
.store(in: &subscription)
contentPreferenceSubject
.removeDuplicates()
.debounce(for: .seconds(0.3), scheduler: RunLoop.main)
.sink { [weak self] state in
self?.updateContentPreference(state: state)
}
.store(in: &subscription)
}
private func updateContentPreference(state: ContentPreferenceState) {
let request = makeUpdateContentPreferenceRequest(from: lastSyncedState, to: state)
if request.isEmpty {
return
}
let requestToken = UUID()
latestRequestToken = requestToken
isLoading = true
userRepository
.updateContentPreference(
request: request
)
.sink { [weak self] result in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self = self else { return }
guard self.latestRequestToken == requestToken else { return }
do {
let decoded = try JSONDecoder().decode(ApiResponse<UpdateContentPreferenceResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
let serverState = ContentPreferenceState(
isAdultContentVisible: data.isAdultContentVisible,
contentType: data.isAdultContentVisible ? data.contentType : .ALL
)
self.applyServerState(serverState)
} else {
self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true
}
} catch {
self.errorMessage = I18n.Common.commonError
self.isShowPopup = true
}
self.isLoading = false
}
.store(in: &subscription)
}
private func applyLocalState(_ state: ContentPreferenceState) {
UserDefaults.set(state.isAdultContentVisible, forKey: .isAdultContentVisible)
UserDefaults.set(state.contentType.rawValue, forKey: .contentPreference)
AppState.shared.isRestartApp = true
}
private func applyServerState(_ state: ContentPreferenceState) {
isApplyingServerState = true
isAdultContentVisible = state.isAdultContentVisible
adultContentPreference = state.contentType
applyLocalState(state)
lastSyncedState = state
isApplyingServerState = false
}
private func makeUpdateContentPreferenceRequest(from previousState: ContentPreferenceState, to currentState: ContentPreferenceState) -> UpdateContentPreferenceRequest {
let isAdultContentVisible = previousState.isAdultContentVisible != currentState.isAdultContentVisible
? currentState.isAdultContentVisible
: nil
let contentType = previousState.contentType != currentState.contentType
? currentState.contentType
: nil
return UpdateContentPreferenceRequest(
isAdultContentVisible: isAdultContentVisible,
contentType: contentType
)
}
func handleAdultContentToggleTap() {
if isAdultContentVisible {
isAdultContentVisible = false
} else {
isShowAdultContentAgeCheckDialog = true
}
}
@Published var adultContentPreference = ContentType(rawValue: UserDefaults.string(forKey: .contentPreference)) ?? ContentType.ALL {
didSet {
if oldValue != adultContentPreference {
UserDefaults.set(adultContentPreference.rawValue, forKey: .contentPreference)
AppState.shared.isRestartApp = true
}
func confirmAdultContentAgeCheck() {
isShowAdultContentAgeCheckDialog = false
isAdultContentVisible = true
}
func cancelAdultContentAgeCheck() {
isShowAdultContentAgeCheckDialog = false
}
}
private struct ContentPreferenceState: Equatable {
let isAdultContentVisible: Bool
let contentType: ContentType
}

View File

@@ -0,0 +1,10 @@
import Foundation
struct UpdateContentPreferenceRequest: Encodable {
let isAdultContentVisible: Bool?
let contentType: ContentType?
var isEmpty: Bool {
return isAdultContentVisible == nil && contentType == nil
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
struct UpdateContentPreferenceResponse: Decodable {
let isAdultContentVisible: Bool
let contentType: ContentType
enum CodingKeys: String, CodingKey {
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ContentType(rawValue: rawContentType ?? "") ?? .ALL
}
}

View File

@@ -23,4 +23,55 @@ struct GetMemberInfoResponse: Decodable {
let followingChannelLiveNotice: Bool?
let followingChannelUploadContentNotice: Bool?
let auditionNotice: Bool?
let countryCode: String
let isAdultContentVisible: Bool
let contentType: String
enum CodingKeys: String, CodingKey {
case can
case point
case isAuth
case gender
case signupDate
case chargeCount
case role
case messageNotice
case followingChannelLiveNotice
case followingChannelUploadContentNotice
case auditionNotice
case countryCode
case isAdultContentVisible
case contentType
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
can = try container.decode(Int.self, forKey: .can)
point = try container.decode(Int.self, forKey: .point)
isAuth = try container.decode(Bool.self, forKey: .isAuth)
gender = try container.decodeIfPresent(String.self, forKey: .gender)
signupDate = try container.decode(String.self, forKey: .signupDate)
chargeCount = try container.decode(Int.self, forKey: .chargeCount)
role = try container.decode(MemberRole.self, forKey: .role)
messageNotice = try container.decodeIfPresent(Bool.self, forKey: .messageNotice)
followingChannelLiveNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelLiveNotice)
followingChannelUploadContentNotice = try container.decodeIfPresent(Bool.self, forKey: .followingChannelUploadContentNotice)
auditionNotice = try container.decodeIfPresent(Bool.self, forKey: .auditionNotice)
countryCode =
try container
.decodeIfPresent(String.self, forKey: .countryCode)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased() ?? ""
isAdultContentVisible = try container.decodeIfPresent(Bool.self, forKey: .isAdultContentVisible) ?? true
let rawContentType =
try container
.decodeIfPresent(String.self, forKey: .contentType)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
contentType = ["ALL", "MALE", "FEMALE"].contains(rawContentType ?? "") ? (rawContentType ?? "ALL") : "ALL"
}
}

View File

@@ -16,6 +16,12 @@ struct SettingsView: View {
var body: some View {
let cardWidth = screenSize().width - 26.7
let isAuth = UserDefaults.bool(forKey: .auth)
let normalizedCountryCode = UserDefaults
.string(forKey: .countryCode)
.trimmingCharacters(in: .whitespacesAndNewlines)
.uppercased()
let isNonKoreanCountry = !normalizedCountryCode.isEmpty && normalizedCountryCode != "KR"
BaseView(isLoading: $viewModel.isLoading) {
GeometryReader { geo in
@@ -65,7 +71,7 @@ struct SettingsView: View {
AppState.shared.setAppStep(step: .languageSettings)
}
if UserDefaults.bool(forKey: .auth) {
if isAuth || isNonKoreanCountry {
Rectangle()
.frame(width: cardWidth - 26.7, height: 0.3)
.foregroundColor(Color.gray90)

View File

@@ -19,6 +19,7 @@ enum UserApi {
case searchUser(nickname: String)
case getMypage
case getMemberInfo
case updateContentPreference(request: UpdateContentPreferenceRequest)
case notification(request: UpdateNotificationSettingRequest)
case logout
case logoutAllDevice
@@ -78,6 +79,9 @@ extension UserApi: TargetType {
case .getMemberInfo:
return "/member/info"
case .updateContentPreference:
return "/member/content-preference"
case .notification:
return "/member/notification"
@@ -143,6 +147,9 @@ extension UserApi: TargetType {
case .searchUser, .getMypage, .getMemberInfo, .getMyProfile, .getChangeNicknamePrice, .checkNickname, .getBlockedMemberList, .getBlockedMemberIdList, .getMemberProfile:
return .get
case .updateContentPreference:
return .patch
case .updatePushToken, .profileUpdate, .changeNickname, .updateIdfa, .updateMarketingInfo:
return .put
}
@@ -183,6 +190,9 @@ extension UserApi: TargetType {
case .notification(let request):
return .requestJSONEncodable(request)
case .updateContentPreference(let request):
return .requestJSONEncodable(request)
case .signOut(let request):
return .requestJSONEncodable(request)

View File

@@ -53,6 +53,10 @@ final class UserRepository {
return api.requestPublisher(.getMemberInfo)
}
func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.updateContentPreference(request: request))
}
func getMemberCan() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getMemberInfo)
}

View File

@@ -0,0 +1,178 @@
# 20260326 회원정보 응답 확장 및 콘텐츠 보기 설정 연동
## 개요
- `/member/info` 응답 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)를 앱 상태에 반영한다.
- 설정 화면의 `콘텐츠 보기 설정` 메뉴 노출 조건을 기존 `인증 사용자` 기준에서 `인증 사용자 또는 비한국 국가 코드`까지 확장한다.
- 콘텐츠 보기 설정 변경 시 `/member/content-preference`(`PATCH`)를 호출해 서버와 클라이언트 값을 동기화한다.
- 설정값 연타 시 `debounce`로 마지막 변경값만 서버에 전송하고, 전송 중에는 로딩 다이얼로그를 노출한다.
## 요구사항 요약
- `GET /member/info`
- 추가 응답 필드
- `countryCode: String` (접속 국가 코드)
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- 메뉴 노출 규칙
- 현재 유지: `UserDefaults.bool(forKey: .auth) == true`이면 노출
- 추가: `countryCode != "KR"`인 경우에도 노출
- `PATCH /member/content-preference`
- 콘텐츠 보기 설정 변경 시 호출
- 응답 필드
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- UX
- API 호출 시 Loading Dialog 표시
- 연속 입력 시 마지막 값만 서버 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `GetMemberInfoResponse``countryCode`, `isAdultContentVisible`, `contentType`를 디코딩한다.
- [x] AC2: `HomeViewModel.getMemberInfo`, `AppViewModel.getMemberInfo`에서 신규 필드가 `UserDefaults`에 저장된다.
- [x] AC3: `SettingsView``콘텐츠 보기 설정` 메뉴가 `auth == true || normalizedCountryCode != "KR"` 조건에서 노출된다.
- [x] AC4: `ContentSettingsView` 내 토글/라디오 변경 시 `/member/content-preference` `PATCH`가 호출된다.
- [x] AC5: 콘텐츠 설정 API 호출 중 `LoadingView`가 표시되고, 완료/실패 시 정상 해제된다.
- [x] AC6: 짧은 시간 내 연타(토글/라디오 연속 변경) 시 마지막 상태 1건만 전송된다.
- [x] AC7: 서버 응답 성공 시 로컬(`UserDefaults`) 상태가 최종값과 일치한다.
## 구현 체크리스트
### 1) 회원정보 응답 모델/저장 키 확장
- [x] `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가
- 기존 디코딩 영향(옵셔널/기본값 정책) 점검
- [x] `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `UserDefaultsKey`에 국가 코드 저장 키 추가(예: `countryCode`)
### 2) `/member/info` 수신 데이터 저장 경로 확장
- [x] `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] `SodaLive/Sources/App/AppViewModel.swift`
- `getMemberInfo()` 성공 시 신규 3개 필드 저장 로직 추가
- [x] 저장 정책 정리
- 국가 코드는 대문자 정규화(`uppercased`) 후 저장
- `contentType`는 서버값 우선 저장, 미존재/비정상 값은 `ALL` fallback 검토
### 3) 설정 메뉴 노출 조건 확장
- [x] `SodaLive/Sources/Settings/SettingsView.swift`
- 기존 `if UserDefaults.bool(forKey: .auth)` 조건을
`if isAuth || isNonKoreanCountry` 형태로 확장
- `isNonKoreanCountry` 계산 시 공백/소문자 입력 대비 정규화 처리
### 4) 콘텐츠 설정 PATCH API 추가
- [x] `SodaLive/Sources/User/UserApi.swift`
- `case updateContentPreference(request: UpdateContentPreferenceRequest)` 추가
- `path`: `/member/content-preference`
- `method`: `.patch`
- `task`: `.requestJSONEncodable(request)`
- [x] `SodaLive/Sources/User/UserRepository.swift`
- `updateContentPreference(...)` 메서드 추가
- [x] 신규 DTO 추가
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift`
- [x] `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift`
### 5) 콘텐츠 설정 화면 상태/전송 로직 보강
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `@Published isLoading`, `errorMessage`, `isShowPopup` 추가
- 토글/라디오 변경 이벤트를 `Subject`로 수집
- `debounce` + `removeDuplicates`로 마지막 값만 전송
- API 성공 시 응답값 기준으로 로컬 상태 최종 확정
- API 실패 시 에러 토스트 노출 및 로딩 해제
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `BaseView(isLoading: $viewModel.isLoading)` 적용으로 Loading Dialog 표시
- `.sodaToast(...)` 연결로 실패 메시지 표시
### 6) 회귀 영향 점검
- [x] `UserDefaults.isAdultContentVisible()` 및 기존 콘텐츠 조회 API 파라미터 경로(`HomeTabRepository`, `ContentRepository`, `SearchRepository` 등)에서 신규 저장값 반영 여부 점검
- [x] 앱 재시작 플래그(`AppState.shared.isRestartApp`)와 서버 동기화 타이밍 충돌 여부 점검
### 7) 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics` 확인
- [x] 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- [x] 빌드(개발): `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` / `SodaLive-dev test`
- [ ] 수동 QA
- [ ] 한국 계정(`countryCode == KR`, 미인증): 메뉴 비노출
- [ ] 비한국 계정(`countryCode != KR`, 미인증): 메뉴 노출
- [ ] 인증 계정(`isAuth == true`): 국가코드 무관 메뉴 노출
- [ ] 토글/라디오 연타 시 마지막 선택값만 서버 반영
- [ ] API 호출 중 로딩 다이얼로그 표시 및 완료 후 해제
### 8) 국가 기반 성인 접근 분기 및 18+ 확인 팝업
- [x] `SodaLive/Sources/Main/Home/HomeView.swift`
- 성인 라이브 진입 시 국가코드 분기 적용
- `KR(또는 빈값)` + 미인증: 기존 본인인증 팝업 유지
- `non-KR` + 민감 콘텐츠 OFF: `contentViewSettings` 이동 + 안내 팝업 노출
- [x] `SodaLive/Sources/Live/Now/All/LiveNowAllView.swift`
- `HomeView`와 동일한 국가 분기/가드 정책 반영
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- 민감 콘텐츠 ON 전 18+ 확인 상태 추가
- `handleAdultContentToggleTap`, `confirmAdultContentAgeCheck`, `cancelAdultContentAgeCheck` 구현
- [x] `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- 스위치 탭 동작을 뷰모델 핸들러로 연결
- `SodaDialog``아니오/예` 처리 연결(예: ON + API 흐름, 아니오: OFF 유지)
- [x] `SodaLive/Sources/I18n/I18n.swift`
- `adultContentAgeCheckTitle`, `adultContentAgeCheckDesc`, `adultContentEnableGuide` 국제화 문자열 추가
- [x] `SodaLive/Sources/Live/Room/LiveRoomViewModel.swift`
- `requiresAdultAuthenticationByCountry()` 도입
- 성인 방 진입 시 인증 요구 조건을 KR 기반으로 일관화
## 영향 파일(예상)
- `SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift`
- `SodaLive/Sources/Extensions/UserDefaultsExtension.swift`
- `SodaLive/Sources/Main/Home/HomeViewModel.swift`
- `SodaLive/Sources/App/AppViewModel.swift`
- `SodaLive/Sources/Settings/SettingsView.swift`
- `SodaLive/Sources/User/UserApi.swift`
- `SodaLive/Sources/User/UserRepository.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsView.swift`
- `SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift`
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift` (신규)
- `SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift` (신규)
## 리스크 및 의존성
- 백엔드가 `contentType` 문자열을 `ALL/MALE/FEMALE` 외 값으로 내려주면 디코딩 실패 가능성이 있어 방어 로직이 필요하다.
- `/member/content-preference` 응답/에러 정책이 미정이면 실패 시 롤백 기준(로컬 유지/복구) 정의가 필요하다.
- `countryCode` 미수신 시 기본 노출 정책(비노출 권장)을 명확히 정해야 메뉴 오노출을 방지할 수 있다.
## 검증 기록
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장/콘텐츠 설정 서버 동기화 작업을 위한 구현 계획 문서 작성
- 왜: 요청 범위(응답 필드 확장, 메뉴 노출 조건 변경, PATCH 연동, 로딩/디바운스)를 코드 경로 기준으로 실행 가능한 체크리스트로 정리하기 위함
- 어떻게: 기존 구현 파일(`UserApi`, `UserRepository`, `GetMemberInfoResponse`, `SettingsView`, `ContentSettingsViewModel`)과 기존 계획 문서 포맷을 조사해 항목화
- 실행 명령/도구: `read(docs/*)`, `grep("/member/info|getMemberInfo|isAdultContentVisible|contentType|debounce")`, `read(UserApi.swift, UserRepository.swift, SettingsView.swift, ContentSettingsViewModel.swift 등)`
- 결과: 구현 전용 체크리스트/완료 기준/검증 계획/리스크가 포함된 계획 문서 초안 작성 완료
- 일시: 2026-03-26
- 무엇: 회원정보 응답 확장 및 콘텐츠 보기 설정 서버 동기화 구현 완료
- 왜: `/member/info` 확장 필드 반영, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, debounce/로딩 UX 요구사항을 충족하기 위함
- 어떻게: `GetMemberInfoResponse`/`UserDefaultsKey` 확장, `HomeViewModel`/`AppViewModel` 저장 로직 보강, `SettingsView` 노출 조건 변경, `UserApi`/`UserRepository` PATCH 추가, `ContentSettingsViewModel` Subject+debounce 동기화 및 `ContentSettingsView` 로딩/토스트 연결
- 실행 명령/도구:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `lsp_diagnostics(수정 파일)`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트 명령은 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 타깃 미구성)
- `lsp_diagnostics`는 SourceKit 해석 범위 한계로 다수의 모듈 미해결 오류를 반환했으나, 실제 Xcode 빌드는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(체크리스트 유지)
- 일시: 2026-03-27
- 무엇: 국가 기반 성인 접근 분기 및 민감 콘텐츠 ON 18+ 확인 팝업 구현 검증
- 왜: 한국/비한국 정책 분기와 민감 콘텐츠 ON 보호 UX(국제화 포함) 요구사항을 반영하고, 실제 빌드 기준으로 회귀 여부를 확인하기 위함
- 어떻게:
- `HomeView`, `LiveNowAllView` 성인 진입 가드에 국가코드 분기 추가
- `ContentSettingsViewModel`/`ContentSettingsView`에 18+ 확인 다이얼로그 플로우 추가
- `I18n.Settings`에 신규 문구 추가 및 `LiveRoomViewModel` 성인 인증 조건을 KR 기반으로 정렬
- 실행 명령/도구:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `lsp_diagnostics(I18n.swift, ContentSettingsViewModel.swift, ContentSettingsView.swift, HomeView.swift, LiveNowAllView.swift, LiveRoomViewModel.swift)`
- 결과:
- `SodaLive` 빌드는 최초 병렬 실행 시 `build.db` lock으로 실패했으나, 단독 재실행에서 `BUILD SUCCEEDED`
- `SodaLive-dev` 빌드는 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 제약으로 다수 에러를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 한계로 미실행(체크리스트 유지)

View File

@@ -0,0 +1,70 @@
# 20260327 마이페이지 본인인증 아이템 국가 조건 적용
## 개요
- `MyPageView`의 카테고리 버튼 중 `본인인증/인증완료` 아이템을 접속국가가 한국(`KR`)인 경우에만 노출되도록 변경한다.
- 기존 인증 플로우(Bootpay 호출, 인증 상태 문구)는 한국 사용자에서만 기존대로 유지한다.
## 요구사항 요약
- 대상 파일: `SodaLive/Sources/MyPage/MyPageView.swift`
- 변경 조건:
- 접속국가 코드가 `KR`(정규화 기준 적용)일 때만 `본인인증/인증완료` 아이템 표시
- 국가코드 미수신(빈값) 시 기존 저장소 관례에 맞춰 한국 정책(`KR`)으로 취급
- `KR`이 아니면 해당 아이템 미표시
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `MyPageView`에서 접속국가 코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: `CategoryButtonsView``본인인증/인증완료` 아이템이 한국 사용자에게만 노출된다.
- [x] AC3: 한국 사용자의 기존 인증 플로우(`isShowAuthView = true`)가 유지된다.
- [x] AC4: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `MyPageView`에서 국가코드 기반 불리언(`isKoreanCountry`) 계산 로직 추가
- [x] `CategoryButtonsView`에 국가코드 조건 전달 파라미터 추가
- [x] 카테고리 그리드의 `본인인증/인증완료` 아이템 KR 조건부 렌더링 적용
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 명령 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.swift")`
- [x] 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- [x] 빌드(개발): `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 마이페이지 본인인증 아이템 국가 조건 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 검증 절차를 체크리스트 기반으로 고정하기 위함
- 어떻게: 기존 `docs` 문서 포맷을 기준으로 요구사항/완료기준/검증계획을 정리
- 실행 명령/도구: `read(docs/)`, `apply_patch(문서 생성)`
- 결과: 구현용 계획 문서 초안 생성 완료
- 일시: 2026-03-27
- 무엇: 마이페이지 `본인인증/인증완료` 아이템 KR 조건부 노출 구현 및 검증
- 왜: 비한국 접속국가에서 해당 아이템이 노출되지 않도록 정책을 적용하기 위함
- 어떻게:
- `MyPageView`에서 `countryCode``trim + uppercased`로 정규화하고 `isKoreanCountry = normalizedCountryCode.isEmpty || normalizedCountryCode == "KR"` 계산
- `CategoryButtonsView``isKoreanCountry` 전달 파라미터를 추가하고, `ic_my_auth` 아이템을 `if isKoreanCountry`로 감싸 조건부 렌더링
- 사용자 요청 search-mode에 맞춰 explore/librarian 병렬 탐색 + Grep/ast-grep 직접 탐색 결과를 교차 검증
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Find KR gating patterns")`
- `task(subagent_type="explore", description="Trace MyPage auth item")`
- `task(subagent_type="librarian", description="Find SwiftUI conditional item patterns")`
- `task(subagent_type="librarian", description="Find locale/country code handling examples")`
- Direct search:
- `grep("본인인증|인증완료|ic_my_auth", MyPageView.swift)`
- `ast_grep_search("CategoryButtonItem(icon: \"ic_my_auth\", title: $TITLE) { $$$ }", lang: "swift")`
- `rg` 시도(`command not found: rg`)로 환경 미설치 확인
- 검증:
- `lsp_diagnostics("SodaLive/Sources/MyPage/MyPageView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `SodaLive` / `SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 `xcodebuild` 통과로 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경 한계로 미실행(실기기/시뮬레이터에서 KR/non-KR 노출 확인 필요)

View File

@@ -0,0 +1,94 @@
# 20260327 캐릭터 리스트 콘텐츠 설정 이동 안내 표시 개선
## 개요
- 채팅 캐릭터 리스트에서 `isAdultContentVisible == false`로 인해 콘텐츠 보기 설정으로 이동할 때 안내 토스트/팝업이 사용자에게 보이지 않는 문제를 수정한다.
- 이동 시점과 안내 표시 시점을 조정해 사용자가 안내 문구를 실제로 확인할 수 있도록 한다.
## 요구사항 요약
- 대상 흐름: 캐릭터 리스트 상세 진입 가드에서 non-KR + 민감 콘텐츠 OFF 분기
- 문제: 현재는 현재 화면에 토스트를 띄우고 곧바로 화면 전환되어 안내가 체감되지 않음
- 목표: 콘텐츠 보기 설정 화면 전환 후에도 안내 메시지가 사용자에게 명확히 보이도록 처리
## 완료 기준 (Acceptance Criteria)
- [x] AC1: non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정으로 이동 동작은 유지된다.
- [x] AC2: 안내 메시지가 실제로 보이는 시점으로 표시 로직이 조정된다.
- [x] AC3: KR 인증 분기/기존 인증 플로우에는 영향이 없다.
- [x] AC4: 관련 화면에서 빌드/진단 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 팝업 렌더링 위치/생명주기 확인
- [x] 기존 이동 + 메시지 설정 순서의 문제 원인 확정
- [x] 최소 수정으로 안내 메시지 표시 시점 조정
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 리스트 콘텐츠 설정 이동 시 안내 표시 개선 작업 계획 문서 작성
- 왜: 구현 전 변경 범위/완료 기준/검증 절차를 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/체크리스트/검증계획을 정리
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 채팅 캐릭터 리스트의 non-KR + 민감 콘텐츠 OFF 분기에서 콘텐츠 설정 이동 안내 표시 시점 조정
- 왜: 토스트를 먼저 띄우고 즉시 화면 전환하면 사용자가 안내 메시지를 보기 어려워 UX가 손실되기 때문
- 어떻게:
- analyze-mode 요구에 맞춰 병렬 탐색 수행
- explore: `Trace error popup lifecycle`, `Find message-after-navigation patterns`
- direct: `grep/ast-grep/lsp_symbols``AppState.errorMessage`, `isShowErrorPopup`, `.contentViewSettings`, `sodaToast` 렌더링 위치 확인
- `ChatTabView`에서 non-KR 분기를 `if !isKoreanCountry && !UserDefaults.isAdultContentVisible()`로 유지
- 기존의 “토스트 세팅 후 즉시 이동” 대신 `moveToContentSettingsWithGuideToast()`
- 먼저 `.contentViewSettings`로 이동
- `DispatchQueue.main.asyncAfter(0.2)` 후 안내 토스트 표시
- scope 최소화를 위해 요청 대상인 채팅 캐릭터 리스트 경로(`ChatTabView`)만 수정
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고했으나, 실제 빌드 통과로 컴파일 정상 확인
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF 시 콘텐츠 설정 화면에서 안내 토스트 노출 확인 필요)
- 일시: 2026-03-27
- 무엇: 재현 보고(토스트 미노출) 기반 2차 수정 — 콘텐츠 설정 화면에서 안내 토스트를 직접 소비하도록 변경
- 왜: 기존 방식은 전환 타이밍/전역 토스트 상태 의존으로 인해 사용자 환경에서 안내가 보이지 않는 케이스가 재현되었기 때문
- 어떻게:
- 원인 확인: `ContentSettingsView`는 로컬 토스트(`viewModel.isShowPopup`)만 표시하고, 캐릭터 리스트 경로는 `AppState` 전역 토스트 상태 타이밍에 의존
- `AppState`에 일회성 전달 상태 추가
- `pendingContentSettingsGuideMessage`
- `setPendingContentSettingsGuideMessage(_:)`
- `consumePendingContentSettingsGuideMessage()`
- `ChatTabView.moveToContentSettingsWithGuideToast()`에서 전역 토스트 토글 대신
- 안내 문구를 pending 상태로 저장
- `.contentViewSettings` 이동만 수행
- `ContentSettingsView.onAppear`에서 pending 문구를 consume하여
- `viewModel.errorMessage` 설정
- `viewModel.isShowPopup = true`로 로컬 토스트 즉시 노출
- analyze-mode 병렬 탐색 결과(`Trace content settings toast suppression`, `Find reliable post-redirect notice patterns`)를 반영해 최소 변경으로 해결
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/App/AppState.swift")`
- `lsp_diagnostics("SodaLive/Sources/Chat/ChatTabView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경 한계로 모듈/스코프 미해결 오류를 보고했으나 실제 빌드는 통과
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 non-KR + 민감 콘텐츠 OFF → 캐릭터 탭 → 콘텐츠 설정 진입 직후 안내 토스트 노출 확인 필요)

View File

@@ -0,0 +1,64 @@
# 20260327 캐릭터 상세 진입 인증 국가 분기 적용
## 개요
- 캐릭터(또는 크리에이터) 터치로 상세 페이지로 이동할 때 수행되는 인증 체크를 접속국가 기준으로 분기한다.
- 한국(`KR`) 사용자는 기존 본인인증 체크를 유지하고, 비한국 사용자는 콘텐츠 보기 설정 경로를 안내하는 기존 정책과 동일하게 맞춘다.
## 요구사항 요약
- 대상: 캐릭터 상세 진입 탭 핸들러의 인증 가드 로직
- 변경 사항:
- `KR`(정규화 기준, 빈값 포함) 사용자: 기존 본인인증 체크 유지
- `non-KR` 사용자: 인증 대신 콘텐츠 보기 설정 유도 정책 적용
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 캐릭터 상세 진입 인증 가드에서 국가코드 정규화(`trim + uppercased`)가 적용된다.
- [x] AC2: 한국 사용자는 기존 본인인증 체크 흐름이 유지된다.
- [x] AC3: 비한국 사용자는 콘텐츠 보기 설정 유도 분기로 동작한다.
- [x] AC4: 기존 네비게이션/팝업 흐름과 충돌 없이 동작한다.
- [x] AC5: 빌드/진단/테스트 시도 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] 캐릭터 상세 진입 인증 체크 위치 식별
- [x] 기존 국가 분기 정책 스니펫 확인 및 재사용 지점 선정
- [x] 탭 핸들러 분기 로직 변경
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 기록 문서화
## 검증 계획
- [x] 정적 진단: 수정 파일 `lsp_diagnostics`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 국가 분기 적용 작업 계획 문서 작성
- 왜: 구현 전 변경 범위와 완료 기준을 고정해 요청사항을 정확히 반영하기 위함
- 어떻게: docs 규칙에 맞춰 요구사항/완료기준/검증계획을 체크리스트로 구성
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: 캐릭터 상세 진입 인증 체크를 국가코드 기준(KR/non-KR)으로 분기 적용
- 왜: 기존 “모든 국가 auth 필수” 로직을 정책 변경사항(한국은 본인인증, 비한국은 콘텐츠 보기 설정 유도)에 맞추기 위함
- 어떻게:
- `ChatTabView.handleCharacterSelection(_:)``HomeTabView.handleCharacterSelection(_:)``countryCode` 정규화(`trim + uppercased`) 추가
- `isKoreanCountry`일 때만 기존 `auth == false` 본인인증 팝업(`isShowAuthConfirmView`) 흐름 유지
- `!isKoreanCountry && !UserDefaults.isAdultContentVisible()` 조건에서
- `AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide`
- `AppState.shared.isShowErrorPopup = true`
- `AppState.shared.setAppStep(step: .contentViewSettings)`
로 유도하고 상세 진입 차단
- 실행 명령/도구:
- 탐색: `task(subagent_type="explore", description="Find character detail auth gate")`, `task(subagent_type="explore", description="Find country branch conventions")`
- 진단: `lsp_diagnostics(ChatTabView.swift)`, `lsp_diagnostics(HomeTabView.swift)`
- 빌드: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 테스트 시도: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경에서 `No such module 'Bootpay'`를 보고하지만 실제 xcodebuild는 통과하여 컴파일 정상 확인
- 수동 QA는 현재 CLI 환경에서 UI 탭/팝업 플로우 실행이 불가하여 미실행(실기기/시뮬레이터에서 KR/non-KR 분기 확인 필요)

View File

@@ -0,0 +1,72 @@
# 20260327 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송
## 개요
- `userApi.updateContentPreference` 요청 시 `contentType`, `isAdultContentVisible`를 항상 같이 보내지 않고, 실제 변경된 필드만 PATCH payload에 포함되도록 수정한다.
- 요청 모델을 optional 파라미터로 변경하고, 기존 UI/동기화 동작은 유지한다.
## 요구사항 요약
- 대상 API: `PATCH /member/content-preference`
- 변경 사항:
- 요청 DTO의 `contentType`, `isAdultContentVisible`를 optional로 전환
- 토글 변경 시에는 `isAdultContentVisible`만 전송
- 콘텐츠 타입 변경 시에는 `contentType`만 전송
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `UpdateContentPreferenceRequest`가 optional 필드를 사용한다.
- [x] AC2: 토글 변경 요청 payload에 `isAdultContentVisible`만 포함된다.
- [x] AC3: 콘텐츠 타입 변경 요청 payload에 `contentType`만 포함된다.
- [x] AC4: 기존 debounce/로딩/에러 처리 흐름이 유지된다.
- [x] AC5: 빌드/진단 검증 결과가 문서에 기록된다.
## 구현 체크리스트
- [x] `UpdateContentPreferenceRequest` optional 필드 전환
- [x] `ContentSettingsViewModel` 요청 생성 로직을 변경 필드 기반으로 분기
- [x] `UserApi`/`UserRepository` 호출부 영향 점검
- [x] 수정 파일 진단 및 워크스페이스 빌드/테스트 실행
- [x] 검증 결과 문서화
## 검증 계획
- [x] 정적 진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift")`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트 시도:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-27
- 무엇: 콘텐츠 설정 PATCH 변경 필드 옵셔널 전송 작업 계획 문서 작성
- 왜: 구현 범위와 검증 절차를 선행 고정하여 요청사항을 정확히 반영하기 위함
- 어떻게: 기존 docs 포맷 기준으로 완료 기준/체크리스트/검증 계획 수립
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 구현 계획 문서 생성 완료
- 일시: 2026-03-27
- 무엇: `updateContentPreference`를 변경 필드만 전송하도록 optional request + diff 기반 전송 로직 적용
- 왜: PATCH 호출 시 `contentType`/`isAdultContentVisible`를 항상 함께 보내지 않고 실제 변경 필드만 서버에 전달하기 위함
- 어떻게:
- `UpdateContentPreferenceRequest``Bool?`/`ContentType?`로 변경하고 `isEmpty` 계산 프로퍼티 추가
- `ContentSettingsViewModel``lastSyncedState`를 추가해 이전 동기화 상태 대비 변경 필드를 계산
- `makeUpdateContentPreferenceRequest(from:to:)`에서 변경된 값만 request에 채우고, 빈 요청은 API 호출 생략
- 서버 성공 응답 시 `applyServerState`에서 `lastSyncedState`를 갱신해 후속 diff 기준 일관성 유지
- search-mode 준수를 위해 explore 에이전트 2개 병렬 실행으로 호출 흐름/optional 패턴 교차 확인
- 실행 명령/도구:
- Background agents:
- `task(subagent_type="explore", description="Trace content-preference flow")`
- `task(subagent_type="explore", description="Find optional PATCH patterns")`
- 코드/진단:
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/UpdateContentPreferenceRequest.swift")`
- `lsp_diagnostics("SodaLive/Sources/Settings/Content/ContentSettingsViewModel.swift")`
- 빌드/테스트:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- `SodaLive`/`SodaLive-dev` Debug 빌드 모두 `BUILD SUCCEEDED`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 모듈 해석 한계로 다수 에러를 반환했으나, 실제 xcodebuild 통과로 컴파일 정상 확인
- 코드상으로 토글 변경 시 `isAdultContentVisible`만, 타입 변경 시 `contentType`만 request에 포함되도록 반영 완료