From af772350c93cb5ec2b35cfafa92d0bed8bea2965 Mon Sep 17 00:00:00 2001 From: lkddi Date: Sun, 26 Apr 2026 11:31:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=94=A8=E6=88=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E4=BB=98=E8=B4=B9=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Enums/CurrencySource.php | 4 + app/Http/Controllers/BankController.php | 34 ++- app/Http/Controllers/UserController.php | 120 ++++++++- .../Requests/RevealProfileInfoRequest.php | 55 +++++ app/Services/UserCurrencyService.php | 55 +++++ resources/js/chat-room/bank-modal.js | 89 ++++++- resources/js/chat-room/dialog.js | 12 +- resources/js/chat-room/user-card.js | 184 ++++++++++++++ .../chat/partials/global-dialog.blade.php | 2 +- .../chat/partials/user-actions.blade.php | 26 +- routes/web.php | 1 + tests/Feature/BankControllerTest.php | 210 +++++++++++++++- tests/Feature/UserControllerTest.php | 231 ++++++++++++++++++ 13 files changed, 987 insertions(+), 36 deletions(-) create mode 100644 app/Http/Requests/RevealProfileInfoRequest.php diff --git a/app/Enums/CurrencySource.php b/app/Enums/CurrencySource.php index 5260354..0ff2f02 100644 --- a/app/Enums/CurrencySource.php +++ b/app/Enums/CurrencySource.php @@ -140,6 +140,9 @@ enum CurrencySource: string /** 看视频赚金币与经验奖励 */ case VIDEO_REWARD = 'video_reward'; + /** 查看别人隐藏信息扣费 */ + case USER_INFO_REVEAL = 'user_info_reveal'; + /** * 返回该来源的中文名称,用于后台统计展示。 */ @@ -186,6 +189,7 @@ enum CurrencySource: string self::GOMOKU_WIN => '五子棋获胜奖励', self::GOMOKU_REFUND => '五子棋入场费返还', self::VIDEO_REWARD => '看视频奖励', + self::USER_INFO_REVEAL => '信息查看付费', }; } } diff --git a/app/Http/Controllers/BankController.php b/app/Http/Controllers/BankController.php index b090540..7580423 100644 --- a/app/Http/Controllers/BankController.php +++ b/app/Http/Controllers/BankController.php @@ -14,11 +14,16 @@ namespace App\Http\Controllers; use App\Models\BankLog; +use App\Models\Sysparam; +use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +/** + * 类功能:处理银行余额、存取款和存款排行展示。 + */ class BankController extends Controller { /** @@ -45,20 +50,27 @@ class BankController extends Controller */ public function ranking(Request $request): JsonResponse { + /** @var User $operator */ + $operator = Auth::user(); $direction = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc'; - $users = \App\Models\User::where('bank_jjb', '>', 0) + $users = User::where('bank_jjb', '>', 0) ->orderBy('bank_jjb', $direction) ->paginate(20, ['id', 'username', 'bank_jjb', 'sex', 'usersf', 'user_level']); return response()->json([ 'status' => 'success', - 'ranking' => $users->map(function ($u) { - // 提供必要的前端展示字段 + 'ranking' => $users->map(function (User $u) use ($operator) { + $canViewBalance = $this->canViewBankBalance($operator, $u); + + // 提供必要的前端展示字段,普通用户查看别人存款时只返回星号,防止前端绕过遮罩。 return [ 'id' => $u->id, 'username' => $u->username, - 'bank_jjb' => $u->bank_jjb, + 'bank_jjb' => $canViewBalance ? ($u->bank_jjb ?? 0) : '******', + 'bank_jjb_masked' => ! $canViewBalance, + 'can_reveal' => ! $canViewBalance, + 'reveal_cost' => UserController::INFO_REVEAL_COST, 'sex' => $u->sex, 'usersf' => $u->usersf, 'user_level' => $u->user_level, @@ -158,4 +170,18 @@ class BankController extends Controller 'bank_jjb' => $fresh->bank_jjb, ]); } + + /** + * 判断操作者是否可以免费查看目标用户银行存款。 + */ + private function canViewBankBalance(User $operator, User $targetUser): bool + { + if ($operator->id === $targetUser->id) { + return true; + } + + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + return (int) $operator->user_level >= $superLevel; + } } diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index aa740e0..3190caa 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -17,10 +17,12 @@ namespace App\Http\Controllers; +use App\Enums\CurrencySource; use App\Events\UserKicked; use App\Events\UserMuted; use App\Events\UserStatusUpdated; use App\Http\Requests\ChangePasswordRequest; +use App\Http\Requests\RevealProfileInfoRequest; use App\Http\Requests\UpdateChatPreferencesRequest; use App\Http\Requests\UpdateDailyStatusRequest; use App\Http\Requests\UpdateProfileRequest; @@ -29,6 +31,7 @@ use App\Models\Sysparam; use App\Models\User; use App\Services\ChatStateService; use App\Services\ChatUserPresenceService; +use App\Services\UserCurrencyService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -40,12 +43,28 @@ use Illuminate\Support\Facades\Redis; */ class UserController extends Controller { + /** + * 查看别人隐藏信息的单次扣费金额。 + */ + public const INFO_REVEAL_COST = 1000; + + /** + * 可付费查看的信息字段和中文名称。 + */ + private const REVEALABLE_INFO_LABELS = [ + 'exp_num' => '经验', + 'jjb' => '金币', + 'bank_jjb' => '存款', + 'meili' => '魅力', + ]; + /** * 构造用户控制器依赖。 */ public function __construct( private readonly ChatStateService $chatState, private readonly ChatUserPresenceService $chatUserPresenceService, + private readonly UserCurrencyService $currencyService, ) {} /** @@ -88,21 +107,29 @@ class UserController extends Controller 'position_rank' => (int) ($activePosition?->rank ?? 0), ]; - // 只有等级不低于对方,或者自己看自己时,才能看到详细的财富、经验资产 - if ($operator && ($operator->user_level >= $targetUser->user_level || $operator->id === $targetUser->id)) { - $data['exp_num'] = $targetUser->exp_num ?? 0; - $data['jjb'] = $targetUser->jjb ?? 0; - $data['meili'] = $targetUser->meili ?? 0; - } + // 经验、金币、魅力卡片始终展示;等级不足时只显示星号,不隐藏字段本身。 + $canViewAssetNumbers = $operator && $this->canViewAssetNumbers($operator, $targetUser); + $data['exp_num'] = $canViewAssetNumbers ? ($targetUser->exp_num ?? 0) : '******'; + $data['jjb'] = $canViewAssetNumbers ? ($targetUser->jjb ?? 0) : '******'; + $data['meili'] = $canViewAssetNumbers ? ($targetUser->meili ?? 0) : '******'; + $data['asset_numbers_masked'] = ! $canViewAssetNumbers; + $data['asset_numbers_can_reveal'] = ! $canViewAssetNumbers; + $data['asset_numbers_reveal_cost'] = self::INFO_REVEAL_COST; + $data['asset_reveal_user_id'] = $targetUser->id; // 银行存款:只有超级管理员(user_level >= superlevel)或本人才能查看具体金额,其余一律显示星号 if ($operator) { $isSelf = $operator->id === $targetUser->id; $superLevel = (int) Sysparam::getValue('superlevel', '100'); $isSuperAdmin = $operator->user_level >= $superLevel; - $data['bank_jjb'] = ($isSelf || $isSuperAdmin) - ? ($targetUser->bank_jjb ?? 0) - : '******'; + $canViewBankBalance = $isSelf || $isSuperAdmin; + + // 名片里的别人存款默认只返回星号,真实金额必须通过付费查看接口当次获取。 + $data['bank_jjb'] = $canViewBankBalance ? ($targetUser->bank_jjb ?? 0) : '******'; + $data['bank_jjb_masked'] = ! $canViewBankBalance; + $data['bank_jjb_can_reveal'] = ! $canViewBankBalance; + $data['bank_jjb_reveal_cost'] = self::INFO_REVEAL_COST; + $data['bank_reveal_user_id'] = $targetUser->id; } // 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接 @@ -191,6 +218,54 @@ class UserController extends Controller ]); } + /** + * 付费查看用户资料中被星号隐藏的经验、金币、存款或魅力。 + */ + public function revealInfo(RevealProfileInfoRequest $request): JsonResponse + { + /** @var User $operator */ + $operator = Auth::user(); + $targetUser = User::query() + ->select(['id', 'username', 'user_level', 'exp_num', 'jjb', 'bank_jjb', 'meili']) + ->findOrFail($request->integer('user_id')); + $asset = (string) $request->string('asset'); + $assetLabel = self::REVEALABLE_INFO_LABELS[$asset]; + $charged = false; + $operatorGold = (int) $operator->jjb; + + if (! $this->canViewProfileInfo($operator, $targetUser, $asset)) { + $operatorGold = $this->currencyService->deductGoldIfEnough( + user: $operator, + amount: self::INFO_REVEAL_COST, + source: CurrencySource::USER_INFO_REVEAL, + remark: "查看 {$targetUser->username} 的{$assetLabel}", + ); + + if ($operatorGold === null) { + return response()->json([ + 'status' => 'error', + 'message' => '金币不足,查看'.$assetLabel.'需要 '.self::INFO_REVEAL_COST.' 金币。', + ]); + } + + $charged = true; + } + + return response()->json([ + 'status' => 'success', + 'message' => $charged + ? '已扣除 '.self::INFO_REVEAL_COST.' 金币,'.$assetLabel.'已显示。' + : $assetLabel.'已显示。', + 'user_id' => $targetUser->id, + 'username' => $targetUser->username, + 'asset' => $asset, + 'value' => $targetUser->{$asset} ?? 0, + 'charged' => $charged, + 'reveal_cost' => self::INFO_REVEAL_COST, + 'jjb' => $operatorGold, + ]); + } + /** * 修改个人资料 (对应 USERSET.ASP) */ @@ -531,4 +606,31 @@ class UserController extends Controller return response()->json(['status' => 'success', 'message' => "用户 {$username} 已被封号并封IP{$ipInfo}。"]); } + + /** + * 判断操作者是否可以免费查看目标用户经验、金币与魅力。 + */ + private function canViewAssetNumbers(User $operator, User $targetUser): bool + { + return $operator->id === $targetUser->id + || (int) $operator->user_level >= (int) $targetUser->user_level; + } + + /** + * 判断操作者是否可以免费查看指定资料信息。 + */ + private function canViewProfileInfo(User $operator, User $targetUser, string $asset): bool + { + if ($asset !== 'bank_jjb') { + return $this->canViewAssetNumbers($operator, $targetUser); + } + + if ($operator->id === $targetUser->id) { + return true; + } + + $superLevel = (int) Sysparam::getValue('superlevel', '100'); + + return (int) $operator->user_level >= $superLevel; + } } diff --git a/app/Http/Requests/RevealProfileInfoRequest.php b/app/Http/Requests/RevealProfileInfoRequest.php new file mode 100644 index 0000000..5b1ebc4 --- /dev/null +++ b/app/Http/Requests/RevealProfileInfoRequest.php @@ -0,0 +1,55 @@ +|string> + */ + public function rules(): array + { + return [ + 'user_id' => ['required', 'integer', 'exists:users,id'], + 'asset' => ['required', 'string', Rule::in(['exp_num', 'jjb', 'bank_jjb', 'meili'])], + ]; + } + + /** + * 获取付费查看信息请求的中文验证提示。 + * + * @return array + */ + public function messages(): array + { + return [ + 'user_id.required' => '缺少要查看的用户。', + 'user_id.integer' => '要查看的用户无效。', + 'user_id.exists' => '要查看的用户不存在。', + 'asset.required' => '缺少要查看的信息类型。', + 'asset.in' => '要查看的信息类型无效。', + ]; + } +} diff --git a/app/Services/UserCurrencyService.php b/app/Services/UserCurrencyService.php index 5ebabe8..deaf665 100644 --- a/app/Services/UserCurrencyService.php +++ b/app/Services/UserCurrencyService.php @@ -19,6 +19,9 @@ use App\Models\UserCurrencyLog; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +/** + * 类功能:统一处理用户经验、金币与魅力变更,并记录对应流水。 + */ class UserCurrencyService { /** @@ -85,6 +88,58 @@ class UserCurrencyService }); } + /** + * 在余额充足时扣除用户流通金币,并返回扣费后的金币余额。 + * + * @param User $user 被扣费用户 + * @param int $amount 扣费金币数量 + * @param CurrencySource $source 扣费来源 + * @param string $remark 扣费备注 + * @param int|null $roomId 所在房间 ID(可选) + */ + public function deductGoldIfEnough( + User $user, + int $amount, + CurrencySource $source, + string $remark = '', + ?int $roomId = null, + ): ?int { + if ($amount <= 0) { + return (int) $user->jjb; + } + + return DB::transaction(function () use ($user, $amount, $source, $remark, $roomId): ?int { + // 付费查看属于真实消费,先锁定用户行再判断余额,避免并发点击透支金币。 + $lockedUser = User::query() + ->whereKey($user->id) + ->lockForUpdate() + ->firstOrFail(); + + if ((int) $lockedUser->jjb < $amount) { + return null; + } + + // 扣除流通金币后写入统一流水,方便后台统计与用户追溯消费来源。 + $lockedUser->decrement('jjb', $amount); + $balanceAfter = (int) $lockedUser->fresh()->jjb; + + UserCurrencyLog::create([ + 'user_id' => $lockedUser->id, + 'username' => $lockedUser->username, + 'currency' => 'gold', + 'amount' => -$amount, + 'balance_after' => $balanceAfter, + 'source' => $source->value, + 'remark' => $remark, + 'room_id' => $roomId, + ]); + + $user->setAttribute('jjb', $balanceAfter); + + return $balanceAfter; + }, attempts: 3); + } + /** * 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。 * 每位用户仍独立走事务,单人失败不影响其他人。 diff --git a/resources/js/chat-room/bank-modal.js b/resources/js/chat-room/bank-modal.js index 17d4356..606988a 100644 --- a/resources/js/chat-room/bank-modal.js +++ b/resources/js/chat-room/bank-modal.js @@ -222,6 +222,76 @@ export function bankShowMsg(message, success) { }, 3000); } +/** + * 确认是否扣费查看别人银行存款。 + * + * @param {number} cost 查看费用 + * @returns {Promise} + */ +async function confirmRevealBankBalance(cost) { + const message = `查看 TA 的银行存款需扣除 ${Number(cost || 0).toLocaleString()} 金币,是否继续?`; + + if (typeof window.chatDialog?.confirm === "function") { + return Boolean(await window.chatDialog.confirm(message, "信息查看付费", "#d97706", "扣费查看", "取消")); + } + + return window.confirm(message); +} + +/** + * 付费查看排行榜里的指定用户存款,并只更新当前行展示。 + * + * @param {HTMLElement} trigger 触发查看的按钮 + * @returns {Promise} + */ +async function revealRankingBankBalance(trigger) { + const userId = Number.parseInt(trigger.getAttribute("data-bank-reveal-user-id") || "0", 10); + const cost = Number.parseInt(trigger.getAttribute("data-bank-reveal-cost") || "1000", 10); + if (!userId || trigger.getAttribute("aria-disabled") === "true") { + return; + } + + const confirmed = await confirmRevealBankBalance(cost); + if (!confirmed) { + return; + } + + trigger.setAttribute("aria-disabled", "true"); + try { + const response = await fetch("/user/reveal-info", { + method: "POST", + headers: { + "X-CSRF-TOKEN": csrf(), + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ user_id: userId, asset: "bank_jjb" }), + }); + const data = await response.json(); + + if (data.status !== "success") { + await window.chatDialog?.alert?.(data.message || "查看失败,请稍后重试。", "查看失败", "#cc4444"); + return; + } + + const valueWrap = trigger.closest(".bank-rank-val"); + if (valueWrap) { + valueWrap.innerHTML = `🏦 ${Number(data.value || 0).toLocaleString()}`; + } + + if (window.chatContext && data.jjb !== undefined) { + // 同步流通金币缓存,避免后续购买或游戏继续使用扣费前余额。 + window.chatContext.myGold = data.jjb; + } + + bankShowMsg(data.message || "存款金额已显示。", true); + } catch (error) { + await window.chatDialog?.alert?.("网络异常,请稍后重试。", "查看失败", "#cc4444"); + } finally { + trigger.removeAttribute("aria-disabled"); + } +} + /** * 切换排行榜排序并回到第一页。 * @@ -330,6 +400,16 @@ function renderRankingRow(user, index) { const username = escapeHtml(user.username || ""); const sexSymbol = user.sex === "女" ? "♀" : "♂"; + const bankBalanceHtml = user.can_reveal + ? `` + : `🏦 ${Number(user.bank_jjb || 0).toLocaleString()}`; + return `
${absoluteRank}
@@ -337,7 +417,7 @@ function renderRankingRow(user, index) {
${username} ${sexSymbol}
- 🏦 ${Number(user.bank_jjb || 0).toLocaleString()} + ${bankBalanceHtml}
`; } @@ -412,6 +492,13 @@ export function bindBankControls() { return; } + const revealButton = event.target.closest("[data-bank-reveal-user-id]"); + if (revealButton instanceof HTMLElement) { + event.preventDefault(); + void revealRankingBankBalance(revealButton); + return; + } + const pageButton = event.target.closest("[data-bank-rank-page-delta]"); if (pageButton) { event.preventDefault(); diff --git a/resources/js/chat-room/dialog.js b/resources/js/chat-room/dialog.js index df6e857..deea175 100644 --- a/resources/js/chat-room/dialog.js +++ b/resources/js/chat-room/dialog.js @@ -23,9 +23,11 @@ function getDialogElement(id) { * @param {string} options.color * @param {string} options.type * @param {string} [options.defaultVal] + * @param {string} [options.confirmText] + * @param {string} [options.cancelText] * @returns {void} */ -function openDialog({ message, title, color, type, defaultVal }) { +function openDialog({ message, title, color, type, defaultVal, confirmText, cancelText }) { currentDialogType = type; const header = getDialogElement("global-dialog-header"); @@ -44,6 +46,8 @@ function openDialog({ message, title, color, type, defaultVal }) { header.style.background = color; messageBox.textContent = message; confirmButton.style.background = color; + confirmButton.textContent = confirmText || "确定"; + cancelButton.textContent = cancelText || "取消"; if (type === "prompt") { inputElement.value = defaultVal ?? ""; @@ -105,12 +109,14 @@ function createChatDialogApi() { * @param {string} message * @param {string} title * @param {string} color + * @param {string} confirmText + * @param {string} cancelText * @returns {Promise} */ - confirm(message, title = "请确认", color = "#cc4444") { + confirm(message, title = "请确认", color = "#cc4444", confirmText = "确定", cancelText = "取消") { return new Promise((resolve) => { dialogResolve = resolve; - openDialog({ message, title, color, type: "confirm" }); + openDialog({ message, title, color, type: "confirm", confirmText, cancelText }); }); }, diff --git a/resources/js/chat-room/user-card.js b/resources/js/chat-room/user-card.js index 3cdf177..17469cf 100644 --- a/resources/js/chat-room/user-card.js +++ b/resources/js/chat-room/user-card.js @@ -121,6 +121,190 @@ export function userCardComponent() { return operatorPositionRank >= targetPositionRank; }, + /** 返回名片资产字段的中文名称。 */ + assetValueLabel(asset) { + return { + exp_num: "经验", + jjb: "金币", + meili: "魅力", + }[asset] || "资产"; + }, + + /** 判断名片资产字段是否处于可付费查看的隐藏状态。 */ + canRevealAssetValue(asset) { + return Boolean( + this.userInfo.asset_numbers_can_reveal + && this.userInfo.asset_numbers_masked + && this.userInfo[asset] === "******" + && this.userInfo.asset_reveal_user_id + ); + }, + + /** 格式化名片里的经验、金币、魅力显示。 */ + displayAssetValue(asset) { + if (this.canRevealAssetValue(asset)) { + return "****** 👁️"; + } + + return Number(this.userInfo[asset] || 0).toLocaleString(); + }, + + /** 返回名片资产字段的悬停提示。 */ + assetValueTitle(asset) { + if (this.canRevealAssetValue(asset)) { + const cost = Number(this.userInfo.asset_numbers_reveal_cost || 1000).toLocaleString(); + + return `点击查看${this.assetValueLabel(asset)},需扣除 ${cost} 金币`; + } + + return this.assetValueLabel(asset); + }, + + /** 返回名片资产字段的点击态样式。 */ + assetValueStyle(asset, color) { + const clickable = this.canRevealAssetValue(asset); + + return [ + "font-weight: 700", + `color: ${color}`, + "font-size: 14px", + clickable ? "cursor: pointer" : "cursor: default", + "text-decoration: none", + ].join("; "); + }, + + /** 付费查看当前名片里的经验、金币或魅力。 */ + async revealAssetValue(asset) { + if (!this.canRevealAssetValue(asset)) { + return; + } + + const cost = Number(this.userInfo.asset_numbers_reveal_cost || 1000); + const label = this.assetValueLabel(asset); + const confirmed = await this.$confirm( + `查看 TA 的${label}需扣除 ${cost.toLocaleString()} 金币,是否继续?`, + "信息查看付费", + "#d97706", + "扣费查看", + "取消" + ); + + if (!confirmed) { + return; + } + + try { + const response = await fetch("/user/reveal-info", { + method: "POST", + headers: this._headers(), + body: JSON.stringify({ + user_id: this.userInfo.asset_reveal_user_id, + asset, + }), + }); + const data = await response.json(); + + if (data.status !== "success") { + this.$alert(data.message || "查看失败,请稍后重试。", "查看失败", "#cc4444"); + return; + } + + // 只解锁当前点击的资产字段,其他隐藏字段仍保持星号并可单独付费查看。 + this.userInfo[asset] = data.value; + + if (window.chatContext && data.jjb !== undefined) { + window.chatContext.myGold = data.jjb; + } + + this.$alert(data.message || `${label}已显示。`, "查看成功", "#16a34a"); + } catch (e) { + this.$alert("网络异常,请稍后重试。", "查看失败", "#cc4444"); + } + }, + + /** 格式化名片里的银行存款显示。 */ + displayBankBalance() { + if (this.userInfo.bank_jjb_can_reveal && this.userInfo.bank_jjb_masked) { + return "****** 👁️"; + } + + return Number(this.userInfo.bank_jjb || 0).toLocaleString(); + }, + + /** 返回银行存款字段的悬停提示。 */ + bankBalanceTitle() { + if (this.userInfo.bank_jjb_can_reveal && this.userInfo.bank_jjb_masked) { + const cost = Number(this.userInfo.bank_jjb_reveal_cost || 1000).toLocaleString(); + + return `点击查看存款,需扣除 ${cost} 金币`; + } + + return "银行存款"; + }, + + /** 返回银行存款字段的点击态样式。 */ + bankBalanceStyle() { + const clickable = this.userInfo.bank_jjb_can_reveal && this.userInfo.bank_jjb_masked; + + return [ + "font-weight: 700", + "color: #059669", + "font-size: 14px", + clickable ? "cursor: pointer" : "cursor: default", + "text-decoration: none", + ].join("; "); + }, + + /** 付费查看当前名片用户的银行存款。 */ + async revealBankBalance() { + if (!this.userInfo.bank_jjb_can_reveal || !this.userInfo.bank_jjb_masked || !this.userInfo.bank_reveal_user_id) { + return; + } + + const cost = Number(this.userInfo.bank_jjb_reveal_cost || 1000); + const confirmed = await this.$confirm( + `查看 TA 的银行存款需扣除 ${cost.toLocaleString()} 金币,是否继续?`, + "信息查看付费", + "#d97706", + "扣费查看", + "取消" + ); + + if (!confirmed) { + return; + } + + try { + const response = await fetch("/user/reveal-info", { + method: "POST", + headers: this._headers(), + body: JSON.stringify({ + user_id: this.userInfo.bank_reveal_user_id, + asset: "bank_jjb", + }), + }); + const data = await response.json(); + + if (data.status !== "success") { + this.$alert(data.message || "查看失败,请稍后重试。", "查看失败", "#cc4444"); + return; + } + + // 仅解锁当前名片这一处显示,重新打开名片时仍以后端返回的遮罩状态为准。 + this.userInfo.bank_jjb = data.value; + this.userInfo.bank_jjb_masked = false; + this.userInfo.bank_jjb_can_reveal = false; + + if (window.chatContext && data.jjb !== undefined) { + window.chatContext.myGold = data.jjb; + } + + this.$alert(data.message || "存款金额已显示。", "查看成功", "#16a34a"); + } catch (e) { + this.$alert("网络异常,请稍后重试。", "查看失败", "#cc4444"); + } + }, + /** 切换好友关系(加好友 / 删好友) */ async toggleFriend() { if (this.friendLoading) return; diff --git a/resources/views/chat/partials/global-dialog.blade.php b/resources/views/chat/partials/global-dialog.blade.php index 25b615b..4ff4d7b 100644 --- a/resources/views/chat/partials/global-dialog.blade.php +++ b/resources/views/chat/partials/global-dialog.blade.php @@ -3,7 +3,7 @@ 提供全局 JS API: - window.chatDialog.alert(message, title?, color?) → Promise - - window.chatDialog.confirm(message, title?, color?) → Promise + - window.chatDialog.confirm(message, title?, color?, confirmText?, cancelText?) → Promise - window.chatDialog.prompt(message, defaultVal?, title?, color?) → Promise 任何 JS 代码(Alpine.js 组件、toolbar、scripts 等)均可直接调用, diff --git a/resources/views/chat/partials/user-actions.blade.php b/resources/views/chat/partials/user-actions.blade.php index ec0a34e..0a6926d 100644 --- a/resources/views/chat/partials/user-actions.blade.php +++ b/resources/views/chat/partials/user-actions.blade.php @@ -106,35 +106,43 @@ {{-- 详细信息区:外层 x-show 控制显隐,内层单独写 flex 避免被 Alpine 覆盖 --}} -
+
经验 - +
金币 - +
存款 - +
魅力 - +
diff --git a/routes/web.php b/routes/web.php index 7196202..e67eee7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -86,6 +86,7 @@ Route::middleware(['chat.auth'])->group(function () { // ═══════════════════════════════════════════════════════════════════用户个人积分流水日志(查询自己的经验/金币/魅力历史) Route::get('/my/currency-logs', [\App\Http\Controllers\LeaderboardController::class, 'myLogs'])->name('currency.my-logs'); + Route::post('/user/reveal-info', [UserController::class, 'revealInfo'])->name('user.reveal-info'); // ---- 勤务台(展示四榜)---- Route::get('/duty-hall', [\App\Http\Controllers\DutyHallController::class, 'index'])->name('duty-hall.index'); diff --git a/tests/Feature/BankControllerTest.php b/tests/Feature/BankControllerTest.php index 561ed66..a00cbc4 100644 --- a/tests/Feature/BankControllerTest.php +++ b/tests/Feature/BankControllerTest.php @@ -1,17 +1,41 @@ 'superlevel'], ['body' => '100']); + } + + /** + * 测试银行信息接口返回本人余额和最近流水。 + */ + public function test_info_returns_balances_and_logs(): void { $user = User::factory()->create([ 'jjb' => 1000, @@ -36,7 +60,10 @@ class BankControllerTest extends TestCase $response->assertJsonCount(1, 'logs'); } - public function test_deposit_transfers_jjb_to_bank() + /** + * 测试存款会把流通金币转入银行账户。 + */ + public function test_deposit_transfers_jjb_to_bank(): void { $user = User::factory()->create([ 'jjb' => 1000, @@ -68,7 +95,10 @@ class BankControllerTest extends TestCase ]); } - public function test_deposit_fails_if_insufficient_funds() + /** + * 测试流通金币不足时不能存款。 + */ + public function test_deposit_fails_if_insufficient_funds(): void { $user = User::factory()->create([ 'jjb' => 100, @@ -89,7 +119,10 @@ class BankControllerTest extends TestCase ]); } - public function test_withdraw_transfers_bank_to_jjb() + /** + * 测试取款会把银行存款转回流通金币。 + */ + public function test_withdraw_transfers_bank_to_jjb(): void { $user = User::factory()->create([ 'jjb' => 0, @@ -115,7 +148,10 @@ class BankControllerTest extends TestCase ]); } - public function test_withdraw_fails_if_insufficient_funds() + /** + * 测试银行余额不足时不能取款。 + */ + public function test_withdraw_fails_if_insufficient_funds(): void { $user = User::factory()->create([ 'jjb' => 0, @@ -132,12 +168,18 @@ class BankControllerTest extends TestCase ]); } - public function test_ranking_returns_paginated_users_ordered_by_bank_jjb() + /** + * 测试普通用户查看存款排行时,别人存款被星号遮罩且本人存款可见。 + */ + public function test_ranking_masks_other_users_bank_balance_for_normal_user(): void { - $user = User::factory()->create(); // To act as + $user = User::factory()->create([ + 'bank_jjb' => 750, + 'username' => 'Viewer', + 'user_level' => 10, + ]); User::factory()->create(['bank_jjb' => 1000, 'username' => 'Rich']); - User::factory()->create(['bank_jjb' => 500, 'username' => 'Poorer']); $response = $this->actingAs($user)->getJson(route('bank.ranking')); @@ -149,6 +191,156 @@ class BankControllerTest extends TestCase $ranking = $response->json('ranking'); $this->assertCount(2, $ranking); $this->assertEquals('Rich', $ranking[0]['username']); - $this->assertEquals('Poorer', $ranking[1]['username']); + $this->assertSame('******', $ranking[0]['bank_jjb']); + $this->assertTrue($ranking[0]['can_reveal']); + $this->assertEquals('Viewer', $ranking[1]['username']); + $this->assertSame(750, $ranking[1]['bank_jjb']); + $this->assertFalse($ranking[1]['can_reveal']); + } + + /** + * 测试 superlevel 用户查看存款排行时所有金额真实可见。 + */ + public function test_ranking_shows_all_bank_balances_for_superlevel_user(): void + { + $admin = User::factory()->create([ + 'user_level' => 100, + 'bank_jjb' => 1, + ]); + + User::factory()->create(['bank_jjb' => 1000, 'username' => 'Rich']); + + $response = $this->actingAs($admin)->getJson(route('bank.ranking')); + + $response->assertOk(); + $ranking = $response->json('ranking'); + $this->assertSame(1000, $ranking[0]['bank_jjb']); + $this->assertFalse($ranking[0]['can_reveal']); + } + + /** + * 测试普通用户付费查看别人存款时扣除金币并写入流水。 + */ + public function test_reveal_balance_charges_normal_user_and_returns_target_bank_balance(): void + { + $viewer = User::factory()->create([ + 'jjb' => 1500, + 'user_level' => 10, + ]); + $target = User::factory()->create([ + 'username' => 'TargetUser', + 'bank_jjb' => 4321, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'bank_jjb', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('asset', 'bank_jjb') + ->assertJsonPath('value', 4321) + ->assertJsonPath('charged', true) + ->assertJsonPath('jjb', 500); + + $this->assertDatabaseHas('users', [ + 'id' => $viewer->id, + 'jjb' => 500, + ]); + $this->assertDatabaseHas('user_currency_logs', [ + 'user_id' => $viewer->id, + 'currency' => 'gold', + 'amount' => -1000, + 'balance_after' => 500, + 'source' => CurrencySource::USER_INFO_REVEAL->value, + 'remark' => '查看 TargetUser 的存款', + ]); + } + + /** + * 测试金币不足时不能付费查看别人存款,也不会泄露金额。 + */ + public function test_reveal_balance_fails_without_enough_gold(): void + { + $viewer = User::factory()->create([ + 'jjb' => 999, + 'user_level' => 10, + ]); + $target = User::factory()->create([ + 'bank_jjb' => 4321, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'bank_jjb', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'error') + ->assertJsonMissingPath('value'); + + $this->assertDatabaseHas('users', [ + 'id' => $viewer->id, + 'jjb' => 999, + ]); + $this->assertSame(0, UserCurrencyLog::query() + ->where('user_id', $viewer->id) + ->where('source', CurrencySource::USER_INFO_REVEAL->value) + ->count()); + } + + /** + * 测试查看自己存款不扣费。 + */ + public function test_reveal_own_balance_is_free(): void + { + $viewer = User::factory()->create([ + 'jjb' => 10, + 'bank_jjb' => 2222, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $viewer->id, + 'asset' => 'bank_jjb', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('asset', 'bank_jjb') + ->assertJsonPath('value', 2222) + ->assertJsonPath('charged', false) + ->assertJsonPath('jjb', 10); + + $this->assertSame(0, UserCurrencyLog::query() + ->where('user_id', $viewer->id) + ->where('source', CurrencySource::USER_INFO_REVEAL->value) + ->count()); + } + + /** + * 测试 superlevel 用户查看别人存款不扣费。 + */ + public function test_superlevel_reveal_other_balance_is_free(): void + { + $admin = User::factory()->create([ + 'jjb' => 10, + 'user_level' => 100, + ]); + $target = User::factory()->create([ + 'bank_jjb' => 3333, + ]); + + $response = $this->actingAs($admin)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'bank_jjb', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('asset', 'bank_jjb') + ->assertJsonPath('value', 3333) + ->assertJsonPath('charged', false) + ->assertJsonPath('jjb', 10); } } diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php index eb66d93..6ad8291 100644 --- a/tests/Feature/UserControllerTest.php +++ b/tests/Feature/UserControllerTest.php @@ -7,9 +7,11 @@ namespace Tests\Feature; +use App\Enums\CurrencySource; use App\Models\Room; use App\Models\Sysparam; use App\Models\User; +use App\Models\UserCurrencyLog; use App\Services\ChatUserPresenceService; use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -63,6 +65,235 @@ class UserControllerTest extends TestCase ->assertJsonPath('data.user_level', 10); } + /** + * 测试普通用户查看别人名片时银行存款默认显示星号。 + */ + public function test_normal_user_sees_masked_bank_balance_on_other_profile(): void + { + $viewer = User::factory()->create([ + 'user_level' => 10, + ]); + $target = User::factory()->create([ + 'username' => 'targetuser', + 'user_level' => 1, + 'exp_num' => 123, + 'jjb' => 456, + 'bank_jjb' => 6000, + 'meili' => 789, + ]); + + $response = $this->actingAs($viewer)->getJson("/user/{$target->username}"); + + $response->assertOk() + ->assertJsonPath('data.exp_num', 123) + ->assertJsonPath('data.jjb', 456) + ->assertJsonPath('data.meili', 789) + ->assertJsonPath('data.bank_jjb', '******') + ->assertJsonPath('data.bank_jjb_masked', true) + ->assertJsonPath('data.bank_jjb_can_reveal', true) + ->assertJsonPath('data.bank_jjb_reveal_cost', 1000) + ->assertJsonPath('data.bank_reveal_user_id', $target->id); + } + + /** + * 测试低等级用户查看高等级名片时资产卡片仍展示但具体数值被星号遮罩。 + */ + public function test_low_level_user_sees_masked_asset_numbers_on_higher_profile(): void + { + $viewer = User::factory()->create([ + 'user_level' => 1, + ]); + $target = User::factory()->create([ + 'username' => 'highlevel', + 'user_level' => 10, + 'exp_num' => 123, + 'jjb' => 456, + 'bank_jjb' => 6000, + 'meili' => 789, + ]); + + $response = $this->actingAs($viewer)->getJson("/user/{$target->username}"); + + $response->assertOk() + ->assertJsonPath('data.exp_num', '******') + ->assertJsonPath('data.jjb', '******') + ->assertJsonPath('data.bank_jjb', '******') + ->assertJsonPath('data.meili', '******') + ->assertJsonPath('data.bank_jjb_masked', true) + ->assertJsonPath('data.bank_jjb_can_reveal', true) + ->assertJsonPath('data.asset_numbers_masked', true) + ->assertJsonPath('data.asset_numbers_can_reveal', true) + ->assertJsonPath('data.asset_numbers_reveal_cost', 1000) + ->assertJsonPath('data.asset_reveal_user_id', $target->id); + } + + /** + * 测试低等级用户付费查看高等级用户隐藏经验时扣费并返回真实数值。 + */ + public function test_low_level_user_can_pay_to_reveal_hidden_exp_number(): void + { + $viewer = User::factory()->create([ + 'user_level' => 1, + 'jjb' => 1500, + ]); + $target = User::factory()->create([ + 'username' => 'highlevel', + 'user_level' => 10, + 'exp_num' => 12345, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'exp_num', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('asset', 'exp_num') + ->assertJsonPath('value', 12345) + ->assertJsonPath('charged', true) + ->assertJsonPath('jjb', 500); + + $this->assertDatabaseHas('users', [ + 'id' => $viewer->id, + 'jjb' => 500, + ]); + $this->assertDatabaseHas('user_currency_logs', [ + 'user_id' => $viewer->id, + 'currency' => 'gold', + 'amount' => -1000, + 'balance_after' => 500, + 'source' => CurrencySource::USER_INFO_REVEAL->value, + 'remark' => '查看 highlevel 的经验', + ]); + } + + /** + * 测试低等级用户付费查看高等级用户隐藏金币时返回目标金币且同步扣费后余额。 + */ + public function test_low_level_user_can_pay_to_reveal_hidden_gold_number(): void + { + $viewer = User::factory()->create([ + 'user_level' => 1, + 'jjb' => 2000, + ]); + $target = User::factory()->create([ + 'username' => 'goldtarget', + 'user_level' => 10, + 'jjb' => 4567, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'jjb', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('asset', 'jjb') + ->assertJsonPath('value', 4567) + ->assertJsonPath('charged', true) + ->assertJsonPath('jjb', 1000); + } + + /** + * 测试金币不足时不能查看隐藏资产,也不会泄露真实数值。 + */ + public function test_reveal_hidden_asset_fails_without_enough_gold(): void + { + $viewer = User::factory()->create([ + 'user_level' => 1, + 'jjb' => 999, + ]); + $target = User::factory()->create([ + 'user_level' => 10, + 'meili' => 789, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'meili', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'error') + ->assertJsonMissingPath('value'); + + $this->assertDatabaseHas('users', [ + 'id' => $viewer->id, + 'jjb' => 999, + ]); + $this->assertSame(0, UserCurrencyLog::query() + ->where('user_id', $viewer->id) + ->where('source', CurrencySource::USER_INFO_REVEAL->value) + ->count()); + } + + /** + * 测试等级足够时通过查看接口读取资产不扣费。 + */ + public function test_reveal_asset_is_free_when_level_allows_direct_view(): void + { + $viewer = User::factory()->create([ + 'user_level' => 10, + 'jjb' => 50, + ]); + $target = User::factory()->create([ + 'user_level' => 1, + 'meili' => 789, + ]); + + $response = $this->actingAs($viewer)->postJson(route('user.reveal-info'), [ + 'user_id' => $target->id, + 'asset' => 'meili', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success') + ->assertJsonPath('value', 789) + ->assertJsonPath('charged', false) + ->assertJsonPath('jjb', 50); + } + + /** + * 测试查看自己名片时银行存款真实可见且不需要付费查看。 + */ + public function test_self_profile_shows_real_bank_balance(): void + { + $user = User::factory()->create([ + 'username' => 'selfuser', + 'bank_jjb' => 7000, + ]); + + $response = $this->actingAs($user)->getJson("/user/{$user->username}"); + + $response->assertOk() + ->assertJsonPath('data.bank_jjb', 7000) + ->assertJsonPath('data.bank_jjb_masked', false) + ->assertJsonPath('data.bank_jjb_can_reveal', false); + } + + /** + * 测试 superlevel 用户查看别人名片时银行存款真实可见。 + */ + public function test_superlevel_profile_shows_other_bank_balance(): void + { + $admin = User::factory()->create([ + 'user_level' => 100, + ]); + $target = User::factory()->create([ + 'username' => 'richuser', + 'bank_jjb' => 8000, + ]); + + $response = $this->actingAs($admin)->getJson("/user/{$target->username}"); + + $response->assertOk() + ->assertJsonPath('data.bank_jjb', 8000) + ->assertJsonPath('data.bank_jjb_masked', false) + ->assertJsonPath('data.bank_jjb_can_reveal', false); + } + /** * 测试不改邮箱时可以正常更新个人资料。 */