Files
chatroom/app/Http/Controllers/RedPacketController.php

375 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 文件功能:聊天室礼包(红包)控制器
*
* 提供两个核心接口:
* - send() superlevel 站长凭空发出 888 数量 10 份礼包(金币 or 经验)
* - claim() :在线用户抢礼包(先到先得,每人一份)
*
* 接入 UserCurrencyService 记录所有货币变动流水。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers;
use App\Enums\CurrencySource;
use App\Events\MessageSent;
use App\Events\RedPacketClaimed;
use App\Events\RedPacketSent;
use App\Jobs\SaveMessageJob;
use App\Models\RedPacketClaim;
use App\Models\RedPacketEnvelope;
use App\Models\Sysparam;
use App\Services\ChatStateService;
use App\Services\UserCurrencyService;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RedPacketController extends Controller
{
/** 礼包固定总数量 */
private const TOTAL_AMOUNT = 888;
/** 礼包固定份数 */
private const TOTAL_COUNT = 10;
/** 礼包有效期(秒) */
private const EXPIRE_SECONDS = 300;
/**
* 构造函数:注入依赖服务
*/
public function __construct(
private readonly ChatStateService $chatState,
private readonly UserCurrencyService $currencyService,
) {}
/**
* superlevel 站长凭空发出礼包。
*
* 不扣发包人自身货币888 数量凭空发出分 10 份。
* type 参数决定本次发出的是金币gold还是经验exp
*
* @param Request $request 需包含 room_id 和 typegold / exp
*/
public function send(Request $request): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
'type' => 'required|in:gold,exp',
]);
$user = Auth::user();
$roomId = (int) $request->input('room_id');
$type = $request->input('type'); // 'gold' 或 'exp'
// 权限校验:仅 superlevel 可发礼包
$superLevel = (int) Sysparam::getValue('superlevel', '100');
if ($user->user_level < $superLevel) {
return response()->json(['status' => 'error', 'message' => '仅站长可发礼包红包'], 403);
}
// 检查该用户在此房间是否有进行中的红包(防止刷包)
$activeExists = RedPacketEnvelope::query()
->where('sender_id', $user->id)
->where('room_id', $roomId)
->where('status', 'active')
->where('expires_at', '>', now())
->exists();
if ($activeExists) {
return response()->json(['status' => 'error', 'message' => '您有一个礼包尚未领完,请稍后再发!'], 422);
}
// 随机拆分数量(二倍均值法,保证每份至少 1总额精确等于 TOTAL_AMOUNT
$amounts = $this->splitAmount(self::TOTAL_AMOUNT, self::TOTAL_COUNT);
// 货币展示文案
$typeLabel = $type === 'exp' ? '经验' : '金币';
$typeIcon = $type === 'exp' ? '✨' : '💰';
$btnBg = $type === 'exp'
? 'linear-gradient(135deg,#7c3aed,#4f46e5)'
: 'linear-gradient(135deg,#dc2626,#ea580c)';
// 事务:创建红包记录 + Redis 写入分额
$envelope = DB::transaction(function () use ($user, $roomId, $type, $amounts): RedPacketEnvelope {
// 创建红包主记录(凭空发出,不扣发包人货币)
$envelope = RedPacketEnvelope::create([
'sender_id' => $user->id,
'sender_username' => $user->username,
'room_id' => $roomId,
'type' => $type,
'total_amount' => self::TOTAL_AMOUNT,
'total_count' => self::TOTAL_COUNT,
'claimed_count' => 0,
'claimed_amount' => 0,
'status' => 'active',
'expires_at' => now()->addSeconds(self::EXPIRE_SECONDS),
]);
// 将拆分好的数量序列存入 RedisListLPOP 抢红包)
$key = "red_packet:{$envelope->id}:amounts";
foreach ($amounts as $amt) {
\Illuminate\Support\Facades\Redis::rpush($key, $amt);
}
// 多留 60s确保领完后仍可回查
\Illuminate\Support\Facades\Redis::expire($key, self::EXPIRE_SECONDS + 60);
return $envelope;
});
// 广播系统公告,含可点击「立即抢包」按钮
// 注意这里不能死命传 self::EXPIRE_SECONDS因为这句话会被存入数据库的历史记录。我们需要在取出来的时候能根据发包时间动态变化
// 啊等等!由于这条消息是直接静态写入 `chat_messages` 内容里的,这就意味着如果在这里计算,存进去的还是 300。
// 所以我们还是传 `self::EXPIRE_SECONDS` 作为总寿命,在前端逻辑里利用 `Date.now()` 和消息的 `sent_at` 来算出真实剩余倒计时更为严谨!
$btnHtml = '<button data-sent-at="'.time().'" onclick="showRedPacketModal('
.$envelope->id
.',\''.$user->username.'\','
.self::TOTAL_AMOUNT.','
.self::TOTAL_COUNT.','
.self::EXPIRE_SECONDS
.',\''.$type.'\''
.')" style="margin-left:8px;padding:2px 10px;background:'.$btnBg.';'
.'color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:12px;font-weight:bold;'
.'box-shadow:0 2px 6px rgba(0,0,0,0.3);">'.$typeIcon.' 立即抢包</button>';
$msg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统公告',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 发出了一个 <b>".self::TOTAL_AMOUNT."</b> {$typeLabel}的礼包!共 ".self::TOTAL_COUNT." 份,先到先得,快去抢!{$btnHtml}",
'is_secret' => false,
'font_color' => $type === 'exp' ? '#6d28d9' : '#b91c1c',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $msg);
broadcast(new MessageSent($roomId, $msg));
SaveMessageJob::dispatch($msg);
// 广播红包事件(触发前端弹出红包卡片)
broadcast(new RedPacketSent(
roomId: $roomId,
envelopeId: $envelope->id,
senderUsername: $user->username,
totalAmount: self::TOTAL_AMOUNT,
totalCount: self::TOTAL_COUNT,
expireSeconds: self::EXPIRE_SECONDS,
type: $type,
));
return response()->json([
'status' => 'success',
'message' => "🧧 {$typeLabel}礼包已发出!".self::TOTAL_AMOUNT." {$typeLabel} · ".self::TOTAL_COUNT.' 份',
]);
}
/**
* 查询礼包当前状态(弹窗打开时实时刷新用)。
*
* 返回:剩余份数、是否已过期、当前用户是否已领取。
*
* @param int $envelopeId 红包 ID
*/
public function status(int $envelopeId): JsonResponse
{
$envelope = RedPacketEnvelope::find($envelopeId);
if (! $envelope) {
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
}
$user = Auth::user();
$isExpired = $envelope->expires_at->isPast();
$remainingCount = $envelope->remainingCount();
$hasClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
->where('user_id', $user->id)
->exists();
// 若已过期但 status 尚未同步,顺手更新为 expired
if ($isExpired && $envelope->status === 'active') {
$envelope->update(['status' => 'expired']);
}
return response()->json([
'status' => 'success',
'remaining_count' => $remainingCount,
'total_count' => $envelope->total_count,
'envelope_status' => $isExpired ? 'expired' : $envelope->status,
'is_expired' => $isExpired,
'has_claimed' => $hasClaimed,
'type' => $envelope->type ?? 'gold',
]);
}
/**
* 用户抢礼包(先到先得)。
*
* 使用 Redis LPOP 原子操作获取数量,再写入数据库流水。
* 重复领取通过 unique 约束保障幂等性。
* 按红包 type 字段决定入账金币还是经验。
*
* @param Request $request 需包含 room_id
* @param int $envelopeId 红包 ID
*/
public function claim(Request $request, int $envelopeId): JsonResponse
{
$request->validate([
'room_id' => 'required|integer',
]);
$user = Auth::user();
$roomId = (int) $request->input('room_id');
// 加载红包记录
$envelope = RedPacketEnvelope::find($envelopeId);
if (! $envelope) {
return response()->json(['status' => 'error', 'message' => '红包不存在'], 404);
}
// 检查红包是否可领
if (! $envelope->isClaimable()) {
return response()->json(['status' => 'error', 'message' => '红包已抢完或已过期'], 422);
}
// 检查是否已领取过
$alreadyClaimed = RedPacketClaim::where('envelope_id', $envelopeId)
->where('user_id', $user->id)
->exists();
if ($alreadyClaimed) {
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 从 Redis 原子 POP 一份数量
$redisKey = "red_packet:{$envelopeId}:amounts";
$amount = \Illuminate\Support\Facades\Redis::lpop($redisKey);
if ($amount === null || $amount === false) {
return response()->json(['status' => 'error', 'message' => '礼包已被抢完!'], 422);
}
$amount = (int) $amount;
// 兼容旧记录type 字段可能为 null
$envelopeType = $envelope->type ?? 'gold';
// 事务:写领取记录 + 更新统计 + 货币入账
try {
DB::transaction(function () use ($envelope, $user, $amount, $roomId, $envelopeType): void {
// 写领取记录unique 约束保障不重复)
RedPacketClaim::create([
'envelope_id' => $envelope->id,
'user_id' => $user->id,
'username' => $user->username,
'amount' => $amount,
'claimed_at' => now(),
]);
// 更新红包统计
$envelope->increment('claimed_count');
$envelope->increment('claimed_amount', $amount);
// 若已全部领完,关闭红包
$envelope->refresh();
if ($envelope->claimed_count >= $envelope->total_count) {
$envelope->update(['status' => 'completed']);
}
// 按类型入账(金币或经验)
if ($envelopeType === 'exp') {
$this->currencyService->change(
$user,
'exp',
$amount,
CurrencySource::RED_PACKET_RECV_EXP,
"抢到礼包 {$amount} 经验(红包#{$envelope->id}",
$roomId,
);
} else {
$this->currencyService->change(
$user,
'gold',
$amount,
CurrencySource::RED_PACKET_RECV,
"抢到礼包 {$amount} 金币(红包#{$envelope->id}",
$roomId,
);
}
});
} catch (UniqueConstraintViolationException) {
// 并发重复领取:将数量放回 Redis补偿
\Illuminate\Support\Facades\Redis::rpush($redisKey, $amount);
return response()->json(['status' => 'error', 'message' => '您已经领过这个礼包了'], 422);
}
// 广播领取事件(给自己的私有频道,前端弹 Toast
broadcast(new RedPacketClaimed($user, $amount, $envelope->id));
// 在聊天室发送领取播报(所有人可见)
$typeLabel = $envelopeType === 'exp' ? '经验' : '金币';
$typeIcon = $envelopeType === 'exp' ? '✨' : '💰';
$claimedMsg = [
'id' => $this->chatState->nextMessageId($roomId),
'room_id' => $roomId,
'from_user' => '系统传音',
'to_user' => '',
'content' => "🧧 <b>{$user->username}</b> 抢到了 <b>{$amount}</b> {$typeLabel}礼包!{$typeIcon}",
'is_secret' => false,
'font_color' => $envelopeType === 'exp' ? '#6d28d9' : '#d97706',
'action' => '',
'sent_at' => now()->toDateTimeString(),
];
$this->chatState->pushMessage($roomId, $claimedMsg);
broadcast(new MessageSent($roomId, $claimedMsg));
SaveMessageJob::dispatch($claimedMsg);
$balanceField = $envelopeType === 'exp' ? 'exp_num' : 'jjb';
$balanceNow = $user->fresh()->$balanceField;
return response()->json([
'status' => 'success',
'amount' => $amount,
'type' => $envelopeType,
'message' => "🧧 恭喜!您抢到了 {$amount} {$typeLabel}!当前{$typeLabel}{$balanceNow}",
]);
}
/**
* 随机拆分礼包数量。
*
* 使用「二倍均值法」:每次随机数量不超过剩余均值的 2 倍,
* 保证每份至少 1 且总额精确等于 totalAmount。
*
* @param int $total 总数量
* @param int $count 份数
* @return int[] 每份数量数组
*/
private function splitAmount(int $total, int $count): array
{
$amounts = [];
$remaining = $total;
for ($i = 1; $i < $count; $i++) {
$leftCount = $count - $i;
$max = min((int) floor($remaining / $leftCount * 2), $remaining - $leftCount);
$max = max(1, $max);
$amount = random_int(1, $max);
$amounts[] = $amount;
$remaining -= $amount;
}
// 最后一份为剩余全部
$amounts[] = $remaining;
// 打乱顺序,避免后来者必得少
shuffle($amounts);
return $amounts;
}
}