diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 66af706..04d7d59 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -6,6 +6,7 @@ * 对应数据表:user_currency_logs.source(varchar 字段,非 ENUM,可自由扩展) * * @author ChatRoom Laravel + * * @version 1.0.0 */ @@ -14,7 +15,7 @@ namespace App\Enums; enum CurrencySource: string { /** 自动存点(Horizon 定时任务,每5分钟给在线用户加经验/金币) */ - case AUTO_SAVE = 'auto_save'; + case AUTO_SAVE = 'auto_save'; /** 钓鱼收竿奖励(获得经验或金币) */ case FISHING_GAIN = 'fishing_gain'; @@ -23,20 +24,23 @@ enum CurrencySource: string case FISHING_COST = 'fishing_cost'; /** 送出礼物(送方扣金币) */ - case SEND_GIFT = 'send_gift'; + case SEND_GIFT = 'send_gift'; /** 收到礼物(收方魅力增加) */ - case RECV_GIFT = 'recv_gift'; + case RECV_GIFT = 'recv_gift'; /** 新人礼包(首次登录赠送金币) */ case NEWBIE_BONUS = 'newbie_bonus'; /** 商城购买消耗(扣除金币) */ - case SHOP_BUY = 'shop_buy'; + case SHOP_BUY = 'shop_buy'; /** 管理员手动调整(后台直接修改经验/金币/魅力) */ case ADMIN_ADJUST = 'admin_adjust'; + /** 职务奖励(在职管理员通过名片弹窗向用户发放奖励金币) */ + case POSITION_REWARD = 'position_reward'; + // ─── 以后新增活动,在这里加一行即可,数据库无需变更 ─────────── // case SIGN_IN = 'sign_in'; // 每日签到 // case TASK_REWARD = 'task_reward'; // 任务奖励 @@ -48,14 +52,15 @@ enum CurrencySource: string public function label(): string { return match ($this) { - self::AUTO_SAVE => '自动存点', + self::AUTO_SAVE => '自动存点', self::FISHING_GAIN => '钓鱼奖励', self::FISHING_COST => '钓鱼消耗', - self::SEND_GIFT => '送出礼物', - self::RECV_GIFT => '收到礼物', + self::SEND_GIFT => '送出礼物', + self::RECV_GIFT => '收到礼物', self::NEWBIE_BONUS => '新人礼包', - self::SHOP_BUY => '商城购买', + self::SHOP_BUY => '商城购买', self::ADMIN_ADJUST => '管理员调整', + self::POSITION_REWARD => '职务奖励', }; } } diff --git a/app/Http/Controllers/Admin/PositionController.php b/app/Http/Controllers/Admin/PositionController.php index 3529691..0bedcb5 100644 --- a/app/Http/Controllers/Admin/PositionController.php +++ b/app/Http/Controllers/Admin/PositionController.php @@ -50,6 +50,8 @@ class PositionController extends Controller 'level' => 'required|integer|min:1|max:100', 'max_persons' => 'nullable|integer|min:1', 'max_reward' => 'nullable|integer|min:0', + 'daily_reward_limit' => 'nullable|integer|min:0', + 'recipient_daily_limit' => 'nullable|integer|min:0', 'sort_order' => 'required|integer|min:0', 'appointable_ids' => 'nullable|array', 'appointable_ids.*' => 'exists:positions,id', @@ -79,6 +81,8 @@ class PositionController extends Controller 'level' => 'required|integer|min:1|max:100', 'max_persons' => 'nullable|integer|min:1', 'max_reward' => 'nullable|integer|min:0', + 'daily_reward_limit' => 'nullable|integer|min:0', + 'recipient_daily_limit' => 'nullable|integer|min:0', 'sort_order' => 'required|integer|min:0', 'appointable_ids' => 'nullable|array', 'appointable_ids.*' => 'exists:positions,id', diff --git a/app/Http/Controllers/AdminCommandController.php b/app/Http/Controllers/AdminCommandController.php index 0801dba..ce0babb 100644 --- a/app/Http/Controllers/AdminCommandController.php +++ b/app/Http/Controllers/AdminCommandController.php @@ -15,12 +15,15 @@ namespace App\Http\Controllers; +use App\Enums\CurrencySource; use App\Events\MessageSent; use App\Jobs\SaveMessageJob; use App\Models\Message; +use App\Models\PositionAuthorityLog; use App\Models\Sysparam; use App\Models\User; use App\Services\ChatStateService; +use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -33,6 +36,7 @@ class AdminCommandController extends Controller */ public function __construct( private readonly ChatStateService $chatState, + private readonly UserCurrencyService $currencyService, ) {} /** @@ -386,12 +390,12 @@ class AdminCommandController extends Controller { $request->validate([ 'room_id' => 'required|integer', - 'type' => 'required|in:fireworks,rain,lightning,snow', + 'type' => 'required|in:fireworks,rain,lightning,snow', ]); - $admin = Auth::user(); - $roomId = $request->input('room_id'); - $type = $request->input('type'); + $admin = Auth::user(); + $roomId = $request->input('room_id'); + $type = $request->input('type'); $superLevel = (int) Sysparam::getValue('superlevel', '100'); // 仅 superlevel 等级可触发特效 @@ -405,6 +409,139 @@ class AdminCommandController extends Controller return response()->json(['status' => 'success', 'message' => "已触发特效:{$type}"]); } + /** + * 职务奖励金币(凭空发放,无需扣操作者余额) + * + * 三层限额校验: + * 1. amount ≤ position.max_reward (单次上限) + * 2. 今日累计发放 + amount ≤ position.daily_reward_limit (操作人单日累计上限) + * 3. 今日对同一接收者发放次数 < position.recipient_daily_limit(同一接收者每日次数限) + * + * 成功后: + * - 通过 UserCurrencyService 给接收者增加金币 + * - 写入 PositionAuthorityLog(action_type=reward,记录到履职记录) + * - 向房间发送悄悄话通知接收者 + * + * @param Request $request 需包含 username, room_id, amount + */ + public function reward(Request $request): JsonResponse + { + $request->validate([ + 'username' => 'required|string', + 'room_id' => 'required|integer', + 'amount' => 'required|integer|min:1|max:99999', + ]); + + $admin = Auth::user(); + $roomId = (int) $request->input('room_id'); + $amount = (int) $request->input('amount'); + $targetUsername = $request->input('username'); + + // 不能给自己发放 + if ($admin->username === $targetUsername) { + return response()->json(['status' => 'error', 'message' => '不能给自己发放奖励'], 422); + } + + // 必须有在职职务且职务配置了 max_reward + $userPosition = $admin->activePosition; + if (! $userPosition) { + return response()->json(['status' => 'error', 'message' => '你当前没有在职职务,无权发放奖励'], 403); + } + + $position = $userPosition->position; + if (! $position?->max_reward) { + return response()->json(['status' => 'error', 'message' => '你的职务未配置奖励权限'], 403); + } + + // 目标用户必须存在 + $target = User::where('username', $targetUsername)->first(); + if (! $target) { + return response()->json(['status' => 'error', 'message' => '用户不存在'], 404); + } + + // ① 单次上限校验 + if ($amount > $position->max_reward) { + return response()->json([ + 'status' => 'error', + 'message' => "单次奖励上限为 {$position->max_reward} 金币,请调整金额", + ], 422); + } + + // ② 操作人单日累计上限校验 + if ($position->daily_reward_limit) { + $todayTotal = PositionAuthorityLog::where('user_id', $admin->id) + ->where('action_type', 'reward') + ->whereDate('created_at', today()) + ->sum('amount'); + + if ($todayTotal + $amount > $position->daily_reward_limit) { + $remaining = max(0, $position->daily_reward_limit - $todayTotal); + + return response()->json([ + 'status' => 'error', + 'message' => "今日剩余可发放额度为 {$remaining} 金币,超出单日上限({$position->daily_reward_limit})", + ], 422); + } + } + + // ③ 同一接收者每日次数上限校验 + if ($position->recipient_daily_limit) { + $recipientCount = PositionAuthorityLog::where('user_id', $admin->id) + ->where('target_user_id', $target->id) + ->where('action_type', 'reward') + ->whereDate('created_at', today()) + ->count(); + + if ($recipientCount >= $position->recipient_daily_limit) { + return response()->json([ + 'status' => 'error', + 'message' => "今日已对 {$targetUsername} 发放过 {$position->recipient_daily_limit} 次奖励,已达上限", + ], 422); + } + } + + // 发放金币(通过 UserCurrencyService 原子性更新 + 写流水) + $this->currencyService->change( + $target, + 'gold', + $amount, + CurrencySource::POSITION_REWARD, + "{$admin->username}({$position->name})职务奖励", + $roomId, + ); + + // 写履职记录(PositionAuthorityLog) + PositionAuthorityLog::create([ + 'user_id' => $admin->id, + 'user_position_id' => $userPosition->id, + 'action_type' => 'reward', + 'target_user_id' => $target->id, + 'amount' => $amount, + 'remark' => "发放奖励金币 {$amount} 枚给 {$targetUsername}", + ]); + + // 向聊天室发送悄悄话通知接收者 + $msg = [ + 'id' => $this->chatState->nextMessageId($roomId), + 'room_id' => $roomId, + 'from_user' => '系统', + 'to_user' => $targetUsername, + 'content' => "🎁 {$admin->username}({$position->name})向你发放了 {$amount} 枚金币奖励!当前金币:{$target->fresh()->jjb} 枚。", + 'is_secret' => true, + 'font_color' => '#d97706', + 'action' => '', + 'sent_at' => now()->toDateTimeString(), + ]; + $this->chatState->pushMessage($roomId, $msg); + broadcast(new MessageSent($roomId, $msg)); + SaveMessageJob::dispatch($msg); + + return response()->json([ + 'status' => 'success', + 'message' => "已向 {$targetUsername} 发放 {$amount} 金币奖励 🎉", + ]); + } + /** * 权限检查:管理员是否可对目标用户执行指定操作 * diff --git a/app/Models/Position.php b/app/Models/Position.php index 07a1263..bc3d4a7 100644 --- a/app/Models/Position.php +++ b/app/Models/Position.php @@ -32,6 +32,8 @@ class Position extends Model 'level', 'max_persons', 'max_reward', + 'daily_reward_limit', + 'recipient_daily_limit', 'sort_order', ]; @@ -45,6 +47,8 @@ class Position extends Model 'level' => 'integer', 'max_persons' => 'integer', 'max_reward' => 'integer', + 'daily_reward_limit' => 'integer', + 'recipient_daily_limit' => 'integer', 'sort_order' => 'integer', ]; } diff --git a/database/migrations/2026_03_01_110351_add_reward_limits_to_positions_table.php b/database/migrations/2026_03_01_110351_add_reward_limits_to_positions_table.php new file mode 100644 index 0000000..dd8690f --- /dev/null +++ b/database/migrations/2026_03_01_110351_add_reward_limits_to_positions_table.php @@ -0,0 +1,46 @@ +unsignedInteger('daily_reward_limit') + ->nullable() + ->after('max_reward') + ->comment('操作人单日累计可发奖励金币上限(null=不限)'); + + // 同一接收者每日最多收到本职务奖励的次数(null=不限) + $table->unsignedSmallInteger('recipient_daily_limit') + ->nullable() + ->after('daily_reward_limit') + ->comment('同一接收者每天最多可接收本职务奖励次数(null=不限)'); + }); + } + + /** + * 回滚迁移 + */ + public function down(): void + { + Schema::table('positions', function (Blueprint $table) { + $table->dropColumn(['daily_reward_limit', 'recipient_daily_limit']); + }); + } +}; diff --git a/resources/views/admin/positions/index.blade.php b/resources/views/admin/positions/index.blade.php index 4a1b4ea..a07df18 100644 --- a/resources/views/admin/positions/index.blade.php +++ b/resources/views/admin/positions/index.blade.php @@ -24,13 +24,15 @@ level: 60, max_persons: 1, max_reward: '', + daily_reward_limit: '', + recipient_daily_limit: '', sort_order: 0 }, openCreate() { this.editing = null; this.selectedIds = []; - this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', sort_order: 0 }; + this.form = { department_id: '', name: '', icon: '🎖️', rank: 50, level: 60, max_persons: 1, max_reward: '', daily_reward_limit: '', recipient_daily_limit: '', sort_order: 0 }; this.showForm = true; }, openEdit(pos, appointableIds) { @@ -44,6 +46,8 @@ level: pos.level, max_persons: pos.max_persons || '', max_reward: pos.max_reward || '', + daily_reward_limit: pos.daily_reward_limit || '', + recipient_daily_limit: pos.recipient_daily_limit || '', sort_order: pos.sort_order, }; this.showForm = true; @@ -170,6 +174,8 @@ level: {{ $pos->level }}, max_persons: {{ $pos->max_persons ?? 'null' }}, max_reward: {{ $pos->max_reward ?? 'null' }}, + daily_reward_limit: {{ $pos->daily_reward_limit ?? 'null' }}, + recipient_daily_limit: {{ $pos->recipient_daily_limit ?? 'null' }}, sort_order: {{ $pos->sort_order }}, requestUrl: '{{ route('admin.positions.update', $pos->id) }}' }, {{ json_encode($appointableIds) }})" @@ -251,9 +257,21 @@ class="w-full border rounded-md p-2 text-sm" placeholder="留空不限">
- + + class="w-full border rounded-md p-2 text-sm" placeholder="每次最多可发放金币数"> +
+
+ + +
+
+ +
diff --git a/resources/views/chat/frame.blade.php b/resources/views/chat/frame.blade.php index 52ed5c4..a3a8123 100644 --- a/resources/views/chat/frame.blade.php +++ b/resources/views/chat/frame.blade.php @@ -46,9 +46,11 @@ chatBotClearUrl: "{{ route('chatbot.clear') }}", chatBotEnabled: {{ \App\Models\Sysparam::getValue('chatbot_enabled', '0') === '1' ? 'true' : 'false' }}, hasPosition: {{ Auth::user()->activePosition || Auth::user()->user_level >= $superLevel ? 'true' : 'false' }}, + myMaxReward: {{ Auth::user()->activePosition?->position?->max_reward ?? 0 }}, appointPositionsUrl: "{{ route('chat.appoint.positions') }}", appointUrl: "{{ route('chat.appoint.appoint') }}", - revokeUrl: "{{ route('chat.appoint.revoke') }}" + revokeUrl: "{{ route('chat.appoint.revoke') }}", + rewardUrl: "{{ route('command.reward') }}" }; @vite(['resources/css/app.css', 'resources/js/app.js', 'resources/js/chat.js']) diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index 2196654..057f4ea 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -93,6 +93,11 @@ giftCount: 1, sendingGift: false, + // 职务奖励金币 + rewardAmount: 0, + sendingReward: false, + showRewardPanel: false, + // 任命相关 showAppointPanel: false, appointPositions: [], @@ -447,17 +452,56 @@ }) }); const data = await res.json(); - alert(data.message); + this.$alert(data.message, data.status === 'success' ? '送礼成功 🎁' : '操作失败', + data.status === 'success' ? '#e11d48' : '#cc4444'); if (data.status === 'success') { this.showUserModal = false; this.giftCount = 1; } } catch (e) { - alert('网络异常'); + this.$alert('网络异常', '错误', '#cc4444'); } this.sendingGift = false; }, + /** 职务奖励:向用户发放金币(凭空产生,记入履职记录) */ + async sendReward() { + if (this.sendingReward) return; + const maxOnce = window.chatContext?.myMaxReward ?? 0; + const amount = parseInt(this.rewardAmount, 10); + if (!amount || amount <= 0) { + this.$alert('请输入有效的奖励金额', '提示', '#f59e0b'); + return; + } + if (amount > maxOnce) { + this.$alert(`单次奖励上限为 ${maxOnce} 金币`, '超出上限', '#cc4444'); + return; + } + this.sendingReward = true; + try { + const res = await fetch(window.chatContext.rewardUrl, { + method: 'POST', + headers: this._headers(), + body: JSON.stringify({ + username: this.userInfo.username, + room_id: window.chatContext.roomId, + amount, + }) + }); + const data = await res.json(); + const ok = data.status === 'success'; + this.$alert(data.message, ok ? '奖励发放成功 🎉' : '操作失败', + ok ? '#d97706' : '#cc4444'); + if (ok) { + this.showRewardPanel = false; + this.rewardAmount = 0; + } + } catch (e) { + this.$alert('网络异常,请稍后重试', '错误', '#cc4444'); + } + this.sendingReward = false; + }, + /** 通用请求头 */ _headers() { return { @@ -610,23 +654,33 @@
- {{-- 普通操作按鈕:写私信 + 送花 + 内联礼物面板 --}} -
+ {{-- 操作按钮区:加好友 + 送礼物 + 送金币(有职务且有奖励权限时显示) --}} +
-
+ + {{-- 内联奖励金币面板(仅职务持有者且有 max_reward 时可用) --}} +
+ +
+ 🪙 发放奖励金币 + + 单次上限: 金币 + + +
+ +
+ + +
+

+ 金币将直接发放给对方账户,本操作记入你的履职记录。 +

+
{{-- 管理操作 + 职务操作 合并折叠区 --}} diff --git a/routes/web.php b/routes/web.php index 96e6d52..2d302d4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -112,6 +112,7 @@ Route::middleware(['chat.auth'])->group(function () { Route::post('/command/kick', [AdminCommandController::class, 'kick'])->name('command.kick'); Route::post('/command/mute', [AdminCommandController::class, 'mute'])->name('command.mute'); Route::post('/command/freeze', [AdminCommandController::class, 'freeze'])->name('command.freeze'); + Route::post('/command/reward', [AdminCommandController::class, 'reward'])->name('command.reward'); Route::get('/command/whispers/{username}', [AdminCommandController::class, 'viewWhispers'])->name('command.whispers'); Route::post('/command/announce', [AdminCommandController::class, 'announce'])->name('command.announce'); Route::post('/command/clear-screen', [AdminCommandController::class, 'clearScreen'])->name('command.clear_screen');