laravel + redis + bitmap 实现签到功能

<?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;
    }

    /**
     * 获取指定日期是否有签到
     * @param User $user
     * @param Carbon $date
     * @return bool
     */
    public function hasSigned(User $user, Carbon $date): bool
    {
        $key = $this->buildKey($user, $date);
        $offset = $date->day - 1;

        return (bool)$this->redis->getBit($key, $offset);
    }

    /**
     * 获取连续签到天数
     * @param User $user
     * @param Carbon $endDate
     * @param Carbon|null $startDate
     * @return int
     */
    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;

        // endDate -> startDate
        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;
    }

    /**
     * 获取跨月连续签到次数
     * @param User $user
     * @param Carbon $endDate 截止日期
     * @return int
     */
    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;
    }

    /**
     * 获取单月连续签到次数
     * @param User $user
     * @param Carbon $endDate 截止日期
     * @return int
     */
    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;
    }

    /**
     * 通过bitField获取签到天数
     * @param User $user
     * @param Carbon $endDate 截止时间
     * @return int
     */
    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;
    }

    /**
     * 获取签到天数
     * @param User $user
     * @param Carbon $date
     * @return int
     */
    public function getMonthSignCount(User $user, Carbon $date): int
    {
        $key = $this->buildKey($user, $date);

        return $this->redis->bitCount($key);
    }

    /**
     * 获取签到map
     * @param User $user
     * @param Carbon $endDate 截止日期
     * @return array
     */
    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;
    }

    /**
     * @param string $key
     * @param int $length
     * @param ?int $offset
     * @return int
     */
    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;
    }

    /**
     * 生成key,因为 BITFIELD 指令无符号获取的偏移量最大是63,所以一个key只存一个月份的数据
     * @param User $user
     * @param Carbon $date
     * @return string
     */
    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
    {
        /**
         * @var PhpRedisConnection $redis
         */
        $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 协议》,转载必须注明作者和本文链接
本帖由系统于 1年前 自动加精
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!