diff --git a/app/Events/EnvelopeClaimed.php b/app/Events/EnvelopeClaimed.php new file mode 100644 index 0000000..9551732 --- /dev/null +++ b/app/Events/EnvelopeClaimed.php @@ -0,0 +1,64 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel('user.'.$this->claimer->id)]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'ceremony_id' => $this->ceremonyId, + 'amount' => $this->amount, + 'message' => "🎉 成功领取 {$this->amount} 金币婚礼红包!", + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'envelope.claimed'; + } +} diff --git a/app/Events/MarriageAccepted.php b/app/Events/MarriageAccepted.php new file mode 100644 index 0000000..140ef70 --- /dev/null +++ b/app/Events/MarriageAccepted.php @@ -0,0 +1,68 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + $this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']); + + return [ + 'marriage_id' => $this->marriage->id, + 'user' => $this->marriage->user?->only(['id', 'username', 'headface']), + 'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']), + 'ring' => $this->marriage->ringItem?->only(['name', 'icon']), + 'married_at' => $this->marriage->married_at, + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.accepted'; + } +} diff --git a/app/Events/MarriageDivorceRequested.php b/app/Events/MarriageDivorceRequested.php new file mode 100644 index 0000000..c6c8fdf --- /dev/null +++ b/app/Events/MarriageDivorceRequested.php @@ -0,0 +1,70 @@ + + */ + public function broadcastOn(): array + { + // 离婚申请方的对方 + $targetId = $this->marriage->user_id === $this->marriage->divorcer_id + ? $this->marriage->partner_id + : $this->marriage->user_id; + + return [new PrivateChannel('user.'.$targetId)]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + $this->marriage->load(['user:id,username', 'partner:id,username']); + + return [ + 'marriage_id' => $this->marriage->id, + 'divorcer_username' => $this->marriage->user_id === $this->marriage->divorcer_id + ? $this->marriage->user?->username + : $this->marriage->partner?->username, + 'timeout_hours' => 72, + 'requested_at' => $this->marriage->divorce_requested_at, + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.divorce_requested'; + } +} diff --git a/app/Events/MarriageDivorced.php b/app/Events/MarriageDivorced.php new file mode 100644 index 0000000..eac9dc0 --- /dev/null +++ b/app/Events/MarriageDivorced.php @@ -0,0 +1,65 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + $this->marriage->load(['user:id,username', 'partner:id,username']); + + return [ + 'user_username' => $this->marriage->user?->username, + 'partner_username' => $this->marriage->partner?->username, + 'divorce_type' => $this->divorceType, + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.divorced'; + } +} diff --git a/app/Events/MarriageExpired.php b/app/Events/MarriageExpired.php new file mode 100644 index 0000000..f1e5548 --- /dev/null +++ b/app/Events/MarriageExpired.php @@ -0,0 +1,60 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel('user.'.$this->marriage->user_id)]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'partner_username' => $this->marriage->partner?->username, + 'ring_name' => $this->marriage->ringItem?->name, + 'message' => '求婚已超时失效,戒指已消失。', + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.expired'; + } +} diff --git a/app/Events/MarriageProposed.php b/app/Events/MarriageProposed.php new file mode 100644 index 0000000..681eb56 --- /dev/null +++ b/app/Events/MarriageProposed.php @@ -0,0 +1,73 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel('user.'.$this->target->id)]; + } + + /** + * 广播数据。 + * + * @return array + */ + public function broadcastWith(): array + { + return [ + 'marriage_id' => $this->marriage->id, + 'proposer' => [ + 'username' => $this->proposer->username, + 'headface' => $this->proposer->headface, + 'user_level' => $this->proposer->user_level, + ], + 'ring' => $this->marriage->ringItem?->only(['name', 'icon']), + 'expires_at' => $this->marriage->expires_at, + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.proposed'; + } +} diff --git a/app/Events/MarriageRejected.php b/app/Events/MarriageRejected.php new file mode 100644 index 0000000..87b6d7d --- /dev/null +++ b/app/Events/MarriageRejected.php @@ -0,0 +1,60 @@ + + */ + public function broadcastOn(): array + { + return [new PrivateChannel('user.'.$this->marriage->user_id)]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'partner_username' => $this->marriage->partner?->username, + 'ring_name' => $this->marriage->ringItem?->name, + 'message' => '对方拒绝了您的求婚,戒指已消失。', + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'marriage.rejected'; + } +} diff --git a/app/Events/WeddingCelebration.php b/app/Events/WeddingCelebration.php new file mode 100644 index 0000000..5fab105 --- /dev/null +++ b/app/Events/WeddingCelebration.php @@ -0,0 +1,73 @@ + + */ + public function broadcastOn(): array + { + return [new PresenceChannel('room.1')]; + } + + /** + * 广播数据(前端据此展示红包弹窗及新人信息)。 + * + * @return array + */ + public function broadcastWith(): array + { + $this->marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']); + + return [ + 'ceremony_id' => $this->ceremony->id, + 'tier_name' => $this->ceremony->tier?->name ?? '婚礼', + 'tier_icon' => $this->ceremony->tier?->icon ?? '🎊', + 'total_amount' => $this->ceremony->total_amount, + 'expires_at' => $this->ceremony->expires_at, + 'user' => $this->marriage->user?->only(['id', 'username', 'headface']), + 'partner' => $this->marriage->partner?->only(['id', 'username', 'headface']), + 'ring' => $this->marriage->ringItem?->only(['name', 'icon']), + ]; + } + + /** 广播事件名称。 */ + public function broadcastAs(): string + { + return 'wedding.celebration'; + } +} diff --git a/app/Http/Controllers/Admin/MarriageManagerController.php b/app/Http/Controllers/Admin/MarriageManagerController.php new file mode 100644 index 0000000..bb08932 --- /dev/null +++ b/app/Http/Controllers/Admin/MarriageManagerController.php @@ -0,0 +1,10 @@ +user(); + $marriage = Marriage::currentFor($user->id); + + if (! $marriage) { + return response()->json(['married' => false]); + } + + $marriage->load(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,slug,icon']); + + return response()->json([ + 'married' => $marriage->status === 'married', + 'status' => $marriage->status, + 'marriage' => [ + 'id' => $marriage->id, + 'user' => $marriage->user, + 'partner' => $marriage->partner, + 'ring' => $marriage->ringItem?->only(['name', 'icon']), + 'intimacy' => $marriage->intimacy, + 'level' => $marriage->level, + 'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level), + 'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level), + 'married_at' => $marriage->married_at?->toDateString(), + 'days' => $marriage->married_at?->diffInDays(now()), + 'proposed_at' => $marriage->proposed_at, + 'expires_at' => $marriage->expires_at, + 'divorce_type' => $marriage->divorce_type, + 'divorcer_id' => $marriage->divorcer_id, + ], + ]); + } + + /** + * 查询目标用户的婚姻信息(用于双击名片展示)。 + */ + public function targetStatus(Request $request): JsonResponse + { + $request->validate(['username' => 'required|string']); + $target = \App\Models\User::where('username', $request->username)->firstOrFail(); + + $marriage = Marriage::query() + ->where('status', 'married') + ->where(function ($q) use ($target) { + $q->where('user_id', $target->id)->orWhere('partner_id', $target->id); + }) + ->with(['user:id,username,headface', 'partner:id,username,headface', 'ringItem:id,name,icon']) + ->first(); + + if (! $marriage) { + return response()->json(['married' => false]); + } + + return response()->json([ + 'married' => true, + 'level_icon' => \App\Services\MarriageIntimacyService::levelIcon($marriage->level), + 'level_name' => \App\Services\MarriageIntimacyService::levelName($marriage->level), + 'partner' => $marriage->user_id === $target->id ? $marriage->partner : $marriage->user, + 'ring' => $marriage->ringItem?->only(['name', 'icon']), + 'days' => $marriage->married_at?->diffInDays(now()), + 'intimacy' => $marriage->intimacy, + ]); + } + + /** + * 发起求婚。 + */ + public function propose(Request $request): JsonResponse + { + $data = $request->validate([ + 'target_username' => 'required|string', + 'ring_purchase_id' => 'required|integer', + ]); + + $proposer = $request->user(); + $target = \App\Models\User::where('username', $data['target_username'])->first(); + + if (! $target) { + return response()->json(['ok' => false, 'message' => '用户不存在。'], 404); + } + + $result = $this->marriage->propose($proposer, $target, $data['ring_purchase_id']); + + if ($result['ok']) { + $marriage = Marriage::find($result['marriage_id']); + // 广播给被求婚方(私人频道) + broadcast(new MarriageProposed($marriage, $proposer, $target)); + } + + return response()->json($result); + } + + /** + * 获取当前用户持有的有效戒指列表(求婚前选择用)。 + */ + public function myRings(Request $request): JsonResponse + { + $rings = UserPurchase::query() + ->where('user_id', $request->user()->id) + ->where('status', 'active') + ->whereHas('item', fn ($q) => $q->where('type', 'ring')) + ->with('item:id,name,slug,icon,price') + ->get() + ->map(fn ($p) => [ + 'purchase_id' => $p->id, + 'name' => $p->item->name, + 'icon' => $p->item->icon, + 'slug' => $p->item->slug, + ]); + + return response()->json(['rings' => $rings]); + } + + /** + * 接受求婚。 + */ + public function accept(Request $request, Marriage $marriage): JsonResponse + { + $result = $this->marriage->accept($marriage, $request->user()); + + if ($result['ok']) { + $marriage->refresh(); + // 广播全房间结婚公告 + broadcast(new MarriageAccepted($marriage)); + } + + return response()->json($result); + } + + /** + * 拒绝求婚。 + */ + public function reject(Request $request, Marriage $marriage): JsonResponse + { + $result = $this->marriage->reject($marriage, $request->user()); + + if ($result['ok']) { + broadcast(new MarriageRejected($marriage)); + } + + return response()->json($result); + } + + /** + * 申请离婚(协议或强制)。 + */ + public function divorce(Request $request, Marriage $marriage): JsonResponse + { + $type = $request->input('type', 'mutual'); // mutual | forced + $result = $this->marriage->divorce($marriage, $request->user(), $type); + + if ($result['ok']) { + $marriage->refresh(); + if ($marriage->status === 'divorced') { + broadcast(new MarriageDivorced($marriage, $type)); + } else { + // 协议离婚:通知对方 + broadcast(new MarriageDivorceRequested($marriage)); + } + } + + return response()->json($result); + } + + /** + * 确认协议离婚。 + */ + public function confirmDivorce(Request $request, Marriage $marriage): JsonResponse + { + $result = $this->marriage->confirmDivorce($marriage, $request->user()); + + if ($result['ok']) { + $marriage->refresh(); + broadcast(new MarriageDivorced($marriage, 'mutual')); + } + + return response()->json($result); + } +} diff --git a/app/Http/Controllers/WeddingController.php b/app/Http/Controllers/WeddingController.php new file mode 100644 index 0000000..a354d61 --- /dev/null +++ b/app/Http/Controllers/WeddingController.php @@ -0,0 +1,115 @@ +json([ + 'tiers' => $this->wedding->activeTiers()->map(fn ($t) => [ + 'id' => $t->id, + 'tier' => $t->tier, + 'name' => $t->name, + 'icon' => $t->icon, + 'amount' => $t->amount, + 'description' => $t->description, + ]), + ]); + } + + /** + * 设置并发起婚礼(结婚后由接受方配置)。 + */ + public function setup(Request $request, Marriage $marriage): JsonResponse + { + $user = $request->user(); + + // 只有婚姻双方可设置 + if (! $marriage->involves($user->id)) { + return response()->json(['ok' => false, 'message' => '无权操作此婚姻。'], 403); + } + if ($marriage->status !== 'married') { + return response()->json(['ok' => false, 'message' => '婚姻状态异常。'], 422); + } + + $data = $request->validate([ + 'tier_id' => 'nullable|integer|exists:wedding_tiers,id', + 'payer_type' => 'required|in:groom,joint', + 'ceremony_type' => 'required|in:immediate,scheduled', + 'ceremony_at' => 'nullable|date|after:now', + ]); + + $ceremonyAt = isset($data['ceremony_at']) ? Carbon::parse($data['ceremony_at']) : null; + + $result = $this->wedding->setup( + $marriage, + $data['tier_id'] ?? null, + $data['payer_type'], + $data['ceremony_type'], + $ceremonyAt, + ); + + // 立即婚礼:直接触发 + if ($result['ok'] && $data['ceremony_type'] === 'immediate') { + $ceremony = WeddingCeremony::find($result['ceremony_id']); + if ($ceremony) { + $triggerResult = $this->wedding->trigger($ceremony); + // 广播全房间婚礼事件 + broadcast(new WeddingCelebration($ceremony, $marriage)); + } + } + + return response()->json($result); + } + + /** + * 领取婚礼红包。 + */ + public function claim(Request $request, WeddingCeremony $ceremony): JsonResponse + { + $result = $this->wedding->claim($ceremony, $request->user()); + + return response()->json($result); + } + + /** + * 查询用户在婚礼中是否有待领取红包。 + */ + public function envelopeStatus(Request $request, WeddingCeremony $ceremony): JsonResponse + { + $claim = $this->wedding->getUnclaimedEnvelope($ceremony, $request->user()->id); + + return response()->json([ + 'has_envelope' => $claim !== null, + 'amount' => $claim?->amount ?? 0, + 'expires_at' => $ceremony->expires_at, + ]); + } +} diff --git a/routes/web.php b/routes/web.php index 7616b60..fa47e77 100644 --- a/routes/web.php +++ b/routes/web.php @@ -72,6 +72,37 @@ Route::middleware(['chat.auth'])->group(function () { Route::post('/friend/{username}/add', [\App\Http\Controllers\FriendController::class, 'addFriend'])->name('friend.add'); Route::delete('/friend/{username}/remove', [\App\Http\Controllers\FriendController::class, 'removeFriend'])->name('friend.remove'); + // ── 婚姻系统(前台)────────────────────────────────────────────── + Route::prefix('marriage')->name('marriage.')->group(function () { + // 查询当前用户婚姻状态 + Route::get('/status', [\App\Http\Controllers\MarriageController::class, 'status'])->name('status'); + // 查询目标用户婚姻信息(名片用) + Route::get('/target', [\App\Http\Controllers\MarriageController::class, 'targetStatus'])->name('target-status'); + // 当前用户持有的戒指列表 + Route::get('/rings', [\App\Http\Controllers\MarriageController::class, 'myRings'])->name('rings'); + // 发起求婚 + Route::post('/propose', [\App\Http\Controllers\MarriageController::class, 'propose'])->name('propose'); + // 接受/拒绝求婚 + Route::post('/{marriage}/accept', [\App\Http\Controllers\MarriageController::class, 'accept'])->name('accept'); + Route::post('/{marriage}/reject', [\App\Http\Controllers\MarriageController::class, 'reject'])->name('reject'); + // 申请离婚(type: mutual|forced) + Route::post('/{marriage}/divorce', [\App\Http\Controllers\MarriageController::class, 'divorce'])->name('divorce'); + // 确认协议离婚 + Route::post('/{marriage}/confirm-divorce', [\App\Http\Controllers\MarriageController::class, 'confirmDivorce'])->name('confirm-divorce'); + }); + + // ── 婚礼系统(前台)────────────────────────────────────────────── + Route::prefix('wedding')->name('wedding.')->group(function () { + // 婚礼档位列表 + Route::get('/tiers', [\App\Http\Controllers\WeddingController::class, 'tiers'])->name('tiers'); + // 设置并发起婚礼 + Route::post('/{marriage}/setup', [\App\Http\Controllers\WeddingController::class, 'setup'])->name('setup'); + // 领取婚礼红包 + Route::post('/ceremony/{ceremony}/claim', [\App\Http\Controllers\WeddingController::class, 'claim'])->name('claim'); + // 查询是否有待领取红包 + Route::get('/ceremony/{ceremony}/envelope', [\App\Http\Controllers\WeddingController::class, 'envelopeStatus'])->name('envelope-status'); + }); + // ---- 第五阶段:具体房间内部聊天核心 ---- // 进入具体房间界面的初始化 Route::get('/room/{id}', [ChatController::class, 'init'])->name('chat.room'); @@ -222,6 +253,32 @@ Route::middleware(['chat.auth', 'chat.has_position'])->prefix('admin')->name('ad Route::post('/vip', [\App\Http\Controllers\Admin\VipController::class, 'store'])->name('vip.store'); Route::put('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'update'])->name('vip.update'); Route::delete('/vip/{vip}', [\App\Http\Controllers\Admin\VipController::class, 'destroy'])->name('vip.destroy'); + + // 💒 婚姻管理(superlevel 及以上) + Route::prefix('marriages')->name('marriages.')->group(function () { + // 总览统计 + Route::get('/', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'index'])->name('index'); + // 婚姻列表(支持筛选) + Route::get('/list', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'list'])->name('list'); + // 求婚记录 + Route::get('/proposals', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'proposals'])->name('proposals'); + // 婚礼红包记录 + Route::get('/ceremonies', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'ceremonies'])->name('ceremonies'); + // 红包领取明细 + Route::get('/ceremonies/{ceremony}/claims', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'claimDetail'])->name('claim-detail'); + // 亲密度日志 + Route::get('/intimacy-logs', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'intimacyLogs'])->name('intimacy-logs'); + // 参数配置(GET=页面,POST=保存) + Route::get('/configs', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'configs'])->name('configs'); + Route::post('/configs', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'updateConfigs'])->name('configs.update'); + // 婚礼档位(GET=页面,PUT=保存) + Route::get('/tiers', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'tiers'])->name('tiers'); + Route::put('/tiers/{tier}', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'updateTier'])->name('tiers.update'); + // 强制离婚 + Route::post('/{marriage}/force-dissolve', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'forceDissolve'])->name('force-dissolve'); + // 取消求婚 + Route::post('/{marriage}/cancel-proposal', [\App\Http\Controllers\Admin\MarriageManagerController::class, 'cancelProposal'])->name('cancel-proposal'); + }); }); // ──────────────────────────────────────────────────────────────