365 lines
12 KiB
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');
|
|
}
|
|
}
|