feat(chat): 채팅 쿼터 광고 충전을 추가한다

This commit is contained in:
Yu Sung
2026-04-30 14:23:15 +09:00
parent 714ad459b0
commit 5823f6ddb2
10 changed files with 423 additions and 133 deletions

View File

@@ -55,8 +55,17 @@ class ChatRoomRepository {
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId)) return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
} }
func purchaseChatQuota(roomId: Int) -> AnyPublisher<Response, MoyaError> { func purchaseChatQuota(
return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest())) roomId: Int,
chargeType: ChatRoomQuotaChargeType = .can,
canOption: ChatRoomQuotaCanOption? = nil
) -> AnyPublisher<Response, MoyaError> {
return talkApi.requestPublisher(
.purchaseChatQuota(
roomId: roomId,
request: ChatQuotaPurchaseRequest(chargeType: chargeType, canOption: canOption)
)
)
} }
func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> { func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {

View File

@@ -148,9 +148,17 @@ struct ChatRoomView: View {
} }
if viewModel.showQuotaNoticeView { if viewModel.showQuotaNoticeView {
ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) { ChatQuotaNoticeItemView(
viewModel.purchaseChatQuota() onSelectAd: {
} viewModel.showRewardedAdForChatQuota()
},
onSelectCan10: {
viewModel.purchaseChatQuota(canOption: .can10)
},
onSelectCan20: {
viewModel.purchaseChatQuota(canOption: .can20)
}
)
.id("quota_\(viewModel.messages.count)") .id("quota_\(viewModel.messages.count)")
.padding(.bottom, 12) .padding(.bottom, 12)
.onAppear { .onAppear {
@@ -309,9 +317,6 @@ struct ChatRoomView: View {
viewModel.getMemberInfo() viewModel.getMemberInfo()
viewModel.enterRoom(roomId: roomId) viewModel.enterRoom(roomId: roomId)
} }
.onDisappear {
viewModel.stopTimer()
}
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
} }
} }

View File

@@ -28,7 +28,7 @@ final class ChatRoomViewModel: ObservableObject {
} }
} }
@Published private(set) var countdownText: String = "00:00:00" @Published private(set) var totalRemaining: Int = 0
@Published private(set) var showQuotaNoticeView: Bool = false @Published private(set) var showQuotaNoticeView: Bool = false
@Published private(set) var showSendingMessage: Bool = false @Published private(set) var showSendingMessage: Bool = false
@@ -72,8 +72,6 @@ final class ChatRoomViewModel: ObservableObject {
private var hasMoreMessages: Bool = true private var hasMoreMessages: Bool = true
private var nextCursor: Int64? = nil private var nextCursor: Int64? = nil
private var timer: Timer?
// MARK: - Actions // MARK: - Actions
func sendMessage() { func sendMessage() {
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
@@ -125,7 +123,7 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self.messages.append(contentsOf: data.messages) self.messages.append(contentsOf: data.messages)
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
self.errorMessage = decoded.message ?? I18n.Common.commonError self.errorMessage = decoded.message ?? I18n.Common.commonError
self.isShowPopup = true self.isShowPopup = true
@@ -177,7 +175,7 @@ final class ChatRoomViewModel: ObservableObject {
self?.hasMoreMessages = data.hasMoreMessages self?.hasMoreMessages = data.hasMoreMessages
self?.nextCursor = data.messages.last?.messageId self?.nextCursor = data.messages.last?.messageId
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
@@ -275,10 +273,25 @@ final class ChatRoomViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
func purchaseChatQuota() { func purchaseChatQuota(canOption: ChatRoomQuotaCanOption) {
purchaseChatQuota(chargeType: .can, canOption: canOption)
}
func showRewardedAdForChatQuota() {
_Concurrency.Task {
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
}
}
}
private func purchaseChatQuota(
chargeType: ChatRoomQuotaChargeType,
canOption: ChatRoomQuotaCanOption?
) {
isLoading = true isLoading = true
repository.purchaseChatQuota(roomId: roomId) repository.purchaseChatQuota(roomId: roomId, chargeType: chargeType, canOption: canOption)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { result in .sink { result in
switch result { switch result {
@@ -295,10 +308,12 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
let can = UserDefaults.int(forKey: .can) if let canOption {
UserDefaults.set(can - 30, forKey: .can) let can = UserDefaults.int(forKey: .can)
UserDefaults.set(can - canOption.needCan, forKey: .can)
}
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
@@ -385,7 +400,7 @@ final class ChatRoomViewModel: ObservableObject {
chatRoomBgImageUrl = nil chatRoomBgImageUrl = nil
roomId = 0 roomId = 0
countdownText = "00:00:00" totalRemaining = 0
showQuotaNoticeView = false showQuotaNoticeView = false
showSendingMessage = false showSendingMessage = false
@@ -421,7 +436,7 @@ final class ChatRoomViewModel: ObservableObject {
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData) let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
if let data = decoded.data, decoded.success { if let data = decoded.data, decoded.success {
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch) self?.updateQuota(totalRemaining: data.totalRemaining)
} else { } else {
if let message = decoded.message { if let message = decoded.message {
self?.errorMessage = message self?.errorMessage = message
@@ -442,78 +457,18 @@ final class ChatRoomViewModel: ObservableObject {
.store(in: &subscription) .store(in: &subscription)
} }
private func updateQuota(nextRechargeAtEpoch: Int64?) { private func updateQuota(totalRemaining: Int) {
isLoading = true self.totalRemaining = totalRemaining
stopTimer() showQuotaNoticeView = totalRemaining <= 0
prepareRewardedAdIfNeeded(totalRemaining: totalRemaining)
}
// epoch private func prepareRewardedAdIfNeeded(totalRemaining: Int) {
guard let nextRechargeAtEpoch else { guard totalRemaining <= 1 else { return }
countdownText = "00:00:00"
showQuotaNoticeView = false _Concurrency.Task {
isLoading = false await YandexRewardedAdManager.shared.preloadAd(for: .chatRoomQuota)
return
} }
// 1
let remainMs = remainingMs(to: nextRechargeAtEpoch)
updateCountdownText(remainMs)
// 0
guard remainMs > 0 else {
checkQuotaStatus()
return
}
isLoading = false
showQuotaNoticeView = true
// (1 )
startTimer(targetEpoch: nextRechargeAtEpoch)
}
private func updateCountdownText(_ remainMs: Int64) {
countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00"
}
private func startTimer(targetEpoch: Int64) {
stopTimer()
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self else { return }
let remain = self.remainingMs(to: targetEpoch)
self.updateCountdownText(remain)
if remain == 0 {
self.stopTimer()
self.checkQuotaStatus()
}
}
if let t = timer { RunLoop.main.add(t, forMode: .common) }
}
func stopTimer() {
timer?.invalidate()
timer = nil
}
private func remainingMs(to epoch: Int64) -> Int64 {
let ms = normalizeToMs(epoch)
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let fudgeMs: Int64 = 5000
// Kotlin
return max(ms - nowMs + fudgeMs, 0)
}
/// /
private func normalizeToMs(_ epoch: Int64) -> Int64 {
epoch < 1_000_000_000_000 ? epoch * 1000 : epoch
}
private func formatMillisToHms(_ ms: Int64) -> String {
let total = ms / 1000
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
return String(format: "%02d:%02d:%02d", h, m, s)
} }
private func getSavedBackgroundImageId() -> Int? { private func getSavedBackgroundImageId() -> Int? {

View File

@@ -9,57 +9,79 @@ import SwiftUI
struct ChatQuotaNoticeItemView: View { struct ChatQuotaNoticeItemView: View {
let remainingTime: String let onSelectAd: () -> Void
let purchase: () -> Void let onSelectCan10: () -> Void
let onSelectCan20: () -> Void
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
VStack(spacing: 8) { Button {
Image("ic_time") onSelectAd()
.resizable() } label: {
.frame(width: 30, height: 30) Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
Text(remainingTime)
.appFont(size: 18, weight: .bold) .appFont(size: 18, weight: .bold)
.foregroundColor(.white) .foregroundColor(Color(hex: "263238"))
.frame(maxWidth: .infinity)
Text(I18n.Chat.Room.quotaWaitForFreeNotice) .padding(.vertical, 14)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 15) .background(Color(hex: "FEF8E3"))
.background(Color(hex: "EC8280"))
.cornerRadius(10)
HStack(spacing: 4) {
Image("ic_can")
Text("10")
.appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238"))
Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12))
.appFont(size: 24, weight: .bold)
.foregroundColor(Color(hex: "263238"))
.padding(.leading, 4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(hex: "B5E7FA"))
.cornerRadius(30) .cornerRadius(30)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 30) RoundedRectangle(cornerRadius: 30)
.stroke(lineWidth: 1) .stroke(lineWidth: 1)
.foregroundColor(Color.button) .foregroundColor(Color(hex: "F7CB50"))
} }
.onTapGesture { .buttonStyle(.plain)
purchase()
HStack(spacing: 8) {
Button {
onSelectCan10()
} label: {
canButtonLabel(can: 10, chatCount: 15)
}
.buttonStyle(.plain)
Button {
onSelectCan20()
} label: {
canButtonLabel(can: 20, chatCount: 40)
}
.buttonStyle(.plain)
} }
} }
} }
private func canButtonLabel(can: Int, chatCount: Int) -> some View {
HStack(spacing: 4) {
Image("ic_can")
HStack(spacing: 0) {
Text("\(can)")
.appFont(size: 20, weight: .bold)
.foregroundColor(Color(hex: "263238"))
Text(" / \(I18n.Chat.Room.quotaChatCount(chatCount))")
.appFont(size: 20, weight: .medium)
.foregroundColor(Color(hex: "263238"))
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color(hex: "B5E7FA"))
.cornerRadius(30)
.overlay {
RoundedRectangle(cornerRadius: 30)
.stroke(lineWidth: 1)
.foregroundColor(Color.button)
}
}
} }
#Preview { #Preview {
ChatQuotaNoticeItemView(remainingTime: "05:59:55") {} ChatQuotaNoticeItemView(
onSelectAd: {},
onSelectCan10: {},
onSelectCan20: {}
)
} }

View File

@@ -7,4 +7,42 @@
struct ChatQuotaPurchaseRequest: Encodable { struct ChatQuotaPurchaseRequest: Encodable {
let container: String = "ios" let container: String = "ios"
let chargeType: ChatRoomQuotaChargeType
let canOption: ChatRoomQuotaCanOption?
init(
chargeType: ChatRoomQuotaChargeType = .can,
canOption: ChatRoomQuotaCanOption? = nil
) {
self.chargeType = chargeType
self.canOption = canOption
}
}
enum ChatRoomQuotaChargeType: String, Encodable {
case can = "CAN"
case ad = "AD"
}
enum ChatRoomQuotaCanOption: String, Encodable {
case can10 = "CAN_10"
case can20 = "CAN_20"
var needCan: Int {
switch self {
case .can10:
return 10
case .can20:
return 20
}
}
var quota: Int {
switch self {
case .can10:
return 15
case .can20:
return 40
}
}
} }

View File

@@ -27,6 +27,10 @@ enum YandexInterstitialPlacement {
case contentDetail case contentDetail
} }
enum YandexRewardedPlacement {
case chatRoomQuota
}
enum YandexAdUnitIdProvider { enum YandexAdUnitIdProvider {
static func banner(for placement: YandexBannerPlacement) -> String { static func banner(for placement: YandexBannerPlacement) -> String {
@@ -64,6 +68,13 @@ enum YandexAdUnitIdProvider {
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
} }
} }
static func rewarded(for placement: YandexRewardedPlacement) -> String {
switch placement {
case .chatRoomQuota:
YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID
}
}
} }
struct YandexInlineBannerView: View { struct YandexInlineBannerView: View {
@@ -303,3 +314,128 @@ extension YandexInterstitialAdManager: InterstitialAdDelegate {
completePendingAction() completePendingAction()
} }
} }
@MainActor
final class YandexRewardedAdManager: NSObject {
static let shared = YandexRewardedAdManager()
private var rewardedAd: RewardedAd?
private var rewardedAdLoader: RewardedAdLoader?
private var currentPlacement: YandexRewardedPlacement?
private var pendingRewardAction: (@MainActor () -> Void)?
private var rewardedPlacement: YandexRewardedPlacement?
private var isLoading = false
func preloadAd(for placement: YandexRewardedPlacement) {
guard !isLoading else {
return
}
if currentPlacement == placement, rewardedAd != nil {
return
}
let loader = RewardedAdLoader()
rewardedAdLoader = loader
rewardedAd = nil
currentPlacement = placement
isLoading = true
Task {
do {
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.rewarded(for: placement)))
guard currentPlacement == placement else {
isLoading = false
return
}
loadedAd.delegate = self
rewardedAd = loadedAd
} catch {
if currentPlacement == placement {
rewardedAd = nil
}
}
if currentPlacement == placement {
isLoading = false
}
}
}
func showAdIfAvailable(for placement: YandexRewardedPlacement, onReward: @escaping @MainActor () -> Void) -> Bool {
guard let presenter = presentingViewController(), let rewardedAd, currentPlacement == placement else {
preloadAd(for: placement)
return false
}
pendingRewardAction = onReward
rewardedPlacement = placement
rewardedAd.show(from: presenter)
return true
}
private func completeRewardIfNeeded() {
let action = pendingRewardAction
pendingRewardAction = nil
action?()
}
private func resetAndPreload() {
pendingRewardAction = nil
rewardedAd = nil
if let rewardedPlacement {
self.rewardedPlacement = nil
preloadAd(for: rewardedPlacement)
}
}
private func presentingViewController() -> UIViewController? {
guard
let rootViewController = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.flatMap({ $0.windows })
.first(where: { $0.isKeyWindow })?
.rootViewController
else {
return nil
}
var topViewController = rootViewController
while let presentedViewController = topViewController.presentedViewController {
topViewController = presentedViewController
}
return topViewController
}
}
extension YandexRewardedAdManager: RewardedAdDelegate {
func rewardedAd(_ rewardedAd: RewardedAd, didReward reward: Reward) {
DEBUG_LOG("리워드 광고 보상 받기 성공")
completeRewardIfNeeded()
}
func rewardedAd(_ rewardedAd: RewardedAd, didFailToShow error: Error) {
DEBUG_LOG("리워드 광고 에러")
resetAndPreload()
}
func rewardedAdDidShow(_ rewardedAd: RewardedAd) {
}
func rewardedAdDidDismiss(_ rewardedAd: RewardedAd) {
DEBUG_LOG("리워드 광고 닫기")
resetAndPreload()
}
func rewardedAdDidClick(_ rewardedAd: RewardedAd) {
}
func rewardedAd(_ rewardedAd: RewardedAd, didTrackImpression impressionData: ImpressionData?) {
}
}

View File

@@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19140297-12"
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13" let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13"
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14" let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14"
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15" let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15"
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19140297-16"

View File

@@ -262,15 +262,19 @@ enum I18n {
pick(ko: "입력 중", en: "Typing", ja: "入力中") pick(ko: "입력 중", en: "Typing", ja: "入力中")
} }
static var quotaWaitForFreeNotice: String { static func quotaAdAction(chatCount: Int) -> String {
pick(ko: "기다리면 무료 이용이 가능합니다.", en: "Wait for free usage.", ja: "待てば無料で利用できます。") pick(
ko: "광고 / \(chatCount)채팅",
en: "Ad / \(chatCount) chats",
ja: "広告 / \(chatCount)チャット"
)
} }
static func quotaPurchaseAction(chatCount: Int) -> String { static func quotaChatCount(_ chatCount: Int) -> String {
pick( pick(
ko: "(채팅 \(chatCount)개) 바로 대화 시작", ko: "\(chatCount)채팅",
en: "(\(chatCount) chats) Start now", en: "\(chatCount) chats",
ja: "(チャット\(chatCount)件) すぐに会話開始" ja: "\(chatCount)チャット"
) )
} }

View File

@@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19157621-10"
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11" let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11"
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12" let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12"
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13" let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13"
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19157621-14"

View File

@@ -0,0 +1,119 @@
# 20260430 채팅 쿼터 충전 확장
## 작업 체크리스트
- [x] `ChatRoomViewModel`의 쿼터 안내 표시 기준을 `nextRechargeAtEpoch` null 여부에서 `totalRemaining <= 0` 기준으로 전환한다.
- [x] 무료 충전이 없어진 정책에 맞춰 채팅방 쿼터 카운트다운/무료 대기 관련 상태와 타이머 로직을 제거한다.
- [x] `ChatQuotaNoticeItemView`를 2단 구성으로 재작성하고, 광고 버튼 1개 + 캔 구매 버튼 2개 UI를 요구사항 스펙에 맞게 반영한다.
- [x] 채팅 쿼터 구매 요청 DTO를 `CAN`/`AD` 충전 타입과 캔 옵션을 전달할 수 있도록 확장한다.
- [x] `ChatRoomQuotaChargeType`, `ChatRoomQuotaCanOption` 모델을 iOS 코드베이스 규칙에 맞게 추가한다.
- [x] 채팅방 쿼터 구매 흐름을 광고 보상 충전과 캔 옵션 충전으로 분기한다.
- [x] `totalRemaining <= 1`일 때 채팅 쿼터 전용 Yandex rewarded 광고를 준비하도록 채팅방 진입/상태 갱신 흐름을 확장한다.
- [x] 광고 버튼 탭 시 Yandex rewarded 광고를 표시하고, 리워드 지급 가능 시점에만 쿼터 충전 API를 호출하도록 연결한다.
- [x] 채팅방 쿼터 관련 문자열/I18n 사용 여부를 정리하고, 제거/추가가 필요한 문구를 반영한다.
- [x] 수정 파일 진단과 빌드를 실행하고 결과를 검증 기록에 남긴다.
## 작업 기준
- 사용자 요청 대상 화면:
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
- 현재 쿼터 상태/로직:
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift`
- `SodaLive/Sources/Chat/Talk/Room/Enter/ChatRoomEnterResponse.swift`
- `SodaLive/Sources/Chat/Talk/Room/Message/SendChatMessageResponse.swift`
- 쿼터 구매 API/DTO:
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift`
- `SodaLive/Sources/Chat/Talk/TalkApi.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
- 광고 공용 지원:
- `SodaLive/Sources/Common/YandexAdSupport.swift`
- `SodaLive/Sources/App/AppDelegate.swift`
- 문자열 경로:
- `SodaLive/Sources/I18n/I18n.swift`
- 공식 문서:
- `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`
## QA 기준
- `ChatRoomViewModel``nextRechargeAtEpoch` null 여부와 무관하게 `totalRemaining <= 0`일 때만 `showQuotaNoticeView``true`로 만든다.
- 채팅방 쿼터 안내 영역에는 더 이상 시간 아이콘, 카운트다운 텍스트, `기다리면 무료 이용이 가능합니다` 문구가 표시되지 않는다.
- 상단 광고 버튼은 `광고 / 5채팅` 라벨로 표시되고, 배경색은 hex `FEF8E3`(RGB `254, 248, 227`), 보더는 hex `F7CB50`(RGB `247, 203, 80`)로 적용된다.
- 하단에는 가로 2개 버튼이 표시되고, 왼쪽은 `ic_can + 10 / 15채팅`, 오른쪽은 `ic_can + 20 / 40채팅` 구성이 적용된다.
- 버튼 내 캔 숫자는 bold, 채팅 개수 텍스트는 medium 폰트로 구분된다.
- 채팅 쿼터 구매 요청은 광고 충전 시 `chargeType = AD`, 캔 충전 시 `chargeType = CAN`과 선택한 `canOption`을 함께 전송한다.
- `totalRemaining <= 1` 상태 진입 시 채팅 쿼터 전용 rewarded 광고가 준비되고, 광고 버튼 탭 시 로드된 광고가 있으면 표시된다.
- rewarded 광고 보상 가능 콜백에서만 쿼터 충전 API가 호출되고, 로드 실패/표시 실패/보상 미지급 시에는 API가 호출되지 않는다.
- 광고/캔 충전 성공 후 채팅 쿼터 UI와 사용자 can 잔액이 응답 기준으로 올바르게 갱신된다.
- 변경 파일 `lsp_diagnostics` 확인과 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 통과한다.
## 구현 메모
- 현재 `ChatRoomViewModel.updateQuota(nextRechargeAtEpoch:)`는 epoch 존재 여부와 타이머로 쿼터 안내 노출을 제어하므로, `totalRemaining`을 받는 형태로 시그니처와 호출부 전체를 함께 정리해야 한다.
- `ChatRoomEnterResponse`, `SendChatMessageResponse`, `ChatQuotaStatusResponse`에는 이미 `totalRemaining`이 있으므로, UI 분기와 광고 준비 판단은 이 값을 기준으로 통일한다.
- 현재 `ChatQuotaPurchaseRequest``container`만 전송하므로, 요청 스펙 확장 시 기존 기본값(`ios`)은 유지하고 charge type / can option만 추가한다.
- `ChatQuotaNoticeItemView`의 기존 `I18n.Chat.Room.quotaWaitForFreeNotice`, `quotaPurchaseAction(chatCount:)`는 신규 UI에 맞지 않을 수 있으므로 재사용 여부를 점검하고 필요 시 신규 문자열을 추가한다.
- 기존 `YandexAdSupport.swift`에는 `YandexInlineBannerView`, `YandexInterstitialAdManager`만 있으므로, rewarded 광고는 동일 파일에 공용 매니저를 추가하는 방향을 우선 검토한다.
- Yandex 공식 rewarded 문서 기준으로 SDK 호출은 메인 스레드에서 수행하고, `RewardedAdLoader` 로드 성공 후 광고 객체를 유지한 뒤 `didReward` 시점에만 보상 API를 연결한다.
- 광고 unit id는 기존 배너/전면광고 unit id를 재사용하지 않고, 채팅 쿼터 rewarded 광고 전용 상수를 Constants 계층에 별도로 추가한다. 실제 상수 추가 위치는 구현 시 운영/디버그 Constants 파일 확인 후 확정한다.
- `totalRemaining <= 1`에서 “광고 준비”를 요구하므로, 채팅방 진입 직후/메시지 전송 응답/쿼터 상태 재조회/충전 완료 이후까지 동일 조건으로 preload 경로가 유지되어야 한다.
## 검증 기록
- 2026-04-30 / 계획 수립
- 무엇/왜/어떻게: 채팅 쿼터 충전 확장 구현 전에 현재 채팅방 쿼터 표시 로직, 구매 DTO, Yandex 광고 지원 범위를 확인하고 실제 수정 범위를 계획 문서로 정리했다.
- 확인 근거:
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
- `SodaLive/Sources/Common/YandexAdSupport.swift`
- `SodaLive/Sources/I18n/I18n.swift`
- `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`
- 기존 계획 문서 패턴: `docs/20260320_채팅창얼림버튼및문구수정.md`, `docs/20260428_채팅탭Yandex배너추가.md`, `docs/20260428_Yandex광고화면배치구현.md`
- 결과:
- 현재 쿼터 안내는 `nextRechargeAtEpoch` 기반 카운트다운 구조임을 확인
- `totalRemaining`은 응답 모델에 존재하지만 UI 분기에는 아직 미사용임을 확인
- 공용 Yandex 지원은 배너/인터스티셜까지 구현되어 있고 rewarded 지원은 별도 추가가 필요함을 확인
- 위 범위를 기준으로 구현 체크리스트와 QA 기준을 확정
- 2026-04-30 / 구현 및 검증
- 무엇/왜/어떻게: 무료 충전 제거 정책에 맞춰 채팅 쿼터 안내 노출 기준을 `totalRemaining <= 0`으로 전환하고, 광고/캔 충전 UI와 `CAN`/`AD` 구매 요청 DTO, Yandex rewarded 광고 보상 콜백 기반 API 호출 흐름을 구현했다.
- 실행 명령/도구:
- `lsp_diagnostics`:
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
- `SodaLive/Sources/Common/YandexAdSupport.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
- `SodaLive/Sources/I18n/I18n.swift`
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `rg "countdownText|quotaWaitForFreeNotice|quotaPurchaseAction|remainingTime|stopTimer|startTimer\(" "SodaLive/Sources/Chat/Talk/Room" "SodaLive/Sources/I18n/I18n.swift"`
- `rg "totalRemaining <= 0|totalRemaining <= 1|ChatRoomQuotaChargeType|ChatRoomQuotaCanOption|YandexRewardedAdManager|YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID|quotaAdAction|quotaChatCount" "SodaLive/Sources"`
- 결과:
- `ChatQuotaPurchaseRequest.swift`는 LSP 진단 없음.
- 나머지 SwiftUI/네트워크/광고 파일은 SourceKit 단독 해석 환경에서 `Kingfisher`, `Moya`, `YandexMobileAds`, 프로젝트 확장 심볼을 해석하지 못하는 환경성 오류가 있었지만, 실제 `xcodebuild` 실컴파일은 두 스킴 모두 통과했다.
- `SodaLive-dev` Debug 빌드 성공.
- `SodaLive` Debug 빌드 성공. Crashlytics dSYM 관련 기존 빌드 경고는 있었지만 빌드는 성공했다.
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가했다.
- 제거 대상 카운트다운/무료 대기 심볼 검색 결과는 없음.
- 신규 쿼터 기준/DTO/광고/I18n 심볼 검색 결과가 기대 위치에서 확인됨.
- 채팅 쿼터 rewarded ad unit id는 별도 상수로 추가했으며, 실제 운영 unit id가 제공되지 않아 현재 값은 Yandex 공식 demo rewarded unit id(`demo-rewarded-yandex`)로 둔다.
- 2026-04-30 / rewarded 콜백 미호출 수정
- 무엇/왜/어떻게: rewarded 광고가 표시된 뒤 `didReward`/`didDismiss` 콜백이 호출되지 않는 문제를 공식 문서 기준으로 점검하고, 로드 성공 직후 `RewardedAd.delegate`를 설정하며 광고 표시 중 `RewardedAd` 강한 참조를 유지하도록 수정했다.
- 실행 명령/도구:
- 공식 문서 확인: `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`, `https://ads.yandex.com/helpcenter/en/dev/ios/demo-blocks`
- `rg "loadedAd.delegate = self|rewardedAd.show|rewardedAd = nil|didReward|purchaseChatQuota\(chargeType: \.ad" "SodaLive/Sources/Common/YandexAdSupport.swift" "SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift"`
- `lsp_diagnostics`: `SodaLive/Sources/Common/YandexAdSupport.swift`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- 결과:
- 공식 문서에는 iOS Simulator에서 rewarded 콜백이 동작하지 않는다는 제한이 명시되어 있지 않다.
- 공식 문서는 `RewardedAd``RewardedAdLoader`의 강한 참조 유지, 로드 성공 후 delegate 설정을 권장한다.
- `YandexRewardedAdManager.preloadAd`에서 로드 성공 직후 `loadedAd.delegate = self`를 설정하도록 변경했다.
- `showAdIfAvailable`에서 광고 표시 직전 `rewardedAd`를 nil로 만들지 않도록 변경해 표시 생명주기 동안 강한 참조를 유지했다.
- `SodaLive-dev` Debug 빌드 성공.
- SourceKit 단독 LSP는 기존과 동일하게 `YandexMobileAds` 모듈 미해결 환경성 오류를 보고했으나, 실제 `xcodebuild`는 통과했다.