新增:个性装扮支持多份购买,同款续购自动叠加天数
This commit is contained in:
@@ -52,12 +52,14 @@ class DecorationService
|
||||
* 购买装扮:扣金币、写购买记录、更新 users.active_decorations。
|
||||
*
|
||||
* 同槽位的旧装扮会被新购买覆盖(旧装扮不退款),不同槽位可并行持有。
|
||||
* 若购买的是已激活的同款样式,则自动叠加天数而非覆盖重置。
|
||||
*
|
||||
* @param User $user 购买用户
|
||||
* @param ShopItem $item 装扮商品
|
||||
* @param int $quantity 购买份数
|
||||
* @return array{ok:bool, message:string, balance_after?:int, slot?:string, style?:string, expires_at?:string}
|
||||
*/
|
||||
public function purchase(User $user, ShopItem $item): array
|
||||
public function purchase(User $user, ShopItem $item, int $quantity = 1): array
|
||||
{
|
||||
// 根据商品类型映射到对应槽位
|
||||
$slot = self::TYPE_TO_SLOT[$item->type] ?? null;
|
||||
@@ -65,14 +67,29 @@ class DecorationService
|
||||
return ['ok' => false, 'message' => '未知装扮类型'];
|
||||
}
|
||||
|
||||
$totalPrice = $item->price * $quantity;
|
||||
|
||||
// 校验金币余额
|
||||
if ($user->jjb < $item->price) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 [{$item->name}] 需要 {$item->price} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
if ($user->jjb < $totalPrice) {
|
||||
return ['ok' => false, 'message' => "金币不足,购买 {$quantity} 份 [{$item->name}] 需要 {$totalPrice} 金币,当前仅有 {$user->jjb} 金币。"];
|
||||
}
|
||||
|
||||
// 计算过期时间(至少 1 天)
|
||||
$days = max(1, (int) ($item->duration_days ?? 1));
|
||||
$expiresAt = Carbon::now()->addDays($days);
|
||||
$totalDays = $days * $quantity;
|
||||
|
||||
// 检查同一槽位是否已激活相同样式 → 叠加天数
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$isSameActive = ! empty($decorations[$slot])
|
||||
&& ($decorations[$slot]['style'] ?? '') === $item->slug;
|
||||
|
||||
if ($isSameActive) {
|
||||
// 在现有到期时间上追加天数
|
||||
$existingExpires = Carbon::parse($decorations[$slot]['expires_at']);
|
||||
$expiresAt = $existingExpires->copy()->addDays($totalDays);
|
||||
} else {
|
||||
$expiresAt = Carbon::now()->addDays($totalDays);
|
||||
}
|
||||
|
||||
// 按装扮类型使用不同的流水来源标识,便于后台按类型筛选消费记录
|
||||
$source = match ($item->type) {
|
||||
@@ -84,11 +101,11 @@ class DecorationService
|
||||
};
|
||||
|
||||
// 事务包裹:扣金币、写购买记录、更新激活状态三步原子操作
|
||||
DB::transaction(function () use ($user, $item, $slot, $days, $expiresAt, $source) {
|
||||
DB::transaction(function () use ($user, $item, $slot, $totalPrice, $totalDays, $expiresAt, $source) {
|
||||
// ① 通过统一积分服务扣除金币(含流水记录)
|
||||
$this->currencyService->change(
|
||||
$user, 'gold', -$item->price, $source,
|
||||
"购买装扮:{$item->name}({$days}天)"
|
||||
$user, 'gold', -$totalPrice, $source,
|
||||
"购买装扮:{$item->name}({$totalDays}天)"
|
||||
);
|
||||
|
||||
// ② 写入购买记录(用于后台统计与用户回溯)
|
||||
@@ -96,11 +113,11 @@ class DecorationService
|
||||
'user_id' => $user->id,
|
||||
'shop_item_id' => $item->id,
|
||||
'status' => 'active',
|
||||
'price_paid' => $item->price,
|
||||
'price_paid' => $totalPrice,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位覆盖,不同槽位合并)
|
||||
// ③ 更新用户 active_decorations JSON 字段(同槽位合并,不同槽位追加)
|
||||
$decorations = $this->getActiveDecorations($user);
|
||||
$decorations[$slot] = [
|
||||
'style' => $item->slug,
|
||||
@@ -113,13 +130,19 @@ class DecorationService
|
||||
// 重新读取最新余额,避免缓存脏数据
|
||||
$balanceAfter = (int) $user->fresh()->jjb;
|
||||
|
||||
// 计算叠加后的总天数显示(如果是续费,显示累计总天数)
|
||||
$displayDays = $isSameActive
|
||||
? (int) Carbon::now()->diffInDays(Carbon::parse($expiresAt), false) + 1
|
||||
: $totalDays;
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$days}天有效)",
|
||||
'message' => "购买成功!{$item->icon} {$item->name} 已激活({$displayDays}天有效)",
|
||||
'balance_after' => $balanceAfter,
|
||||
'slot' => $slot,
|
||||
'style' => $item->slug,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,10 @@ class ShopService
|
||||
'auto_fishing' => $this->buyAutoFishingCard($user, $item),
|
||||
ShopItem::TYPE_SIGN_REPAIR => $this->buySignRepairCard($user, $item, $quantity),
|
||||
// ── 个人装扮购买(委托给 DecorationService)───────────────
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item),
|
||||
'msg_bubble' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_name_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'msg_text_color' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
'avatar_frame' => $this->decorationService->purchase($user, $item, $quantity),
|
||||
default => ['ok' => false, 'message' => '未知商品类型'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -336,10 +336,10 @@ export function renderDecorations(data) {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
list.innerHTML = "";
|
||||
|
||||
// 购买说明明确同类型替换规则,避免用户误以为可以叠加多款气泡或头像框。
|
||||
// 购买说明:已激活同款支持叠加天数,不同款式替换时旧装扮作废不退款。
|
||||
const note = document.createElement("div");
|
||||
note.className = "decoration-note";
|
||||
note.innerHTML = "📌 购买说明:每个类型只生效一个,购买同类型新装扮后,旧装扮自动作废且不退款。";
|
||||
note.innerHTML = "📌 购买说明:同类型只生效一款;已激活的同款续购自动叠加天数,无需一次买满;购买不同款式时旧装扮自动作废且不退款。";
|
||||
list.appendChild(note);
|
||||
|
||||
DECORATION_GROUPS.forEach((group) => {
|
||||
@@ -548,11 +548,16 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
// 个性装扮支持多份购买(叠加天数),弹出数量选择
|
||||
if (DECORATION_TYPE_TO_SLOT[item.type] && item.type !== "sign_repair") {
|
||||
quantity = await window.promptQuantity?.(`购买多份【${item.name}】可叠加天数\n已激活的同款续购自动延长,无需一次买满`, 1, 99) ?? 1;
|
||||
if (quantity === null || quantity === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const validityText = buildValidityText(item);
|
||||
const replacementText = DECORATION_TYPE_TO_SLOT[item.type]
|
||||
? "\n购买说明:同类型只生效最新购买,原有同类型装扮会自动作废且不退款。"
|
||||
: "";
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}${replacementText}\n\n确定购买吗?`;
|
||||
const confirmMessage = `确认花费 💰 ${Number(Number(item.price || 0) * quantity).toLocaleString()} 金币购买\n【${item.name}】${quantity > 1 ? ` × ${quantity}` : ""}${validityText ? `\n${validityText}` : ""}\n\n确定购买吗?`;
|
||||
const confirmed = await confirmShopPurchase(confirmMessage);
|
||||
|
||||
if (confirmed) {
|
||||
@@ -560,6 +565,35 @@ async function confirmAndBuyItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用数量输入弹窗。
|
||||
*
|
||||
* @param {string} hint 提示文案
|
||||
* @param {number} [minVal=1] 最小值
|
||||
* @param {number} [maxVal=99] 最大值
|
||||
* @returns {Promise<number|null>} 返回数量,用户取消返回 null
|
||||
*/
|
||||
window.promptQuantity = async (hint, minVal = 1, maxVal = 99) => {
|
||||
if (window.chatDialog?.prompt) {
|
||||
const result = await window.chatDialog.prompt(hint, "1", "购买数量");
|
||||
if (result === null || result === undefined) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
window.chatDialog?.alert?.(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
const result = window.prompt(`${hint}\n数量(${minVal}~${maxVal}):`, "1");
|
||||
if (result === null) return null;
|
||||
const val = parseInt(result, 10);
|
||||
if (isNaN(val) || val < minVal || val > maxVal) {
|
||||
alert(`请输入 ${minVal}~${maxVal} 之间的整数`);
|
||||
return null;
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容全局弹窗组件缺失时的原生确认。
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user