功能:婚姻系统第7步(WeddingService)
- setup():验证余额、立即/定时扣款或冻结 - trigger():获取在线用户、随机红包分配、写入 claims - claim():领取红包、金币入账(乐观锁防并发重复领) - distributeRedPacket():二倍均值算法,总和精确等于 total - refundCeremony():在线为0时退还冻结金币
This commit is contained in:
@@ -1,14 +1,353 @@
|
||||
<?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,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────── 婚礼设置 ────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new class instance.
|
||||
* 设置并创建婚礼(接受求婚后调用)。
|
||||
*
|
||||
* @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 __construct()
|
||||
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 是 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<int, WeddingTier>
|
||||
*/
|
||||
public function activeTiers(): Collection
|
||||
{
|
||||
return WeddingTier::query()->where('is_active', true)->orderBy('tier')->get();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user