ActivityQuotaService.php 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. <?php
  2. namespace App\Services;
  3. use App\Models\ActivityReward;
  4. use App\Models\ActivityRewardRecipient;
  5. use Illuminate\Support\Facades\DB;
  6. class ActivityQuotaService
  7. {
  8. /** A preliminary check; award() always checks again under the activity lock. */
  9. public static function canAward(ActivityReward $activity, $memberId): bool
  10. {
  11. if ((int)$activity->type === 2) return true;
  12. if (!$activity->quota_enabled) {
  13. return $activity->budget === null || bccomp((string)($activity->budget_used ?? '0'), (string)$activity->budget, 2) < 0;
  14. }
  15. $member = static::memberKey($memberId);
  16. if ($member === null) return false;
  17. if (static::hasReceived($activity->id, $member)) return true;
  18. return $activity->reward_quota === null || (int)$activity->rewarded_count < (int)$activity->reward_quota;
  19. }
  20. /** Count a member only on their first successful positive reward for this activity. */
  21. public static function award(ActivityReward $activity, $memberId, $amount, callable $grant): bool
  22. {
  23. $member = static::memberKey($memberId);
  24. if ($member === null || !is_numeric($amount)
  25. || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$amount)
  26. || bccomp((string)$amount, '0', 2) <= 0) {
  27. return false;
  28. }
  29. return DB::transaction(function () use ($activity, $member, $amount, $grant) {
  30. $locked = ActivityReward::where('id', $activity->id)->lockForUpdate()->first();
  31. if (!$locked) return false;
  32. if ((int)$locked->type === 2) {
  33. $grant();
  34. return true;
  35. }
  36. if (!$locked->quota_enabled) return ActivityBudgetService::award($locked, $amount, $grant);
  37. // Match the locked counter with a current read, even inside an older caller snapshot.
  38. $received = static::hasReceived($locked->id, $member, true);
  39. if (!$received && $locked->reward_quota !== null
  40. && (int)$locked->rewarded_count >= (int)$locked->reward_quota) {
  41. return false;
  42. }
  43. $grant();
  44. if (!$received) {
  45. ActivityRewardRecipient::create([
  46. 'activity_id' => $locked->id,
  47. 'member_id' => $member,
  48. 'created_at' => date('Y-m-d H:i:s'),
  49. ]);
  50. $locked->rewarded_count = (int)$locked->rewarded_count + 1;
  51. $locked->save();
  52. }
  53. return true;
  54. });
  55. }
  56. private static function hasReceived($activityId, string $memberId, bool $currentRead = false): bool
  57. {
  58. $query = ActivityRewardRecipient::where('activity_id', $activityId)->where('member_id', $memberId);
  59. return $currentRead ? $query->lockForUpdate()->first() !== null : $query->exists();
  60. }
  61. private static function memberKey($memberId): ?string
  62. {
  63. if (!is_int($memberId) && !is_string($memberId)) return null;
  64. $member = trim((string)$memberId);
  65. return $member === '' || $member === '0' || strlen($member) > 64 ? null : $member;
  66. }
  67. }