'经验', 'jjb' => '金币', 'bank_jjb' => '存款', 'meili' => '魅力', ]; /** * 构造用户控制器依赖。 */ public function __construct( private readonly ChatStateService $chatState, private readonly ChatUserPresenceService $chatUserPresenceService, private readonly UserCurrencyService $currencyService, private readonly PositionPermissionService $positionPermissionService, ) {} /** * 查看其他用户资料片 (对应 USERinfo.ASP) */ public function show(string $username): JsonResponse { $targetUser = User::where('username', $username)->firstOrFail(); $operator = Auth::user(); // 探测原图 $headfaceOriginal = $targetUser->headfaceUrl; if (str_starts_with((string) $targetUser->headface, 'storage/')) { $info = pathinfo($targetUser->headface); $origPath = $info['dirname'].'/'.$info['filename'].'_original.'.($info['extension'] ?? 'jpg'); if (\Illuminate\Support\Facades\Storage::disk('public')->exists(substr($origPath, 8))) { $headfaceOriginal = '/'.$origPath; } } // 基础公开信息 $activePosition = $targetUser->activePosition?->load('position.department')->position; $data = [ 'username' => $targetUser->username, 'sex' => match ((int) $targetUser->sex) { 1 => '男', 2 => '女', default => '' }, 'headface' => $targetUser->headface, 'headface_original' => $headfaceOriginal, 'usersf' => $targetUser->usersf, 'user_level' => $targetUser->user_level, 'qianming' => $targetUser->qianming, 'sign' => $targetUser->sign ?? '这个人很懒,什么都没留下。', 'created_at' => $targetUser->created_at->format('Y-m-d'), // 在职职务(供名片弹窗任命/撤销判断) 'position_name' => $activePosition?->name ?? '', 'position_icon' => $activePosition?->icon ?? '', 'department_name' => $activePosition?->department?->name ?? '', 'department_rank' => (int) ($activePosition?->department?->rank ?? 0), 'position_rank' => (int) ($activePosition?->rank ?? 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; $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; } // 仅当自己看自己时,附加邀请相关信息,用于展示专属邀请链接 if ($operator && $operator->id === $targetUser->id) { $data['id'] = $targetUser->id; $data['invitees_count'] = $targetUser->invitees()->count(); } // 职务履历(所有任职记录,按任命时间倒序;positions() 关系已含 with) $data['position_history'] = $targetUser->positions ->map(fn ($up) => [ 'position_icon' => $up->position?->icon ?? '', 'position_name' => $up->position?->name ?? '', 'department_name' => $up->position?->department?->name ?? '', 'appointed_at' => $up->appointed_at?->format('Y-m-d'), 'revoked_at' => $up->revoked_at?->format('Y-m-d'), 'is_active' => $up->is_active, 'duration_days' => $up->duration_days, ]) ->values() ->all(); $data['vip']['Name'] = $targetUser->vipName(); $data['vip']['Icon'] = $targetUser->vipIcon(); $signIdentity = $targetUser->currentSignInIdentity(); $latestSignIn = $targetUser->dailySignIns()->first(); $data['sign_in'] = [ 'streak_days' => (int) ($latestSignIn?->streak_days ?? 0), 'identity' => $signIdentity ? [ 'key' => $signIdentity->badge_code, 'label' => $signIdentity->badge_name, 'icon' => $signIdentity->badge_icon ?? '✅', 'color' => $signIdentity->badge_color ?? '#0f766e', 'expires_at' => $signIdentity->expires_at?->toIso8601String(), ] : null, ]; // 管理员网络信息仅对站长或拥有「封IP」职务权限的操作者展示。 $canViewNetworkInfo = $operator && ($operator->id === 1 || $this->positionPermissionService->hasPermission($operator, PositionPermissionRegistry::USER_BANIP)); if ($canViewNetworkInfo) { $data['first_ip'] = $targetUser->first_ip; // last_ip 目前定义为『上次登录IP』(取数据库 previous_ip) $data['last_ip'] = $targetUser->previous_ip; // login_ip 目前定义为『本次登录IP』(取数据库 last_ip) $data['login_ip'] = $targetUser->last_ip; // 解析归属地:使用 ip2region 离线库,直接返回原生中文(省|市|ISP) $ipToLookup = $targetUser->last_ip; if ($ipToLookup) { try { // 不传路径,使用 zoujingli/ip2region 包自带的内置数据库 $ip2r = new \Ip2Region; $info = $ip2r->getIpInfo($ipToLookup); if ($info) { $country = $info['country'] ?? ''; $province = $info['province'] ?? ''; $city = $info['city'] ?? ''; // 过滤掉占位符 "0" $province = ($province === '0') ? '' : $province; $city = ($city === '0') ? '' : $city; if ($country === '中国') { $data['location'] = trim($province.($province !== $city ? ' '.$city : '')); } else { $data['location'] = $country ?: '未知区域'; } if (empty($data['location'])) { $data['location'] = '未知区域'; } } else { $data['location'] = '未知区域'; } } catch (\Exception $e) { $data['location'] = '解析失败'; } } else { $data['location'] = '暂无记录'; } } return response()->json([ 'status' => 'success', 'data' => $data, ]); } /** * 付费查看用户资料中被星号隐藏的经验、金币、存款或魅力。 */ 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) */ public function updateProfile(UpdateProfileRequest $request): JsonResponse { $user = Auth::user(); $data = $request->validated(); // 当用户试图更新邮箱,并且新邮箱不等于当前旧邮箱时启动验证码拦截 if (isset($data['email']) && $data['email'] !== $user->email) { // 首先判断系统开关是否开启,没开启直接禁止修改邮箱 if (\App\Models\SysParam::where('alias', 'smtp_enabled')->value('body') !== '1') { return response()->json(['status' => 'error', 'message' => '系统未开启邮件服务,当前禁止绑定/修改邮箱。'], 403); } $emailCode = $request->input('email_code'); if (empty($emailCode)) { return response()->json(['status' => 'error', 'message' => '新邮箱需要验证码,请先获取并填写验证码。'], 422); } // 获取缓存的验证码 $codeKey = 'email_verify_code_'.$user->id.'_'.$data['email']; $cachedCode = \Illuminate\Support\Facades\Cache::get($codeKey); if (! $cachedCode || $cachedCode != $emailCode) { return response()->json(['status' => 'error', 'message' => '验证码不正确或已过期(有效期5分钟),请重新获取。'], 422); } // 验证成功后,立即核销该验证码防止二次利用 \Illuminate\Support\Facades\Cache::forget($codeKey); } if (isset($data['headface']) && $data['headface'] !== $user->headface) { $user->deleteCustomAvatar(); } $user->update($data); return response()->json(['status' => 'success', 'message' => '资料更新成功。']); } /** * 保存聊天室屏蔽与禁音偏好。 */ public function updateChatPreferences(UpdateChatPreferencesRequest $request): JsonResponse { $user = Auth::user(); $data = $request->validated(); $preferences = [ // 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。 'blocked_system_senders' => array_values(array_unique($data['blocked_system_senders'] ?? [])), 'sound_muted' => (bool) $data['sound_muted'], ]; $user->update([ 'chat_preferences' => $preferences, ]); return response()->json([ 'status' => 'success', 'message' => '聊天室偏好已保存。', 'data' => $preferences, ]); } /** * 保存聊天室当日状态,并同步当前在线名单显示。 */ public function updateDailyStatus(UpdateDailyStatusRequest $request): JsonResponse { $user = Auth::user(); $data = $request->validated(); $roomId = (int) $data['room_id']; // 仅允许当前确实在线的用户从聊天室内修改状态,避免离线脏请求写入。 if (! $this->chatState->isUserInRoom($roomId, $user->username)) { return response()->json([ 'status' => 'error', 'message' => '请先进入聊天室后再设置状态。', ], 422); } if ($data['action'] === 'clear') { $user->update([ 'daily_status_key' => null, 'daily_status_expires_at' => null, ]); } else { // 状态有效期固定维持到当天结束,次日自动失效。 $user->update([ 'daily_status_key' => $data['status_key'], 'daily_status_expires_at' => now()->endOfDay(), ]); } $user->refresh(); $presencePayload = $this->chatUserPresenceService->build($user); $roomIds = $this->chatState->getUserRooms($user->username); foreach ($roomIds as $activeRoomId) { // 所有当前在线房间都刷新 Redis 载荷,确保头像、会员与状态显示口径一致。 $this->chatState->userJoin((int) $activeRoomId, $user->username, $presencePayload); broadcast(new UserStatusUpdated((int) $activeRoomId, $user->username, $presencePayload)); } return response()->json([ 'status' => 'success', 'message' => $data['action'] === 'clear' ? '状态已清除。' : '状态已更新。', 'data' => [ 'status' => $this->chatUserPresenceService->currentDailyStatus($user), ], ]); } /** * 修改密码 (对应 chpasswd.asp) */ public function changePassword(ChangePasswordRequest $request): JsonResponse { $user = Auth::user(); $oldPasswordInput = $request->input('old_password'); // 双模式密码校验逻辑(沿用 AuthController 中策略) $isOldPasswordCorrect = false; // 优先验证 Bcrypt if (Hash::check($oldPasswordInput, $user->password)) { $isOldPasswordCorrect = true; } // 降级验证旧版 MD5 elseif (md5($oldPasswordInput) === $user->password) { $isOldPasswordCorrect = true; } if (! $isOldPasswordCorrect) { return response()->json(['status' => 'error', 'message' => '当前密码输入不正确。'], 422); } // 验证通过,覆盖为新的 Bcrypt 加密串 $user->password = Hash::make($request->input('new_password')); $user->save(); return response()->json(['status' => 'success', 'message' => '密码已成功修改。下次请使用新密码登录。']); } /** * 生成微信绑定代码 */ public function generateWechatCode(Request $request): JsonResponse { $user = \Illuminate\Support\Facades\Auth::user(); if (! $user) { return response()->json(['status' => 'error', 'message' => '未登录']); } $code = 'BD-'.mt_rand(100000, 999999); \Illuminate\Support\Facades\Cache::put('wechat_bind_code:'.$code, $user->username, 300); // 5分钟有效 return response()->json([ 'status' => 'success', 'code' => $code, 'message' => '生成成功', ]); } /** * 取消绑定微信 */ public function unbindWechat(Request $request): JsonResponse { $user = \Illuminate\Support\Facades\Auth::user(); if (! $user) { return response()->json(['status' => 'error', 'message' => '未登录']); } $user->wxid = null; $user->save(); return response()->json([ 'status' => 'success', 'message' => '解绑成功', ]); } /** * 判断操作者是否可以免费查看目标用户经验、金币与魅力。 */ 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; } }