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="留空不限">