统一用户信息付费查看

This commit is contained in:
2026-04-26 11:31:46 +08:00
parent f0269c7c17
commit af772350c9
13 changed files with 987 additions and 36 deletions
+4
View File
@@ -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 => '信息查看付费',
};
}
}
+30 -4
View File
@@ -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;
}
}
+111 -9
View File
@@ -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;
}
}
@@ -0,0 +1,55 @@
<?php
/**
* 文件功能:用户信息付费查看请求验证器
* 校验用户点击查看别人经验、金币、存款、魅力时提交的目标与信息类型。
*/
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 类功能:验证付费查看用户隐藏信息的请求参数。
*/
class RevealProfileInfoRequest extends FormRequest
{
/**
* 授权所有已登录用户提交查看请求,具体可见性和扣费由控制器统一处理。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取付费查看信息请求的验证规则。
*
* @return array<string, ValidationRule|array<mixed>|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<string, string>
*/
public function messages(): array
{
return [
'user_id.required' => '缺少要查看的用户。',
'user_id.integer' => '要查看的用户无效。',
'user_id.exists' => '要查看的用户不存在。',
'asset.required' => '缺少要查看的信息类型。',
'asset.in' => '要查看的信息类型无效。',
];
}
}
+55
View File
@@ -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);
}
/**
* 批量变更多个用户的货币属性(适用于自动存点:一次操作多人)。
* 每位用户仍独立走事务,单人失败不影响其他人。
+88 -1
View File
@@ -222,6 +222,76 @@ export function bankShowMsg(message, success) {
}, 3000);
}
/**
* 确认是否扣费查看别人银行存款。
*
* @param {number} cost 查看费用
* @returns {Promise<boolean>}
*/
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<void>}
*/
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
? `<button type="button"
data-bank-reveal-user-id="${escapeHtml(user.id)}"
data-bank-reveal-cost="${escapeHtml(user.reveal_cost || 1000)}"
title="点击查看存款,需扣除 ${Number(user.reveal_cost || 1000).toLocaleString()} 金币"
style="border:none;background:transparent;color:#059669;font-weight:bold;cursor:pointer;padding:0;">
****** 👁️
</button>`
: `🏦 ${Number(user.bank_jjb || 0).toLocaleString()}`;
return `<div class="bank-rank-item">
<div class="bank-rank-num ${rankClass}">${absoluteRank}</div>
<img src="${avatarUrl}" onerror="this.src='/images/headface/1.gif'" style="width:32px; height:32px; border-radius:50%; object-fit:cover; border:1px solid #d0e4f5;">
@@ -337,7 +417,7 @@ function renderRankingRow(user, index) {
<div style="font-weight:bold; color:#1e3a8a;">${username} <span style="font-size:12px;">${sexSymbol}</span></div>
</div>
<div class="bank-rank-val">
🏦 ${Number(user.bank_jjb || 0).toLocaleString()}
${bankBalanceHtml}
</div>
</div>`;
}
@@ -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();
+9 -3
View File
@@ -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<boolean>}
*/
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 });
});
},
+184
View File
@@ -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;
@@ -3,7 +3,7 @@
提供全局 JS API
- window.chatDialog.alert(message, title?, color?) Promise<void>
- window.chatDialog.confirm(message, title?, color?) Promise<boolean>
- window.chatDialog.confirm(message, title?, color?, confirmText?, cancelText?) Promise<boolean>
- window.chatDialog.prompt(message, defaultVal?, title?, color?) Promise<string|null>
任何 JS 代码(Alpine.js 组件、toolbar、scripts 等)均可直接调用,
@@ -106,35 +106,43 @@
</div>
{{-- 详细信息区:外层 x-show 控制显隐,内层单独写 flex 避免被 Alpine 覆盖 --}}
<div x-show="userInfo.exp_num !== undefined" style="margin-top: 12px;">
<div x-show="userInfo.username" style="margin-top: 12px;">
<div style="display: flex; flex-direction: row; gap: 8px;">
<!-- 经验 -->
<div
style="flex: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #64748b; font-size: 11px; margin-bottom: 2px;">经验</span>
<span x-text="userInfo.exp_num"
style="font-weight: 700; color: #4f46e5; font-size: 14px;"></span>
<span x-text="displayAssetValue('exp_num')"
x-on:click="revealAssetValue('exp_num')"
:title="assetValueTitle('exp_num')"
:style="assetValueStyle('exp_num', '#4f46e5')"></span>
</div>
<!-- 金币 -->
<div
style="flex: 1; background: #fdfae8; border: 1px solid #fef08a; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #b45309; font-size: 11px; margin-bottom: 2px;">金币</span>
<span x-text="userInfo.jjb"
style="font-weight: 700; color: #d97706; font-size: 14px;"></span>
<span x-text="displayAssetValue('jjb')"
x-on:click="revealAssetValue('jjb')"
:title="assetValueTitle('jjb')"
:style="assetValueStyle('jjb', '#d97706')"></span>
</div>
<!-- 存款 -->
<div
style="flex: 1; background: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #047857; font-size: 11px; margin-bottom: 2px;">存款</span>
<span x-text="userInfo.bank_jjb"
style="font-weight: 700; color: #059669; font-size: 14px;"></span>
<span x-text="displayBankBalance()"
x-on:click="revealBankBalance()"
:title="bankBalanceTitle()"
:style="bankBalanceStyle()"></span>
</div>
<!-- 魅力 -->
<div
style="flex: 1; background: #fdf2f8; border: 1px solid #fbcfe8; border-radius: 6px; padding: 6px 0; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<span style="color: #be185d; font-size: 11px; margin-bottom: 2px;">魅力</span>
<span x-text="userInfo.meili"
style="font-weight: 700; color: #db2777; font-size: 14px;"></span>
<span x-text="displayAssetValue('meili')"
x-on:click="revealAssetValue('meili')"
:title="assetValueTitle('meili')"
:style="assetValueStyle('meili', '#db2777')"></span>
</div>
</div>
</div>
+1
View File
@@ -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');
+201 -9
View File
@@ -1,17 +1,41 @@
<?php
/**
* 文件功能:银行接口功能测试
* 覆盖银行余额、存取款、排行榜遮罩和统一信息查看付费。
*/
namespace Tests\Feature;
use App\Enums\CurrencySource;
use App\Models\BankLog;
use App\Models\Sysparam;
use App\Models\User;
use App\Models\UserCurrencyLog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 类功能:验证银行控制器的资金操作与存款可见性规则。
*/
class BankControllerTest extends TestCase
{
use RefreshDatabase;
public function test_info_returns_balances_and_logs()
/**
* 初始化银行接口测试所需的系统参数。
*/
protected function setUp(): void
{
parent::setUp();
Sysparam::updateOrCreate(['alias' => '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);
}
}
+231
View File
@@ -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);
}
/**
* 测试不改邮箱时可以正常更新个人资料。
*/