Files
chatroom/app/Services/WeddingService.php

414 lines
16 KiB
PHP
Raw Normal View History

<?php
/**
* 文件功能:婚礼仪式与红包业务服务
*
* 处理婚礼设置、金币预扣(冻结)、随机红包分配、触发广播及领取逻辑。
* 所有金币变更通过 UserCurrencyService 统一记账。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Services;
use App\Enums\CurrencySource;
use App\Models\Marriage;
use App\Models\User;
use App\Models\WeddingCeremony;
use App\Models\WeddingEnvelopeClaim;
use App\Models\WeddingTier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
class WeddingService
{
public function __construct(
private readonly UserCurrencyService $currency,
private readonly MarriageConfigService $config,
) {}
// ──────────────────────────── 婚礼设置 ────────────────────────────
/**
* 设置并创建婚礼(接受求婚后调用)。
*
* @param Marriage $marriage 已婚记录
* @param int|null $tierId 婚礼档位 IDnull=不举办普天同庆)
* @param string $payerType 'groom'=男方全付 | 'joint'=各半
* @param string $ceremonyType 'immediate'=立即 | 'scheduled'=定时
* @param Carbon|null $ceremonyAt 定时婚礼时间
* @return array{ok: bool, message: string, ceremony_id: int|null}
*/
public function setup(
Marriage $marriage,
?int $tierId,
string $payerType = 'groom',
string $ceremonyType = 'immediate',
?Carbon $ceremonyAt = null,
): array {
// 不举办(仅公告,不发红包)
if ($tierId === null) {
return $this->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];
});
}
// ──────────────────────────── 婚礼生命周期钩子 ───────────────────
/**
* 将预先设置好的定时婚礼(因求婚冻结)转为即刻开始(解冻并记录消费)。
*/
public function confirmCeremony(WeddingCeremony $ceremony): void
{
DB::transaction(function () use ($ceremony) {
$marriage = $ceremony->marriage;
$tierName = $ceremony->tier?->name ?? '婚礼';
if ($ceremony->ceremony_type === 'scheduled') {
// 解除冻结,正式扣款记账
if ($ceremony->groom_amount > 0) {
$groom = clone $marriage->user;
$marriage->user->decrement('frozen_jjb', $ceremony->groom_amount);
$this->currency->change($groom, 'gold', 0, CurrencySource::WEDDING_ENV_SEND, "求婚成功,正式发红包({$tierName}");
}
if ($ceremony->partner_amount > 0) {
$partner = clone $marriage->partner;
$marriage->partner->decrement('frozen_jjb', $ceremony->partner_amount);
$this->currency->change($partner, 'gold', 0, CurrencySource::WEDDING_ENV_SEND, "求婚成功,正式发红包({$tierName}");
}
// 将类型转为即时开始
$ceremony->update([
'ceremony_type' => 'immediate',
'ceremony_at' => now(),
'expires_at' => now()->addHours($this->config->get('envelope_expire_hours', 24)),
]);
}
});
}
/**
* 撤销由于求婚设置的婚礼,并且解冻/退还因为该婚礼冻结的金币。
*/
public function cancelAndRefund(WeddingCeremony $ceremony): void
{
DB::transaction(function () use ($ceremony) {
$ceremony->update(['status' => 'cancelled']);
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);
}
}
});
}
// ──────────────────────────── 触发婚礼 ────────────────────────────
/**
* 触发婚礼:获取在线用户 分配红包 写入 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(roomId: $ceremony->marriage->room_id ?? 1);
// 在线人数为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<int> 每人分配金额
*/
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);
}
}
}
/**
* Redis room:{roomId}:users Hash 获取当前在线用户 ID 列表。
*
* @param int $roomId 房间 ID
* @return array<int>
*/
private function getOnlineUserIds(int $roomId = 1): array
{
try {
$key = "room:{$roomId}:users";
$users = Redis::hgetall($key);
$ids = [];
foreach ($users as $username => $jsonInfo) {
$info = json_decode($jsonInfo, true);
// user_id 由 ChatController::join() 写入 userData
if (isset($info['user_id'])) {
$ids[] = (int) $info['user_id'];
}
}
return array_values(array_unique($ids));
} 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<int, WeddingTier>
*/
public function activeTiers(): Collection
{
return WeddingTier::query()->where('is_active', true)->orderBy('tier')->get();
}
}