From 384cf8e0781c32bfd37e987a4485f1ca9c1c4859 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 1 Mar 2026 15:04:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A9=9A=E5=A7=BB?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=AC=AC7=E6=AD=A5=EF=BC=88WeddingService?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup():验证余额、立即/定时扣款或冻结 - trigger():获取在线用户、随机红包分配、写入 claims - claim():领取红包、金币入账(乐观锁防并发重复领) - distributeRedPacket():二倍均值算法,总和精确等于 total - refundCeremony():在线为0时退还冻结金币 --- app/Services/WeddingService.php | 345 +++++++++++++++++++++++++++++++- 1 file changed, 342 insertions(+), 3 deletions(-) diff --git a/app/Services/WeddingService.php b/app/Services/WeddingService.php index b59d023..54db91b 100644 --- a/app/Services/WeddingService.php +++ b/app/Services/WeddingService.php @@ -1,14 +1,353 @@ createCeremony($marriage, null, 0, $payerType, $ceremonyType, $ceremonyAt); + } + + $tier = WeddingTier::find($tierId); + if (! $tier || ! $tier->is_active) { + return ['ok' => false, 'message' => '所选婚礼档位不存在或已关闭。', 'ceremony_id' => null]; + } + + // 验证余额 + $groom = $marriage->user; + $partner = $marriage->partner; + $total = $tier->amount; + + if ($payerType === 'groom') { + if (($groom->jjb ?? 0) < $total) { + return ['ok' => false, 'message' => "金币不足,{$tier->name}需要 {$total} 金币,您当前只有 {$groom->jjb} 金币。", 'ceremony_id' => null]; + } + } else { + $half = (int) ceil($total / 2); + if (($groom->jjb ?? 0) < $half) { + return ['ok' => false, 'message' => "金币不足,男方需要 {$half} 金币,当前只有 {$groom->jjb} 金币。", 'ceremony_id' => null]; + } + if (($partner->jjb ?? 0) < $half) { + return ['ok' => false, 'message' => "金币不足,女方需要 {$half} 金币,当前只有 {$partner->jjb} 金币。", 'ceremony_id' => null]; + } + } + + return $this->createCeremony($marriage, $tier, $total, $payerType, $ceremonyType, $ceremonyAt); + } + + /** + * 创建婚礼记录,并预扣(或冻结)金币。 + */ + private function createCeremony( + Marriage $marriage, + ?WeddingTier $tier, + int $total, + string $payerType, + string $ceremonyType, + ?Carbon $ceremonyAt, + ): array { + return DB::transaction(function () use ($marriage, $tier, $total, $payerType, $ceremonyType, $ceremonyAt) { + $groom = $marriage->user; + $partner = $marriage->partner; + $groomAmount = 0; + $partnerAmount = 0; + + if ($total > 0) { + if ($payerType === 'groom') { + $groomAmount = $total; + } else { + $groomAmount = (int) floor($total / 2); + $partnerAmount = $total - $groomAmount; + } + + if ($ceremonyType === 'immediate') { + // 立即扣款 + $this->currency->change($groom, 'gold', -$groomAmount, CurrencySource::WEDDING_ENV_SEND, "婚礼红包发送({$tier->name})"); + if ($partnerAmount > 0) { + $this->currency->change($partner, 'gold', -$partnerAmount, CurrencySource::WEDDING_ENV_SEND, "婚礼红包发送({$tier->name})"); + } + } else { + // 定时婚礼:冻结金币 + $groom->increment('frozen_jjb', $groomAmount); + $groom->decrement('jjb', $groomAmount); + if ($partnerAmount > 0) { + $partner->increment('frozen_jjb', $partnerAmount); + $partner->decrement('jjb', $partnerAmount); + } + } + } + + $expireHours = $this->config->get('envelope_expire_hours', 24); + $at = $ceremonyType === 'immediate' ? now() : ($ceremonyAt ?? now()); + + $ceremony = WeddingCeremony::create([ + 'marriage_id' => $marriage->id, + 'tier_id' => $tier?->id, + 'total_amount' => $total, + 'payer_type' => $payerType, + 'groom_amount' => $groomAmount, + 'partner_amount' => $partnerAmount, + 'ceremony_type' => $ceremonyType, + 'ceremony_at' => $at, + 'status' => $ceremonyType === 'immediate' ? 'active' : 'pending', + 'expires_at' => $at->copy()->addHours($expireHours), + ]); + + return ['ok' => true, 'message' => '婚礼设置成功!', 'ceremony_id' => $ceremony->id]; + }); + } + + // ──────────────────────────── 触发婚礼 ──────────────────────────── + + /** + * 触发婚礼:获取在线用户 → 分配红包 → 写入 claims 表。 + * 由立即婚礼(setup 后直接调用)或 TriggerScheduledWeddings Job 调用。 + * + * @param WeddingCeremony $ceremony 婚礼记录 + * @return array{ok: bool, message: string, online_count: int} + */ + public function trigger(WeddingCeremony $ceremony): array { - // + if (! in_array($ceremony->status, ['pending', 'active'])) { + return ['ok' => false, 'message' => '婚礼状态异常。', 'online_count' => 0]; + } + + // 获取当前在线用户(不含新郎新娘) + $onlineIds = $this->getOnlineUserIds(excludeIds: [ + $ceremony->marriage->user_id, + $ceremony->marriage->partner_id, + ]); + + // 在线人数为0时金币退还 + if (count($onlineIds) === 0 && $ceremony->total_amount > 0) { + $this->refundCeremony($ceremony); + + return ['ok' => false, 'message' => '当前没有在线用户,红包已退还。', 'online_count' => 0]; + } + + // 随机分配红包金额 + $amounts = $ceremony->total_amount > 0 + ? $this->distributeRedPacket($ceremony->total_amount, count($onlineIds)) + : array_fill(0, count($onlineIds), 0); + + DB::transaction(function () use ($ceremony, $onlineIds, $amounts) { + $now = now(); + $claims = []; + foreach ($onlineIds as $i => $userId) { + $claims[] = [ + 'ceremony_id' => $ceremony->id, + 'user_id' => $userId, + 'amount' => $amounts[$i] ?? 0, + 'claimed' => false, + 'created_at' => $now, + ]; + } + + WeddingEnvelopeClaim::insert($claims); + + $ceremony->update([ + 'status' => 'active', + 'online_count' => count($onlineIds), + 'ceremony_at' => now(), + ]); + }); + + return ['ok' => true, 'message' => '婚礼触发成功!', 'online_count' => count($onlineIds)]; + } + + // ──────────────────────────── 领取红包 ──────────────────────────── + + /** + * 用户领取婚礼红包。 + * + * @param WeddingCeremony $ceremony 婚礼记录 + * @param User $claimer 领取用户 + * @return array{ok: bool, message: string, amount: int} + */ + public function claim(WeddingCeremony $ceremony, User $claimer): array + { + $claim = WeddingEnvelopeClaim::query() + ->where('ceremony_id', $ceremony->id) + ->where('user_id', $claimer->id) + ->where('claimed', false) + ->lockForUpdate() + ->first(); + + if (! $claim) { + return ['ok' => false, 'message' => '没有待领取的红包,或红包已被领取。', 'amount' => 0]; + } + + if ($ceremony->expires_at && $ceremony->expires_at->isPast()) { + return ['ok' => false, 'message' => '红包已过期。', 'amount' => 0]; + } + + DB::transaction(function () use ($claim, $ceremony, $claimer) { + $claim->update(['claimed' => true, 'claimed_at' => now()]); + $ceremony->increment('claimed_count'); + $ceremony->increment('claimed_amount', $claim->amount); + + // 金币入账 + if ($claim->amount > 0) { + $marriage = $ceremony->marriage; + $remark = "婚礼红包:{$marriage->user->username} × {$marriage->partner->username}"; + $this->currency->change($claimer, 'gold', $claim->amount, CurrencySource::WEDDING_ENV_RECV, $remark); + } + }); + + return ['ok' => true, 'message' => "已领取 {$claim->amount} 金币!", 'amount' => $claim->amount]; + } + + // ──────────────────────────── 随机红包算法 ───────────────────────── + + /** + * 随机红包分配(二倍均值算法)。 + * 保证每人至少 1 金币,总和精确等于 totalAmount。 + * + * @param int $totalAmount 总金额 + * @param int $count 人数 + * @return array 每人分配金额 + */ + private function distributeRedPacket(int $totalAmount, int $count): array + { + if ($count <= 0 || $totalAmount <= 0) { + return []; + } + + // 人数多于金额时,部分人分到0 + if ($totalAmount < $count) { + $amounts = array_fill(0, $count, 0); + for ($i = 0; $i < $totalAmount; $i++) { + $amounts[$i] = 1; + } + shuffle($amounts); + + return $amounts; + } + + $amounts = []; + $remaining = $totalAmount; + + for ($i = 0; $i < $count - 1; $i++) { + $remainingPeople = $count - $i; + $avgDouble = (int) floor($remaining * 2 / $remainingPeople); + $max = max(1, min($avgDouble - 1, $remaining - ($remainingPeople - 1))); + $amounts[] = random_int(1, $max); + $remaining -= end($amounts); + } + + $amounts[] = $remaining; // 最后一人拿剩余 + shuffle($amounts); // 随机打乱 + + return $amounts; + } + + /** + * 退还定时婚礼金币(在线人数为0时)。 + */ + private function refundCeremony(WeddingCeremony $ceremony): void + { + $ceremony->update(['status' => 'cancelled']); + + // 解冻退还(定时婚礼金币已被冻结在 frozen_jjb) + if ($ceremony->ceremony_type === 'scheduled') { + $marriage = $ceremony->marriage; + if ($ceremony->groom_amount > 0) { + $marriage->user?->decrement('frozen_jjb', $ceremony->groom_amount); + $marriage->user?->increment('jjb', $ceremony->groom_amount); + } + if ($ceremony->partner_amount > 0) { + $marriage->partner?->decrement('frozen_jjb', $ceremony->partner_amount); + $marriage->partner?->increment('jjb', $ceremony->partner_amount); + } + } + } + + /** + * 获取当前在线用户 ID 列表(从 Redis chatroom_users)。 + * + * @param array $excludeIds 排除的用户 ID + * @return array + */ + private function getOnlineUserIds(array $excludeIds = []): array + { + // chatroom_users 是 Hash,key=username,value=user_id(或 JSON) + // 实际取法根据现有 Redis 结构调整 + try { + $raw = Redis::smembers('chatroom_online_ids'); + $ids = array_map('intval', $raw); + + return array_values(array_diff($ids, $excludeIds)); + } catch (\Throwable) { + return []; + } + } + + /** + * 获取用户在指定婚礼中的待领取红包信息。 + */ + public function getUnclaimedEnvelope(WeddingCeremony $ceremony, int $userId): ?WeddingEnvelopeClaim + { + return WeddingEnvelopeClaim::query() + ->where('ceremony_id', $ceremony->id) + ->where('user_id', $userId) + ->where('claimed', false) + ->first(); + } + + /** + * 返回所有激活档位(前台选择用)。 + * + * @return Collection + */ + public function activeTiers(): Collection + { + return WeddingTier::query()->where('is_active', true)->orderBy('tier')->get(); } }