Files
chatroom/app/Services/WeddingService.php

432 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 列表。
* 若 Hash value 中无 user_id旧版登录用户则用 username 批量查库补齐。
*
* @param int $roomId 房间 ID
* @return array<int>
*/
private function getOnlineUserIds(int $roomId = 1): array
{
try {
$key = "room:{$roomId}:users";
$users = Redis::hgetall($key);
if (empty($users)) {
return [];
}
$ids = [];
$fallbacks = []; // 需要 fallback 查库的用户名
foreach ($users as $username => $jsonInfo) {
$info = json_decode($jsonInfo, true);
if (isset($info['user_id'])) {
// 新版登录user_id 直接存在 Redis
$ids[] = (int) $info['user_id'];
} else {
// 旧版登录修复前user_id 缺失,记录 username 待批量查库
$fallbacks[] = $username;
}
}
// 对旧用户批量查库补齐 user_id
if (! empty($fallbacks)) {
$dbIds = User::whereIn('username', $fallbacks)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$ids = array_merge($ids, $dbIds);
}
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();
}
}