Files
chatroom/app/Services/WeddingService.php
lkddi 384cf8e078 功能:婚姻系统第7步(WeddingService)
- setup():验证余额、立即/定时扣款或冻结
- trigger():获取在线用户、随机红包分配、写入 claims
- claim():领取红包、金币入账(乐观锁防并发重复领)
- distributeRedPacket():二倍均值算法,总和精确等于 total
- refundCeremony():在线为0时退还冻结金币
2026-03-01 15:04:49 +08:00

354 lines
13 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];
});
}
// ──────────────────────────── 触发婚礼 ────────────────────────────
/**
* 触发婚礼:获取在线用户 → 分配红包 → 写入 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<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);
}
}
}
/**
* 获取当前在线用户 ID 列表(从 Redis chatroom_users
*
* @param array<int> $excludeIds 排除的用户 ID
* @return array<int>
*/
private function getOnlineUserIds(array $excludeIds = []): array
{
// chatroom_users 是 Hashkey=usernamevalue=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<int, WeddingTier>
*/
public function activeTiers(): Collection
{
return WeddingTier::query()->where('is_active', true)->orderBy('tier')->get();
}
}