功能:站长礼包系统(金币/经验双类型)+ 后台用户编辑权限收紧(仅 id=1 超管)
新增功能:
- 礼包系统:superlevel 站长可发 888 数量 10 份礼包,支持金币/经验双类型
- 发包前三按钮选择(金币礼包 / 经验礼包 / 取消),使用 chatBanner 弹窗
- 聊天室系统公告含「立即抢包」按钮,金币红色/经验紫色配色区分
- WebSocket 实时推送红包弹窗卡片至所有在线用户
- Redis LPOP 原子分发 + 数据库 unique 约束防重领,并发安全
- 弹窗打开自动拉取服务端最新状态(剩余数量/已领/过期实时刷新)
- 新增 GET /red-packet/{id}/status 状态查询接口
- 新增 CurrencySource::RED_PACKET_RECV / RED_PACKET_RECV_EXP 枚举
安全加固:
- 后台用户编辑/强杀按钮仅 id=1 超管可见(前端隐藏 + 后端 403 双重拦截)
This commit is contained in:
@@ -88,9 +88,17 @@ class UserManagerController extends Controller
|
||||
*/
|
||||
public function update(Request $request, User $user): JsonResponse|RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可编辑用户信息
|
||||
if ($currentUser->id !== 1) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['status' => 'error', 'message' => '仅超级管理员(id=1)可编辑用户信息。'], 403);
|
||||
}
|
||||
abort(403, '仅超级管理员(id=1)可编辑用户信息。');
|
||||
}
|
||||
|
||||
// 越权防护:不能修改 等级大于或等于自己 的目标(除非修改自己)
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
return response()->json(['status' => 'error', 'message' => '权限不足:您无法修改同级或高级管理人员资料。'], 403);
|
||||
@@ -177,9 +185,14 @@ class UserManagerController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
$targetUser = $user;
|
||||
$targetUser = $user;
|
||||
$currentUser = Auth::user();
|
||||
|
||||
// 超级管理员专属:仅 id=1 的账号可删除用户
|
||||
if ($currentUser->id !== 1) {
|
||||
abort(403, '仅超级管理员(id=1)可删除用户。');
|
||||
}
|
||||
|
||||
// 越权防护:不允许删除同级或更高等级的账号
|
||||
if ($targetUser->id !== $currentUser->id && $targetUser->user_level >= $currentUser->user_level) {
|
||||
abort(403, '权限不足:无法删除同级或高级账号!');
|
||||
|
||||
365
app/Http/Controllers/RedPacketController.php
Normal file
365
app/Http/Controllers/RedPacketController.php
Normal file
@@ -0,0 +1,365 @@
|
||||
<?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 = 120;
|
||||
|
||||
/**
|
||||
* 构造函数:注入依赖服务
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ChatStateService $chatState,
|
||||
private readonly UserCurrencyService $currencyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* superlevel 站长凭空发出礼包。
|
||||
*
|
||||
* 不扣发包人自身货币,888 数量凭空发出分 10 份。
|
||||
* type 参数决定本次发出的是金币(gold)还是经验(exp)。
|
||||
*
|
||||
* @param Request $request 需包含 room_id 和 type(gold / 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),
|
||||
]);
|
||||
|
||||
// 将拆分好的数量序列存入 Redis(List,LPOP 抢红包)
|
||||
$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;
|
||||
});
|
||||
|
||||
// 广播系统公告,含可点击「立即抢包」按钮
|
||||
$btnHtml = '<button 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();
|
||||
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'remaining_count' => $remainingCount,
|
||||
'total_count' => $envelope->total_count,
|
||||
'envelope_status' => $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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user