432 lines
17 KiB
PHP
432 lines
17 KiB
PHP
<?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 婚礼档位 ID(null=不举办普天同庆)
|
||
* @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();
|
||
}
|
||
}
|