498 lines
20 KiB
PHP
498 lines
20 KiB
PHP
<?php
|
||
|
||
/**
|
||
* 文件功能:用户中心与管理控制器
|
||
* 接管原版 USERinfo.ASP, USERSET.ASP 与 chpasswd.asp。
|
||
* 聊天室管理动作已统一迁移到 AdminCommandController 的职务权限链路。
|
||
*
|
||
* @author ChatRoom Laravel
|
||
*
|
||
* @version 1.1.0
|
||
*/
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Enums\CurrencySource;
|
||
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;
|
||
use App\Models\Sysparam;
|
||
use App\Models\User;
|
||
use App\Services\AchievementService;
|
||
use App\Services\ChatStateService;
|
||
use App\Services\ChatUserPresenceService;
|
||
use App\Services\PositionPermissionService;
|
||
use App\Services\UserCurrencyService;
|
||
use App\Support\PositionPermissionRegistry;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Hash;
|
||
|
||
/**
|
||
* 类功能:处理用户资料、聊天室偏好、当日状态与基础管理动作。
|
||
*/
|
||
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,
|
||
private readonly AchievementService $achievementService,
|
||
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,
|
||
];
|
||
// 名片弹窗只读取已缓存的成就摘要,避免双击用户时同步扫描全量日志造成卡顿。
|
||
$data['achievements'] = $this->achievementService->profileSummaryForUser($targetUser);
|
||
|
||
// 管理员网络信息仅对站长或拥有「封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();
|
||
$existingPreferences = is_array($user->chat_preferences) ? $user->chat_preferences : [];
|
||
$blockedSystemSenders = collect($data['blocked_system_senders'] ?? [])
|
||
->map(function (string $sender): string {
|
||
// 猜谜活动前端文案允许升级,但持久化键仍复用旧值,避免历史偏好失效。
|
||
return $sender === '猜谜活动' ? '猜成语' : $sender;
|
||
})
|
||
->unique()
|
||
->values()
|
||
->all();
|
||
|
||
$preferences = [
|
||
// 去重并重建索引,保持存储结构稳定,便于后续继续扩展其它屏蔽项。
|
||
'blocked_system_senders' => $blockedSystemSenders,
|
||
'sound_muted' => (bool) $data['sound_muted'],
|
||
];
|
||
|
||
// 字号偏好和屏蔽/禁音共用账号配置,旧请求未携带字号时保留原值。
|
||
$fontSize = array_key_exists('font_size', $data) && $data['font_size'] !== null
|
||
? (int) $data['font_size']
|
||
: ($existingPreferences['font_size'] ?? null);
|
||
|
||
if ($fontSize !== null) {
|
||
$preferences['font_size'] = (int) $fontSize;
|
||
}
|
||
|
||
$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;
|
||
}
|
||
}
|