Live Stream replay rental — backend
Replay kiralama backend kontratı
Live Stream Replay Rental - Backend
Endpointler
GET /api/v1/live-stream/:id/replay-statusPOST /api/v1/live-stream/:id/replay-rentalsGET /api/v1/live-stream/my-replay-rentals- Auth:
@ApiAuth(Role.USER)
Amaç
Bitmiş canlı yayın replay kayıtlarını 24 saatliğine kiralatır. Kiralayan kullanıcı süre boyunca recordingUrl görebilir. Süre dolduğunda kiralama kaydı listede kalabilir, ancak recordingUrl response'ta maskelenir.
Owner, broadcaster ve guest kendi yayın replay'ini ödeme yapmadan izleyebilir.
Kiralanabilirlik Kuralı
Bir replay sadece aşağıdaki koşulların tamamı sağlandığında kiralanabilir:
LiveStream.status === ENDED- yayın soft delete edilmemiş
recordingUrlboş değilisActiveReplayOnCreatorProfile === truereplayCreditPrice > 0DUOCROWD/DUOSELFiçin gelir paylaşımı yapılabilecek guest bulunuyor
Bu kurallar LiveStreamReplayAccessService.resolveUnavailableReason içinde merkezi olarak tutulur.
Dosya Haritası
- Controller:
src/live-stream/live-stream.controller.ts - Module wiring:
src/live-stream/live-stream.module.ts - Rental schema:
src/live-stream/schemas/live-stream-replay-rental.schema.ts - Billing transaction enum:
src/live-stream/schemas/stream-billing-transaction.schema.ts - Repository:
src/live-stream/repository/live-stream-replay-rental.repository.ts - Access resolver:
src/live-stream/services/live-stream-replay-access.service.ts - Refund service:
src/live-stream/services/live-stream-replay-rental-refund.service.ts - Status use-case:
src/live-stream/use-cases/get-live-stream-replay-status.usecase.ts - Rent use-case:
src/live-stream/use-cases/rent-live-stream-replay.usecase.ts - My rentals use-case:
src/live-stream/use-cases/get-my-replay-rentals.usecase.ts - Settlement use-case:
src/live-stream/use-cases/settle-live-stream-replay-rentals.usecase.ts - Soft delete integration:
src/live-stream/use-cases/soft-delete-live-stream.usecase.ts - Cron:
src/live-stream/cron/live-stream-cron.service.ts - Mapper masking:
src/live-stream/mappers/live-stream.mapper.ts - User transaction history:
src/users/use-cases/get-my-transaction-history.use-case.ts - Stream transaction history:
src/live-stream/use-cases/get-live-stream-transactions.usecase.ts
Veri Modeli
Collection: LiveStreamReplayRental
Alanlar:
liveStream: kiralanan yayın iduser: kiralayan kullanıcı idstatus:active | expired | refundedrentedAt: kiralama başlangıcıexpiresAt: kiralama bitişipricePaid: kullanıcının ödediği replay kredi tutarıchargePreviousBalance: ücret kesimi öncesi kullanıcı bakiyesichargeNewBalance: ücret kesimi sonrası kullanıcı bakiyesirefundPreviousBalance: iade öncesi kullanıcı bakiyesirefundNewBalance: iade sonrası kullanıcı bakiyesisettlementStatus:pending | distributed | refundedsettledAt: gelir dağıtımı zamanırefundedAt: iade zamanıcreatedAt,updatedAt
Indexler:
{ liveStream: 1, user: 1 }, unique: true
{ user: 1, status: 1, expiresAt: -1 }
{ liveStream: 1, status: 1, expiresAt: 1 }
{ status: 1, settlementStatus: 1, expiresAt: 1 }Önemli kural: Aynı user + liveStream için tek rental access kaydı tutulur. Finansal audit, StreamBillingTransaction kayıtlarında kalır.
Status ve Settlement Status
Rental status:
active: erişim aktif veya cron tarafından henüz expired yapılmamış pending kayıtexpired: 24 saat dolmuş ve gelir dağıtımı yapılmışrefunded: kullanıcıya iade yapılmış
Settlement status:
pending: ücret kesilmiş, gelir dağıtımı/iade bekliyordistributed: gelir creator/guest'e dağıtılmışrefunded: kullanıcıya iade yapılmış
Access response status:
owneractiveexpiredrefundednot_rentedunavailable
Controller Sözleşmesi
@Get('my-replay-rentals')
@ApiAuth(Role.USER)
async getMyReplayRentals(
@CurrentUser() user: JwtPayload,
@Query() query: ListMyReplayRentalsQueryDto,
): Promise<PaginatedResponseDto<LiveStreamReplayRentalResponseDto>>
@Get(':id/replay-status')
@ApiAuth(Role.USER)
async getLiveStreamReplayStatus(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
): Promise<LiveStreamReplayStatusResponseDto>
@Post(':id/replay-rentals')
@ApiAuth(Role.USER)
async rentLiveStreamReplay(
@CurrentUser() user: JwtPayload,
@Param('id') id: string,
): Promise<LiveStreamReplayStatusResponseDto>POST /:id/replay-rentals body almaz.
Response DTO
LiveStreamReplayStatusResponseDto:
type LiveStreamReplayStatusResponseDto = {
streamId: string;
streamStatus: LiveStreamStatus;
isDeleted: boolean;
isReplayAvailable: boolean;
unavailableReason: ReplayUnavailableReason | null;
price: number | null;
rentalDurationHours: 24;
rentalStatus: 'owner' | 'active' | 'expired' | 'refunded' | 'not_rented' | 'unavailable';
canRent: boolean;
canViewRecording: boolean;
rentedAt: string | null;
expiresAt: string | null;
remainingSeconds: number | null;
title: string;
thumbnailUrl: string | null;
creator: UserSummaryDto | null;
liveStreamType: LiveStreamType;
endedAt: string | null;
recordingUrl: string | null;
};recordingUrl sadece canViewRecording === true ise dolu gelir.
LiveStreamReplayRentalResponseDto:
type LiveStreamReplayRentalResponseDto = {
id: string;
streamId: string;
replayAccess: LiveStreamReplayAccessDto;
pricePaid: number;
stream: LiveStreamResponseDto;
createdAt: string;
updatedAt: string;
};stream alanı custom kısa replay objesi değildir. Doğrudan LiveStreamResponseDto shape'i ile döner. Bu response içinde de replayAccess alanı bulunur ve recordingUrl aynı access resolver sonucuna göre maskelenir.
Unavailable reason değerleri:
stream_deletedstream_not_endedrecording_unavailablereplay_disabledreplay_price_missingduo_guest_missing
Access Resolver
LiveStreamReplayAccessService replay erişim kararının tek kaynağıdır.
Sorumlulukları:
- Kiralama uygunluğunu belirlemek
- Owner/broadcaster/guest ücretsiz erişimini belirlemek
- Aktif/expired/refunded rental status normalize etmek
recordingUrlgösterilip gösterilmeyeceğine karar vermekLiveStreamResponseDto.replayAccessalanını üretmek- DUO gelir alıcısı guest'i çözmek
LiveStreamMapper.mapToResponseDto artık default olarak recordingUrl döndürmez. URL döndürmek isteyen akışlar includeRecordingUrl: true opsiyonu ile mapper'a gider. Bu karar LiveStreamReplayAccessService.mapToResponseDto içinde verilir.
Kiralama Akışı
Use-case: RentLiveStreamReplayUseCase
Akış:
streamIdveuserIdObjectId validasyonu yapılır.- Yayın populate edilmiş şekilde okunur.
LiveStreamReplayAccessService.buildStatusile mevcut erişim hesaplanır.- Kullanıcı
ownerveyaactiveise ücret kesilmeden mevcut status döner. canRent === falseise400döner.replayCreditPriceokunur.- Mongo transaction başlar.
- Aynı user/stream rental kaydı transaction içinde tekrar okunur.
- Transaction sırasında aktif ve süresi devam eden rental varsa ücret kesilmez.
- Eski rental
active + pendingama süresi dolmuşsa rerent öncesi eski pencerenin geliri dağıtılır. - Kullanıcı bakiyesi atomik olarak
replayCreditPricekadar düşülür. - Rental yoksa yeni kayıt oluşturulur.
- Rental varsa aynı kayıt tekrar
activeyapılır. StreamBillingTransactioniçinereplay_rental_chargeyazılır.- Transaction commit sonrası kullanıcıya balance socket event gönderilir.
- Yeni replay status döner.
Reactivation alanları:
status = activesettlementStatus = pendingrentedAt = nowexpiresAt = now + 24 saatpricePaid = replayCreditPricesettledAt = nullrefundedAt = null
My Replay Rentals Akışı
Use-case: GetMyReplayRentalsUseCase
Query:
page: default1, max use-case içinde normalize edilirlimit: default20, max100status:active | expired | refunded
Repository status filtresi:
active:status = activeveexpiresAt > nowexpired:status = expiredveyastatus = activeveexpiresAt <= nowrefunded:status = refunded
Her item için:
- rental row bilgisi döner
- populated
liveStream,LiveStreamResponseDtoolarak map edilir replayAccesshesaplanır- aktif erişim yoksa nested
stream.recordingUrlnull kalır
Mapping yapısı:
const liveStream = rental.liveStream as LiveStreamDocument;
const replayStatus = replayAccessService.buildStatusFromRental(
liveStream,
rental,
userId,
);
const stream = await replayAccessService.mapToResponseDto(
liveStream,
userId,
);
return {
id: rental._id.toString(),
streamId: liveStream._id.toString(),
replayAccess: replayAccessService.toAccessDto(replayStatus),
pricePaid: Number(rental.pricePaid ?? 0),
stream,
createdAt: rental.createdAt.toISOString(),
updatedAt: rental.updatedAt.toISOString(),
};replayAccessService.mapToResponseDto iç mapping:
const replayStatus = await replayAccessService.buildStatus(liveStream, userId);
return LiveStreamMapper.mapToResponseDto(liveStream, userId, {
includeRecordingUrl: replayStatus.canViewRecording,
replayAccess: replayAccessService.toAccessDto(replayStatus),
});Bu nedenle LiveStreamReplayRentalResponseDto.stream içinde beklenen shape, mevcut LiveStreamResponseDto ile aynıdır:
type LiveStreamResponseDto = {
id: string;
title: string;
liveStreamType: LiveStreamType;
channelName: string;
broadcasters: LiveStreamBroadcasterResponseDto[];
guests: UserSummaryDto[];
creator: UserSummaryDto | null;
thumbnailUrl: string | null;
recording: boolean;
recordingUrl: string | null;
replayAccess: LiveStreamReplayAccessDto | null;
status: LiveStreamStatus;
accessType: LiveStreamAccessType;
price: number;
interest: string | null;
durationGoal: number | null;
motivation: string | null;
isActiveReplayOnCreatorProfile: boolean | null;
replayCreditPrice: number | null;
startedAt: string | null;
plannedStartDate: string | null;
endedAt: string | null;
plannedEndDate: string | null;
createdAt: string;
updatedAt: string;
role: LiveStreamUserRole | null;
fundingGoal: number | null;
collectedFunding: number | null;
fundingPercentage: number | null;
miniCrowdFundings: MiniCrowdFundingResponseDto[];
};Settlement Cron
Cron: LiveStreamCronService.handleReplayRentalSettlements
Schedule:
@Cron('0 * * * * *') // her dakikaUse-case: SettleLiveStreamReplayRentalsUseCase
Batch:
status = activesettlementStatus = pendingexpiresAt <= nowlimit = 100
Akış:
- Settlement bekleyen expired rental kayıtları alınır.
- Her rental ayrı transaction içinde tekrar okunur.
- Kayıt hala
active + pendingdeğilse skip edilir. - Yayın
findByIdWithDeletedile okunur. - Yayın rental window içinde silinmişse refund yapılır.
- Yayın silinmemişse gelir dağıtılır.
- Rental
status = expired,settlementStatus = distributed,settledAt = nowyapılır. - Commit sonrası balance socket event'leri gönderilir.
Gelir dağıtımı:
SOLO: creatorpricePaid * 0.8DUOCROWD: creatorpricePaid * 0.4, guestpricePaid * 0.4DUOSELF: creatorpricePaid * 0.4, guestpricePaid * 0.4
Platform %20 pay için kullanıcı bakiyesi artırılmaz.
Soft Delete Refund Akışı
Use-case: SoftDeleteLiveStreamUseCase
Yayın soft delete edilirken aynı transaction içinde aktif replay kiralamaları iade edilir.
Refund kapsamı:
liveStream = deleted streamstatus = activesettlementStatus = pendingexpiresAt > now
Refund davranışı:
- Kiralayan kullanıcıya
pricePaidkadar kredi iade edilir. - Rental
status = refundedyapılır. - Rental
settlementStatus = refundedyapılır. refundedAt,refundPreviousBalance,refundNewBalanceyazılır.StreamBillingTransactioniçinereplay_rental_refundyazılır.- Commit sonrası balance socket event gönderilir.
Süresi dolmuş ve dağıtılmış kiralamalar iade edilmez.
Billing Transaction Tipleri
BillingTransactionType yeni değerleri:
REPLAY_RENTAL_CHARGE = 'replay_rental_charge'
REPLAY_RENTAL_REVENUE = 'replay_rental_revenue'
REPLAY_RENTAL_REFUND = 'replay_rental_refund'Kullanım:
replay_rental_charge: kiralayan kullanıcıdan kredi düşümüreplay_rental_revenue: creator/guest gelir yazımıreplay_rental_refund: kiralayan kullanıcıya iade
Bu tipler user transaction history ve stream transaction history aggregation'larına eklendi.
Balance Socket Event Tipleri
BalanceTransactionType yeni değerleri:
REPLAY_RENTAL_CHARGE
REPLAY_RENTAL_REVENUE
REPLAY_RENTAL_REFUNDEvent metadata:
{
streamId: string;
rentalId: string | null;
amount: number;
}Etkilenen Liste/Detail Endpointleri
Aşağıdaki use-case'ler LiveStreamReplayAccessService.mapToResponseDto kullanacak şekilde güncellendi:
GetLiveStreamByIdUseCaseGetPastStreamsUseCaseGetPastStreamsV2UseCaseGetUserPastLiveStreamsUseCaseGetUserPastLiveStreamsV2UseCaseGetMyPastLiveStreamsUseCaseGetMyPastLiveStreamsV2UseCaseGetMyBroadcastedLiveStreamsUseCaseGetMyWatchedLiveStreamsUseCase
Amaç:
- response içinde
replayAccessdönmek recordingUrlsızıntısını engellemek- kiralama süresi dolduğunda URL'yi otomatik null yapmak
Hata Durumları
Status endpoint:
- invalid ObjectId:
400 - yayın yok:
404
Rent endpoint:
- invalid ObjectId:
400 - yayın yok:
404 - replay uygun değil:
400 - fiyat yok:
400 - bakiye yetersiz:
400
My rentals endpoint:
- invalid user id:
400 - invalid status query: validation error
Test Kapsamı
Eklenen test dosyaları:
src/live-stream/services/live-stream-replay-access.service.spec.tssrc/live-stream/use-cases/rent-live-stream-replay.usecase.spec.tssrc/live-stream/use-cases/settle-live-stream-replay-rentals.usecase.spec.ts
Kapsanan ana senaryolar:
- kiralamayan kullanıcı için
recordingUrl = null - aktif rental için
recordingUrldöner - owner ödeme yapmadan replay görebilir
- aktif rental varken tekrar ücret kesilmez
- expired rental aynı kayıt üzerinden tekrar active yapılır
- replay uygun değilse rent reddedilir
- rerent öncesi eski pending pencere settlement yapılır
- SOLO revenue
%80creator'a gider - DUO revenue
%40 + %40creator/guest'e gider - stream rental window içinde silinmişse refund yapılır
Çalıştırılan doğrulama:
npm run build
npm test -- --runInBand src/live-stream/services/live-stream-replay-access.service.spec.ts src/live-stream/use-cases/rent-live-stream-replay.usecase.spec.ts src/live-stream/use-cases/settle-live-stream-replay-rentals.usecase.spec.ts