api/app/Services/GenerationCreditService.php

365 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Enums\GenerationCreditTransactionType;
use App\Enums\RewardedAdSessionStatus;
use App\Exceptions\InsufficientGenerationCredits;
use App\Exceptions\InvalidRewardedAdCallback;
use App\Exceptions\MissingRewardedAdUnit;
use App\Exceptions\RewardedAdUnavailable;
use App\Models\GenerationCreditBalance;
use App\Models\GenerationCreditTransaction;
use App\Models\RewardedAdSession;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class GenerationCreditService
{
public function balance(User $user): int
{
return (int) (GenerationCreditBalance::query()
->whereKey($user->getKey())
->value('balance') ?? 0);
}
/**
* @return array{balance: int, canWatchRewardedAd: bool, nextRewardedAdAt: ?string, rewardedAdConfigured: bool, serverTime: string}
*/
public function summary(User $user): array
{
$now = $this->now();
$rewardedToday = $this->hasRewardedAdCreditForDate($user, $now->toDateString());
$hasAdUnit = $this->hasAnyRewardedAdUnit();
return [
'balance' => $this->balance($user),
'canWatchRewardedAd' => $hasAdUnit && ! $rewardedToday,
'nextRewardedAdAt' => $rewardedToday
? $now->addDay()->startOfDay()->utc()->toIso8601String()
: null,
'rewardedAdConfigured' => $hasAdUnit,
'serverTime' => $now->utc()->toIso8601String(),
];
}
public function createRewardedAdSession(User $user, string $platform): RewardedAdSession
{
$now = $this->now();
$rewardDate = $now->toDateString();
if ($this->hasRewardedAdCreditForDate($user, $rewardDate)) {
throw new RewardedAdUnavailable(__('api.generation_credits.rewarded_ad_unavailable'));
}
$adUnitId = $this->rewardedAdUnitId($platform);
if ($adUnitId === null) {
throw new MissingRewardedAdUnit(__('api.generation_credits.rewarded_ad_not_configured'));
}
$existingSession = RewardedAdSession::query()
->where('user_id', $user->getKey())
->where('status', RewardedAdSessionStatus::CREATED->value)
->whereDate('reward_date', $rewardDate)
->where('expires_at', '>', now())
->latest()
->first();
if ($existingSession) {
return $existingSession;
}
return RewardedAdSession::create([
'user_id' => $user->getKey(),
'custom_data' => Str::random(64),
'reward_date' => $rewardDate,
'expires_at' => now()->addMinutes((int) config('admob.rewarded.session_ttl_minutes', 30)),
'ad_unit_id' => $adUnitId,
]);
}
public function rewardFromAdMobCallback(array $payload): void
{
$customData = $this->requiredString($payload, 'custom_data');
$transactionId = $this->requiredString($payload, 'transaction_id');
$userId = $payload['user_id'] ?? null;
$adUnit = $payload['ad_unit'] ?? null;
$rewardAmount = isset($payload['reward_amount']) ? (int) $payload['reward_amount'] : null;
$rewardItem = isset($payload['reward_item']) ? (string) $payload['reward_item'] : null;
$session = RewardedAdSession::query()
->where('custom_data', $customData)
->first();
if (! $session) {
throw new InvalidRewardedAdCallback('Unknown rewarded ad session.');
}
if (is_string($userId) && $userId !== '' && $userId !== $session->user_id) {
throw new InvalidRewardedAdCallback('Rewarded ad user mismatch.');
}
$this->validateAdUnit($adUnit);
$this->validateReward($rewardAmount, $rewardItem);
DB::transaction(function () use ($session, $transactionId, $payload, $rewardAmount, $rewardItem): void {
$session = RewardedAdSession::query()
->whereKey($session->getKey())
->lockForUpdate()
->firstOrFail();
if ($session->status === RewardedAdSessionStatus::REWARDED) {
return;
}
if ($session->expires_at->isPast()) {
$session->update([
'status' => RewardedAdSessionStatus::EXPIRED,
'callback_payload' => $payload,
'admob_transaction_id' => $transactionId,
'reward_amount' => $rewardAmount,
'reward_item' => $rewardItem,
]);
return;
}
if (GenerationCreditTransaction::query()->where('idempotency_key', $this->adRewardIdempotencyKey($transactionId))->exists()) {
return;
}
$dailyRewardKey = $this->dailyRewardKey($session->reward_date->toDateString());
if (GenerationCreditTransaction::query()
->where('user_id', $session->user_id)
->where('daily_reward_key', $dailyRewardKey)
->exists()) {
$session->update([
'status' => RewardedAdSessionStatus::REJECTED,
'callback_payload' => $payload,
'admob_transaction_id' => $transactionId,
'reward_amount' => $rewardAmount,
'reward_item' => $rewardItem,
]);
return;
}
$balance = $this->balanceForUpdate($session->user);
$balance->balance += 1;
$balance->save();
GenerationCreditTransaction::create([
'user_id' => $session->user_id,
'amount' => 1,
'type' => GenerationCreditTransactionType::AD_REWARD,
'source_type' => $session::class,
'source_id' => $session->getKey(),
'idempotency_key' => $this->adRewardIdempotencyKey($transactionId),
'daily_reward_key' => $dailyRewardKey,
'metadata' => [
'admob' => $payload,
],
]);
$session->update([
'status' => RewardedAdSessionStatus::REWARDED,
'rewarded_at' => now(),
'callback_payload' => $payload,
'admob_transaction_id' => $transactionId,
'reward_amount' => $rewardAmount,
'reward_item' => $rewardItem,
]);
});
}
public function spendForGeneration(User $user): GenerationCreditTransaction
{
return DB::transaction(function () use ($user): GenerationCreditTransaction {
$balance = $this->balanceForUpdate($user);
if ($balance->balance < 1) {
throw new InsufficientGenerationCredits(__('api.generation_credits.insufficient'));
}
$balance->balance -= 1;
$balance->save();
return GenerationCreditTransaction::create([
'user_id' => $user->getKey(),
'amount' => -1,
'type' => GenerationCreditTransactionType::GENERATION_SPEND,
'metadata' => [
'feature' => 'meal_image_analysis',
],
]);
});
}
public function refundGenerationSpend(GenerationCreditTransaction $spend): void
{
DB::transaction(function () use ($spend): void {
if (GenerationCreditTransaction::query()->where('idempotency_key', $this->refundIdempotencyKey($spend))->exists()) {
return;
}
$balance = $this->balanceForUpdate($spend->user);
$balance->balance += abs($spend->amount);
$balance->save();
GenerationCreditTransaction::create([
'user_id' => $spend->user_id,
'amount' => abs($spend->amount),
'type' => GenerationCreditTransactionType::REFUND,
'source_type' => $spend::class,
'source_id' => $spend->getKey(),
'idempotency_key' => $this->refundIdempotencyKey($spend),
'metadata' => [
'feature' => 'meal_image_analysis',
],
]);
});
}
private function balanceForUpdate(User $user): GenerationCreditBalance
{
GenerationCreditBalance::query()->firstOrCreate([
'user_id' => $user->getKey(),
], [
'balance' => 0,
]);
return GenerationCreditBalance::query()
->whereKey($user->getKey())
->lockForUpdate()
->firstOrFail();
}
private function hasRewardedAdCreditForDate(User $user, string $date): bool
{
return GenerationCreditTransaction::query()
->where('user_id', $user->getKey())
->where('daily_reward_key', $this->dailyRewardKey($date))
->exists();
}
private function dailyRewardKey(string $date): string
{
return 'ad:'.$date;
}
private function rewardedAdUnitId(string $platform): ?string
{
$key = match ($platform) {
'android' => 'android_ad_unit_id',
'ios' => 'ios_ad_unit_id',
default => null,
};
if ($key === null) {
return null;
}
$adUnitId = trim((string) config("admob.rewarded.{$key}", ''));
return $adUnitId !== '' ? $adUnitId : null;
}
private function hasAnyRewardedAdUnit(): bool
{
return $this->rewardedAdUnitId('android') !== null
|| $this->rewardedAdUnitId('ios') !== null;
}
private function validateAdUnit(mixed $adUnit): void
{
$expectedAdUnits = $this->expectedRewardedAdUnits();
if ($expectedAdUnits === []) {
return;
}
if (! is_string($adUnit) || ! in_array($adUnit, $this->normalizedExpectedAdUnits($expectedAdUnits), true)) {
throw new InvalidRewardedAdCallback('Unexpected rewarded ad unit.');
}
}
/**
* @param array<int, mixed> $expectedAdUnits
* @return array<int, string>
*/
private function normalizedExpectedAdUnits(array $expectedAdUnits): array
{
$normalized = [];
foreach ($expectedAdUnits as $expectedAdUnit) {
if (! is_string($expectedAdUnit) || trim($expectedAdUnit) === '') {
continue;
}
$expectedAdUnit = trim($expectedAdUnit);
$normalized[] = $expectedAdUnit;
if (str_contains($expectedAdUnit, '/')) {
$normalized[] = str($expectedAdUnit)->afterLast('/')->toString();
}
}
return array_values(array_unique($normalized));
}
/**
* @return array<int, string>
*/
private function expectedRewardedAdUnits(): array
{
return array_values(array_filter([
$this->rewardedAdUnitId('android'),
$this->rewardedAdUnitId('ios'),
]));
}
private function validateReward(?int $rewardAmount, ?string $rewardItem): void
{
$expectedRewardAmount = (int) config('admob.rewarded.reward_amount', 1);
$expectedRewardItem = trim((string) config('admob.rewarded.reward_item', ''));
if ($rewardAmount !== null && $rewardAmount !== $expectedRewardAmount) {
throw new InvalidRewardedAdCallback('Unexpected rewarded ad amount.');
}
if ($expectedRewardItem !== '' && $rewardItem !== null && $rewardItem !== $expectedRewardItem) {
throw new InvalidRewardedAdCallback('Unexpected rewarded ad item.');
}
}
private function requiredString(array $payload, string $key): string
{
$value = $payload[$key] ?? null;
if (! is_string($value) || $value === '') {
throw new InvalidRewardedAdCallback("Missing rewarded ad {$key}.");
}
return $value;
}
private function adRewardIdempotencyKey(string $transactionId): string
{
return 'admob:'.$transactionId;
}
private function refundIdempotencyKey(GenerationCreditTransaction $spend): string
{
return 'refund:'.$spend->getKey();
}
private function now(): CarbonImmutable
{
return CarbonImmutable::now('UTC');
}
}