<?php
namespace App\Services;
use App\Events\UserSignedIn;
use App\Models\User;
use App\Models\UserSignIn;
use Illuminate\Redis\Connections\Connection as RedisConnection;
use Illuminate\Redis\Connections\PhpRedisConnection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Redis;
class SignInService extends Service
{
protected PhpRedisConnection | RedisConnection $redis;
public function __construct($redis = null)
{
$this->redis = $redis ?? Redis::connection('sign_in');
}
public function signIn(User $user, Carbon $date): bool
{
$key = $this->buildKey($user, $date);
$offset = $date->day - 1;
$signedIn = (bool)$this->redis->getBit($key, $offset);
if (!$signedIn) {
$this->redis->setBit($key, $offset, 1);
$continuousDays = $this->getMonthsContinuousSignCount($user, $date);
$periodsDays = $this->getMonthContinuousSignCount($user, $date);
$signIn = UserSignIn::create([
'user_id' => $user->id,
'continuous_days' => $continuousDays,
'periods_days' => $periodsDays,
'date' => $date->toDateString(),
]);
UserSignedIn::dispatch($signIn);
}
return true;
}
public function hasSigned(User $user, Carbon $date): bool
{
$key = $this->buildKey($user, $date);
$offset = $date->day - 1;
return (bool)$this->redis->getBit($key, $offset);
}
public function getContinuousSignCount(User $user, Carbon $endDate, ?Carbon $startDate = null): int
{
$startDate ??= $endDate->copy()->startOfMonth();
[$startDate, $endDate] = [$startDate->copy()->startOfDay(), $endDate->copy()->startOfDay()];
$currentDate = $endDate->copy();
$totalSignCount = 0;
while ($currentDate->gte($startDate)) {
$key = $this->buildKey($user, $currentDate);
$_startDate = $startDate->max($currentDate->copy()->startOfMonth());
$days = $_startDate->diffInDays($currentDate) + 1;
$offset = $_startDate->day - 1;
$count = $this->getBitField($key, $days, $offset);
$signCount = 0;
while ($count & 1) {
$signCount++;
$count >>= 1;
}
$totalSignCount += $signCount;
if ($signCount < $days) {
break;
}
$currentDate->startOfMonth()->subDay();
}
return $totalSignCount;
}
public function getMonthsContinuousSignCount(User $user, Carbon $endDate): int
{
$signCount = $this->getMonthContinuousSignCount($user, $endDate);
if ($signCount == $endDate->day) {
$signCount += $this->getMonthsContinuousSignCount($user, $endDate->copy()->startOfMonth()->subDay());
}
return $signCount;
}
public function getMonthContinuousSignCount(User $user, Carbon $endDate): int
{
$key = $this->buildKey($user, $endDate);
$count = $this->getBitField($key, $endDate->day);
$signCount = 0;
while ($count & 1) {
$signCount++;
$count >>= 1;
}
return $signCount;
}
public function getMonthSignCountByBitField(User $user, Carbon $endDate): int
{
$key = $this->buildKey($user, $endDate);
$count = $this->getBitField($key, $endDate->day);
$signCount = $count & 1;
while ($count >>= 1) {
$signCount++;
}
return $signCount;
}
public function getMonthSignCount(User $user, Carbon $date): int
{
$key = $this->buildKey($user, $date);
return $this->redis->bitCount($key);
}
public function getMonthSignMap(User $user, Carbon $endDate): array
{
$key = $this->buildKey($user, $endDate);
$count = $this->getBitField($key, $endDate->day);
$day = $endDate->day;
$map = array_fill(0, $day, 0);
while ($day--) {
$map[$day] = $count & 1;
if (!($count >>= 1)) {
break;
}
}
return $map;
}
protected function getBitField(string $key, int $length, ?int $offset = 0): int
{
[$count] = call_user_func([$this->redis, 'eval'], <<<LUA
return redis.call('BITFIELD', KEYS[1], 'GET', ARGV[1], ARGV[2])
LUA, 1, $key, 'u'.$length, $offset);
return $count ?? 0;
}
protected function buildKey(User $user, Carbon $date): string
{
return sprintf('sign_in:%u:%u', $user->id, $date->format('Ym'));
}
}
<?php
namespace Tests\Feature;
use App\Events\UserSignedIn;
use App\Models\User;
use App\Services\SignInService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Redis\Connections\PhpRedisConnection;
use Tests\TestCase;
class SignInTest extends TestCase
{
public function test_sign_in(): void
{
$redis = app('redis')->connection('sign_in');
$redis->flushAll();
$service = new SignInService($redis);
$user = User::first();
$now = now()->startOfMonth()->addDays(2);
app('events')->forget(UserSignedIn::class);
collect([...range(0, 5), ...range(7, 10), ...range(9, 15)])
->sortDesc()
->each(fn ($daysAgo) => $service->signIn($user, $now->copy()->subDays($daysAgo)));
$this->assertTrue($service->getContinuousSignCount($user, $now) === 3);
$this->assertTrue($service->getContinuousSignCount($user, $now, $now->copy()->subDays(3)) === 4);
$this->assertTrue($service->getContinuousSignCount($user, $now, $now->copy()->subDay()) === 2);
$this->assertTrue($service->getMonthsContinuousSignCount($user, $now) === 6);
$this->assertTrue($service->getMonthContinuousSignCount($user, $now) === 3);
$this->assertTrue(array_is_list($service->getMonthSignMap($user, $now)));
$this->assertTrue(array_slice($service->getMonthSignMap($user, $now->copy()->addDay()), -1)[0] === 0);
}
public function test_sign_score()
{
$service = app(SignInService::class);
$user = User::first();
$now = now()->startOfMonth()->addDays(3);
$service->signIn($user, $now);
$service->signIn($user, $now);
$service->signIn($user, $now->copy()->addDay());
$this->assertTrue($user->score->usable_score == 65);
}
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結