['max' => 50, 'window' => 300], 'register' => ['max' => 30, 'window' => 3600], 'resetpwd' => ['max' => 20, 'window' => 3600], 'changepwd' => ['max' => 30, 'window' => 3600], 'changeemail' => ['max' => 30, 'window' => 3600], 'changemobile' => ['max' => 30, 'window' => 3600], 'sendEms' => ['max' => 30, 'window' => 300], 'tokenLogin' => ['max' => 80, 'window' => 300], 'receiptLogin' => ['max' => 50, 'window' => 300], 'requestDeletion' => ['max' => 5, 'window' => 3600], 'cancelDeletion' => ['max' => 10, 'window' => 3600], 'deletionStatus' => ['max' => 60, 'window' => 60], 'changeSecQuestion'=> ['max' => 20, 'window' => 3600], 'accountLookup' => ['max' => 20, 'window' => 3600], 'secQuestionLogin' => ['max' => 30, 'window' => 3600], 'emailCodeLogin' => ['max' => 30, 'window' => 3600], 'sendDeletionCode' => ['max' => 5, 'window' => 3600], 'requestDeletionByReceipt' => ['max' => 5, 'window' => 3600], 'checkSmtpStatus' => ['max' => 30, 'window' => 60], ]; private static $testMode = false; private static $secQuestions = [ 1 => '您母亲的姓名是?', 2 => '您的第一只宠物叫什么?', 3 => '您就读的小学名称是?', 4 => '您的出生地是?', 5 => '您最喜欢的电影是?', 6 => '您最好朋友的名字是?', 7 => '您父亲的姓名是?', 8 => '您的童年昵称是?', ]; public function _initialize() { parent::_initialize(); if (!Config::get('fastadmin.usercenter')) { $this->error(__('User center already closed')); } self::$testMode = Config::get('test_mode') ?: false; } private function checkRateLimit($action) { if (!isset(self::$rateLimits[$action])) { return true; } $config = self::$rateLimits[$action]; $key = self::$rateLimitKey . $action . ':' . $this->request->ip(); $cache = cache($key); $count = $cache ? intval($cache) : 0; if ($count >= $config['max']) { $this->error('请求过于频繁,请稍后再试', null, ['retry_after' => $config['window']]); } cache($key, $count + 1, $config['window']); return true; } private function sanitizeString($value, $maxLength = 255) { if (!is_string($value)) { return ''; } $value = trim($value); $value = strip_tags($value); $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); if (mb_strlen($value) > $maxLength) { $value = mb_substr($value, 0, $maxLength); } return $value; } private function validateLength($value, $field, $min = 1, $max = 255) { $len = mb_strlen($value); if ($len < $min || $len > $max) { $this->error("{$field}长度须在{$min}-{$max}之间"); } return true; } private function detectMaliciousInput($value) { $patterns = [ '/]*>/i', '/javascript\s*:/i', '/on\w+\s*=/i', '/union\s+select/i', '/insert\s+into/i', '/delete\s+from/i', '/update\s+\w+\s+set/i', '/drop\s+table/i', '/\.\.[\/\\\\]/i', '/eval\s*\(/i', '/exec\s*\(/i', '/system\s*\(/i', ]; foreach ($patterns as $pattern) { if (preg_match($pattern, $value)) { $this->error('输入内容包含非法字符'); } } return true; } private function isTestUser($userId) { if (!self::$testMode) { return false; } $user = db('user')->where('id', $userId)->find(); return $user && isset($user['is_test']) && $user['is_test'] == 1; } private function verifyReceipt($action, $payloadStr) { $receipt = $this->request->post('receipt', '', 'trim'); $sig = $this->request->post('sig', '', 'trim'); $result = Receipt::verify($receipt, $sig, $action, $payloadStr); if (!$result['valid']) { $this->error($result['msg']); } return true; } /** * @name 哈希密保答案 * @desc 将密保答案转为MD5哈希(小写去空格) */ private function hashSecAnswer($answer) { return md5(mb_strtolower(trim($answer), 'UTF-8')); } /** * @name 验证密保答案 * @desc 验证用户密保答案是否正确 */ private function verifySecAnswer($userId, $answer) { $user = db('user')->where('id', $userId)->find(); if (!$user || empty($user['sec_question']) || empty($user['sec_answer'])) { $this->error('未设置密保问题,无法使用此验证方式'); } $inputHash = $this->hashSecAnswer($answer); if ($inputHash !== $user['sec_answer']) { $this->error('密保答案不正确'); } return true; } /** * @name 多方式身份验证 * @desc 根据verify_method验证用户身份(password/sec_question/receipt) * @param object $user 用户对象 * @param string $receiptAction 回执action类型 * @param string $receiptPayload 回执payload */ private function verifyIdentity($user, $receiptAction, $receiptPayload = '') { $method = $this->request->post('verify_method', 'password', 'trim'); switch ($method) { case 'password': $oldpassword = $this->request->post('oldpassword', '', 'trim'); if (!$oldpassword) { $this->error('旧密码不能为空'); } $this->validateLength($oldpassword, '旧密码', 6, 30); $encryptPassword = $this->auth->getEncryptPassword($oldpassword, $user->salt); if ($encryptPassword !== $user->password) { $this->error('旧密码不正确'); } break; case 'sec_question': $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$secAnswer) { $this->error('密保答案不能为空'); } $this->verifySecAnswer($user->id, $secAnswer); break; case 'receipt': if (!$this->isTestUser($user->id)) { $this->verifyReceipt($receiptAction, $receiptPayload ?: strval($user->id)); } break; default: $this->error('不支持的验证方式,可选: password/sec_question/receipt'); } return true; } /** * @name 获取预置密保问题列表 * @desc 返回系统预置的密保问题,无需登录 * @lastUpdate v10.1.0 新增 */ public function secQuestions() { $questions = []; foreach (self::$secQuestions as $id => $text) { $questions[] = ['id' => $id, 'question' => $text]; } $this->success('', ['questions' => $questions]); } /** * @name 账号密码登录 * @desc 支持用户名/邮箱+密码登录,无需回执,登录后记录设备信息 * @lastUpdate v9.0.0 登录后记录设备信息和在线状态 */ public function login() { $this->checkRateLimit('login'); $account = $this->request->post('account', '', 'trim'); $password = $this->request->post('password', '', 'trim'); if (!$account || !$password) { $this->error(__('Invalid parameters')); } $this->validateLength($account, '账号', 2, 50); $this->validateLength($password, '密码', 6, 30); $this->detectMaliciousInput($account); $ret = $this->auth->login($account, $password); if ($ret) { $this->recordLoginDevice($this->auth->id); $this->updateOnlineStatus($this->auth->id); $token = $this->auth->getToken(); $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 回执登录 * @desc 客户端验证邮箱/手机后,用回执替代密码登录 * @lastUpdate v9.0.0 登录后记录设备信息和在线状态 */ public function receiptLogin() { $this->checkRateLimit('receiptLogin'); $account = $this->request->post('account', '', 'trim'); $receipt = $this->request->post('receipt', '', 'trim'); $sig = $this->request->post('sig', '', 'trim'); if (!$account || !$receipt || !$sig) { $this->error(__('Invalid parameters')); } $this->validateLength($account, '账号', 2, 100); $this->detectMaliciousInput($account); $result = Receipt::verifyFlexible($receipt, $sig, 'receipt_login', $account); if (!$result['valid']) { $this->error($result['msg']); } $user = null; if (Validate::is($account, "email")) { $user = \app\common\model\User::getByEmail($account); } elseif (Validate::regex($account, "^1\d{10}$")) { $user = \app\common\model\User::getByMobile($account); } else { $user = \app\common\model\User::getByUsername($account); } if (!$user) { $this->error(__('User not found')); } if ($user->status != 'normal') { $this->error(__('Account is locked')); } $ret = $this->auth->direct($user->id); if ($ret) { $this->recordLoginDevice($user->id); $this->updateOnlineStatus($user->id); $data = ['userinfo' => $this->auth->getUserinfo()]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 手机号登录 * @desc 手机号+短信验证码登录,未注册自动注册 */ public function mobilelogin() { $this->checkRateLimit('login'); $mobile = $this->request->post('mobile', '', 'trim'); $captcha = $this->request->post('captcha', '', 'trim'); if (!$mobile || !$captcha) { $this->error(__('Invalid parameters')); } if (!Validate::regex($mobile, "^1\d{10}$")) { $this->error(__('Mobile is incorrect')); } $this->validateLength($captcha, '验证码', 4, 6); if (self::$testMode && $captcha === '888888') { $verified = true; } else { $verified = false; try { $verified = Sms::check($mobile, $captcha, 'mobilelogin'); } catch (\Exception $e) { $this->error('短信验证服务暂不可用,请使用邮箱登录'); } } if (!$verified) { $this->error(__('Captcha is incorrect')); } $user = \app\common\model\User::getByMobile($mobile); if ($user) { if ($user->status != 'normal') { $this->error(__('Account is locked')); } $ret = $this->auth->direct($user->id); } else { $ret = $this->auth->register($mobile, Random::alnum(), '', $mobile, []); } if ($ret) { try { Sms::flush($mobile, 'mobilelogin'); } catch (\Exception $e) {} $data = ['userinfo' => $this->auth->getUserinfo()]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name Token令牌登录 * @desc 使用已有Token直接登录 */ public function tokenLogin() { $this->checkRateLimit('tokenLogin'); $token = $this->request->post('token', '', 'trim'); if (!$token) { $this->error(__('Invalid parameters')); } $this->validateLength($token, 'Token', 10, 128); $tokenInfo = \app\common\library\Token::get($token); if (!$tokenInfo) { $this->error('Token无效或已过期'); } $ret = $this->auth->direct($tokenInfo['user_id']); if ($ret) { $data = ['userinfo' => $this->auth->getUserinfo()]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 用户注册 * @desc 注册新用户,需回执验证(客户端已验证邮箱),可选填密保问题/出生日期,注册赠送50积分+50金币 * @lastUpdate v10.3.1 新增birthday字段,自动判定未成年状态(COPPA/GDPR-K合规) */ public function register() { $this->checkRateLimit('register'); $username = $this->request->post('username', '', 'trim'); $password = $this->request->post('password', '', 'trim'); $email = $this->request->post('email', '', 'trim'); $mobile = $this->request->post('mobile', '', 'trim'); $mobileCode = $this->request->post('mobile_code', '', 'trim'); $secQuestion = $this->request->post('sec_question', 0, 'intval'); $secAnswer = $this->request->post('sec_answer', '', 'trim'); $birthday = $this->request->post('birthday', '', 'trim'); // 格式: YYYY-MM-DD if (!$username || !$password || !$email) { $this->error('用户名、密码和邮箱为必填项'); } // 出生日期校验 (feat6 - COPPA/GDPR-K) $isMinor = 0; $ageData = null; if ($birthday) { $ageData = $this->calculateAge($birthday); if (!$ageData['valid']) { $this->error('出生日期格式无效,应为 YYYY-MM-DD'); } if ($ageData['age'] < 0 || $ageData['age'] > 120) { $this->error('出生日期不合理'); } // COPPA: 13岁以下禁止注册; GDPR-K: 16岁以下需家长同意 if ($ageData['age'] < 14) { $this->error('根据《儿童个人信息网络保护规定》及COPPA要求,14岁以下用户不得注册'); } $isMinor = ($ageData['age'] < 18) ? 1 : 0; } $this->validateLength($username, '用户名', 3, 30); $this->validateLength($password, '密码', 6, 30); $this->detectMaliciousInput($username); if (!preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]+$/u', $username)) { $this->error('用户名只能包含字母、数字、下划线和中文'); } if (!Validate::is($email, "email")) { $this->error(__('Email is incorrect')); } $this->validateLength($email, '邮箱', 5, 100); if (\app\common\model\User::getByEmail($email)) { $this->error('该邮箱已被注册'); } $this->verifyReceipt('register', $email); if ($mobile) { if (!Validate::regex($mobile, "^1\d{10}$")) { $this->error(__('Mobile is incorrect')); } if ($mobileCode) { if (!Sms::check($mobile, $mobileCode, 'register')) { $this->error(__('Captcha is incorrect')); } } } // 密保问题验证(先验证,但不放入$extend,注册成功后单独更新) $secQuestionValid = false; $secAnswerHash = ''; if ($secQuestion > 0 && $secAnswer) { if (!isset(self::$secQuestions[$secQuestion])) { $this->error('密保问题编号无效(1-8)'); } $this->validateLength($secAnswer, '密保答案', 1, 50); $secQuestionValid = true; $secAnswerHash = $this->hashSecAnswer($secAnswer); } // 不再将sec_question/sec_answer放入$extend,避免字段不存在时User::create失败 // 保留$extend变量定义,便于后续扩展其他字段 $extend = []; $ret = $this->auth->register($username, $password, $email, $mobile, $extend); if ($ret) { $userId = $this->auth->id; // 注册后操作全部包裹在try-catch中,确保任何失败不影响注册结果 try { // 1. 更新邮箱验证状态 $verification = db('user')->where('id', $userId)->value('verification'); $verification = $verification ? json_decode($verification, true) : []; $verification['email'] = 1; db('user')->where('id', $userId)->update(['verification' => json_encode($verification)]); // 2. 单独更新密保问题(避免字段不存在导致注册整体失败) if ($secQuestionValid) { try { db('user')->where('id', $userId)->update([ 'sec_question' => $secQuestion, 'sec_answer' => $secAnswerHash, ]); } catch (\Exception $e) { // sec_question/sec_answer字段可能不存在(未执行迁移),静默跳过 } } // 2.5 保存出生日期与未成年状态 (feat6 - COPPA/GDPR-K) if ($birthday && $ageData) { try { db('user')->where('id', $userId)->update([ 'birthday' => $birthday, 'is_minor' => $isMinor, ]); } catch (\Exception $e) { // birthday/is_minor字段可能不存在(未执行迁移),静默跳过 \think\Log::record('保存birthday失败: ' . $e->getMessage(), 'debug'); } } // 3. 注册赠送50积分 $registerScore = 50; $registerGold = 50; $userBefore = db('user')->where('id', $userId)->find(); $scoreBefore = isset($userBefore['score']) ? intval($userBefore['score']) : 0; db('user')->where('id', $userId)->setInc('score', $registerScore); // 4. 注册赠送50金币(gold字段可能不存在,需容错) $goldBefore = 0; try { if (isset($userBefore['gold'])) { $goldBefore = intval($userBefore['gold']); db('user')->where('id', $userId)->setInc('gold', $registerGold); } else { // gold字段不存在,尝试添加 try { db('user')->where('id', $userId)->update(['gold' => $registerGold]); } catch (\Exception $e2) { // 字段确实不存在,跳过金币赠送 } } } catch (\Exception $e) { // gold字段操作异常,跳过 } // 5. 积分日志 try { db('coin_log')->insert([ 'user_id' => $userId, 'coin_type' => 'score', 'amount' => $registerScore, 'before' => $scoreBefore, 'after' => $scoreBefore + $registerScore, 'action' => 'register_reward', 'remark' => '新用户注册赠送积分', 'createtime' => time(), ]); } catch (\Exception $e) {} // 6. 金币日志 try { db('coin_log')->insert([ 'user_id' => $userId, 'coin_type' => 'gold', 'amount' => $registerGold, 'before' => $goldBefore, 'after' => $goldBefore + $registerGold, 'action' => 'register_reward', 'remark' => '新用户注册赠送金币', 'createtime' => time(), ]); } catch (\Exception $e) {} } catch (\Exception $e) { // 注册后操作失败不影响注册结果,用户已创建成功 // 记录日志便于排查 try { \think\Log::write('注册后操作异常(userId=' . $userId . '): ' . $e->getMessage(), 'error'); } catch (\Exception $logErr) {} } $token = $this->auth->getToken(); $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; $this->success(__('Sign up successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 退出登录 * @desc 清除Token,退出登录状态 */ public function logout() { if (!$this->request->isPost()) { $this->error(__('Invalid parameters')); } $token = $this->request->header('token', ''); if ($token) { \app\common\library\Token::delete($token); } $userId = $this->auth->id; if ($userId) { \app\common\library\Token::clear($userId); } $this->success(__('Logout successful')); } /** * @name 修改密码 * @desc 修改密码,支持多种验证方式(password/sec_question/receipt) * @lastUpdate v10.1.0 新增verify_method参数,支持密保/回执验证 */ public function changepwd() { $this->checkRateLimit('changepwd'); $user = $this->auth->getUser(); $newpassword = $this->request->post('newpassword', '', 'trim'); if (!$newpassword) { $this->error(__('Invalid parameters')); } $this->validateLength($newpassword, '新密码', 6, 30); $this->verifyIdentity($user, 'changepwd', strval($user->id)); $ret = $this->auth->changepwd($newpassword, '', true); if ($ret) { $this->success(__('Change password successful')); } else { $this->error($this->auth->getError()); } } /** * @name 重置密码 * @desc 通过回执验证重置密码(无需登录),客户端已验证邮箱/手机 */ public function resetpwd() { $this->checkRateLimit('resetpwd'); $type = $this->request->post("type", "email", 'trim'); $mobile = $this->request->post("mobile", '', 'trim'); $email = $this->request->post("email", '', 'trim'); $newpassword = $this->request->post("newpassword", '', 'trim'); if (!$newpassword) { $this->error(__('Invalid parameters')); } $this->validateLength($newpassword, '新密码', 6, 30); if ($type == 'mobile') { if (!Validate::regex($mobile, "^1\d{10}$")) { $this->error(__('Mobile is incorrect')); } $user = \app\common\model\User::getByMobile($mobile); if (!$user) { $this->error(__('User not found')); } $this->verifyReceipt('resetpwd', $mobile); } else { if (!Validate::is($email, "email")) { $this->error(__('Email is incorrect')); } $user = \app\common\model\User::getByEmail($email); if (!$user) { $this->error(__('User not found')); } $this->verifyReceipt('resetpwd', $email); } $this->auth->direct($user->id); $ret = $this->auth->changepwd($newpassword, '', true); if ($ret) { $this->success(__('Reset password successful')); } else { $this->error($this->auth->getError()); } } /** * @name 修改邮箱 * @desc 修改邮箱,支持回执验证或密保验证 * @lastUpdate v10.1.0 新增verify_method=sec_question验证方式 */ public function changeemail() { $this->checkRateLimit('changeemail'); $user = $this->auth->getUser(); $email = $this->request->post('email', '', 'trim'); if (!$email) { $this->error(__('Invalid parameters')); } if (!Validate::is($email, "email")) { $this->error(__('Email is incorrect')); } $this->validateLength($email, '邮箱', 5, 100); if (\app\common\model\User::where('email', $email)->where('id', '<>', $user->id)->find()) { $this->error(__('Email already exists')); } $method = $this->request->post('verify_method', 'receipt', 'trim'); if ($method === 'sec_question') { $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$secAnswer) { $this->error('密保答案不能为空'); } $this->verifySecAnswer($user->id, $secAnswer); } else { $this->verifyReceipt('changeemail', $email); } $verification = $user->verification; $verification->email = 1; $user->verification = $verification; $user->email = $email; $user->save(); $this->success(); } /** * @name 修改手机号 * @desc 修改手机号,支持回执验证或密保验证 * @lastUpdate v10.1.0 新增verify_method=sec_question验证方式 */ public function changemobile() { $this->checkRateLimit('changemobile'); $user = $this->auth->getUser(); $mobile = $this->request->post('mobile', '', 'trim'); if (!$mobile) { $this->error(__('Invalid parameters')); } if (!Validate::regex($mobile, "^1\d{10}$")) { $this->error(__('Mobile is incorrect')); } if (\app\common\model\User::where('mobile', $mobile)->where('id', '<>', $user->id)->find()) { $this->error(__('Mobile already exists')); } $method = $this->request->post('verify_method', 'receipt', 'trim'); if ($method === 'sec_question') { $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$secAnswer) { $this->error('密保答案不能为空'); } $this->verifySecAnswer($user->id, $secAnswer); } else { $this->verifyReceipt('changemobile', $mobile); } $verification = $user->verification; $verification->mobile = 1; $user->verification = $verification; $user->mobile = $mobile; $user->save(); $this->success(); } /** * @name 修改密保问题 * @desc 修改或设置密保问题,需验证身份(password/sec_question/receipt) * @lastUpdate v10.1.0 新增 */ public function changeSecQuestion() { $this->checkRateLimit('changeSecQuestion'); $user = $this->auth->getUser(); $secQuestion = $this->request->post('sec_question', 0, 'intval'); $secAnswer = $this->request->post('sec_answer', '', 'trim'); if ($secQuestion <= 0 || !$secAnswer) { $this->error('密保问题和答案为必填项'); } if (!isset(self::$secQuestions[$secQuestion])) { $this->error('密保问题编号无效(1-8)'); } $this->validateLength($secAnswer, '密保答案', 1, 50); $this->verifyIdentity($user, 'changesecq', strval($user->id)); $answerHash = $this->hashSecAnswer($secAnswer); db('user')->where('id', $user->id)->update([ 'sec_question' => $secQuestion, 'sec_answer' => $answerHash, ]); $this->success('密保问题设置成功', [ 'sec_question' => $secQuestion, 'sec_question_text' => self::$secQuestions[$secQuestion], ]); } /** * @name 发送邮箱验证码(保留兼容) * @desc 保留接口兼容旧客户端,新客户端建议使用回执验证 */ public function sendEms() { $this->checkRateLimit('sendEms'); $email = $this->request->post('email', '', 'trim'); $event = $this->request->post('event', 'register', 'trim'); if (!$email) { $this->error('邮箱不能为空'); } if (!Validate::is($email, "email")) { $this->error(__('Email is incorrect')); } $allowedEvents = ['register', 'changepwd', 'resetpwd', 'changeemail', 'emaillogin']; if (!in_array($event, $allowedEvents)) { $this->error('无效的事件类型'); } $last = Ems::get($email, $event); if ($last && time() - $last['createtime'] < 60) { $this->error(__('发送频繁')); } $userinfo = \app\common\model\User::getByEmail($email); if ($event == 'register' && $userinfo) { $this->error(__('已被注册')); } elseif ($event == 'changeemail' && $userinfo) { $this->error(__('已被占用')); } elseif (in_array($event, ['changepwd', 'resetpwd', 'emaillogin']) && !$userinfo) { // emaillogin允许未注册(会自动注册),此处不阻止 if ($event != 'emaillogin') { $this->error(__('未注册')); } } $ret = Ems::send($email, null, $event); if ($ret) { $this->success(__('发送成功')); } else { $this->error(__('发送失败')); } } /** * @name 校验邮箱验证码(保留兼容) * @desc 保留接口兼容旧客户端 */ public function checkEms() { $email = $this->request->post('email', '', 'trim'); $captcha = $this->request->post('captcha', '', 'trim'); $event = $this->request->post('event', 'register', 'trim'); if (!$email || !$captcha) { $this->error(__('Invalid parameters')); } if (self::$testMode && $captcha === '888888') { $this->success(__('验证码正确')); } $ret = Ems::check($email, $captcha, $event); if ($ret) { $this->success(__('验证码正确')); } else { $this->error(__('验证码不正确')); } } /** * @name 第三方登录 * @desc 第三方平台登录 */ public function third() { $this->checkRateLimit('login'); $url = url('user/index'); $platform = $this->request->post("platform", '', 'trim'); $code = $this->request->post("code", '', 'trim'); $this->detectMaliciousInput($platform); $this->detectMaliciousInput($code); $config = get_addon_config('third'); if (!$config || !isset($config[$platform])) { $this->error(__('Invalid parameters')); } $app = new \addons\third\library\Application($config); $result = $app->{$platform}->getUserInfo(['code' => $code]); if ($result) { $loginret = \addons\third\library\Service::connect($platform, $result); if ($loginret) { $data = [ 'userinfo' => $this->auth->getUserinfo(), 'thirdinfo' => $result ]; $this->success(__('Logged in successful'), $data); } } $this->error(__('Operation failed'), $url); } /** * @name 生成二维码 * @desc 生成二维码登录标识,返回code用于轮询 * @lastUpdate v9.0.0 新增 */ public function qrcodeGenerate() { $code = md5(uniqid('qr', true) . mt_rand() . time()); $expireTime = time() + 300; db('qrcode_login')->insert([ 'code' => $code, 'status' => 'pending', 'user_id' => 0, 'token' => '', 'ip' => $this->request->ip(), 'expire_time' => $expireTime, 'createtime' => time(), 'updatetime' => time(), ]); $this->success('二维码已生成', [ 'code' => $code, 'expire_time' => $expireTime, 'expire_seconds' => 300, 'qrcode_url' => $this->request->domain() . '/qrcode?code=' . $code, ]); } /** * @name 扫码确认 * @desc 已登录用户扫码后确认登录,需登录 * @lastUpdate v9.0.0 新增 */ public function qrcodeConfirm() { $code = $this->request->post('code', '', 'trim'); if (!$code) { $this->error('code必填'); } $qr = db('qrcode_login')->where('code', $code)->find(); if (!$qr) { $this->error('二维码不存在'); } if ($qr['status'] !== 'pending' && $qr['status'] !== 'scanned') { $this->error('二维码状态无效'); } if ($qr['expire_time'] < time()) { db('qrcode_login')->where('code', $code)->update(['status' => 'expired', 'updatetime' => time()]); $this->error('二维码已过期'); } $userId = $this->auth->id; $deviceInfo = json_encode([ 'ip' => $this->request->ip(), 'platform' => $this->request->post('platform', '', 'trim'), 'device_name' => $this->request->post('device_name', '', 'trim'), 'app_name' => $this->request->post('app_name', '', 'trim'), ]); db('qrcode_login')->where('code', $code)->update([ 'status' => 'confirmed', 'user_id' => $userId, 'device_info' => $deviceInfo, 'updatetime' => time(), ]); $this->success('确认登录成功'); } /** * @name 轮询二维码状态 * @desc 客户端轮询二维码状态,confirmed时返回Token * @lastUpdate v9.0.0 新增 */ public function qrcodePoll() { $code = $this->request->param('code', '', 'trim'); if (!$code) { $this->error('code必填'); } $qr = db('qrcode_login')->where('code', $code)->find(); if (!$qr) { $this->error('二维码不存在'); } if ($qr['expire_time'] < time() && $qr['status'] !== 'confirmed') { db('qrcode_login')->where('code', $code)->update(['status' => 'expired', 'updatetime' => time()]); $this->success('', ['status' => 'expired', 'message' => '二维码已过期']); } if ($qr['status'] === 'pending') { $this->success('', ['status' => 'pending', 'message' => '等待扫码']); } if ($qr['status'] === 'scanned') { $this->success('', ['status' => 'scanned', 'message' => '已扫码,等待确认']); } if ($qr['status'] === 'expired') { $this->success('', ['status' => 'expired', 'message' => '二维码已过期']); } if ($qr['status'] === 'cancelled') { $this->success('', ['status' => 'cancelled', 'message' => '已取消']); } if ($qr['status'] === 'confirmed') { $user = \app\common\model\User::get($qr['user_id']); if (!$user || $user->status != 'normal') { $this->error('用户异常'); } $ret = $this->auth->direct($user->id); if ($ret) { $token = $this->auth->getToken(); db('qrcode_login')->where('code', $code)->update([ 'token' => $token, 'updatetime' => time(), ]); $this->recordLoginDevice($user->id); $this->updateOnlineStatus($user->id); $this->success('登录成功', [ 'status' => 'confirmed', 'token' => $token, 'userinfo' => $this->auth->getUserinfo(), ]); } else { $this->error('登录失败'); } } $this->error('未知状态'); } /** * @name 申请账号注销 * @desc 用户提交注销申请,需回执验证(action=delete_account),3天审核期 * @lastUpdate v9.2.0 新增 */ public function requestDeletion() { $this->checkRateLimit('requestDeletion'); $user = $this->auth->getUser(); $reason = $this->request->post('reason', '', 'trim'); $this->verifyReceipt('delete_account', strval($user->id)); if ($reason) { $reason = $this->sanitizeString($reason, 500); } $existing = db('user_deletion') ->where('user_id', $user->id) ->where('status', 0) ->find(); if ($existing) { $statusMap = [0 => '待审核', 1 => '已通过(已注销)', 2 => '已拒绝', 3 => '已自动注销']; $remain = $existing['auto_delete_time'] - time(); $countdown = ''; if ($remain > 0) { $days = floor($remain / 86400); $hours = floor(($remain % 86400) / 3600); $countdown = "{$days}天{$hours}小时后自动注销"; } else { $countdown = '即将自动注销'; } $this->error('已存在待审核的注销申请', [ 'id' => $existing['id'], 'status_text' => $statusMap[$existing['status']] ?? '未知', 'countdown' => $countdown, ]); } $threeDaysLater = time() + 3 * 24 * 3600; $now = time(); $data = [ 'user_id' => $user->id, 'username' => $user->username ?? $user->nickname ?? '', 'reason' => $reason ?: '用户主动申请注销', 'status' => 0, 'admin_id' => 0, 'admin_remark' => '', 'auto_delete_time' => $threeDaysLater, 'createtime' => $now, 'updatetime' => $now, 'deletetime' => null, ]; $result = db('user_deletion')->insertGetId($data); if ($result) { $remain = $threeDaysLater - time(); $days = floor($remain / 86400); $hours = floor(($remain % 86400) / 3600); $countdown = "{$days}天{$hours}小时后自动注销"; // 异步发送注销确认邮件 (防止他人冒用账号注销) $this->sendDeletionNotificationAsync($user, $result, $threeDaysLater, $reason); $this->success('注销申请已提交', [ 'id' => $result, 'user_id' => $user->id, 'status' => 0, 'status_text' => '待审核', 'auto_delete_time' => $threeDaysLater, 'auto_delete_time_text' => date('Y-m-d H:i:s', $threeDaysLater), 'countdown' => $countdown, 'createtime_text' => date('Y-m-d H:i:s', $now), ]); } else { $this->error('提交失败,请稍后再试'); } } /** * @name 异步发送注销确认邮件 * @desc 使用register_shutdown_function在响应发送后执行,不阻塞API响应 * @param object $user 用户对象 * @param int $deletionId 注销记录ID * @param int $autoDeleteTime 自动注销时间戳 * @param string $reason 注销原因 * @lastUpdate v10.3.1 新增邮件通知功能 */ private function sendDeletionNotificationAsync($user, $deletionId, $autoDeleteTime, $reason) { $email = $user->email ?? ''; if (!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return; } // 捕获必要变量,在请求结束后异步发送 $username = $user->username ?? $user->nickname ?? '用户'; $autoDeleteText = date('Y-m-d H:i:s', $autoDeleteTime); $requestTime = date('Y-m-d H:i:s', time()); $ip = $this->request->ip(); $reasonText = $reason ?: '用户主动申请注销'; $cancelUrl = Config::get('site.sitename') ? 'https://s2ss.com/agreements/privacy-rights.html?tab=delete' : ''; register_shutdown_function(function () use ($email, $username, $deletionId, $autoDeleteText, $requestTime, $ip, $reasonText, $cancelUrl) { try { $subject = '【闲言APP】账号注销申请已提交 - 请确认是否本人操作'; $body = <<

⚠️ 账号注销申请确认

您好 {$username}

我们收到了针对您账号的注销申请。如果这是您本人的操作,请忽略此邮件。

如果不是您本人操作,请立即登录App或访问以下链接取消注销:

取消注销申请

申请时间{$requestTime}
注销原因{$reasonText}
IP地址{$ip}
自动注销时间{$autoDeleteText}(3天后)

⚠️ 注销后果:所有收藏、笔记、签到记录、积分、文章、会员权益等数据将被永久删除且不可恢复


此邮件由系统自动发送,请勿回复。如有疑问请联系 ad@avefs.com

© 2026 闲言APP · 弥勒市朋普镇微风暴网络科技工作室

HTML; $emailLib = \app\common\library\Email::instance(); $emailLib->to($email)->subject($subject)->message($body, true)->send(); } catch (\Exception $e) { \think\Log::error('注销确认邮件发送失败: ' . $e->getMessage() . ' email=' . $email); } }); } /** * @name 清理过期注销记录 * @desc 删除6个月前的已完成注销记录(PIPL最小化保留原则),username已哈希存储 * @lastUpdate v10.3.1 新增 - 注销记录保留期从2年缩短为6个月 */ public function cleanupOldDeletionRecords() { $sixMonthsAgo = time() - 6 * 30 * 24 * 3600; $count = db('user_deletion') ->where('status', 'in', [1, 3]) // 已通过或已自动注销 ->where('deletetime', '<', $sixMonthsAgo) ->delete(); $this->success("已清理 {$count} 条过期注销记录"); } /** * @name 查询注销状态 * @desc 查询当前用户的注销申请状态 * @lastUpdate v9.2.0 新增 */ public function deletionStatus() { $this->checkRateLimit('deletionStatus'); $user = $this->auth->getUser(); $record = db('user_deletion') ->where('user_id', $user->id) ->order('createtime desc') ->find(); if (!$record) { $this->success('', ['has_pending' => false]); } $statusMap = [0 => '待审核', 1 => '已通过(已注销)', 2 => '已拒绝', 3 => '已自动注销']; $record['status_text'] = $statusMap[$record['status']] ?? '未知'; $record['createtime_text'] = date('Y-m-d H:i:s', $record['createtime']); $record['auto_delete_time_text'] = date('Y-m-d H:i:s', $record['auto_delete_time']); $remain = $record['auto_delete_time'] - time(); if ($record['status'] == 0 && $remain > 0) { $days = floor($remain / 86400); $hours = floor(($remain % 86400) / 3600); $record['countdown'] = "{$days}天{$hours}小时后自动注销"; } elseif ($record['status'] == 0) { $record['countdown'] = '已超时,待自动注销'; } else { $record['countdown'] = '-'; } $this->success('', [ 'has_pending' => $record['status'] == 0, 'id' => $record['id'], 'status' => $record['status'], 'status_text' => $record['status_text'], 'reason' => $record['reason'], 'auto_delete_time_text' => $record['auto_delete_time_text'], 'countdown' => $record['countdown'], 'createtime_text' => $record['createtime_text'], ]); } /** * @name 取消注销申请 * @desc 用户取消待审核的注销申请 * @lastUpdate v9.2.0 新增 */ public function cancelDeletion() { $this->checkRateLimit('cancelDeletion'); $user = $this->auth->getUser(); $record = db('user_deletion') ->where('user_id', $user->id) ->where('status', 0) ->find(); if (!$record) { $this->error('没有待审核的注销申请'); } $result = db('user_deletion') ->where('id', $record['id']) ->update([ 'status' => 2, 'admin_remark' => '用户主动取消', 'updatetime' => time(), ]); if ($result) { $this->success('注销申请已取消'); } else { $this->error('取消失败,请稍后再试'); } } /** * @name 账号状态查询(应用外) * @desc 无需登录的公开账号状态查询,满足GooglePlay应用外删除账号要求 * 返回5种状态: normal(正常)/blocked(封锁)/deleting(注销中)/deleted(已注销)/no_record(无记录) * 使用回执验证(action=account_lookup, payload=account)防止账号枚举攻击 * @lastUpdate v10.3.0 新增 - 满足GooglePlay应用外账户管理要求 */ public function accountLookup() { $this->checkRateLimit('accountLookup'); $account = $this->request->post('account', '', 'trim'); if (!$account) { $this->error('账号不能为空'); } $this->validateLength($account, '账号', 2, 100); $this->detectMaliciousInput($account); // 回执验证 - 防止账号枚举 $this->verifyReceipt('account_lookup', $account); // 自动识别账号类型: 邮箱/手机号/用户名 $isEmail = filter_var($account, FILTER_VALIDATE_EMAIL) !== false; $isMobile = preg_match('/^1[3-9]\d{9}$/', $account); // 1. 查询user表 $userQuery = db('user'); if ($isEmail) { $userQuery->where('email', $account); } elseif ($isMobile) { $userQuery->where('mobile', $account); } else { $userQuery->where('username', $account); } $user = $userQuery->find(); $statusMap = [ 'normal' => '正常', 'blocked' => '封锁', 'deleting' => '注销中', 'deleted' => '已注销', 'no_record'=> '无记录', ]; // 2. 用户存在 - 检查是否在注销流程中 // 注意: 返回nickname(昵称)而非username(账号),保护隐私 $nickname = isset($user['nickname']) && $user['nickname'] ? $user['nickname'] : ''; // 若昵称为空,使用username首尾字符+星号脱敏 (如 w***u) $displayNickname = $nickname; if (!$displayNickname && isset($user['username']) && $user['username']) { $uname = $user['username']; $len = mb_strlen($uname); if ($len <= 2) { $displayNickname = $uname[0] . '*'; } else { $displayNickname = $uname[0] . str_repeat('*', min($len - 2, 5)) . $uname[$len - 1]; } } if ($user) { $deletion = db('user_deletion') ->where('user_id', $user['id']) ->order('createtime desc') ->find(); if ($deletion) { // 注销中(待审核) if ($deletion['status'] == 0) { $remain = $deletion['auto_delete_time'] - time(); $days = floor(max(0, $remain) / 86400); $hours = floor(max(0, $remain) % 86400 / 3600); $this->success('查询成功', [ 'status' => 'deleting', 'status_text' => $statusMap['deleting'], 'nickname' => $displayNickname, 'has_pending_deletion' => true, 'auto_delete_time' => $deletion['auto_delete_time'], 'auto_delete_time_text' => date('Y-m-d H:i:s', $deletion['auto_delete_time']), 'countdown' => $remain > 0 ? "{$days}天{$hours}小时后自动注销" : '即将自动注销', 'createtime_text' => date('Y-m-d H:i:s', $deletion['createtime']), 'can_cancel' => true, ]); } // 已通过/已自动注销 - 但用户记录还在(理论上不应发生) if (in_array($deletion['status'], [1, 3])) { $this->success('查询成功', [ 'status' => 'deleted', 'status_text' => $statusMap['deleted'], 'nickname' => $displayNickname, 'has_pending_deletion' => false, 'deletetime_text' => $deletion['deletetime'] ? date('Y-m-d H:i:s', $deletion['deletetime']) : date('Y-m-d H:i:s', $deletion['updatetime']), ]); } } // 检查用户状态: normal/hidden $userStatus = isset($user['status']) ? $user['status'] : 'normal'; if ($userStatus === 'hidden') { $this->success('查询成功', [ 'status' => 'blocked', 'status_text' => $statusMap['blocked'], 'nickname' => $displayNickname, 'has_pending_deletion' => false, ]); } $this->success('查询成功', [ 'status' => 'normal', 'status_text' => $statusMap['normal'], 'nickname' => $displayNickname, 'has_pending_deletion' => false, ]); } // 3. 用户不存在 - 查询user_deletion表(可能已注销) $deletionQuery = db('user_deletion'); if ($isEmail) { // user_deletion表只存username, 邮箱无法直接匹配 $deletion = null; } elseif ($isMobile) { $deletion = null; } else { $deletion = $deletionQuery->where('username', $account) ->order('createtime desc') ->find(); } if ($deletion) { // 注销中(用户已无法登录但记录还在等待审核) if ($deletion['status'] == 0) { $remain = $deletion['auto_delete_time'] - time(); $days = floor(max(0, $remain) / 86400); $hours = floor(max(0, $remain) % 86400 / 3600); $this->success('查询成功', [ 'status' => 'deleting', 'status_text' => $statusMap['deleting'], 'has_pending_deletion' => true, 'auto_delete_time_text' => date('Y-m-d H:i:s', $deletion['auto_delete_time']), 'countdown' => $remain > 0 ? "{$days}天{$hours}小时后自动注销" : '即将自动注销', 'can_cancel' => false, ]); } // 已注销 if (in_array($deletion['status'], [1, 3])) { $this->success('查询成功', [ 'status' => 'deleted', 'status_text' => $statusMap['deleted'], 'has_pending_deletion' => false, 'deletetime_text' => $deletion['deletetime'] ? date('Y-m-d H:i:s', $deletion['deletetime']) : date('Y-m-d H:i:s', $deletion['updatetime']), ]); } } // 4. 真正无记录 $this->success('查询成功', [ 'status' => 'no_record', 'status_text' => $statusMap['no_record'], 'has_pending_deletion' => false, ]); } /** * @name 取消二维码 * @desc 取消二维码登录 * @lastUpdate v9.0.0 新增 */ public function qrcodeCancel() { $code = $this->request->post('code', '', 'trim'); if (!$code) { $this->error('code必填'); } $qr = db('qrcode_login')->where('code', $code)->find(); if (!$qr) { $this->error('二维码不存在'); } db('qrcode_login')->where('code', $code)->update(['status' => 'cancelled', 'updatetime' => time()]); $this->_notifyWsRelay($code, 'cancelled'); $this->success('已取消'); } /** * @name 记录登录设备 * @desc 登录成功后记录设备信息到user_device表 * @lastUpdate v9.0.0 新增 */ private function recordLoginDevice($userId) { $deviceName = $this->request->post('device_name', '', 'trim'); $deviceModel = $this->request->post('device_model', '', 'trim'); $platform = $this->request->post('platform', '', 'trim'); $appName = $this->request->post('app_name', '', 'trim'); $deviceId = $this->request->post('device_id', '', 'trim'); $userAgent = $this->request->header('user-agent', ''); $ip = $this->request->ip(); $now = time(); $ipLocation = $this->queryIpLocation($ip); if ($deviceId) { $exists = db('user_device')->where('user_id', $userId)->where('device_id', $deviceId)->find(); if ($exists) { $updateData = [ 'device_name' => $deviceName ?: $exists['device_name'], 'device_model' => $deviceModel ?: $exists['device_model'], 'platform' => $platform ?: $exists['platform'], 'app_name' => $appName ?: $exists['app_name'], 'ip' => $ip, 'last_active_time' => $now, 'is_online' => 1, 'user_agent' => substr($userAgent, 0, 500), 'updatetime' => $now, ]; if ($ipLocation) { if (!empty($ipLocation['city'])) $updateData['ip_city'] = $ipLocation['city']; if (!empty($ipLocation['fw'])) $updateData['ip_range'] = $ipLocation['fw']; } db('user_device')->where('id', $exists['id'])->update($updateData); return; } } $insertData = [ 'user_id' => $userId, 'device_name' => $deviceName, 'device_model' => $deviceModel, 'platform' => $platform, 'app_name' => $appName, 'ip' => $ip, 'device_id' => $deviceId ?: md5($userId . $ip . $userAgent . $now), 'last_active_time' => $now, 'is_online' => 1, 'user_agent' => substr($userAgent, 0, 500), 'createtime' => $now, 'updatetime' => $now, ]; if ($ipLocation) { if (!empty($ipLocation['city'])) $insertData['ip_city'] = $ipLocation['city']; if (!empty($ipLocation['fw'])) $insertData['ip_range'] = $ipLocation['fw']; } db('user_device')->insert($insertData); } /** * @name 查询IP归属地 * @desc 使用纯真IP数据库查询IP归属地,返回city和fw * @lastUpdate v9.2.0 新增,解决登录时ip_city为空问题 */ private function queryIpLocation($ip) { if (!$ip || $ip === '127.0.0.1' || $ip === '0.0.0.0') { return null; } try { $ips = new \Net\Ips('./qqwry.dat'); $resolved = gethostbyname($ip); if (!$resolved || $resolved === $ip) { $resolved = $ip; } $fwq = $ips->Getlocation($resolved); $city = strToUTF8($fwq['country'] . ' ' . $fwq['area']); $fw = ''; $domain = preg_replace('/(\d+)\..*/', '\\1', $resolved); if ('1' <= $domain && $domain <= '126') { $fw = '1.0.0.1 - 126.155.255.254'; } elseif ('128' <= $domain && $domain <= '191') { $fw = '128.0.0.1 - 191.255.255.254'; } elseif ('192' <= $domain && $domain <= '223') { $fw = '192.0.0.1 - 223.255.255.254'; } return ['city' => $city, 'fw' => $fw]; } catch (\Exception $e) { return null; } } /** * @name 更新在线状态 * @desc 更新用户最后活跃时间和在线状态 * @lastUpdate v9.0.0 新增 */ private function updateOnlineStatus($userId) { db('user')->where('id', $userId)->update([ 'last_active_time' => time(), 'is_online' => 1, ]); } /** * @name 通知WebSocket中继服务器 * @desc 二维码状态变更时,通过HTTP通知WebSocket中继服务器推送更新 * @lastUpdate v10.4.0 新增 */ private function _notifyWsRelay($code, $status, $token = '') { try { $wsRelayUrl = Config::get('qrcode_ws_relay_url') ?: 'http://127.0.0.1:9444'; $data = json_encode([ 'code' => $code, 'status' => $status, ]); if ($token) { $data = json_encode([ 'code' => $code, 'status' => $status, 'token' => $token, ]); } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $wsRelayUrl . '/notify', CURLOPT_POST => true, CURLOPT_POSTFIELDS => $data, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2, CURLOPT_CONNECTTIMEOUT => 1, ]); curl_exec($ch); curl_close($ch); } catch (\Exception $e) { // 静默失败,不影响主流程 } } // ================================================================ // 隐私权管理增强 API (v10.3.1) // ================================================================ /** * @name 获取用户隐私偏好设置 * @desc 查询用户对各类数据处理的同意状态 * @lastUpdate v10.3.1 新增 */ public function getConsents() { $this->checkRateLimit('deletionStatus'); $user = $this->auth->getUser(); $consentTypes = ['analytics', 'marketing', 'personalization', 'third_party_share']; $defaults = ['analytics' => 1, 'marketing' => 1, 'personalization' => 1, 'third_party_share' => 0]; $records = db('user_consents')->where('user_id', $user->id)->select(); $map = []; foreach ($records as $r) { $map[$r['consent_type']] = intval($r['consent_value']); } $result = []; foreach ($consentTypes as $type) { $result[] = [ 'type' => $type, 'value' => isset($map[$type]) ? $map[$type] : $defaults[$type], 'default' => $defaults[$type], ]; } $this->success('', ['consents' => $result]); } /** * @name 更新用户隐私偏好设置 * @desc 批量更新用户对各类数据处理的同意状态 * @lastUpdate v10.3.1 新增 */ public function updateConsents() { $this->checkRateLimit('changeemail'); $user = $this->auth->getUser(); $consentsJson = $this->request->post('consents', '', 'trim'); if (!$consentsJson) { $this->error('参数不能为空'); } $consents = json_decode($consentsJson, true); if (!is_array($consents)) { $this->error('参数格式错误'); } $allowedTypes = ['analytics', 'marketing', 'personalization', 'third_party_share']; $now = time(); $ip = $this->request->ip(); $updated = 0; foreach ($consents as $item) { if (!isset($item['type']) || !in_array($item['type'], $allowedTypes)) { continue; } $value = isset($item['value']) ? (intval($item['value']) ? 1 : 0) : 1; $exists = db('user_consents') ->where('user_id', $user->id) ->where('consent_type', $item['type']) ->find(); if ($exists) { db('user_consents')->where('id', $exists['id'])->update([ 'consent_value' => $value, 'consent_source' => 'web', 'ip' => $ip, 'updatetime' => $now, ]); } else { db('user_consents')->insert([ 'user_id' => $user->id, 'consent_type' => $item['type'], 'consent_value' => $value, 'consent_source'=> 'web', 'ip' => $ip, 'createtime' => $now, 'updatetime' => $now, ]); } $updated++; } $this->success("已更新 {$updated} 项隐私偏好设置"); } /** * @name 获取用户登录设备列表 * @desc 查询用户所有登录设备,含最后活跃时间和位置 * @lastUpdate v10.3.1 新增 */ public function listDevices() { $this->checkRateLimit('deletionStatus'); $user = $this->auth->getUser(); $devices = db('user_device') ->where('user_id', $user->id) ->order('last_active_time desc') ->limit(20) ->select(); $result = []; foreach ($devices as &$d) { $d['last_active_text'] = $d['last_active_time'] ? date('Y-m-d H:i:s', $d['last_active_time']) : '-'; $d['is_current'] = ($d['ip'] === $this->request->ip()) ? 1 : 0; $d['is_online_text'] = $d['is_online'] ? '在线' : '离线'; $result[] = [ 'id' => $d['id'], 'device_name' => $d['device_name'] ?: '未知设备', 'device_model' => $d['device_model'] ?: '', 'platform' => $d['platform'] ?: '', 'app_name' => $d['app_name'] ?: '', 'ip' => $d['ip'] ?: '', 'ip_city' => $d['ip_city'] ?? '', 'device_id' => $d['device_id'] ?: '', 'last_active_time'=> $d['last_active_text'], 'is_online' => $d['is_online'], 'is_online_text' => $d['is_online_text'], 'is_current' => $d['is_current'], ]; } $this->success('', ['devices' => $result, 'total' => count($result)]); } /** * @name 远程登出设备 * @desc 撤销指定设备的登录状态,清除该设备的Token * @lastUpdate v10.3.1 新增 */ public function revokeDevice() { $this->checkRateLimit('cancelDeletion'); $user = $this->auth->getUser(); $deviceId = $this->request->post('device_id', '', 'trim'); $deviceDbId = $this->request->post('id', 0, 'intval'); if (!$deviceId && !$deviceDbId) { $this->error('参数错误'); } $query = db('user_device')->where('user_id', $user->id); if ($deviceDbId) { $query->where('id', $deviceDbId); } else { $query->where('device_id', $deviceId); } $device = $query->find(); if (!$device) { $this->error('设备不存在'); } // 标记设备为离线 db('user_device')->where('id', $device['id'])->update([ 'is_online' => 0, 'updatetime' => time(), ]); // 删除该设备关联的Token try { db('user_token') ->where('user_id', $user->id) ->whereLike('token', '%' . substr($device['device_id'] ?? '', 0, 8) . '%') ->delete(); } catch (\Exception $e) {} $this->success('设备已登出'); } /** * @name 查询注销数据删除进度 * @desc 返回注销过程中各类数据的删除状态 * @lastUpdate v10.3.1 新增 */ public function deletionProgress() { $this->checkRateLimit('deletionStatus'); $user = $this->auth->getUser(); $record = db('user_deletion') ->where('user_id', $user->id) ->order('createtime desc') ->find(); if (!$record) { $this->success('', ['has_record' => false]); } $progress = []; if (!empty($record['deletion_progress'])) { $progress = json_decode($record['deletion_progress'], true) ?: []; } // 如果没有进度记录但状态已是已完成,生成默认进度 if (empty($progress) && in_array($record['status'], [1, 3])) { $progress = [ ['table' => 'user', 'name' => '账号主数据', 'status' => 'done'], ['table' => 'user_token', 'name' => '登录凭证', 'status' => 'done'], ['table' => 'user_device', 'name' => '设备记录', 'status' => 'done'], ['table' => 'user_favorite', 'name' => '收藏数据', 'status' => 'done'], ['table' => 'user_note', 'name' => '笔记数据', 'status' => 'done'], ['table' => 'user_signin', 'name' => '签到记录', 'status' => 'done'], ['table' => 'user_score_log', 'name' => '积分流水', 'status' => 'done'], ['table' => 'article', 'name' => '文章数据', 'status' => 'done'], ]; } elseif (empty($progress) && $record['status'] == 0) { $progress = [ ['table' => 'user', 'name' => '账号主数据', 'status' => 'pending'], ['table' => 'user_token', 'name' => '登录凭证', 'status' => 'pending'], ['table' => 'user_device', 'name' => '设备记录', 'status' => 'pending'], ['table' => 'user_favorite', 'name' => '收藏数据', 'status' => 'pending'], ['table' => 'user_note', 'name' => '笔记数据', 'status' => 'pending'], ['table' => 'user_signin', 'name' => '签到记录', 'status' => 'pending'], ['table' => 'user_score_log', 'name' => '积分流水', 'status' => 'pending'], ['table' => 'article', 'name' => '文章数据', 'status' => 'pending'], ]; } $total = count($progress); $done = count(array_filter($progress, function($p) { return ($p['status'] ?? '') === 'done'; })); $percentage = $total > 0 ? round($done / $total * 100) : 0; $statusMap = [0 => '待审核', 1 => '已通过(已注销)', 2 => '已拒绝', 3 => '已自动注销']; $this->success('', [ 'has_record' => true, 'status' => $record['status'], 'status_text' => $statusMap[$record['status']] ?? '未知', 'progress' => $progress, 'percentage' => $percentage, 'done_count' => $done, 'total_count' => $total, 'summary' => $record['deletion_summary'] ?? '', 'createtime_text' => date('Y-m-d H:i:s', $record['createtime']), 'deletetime_text' => $record['deletetime'] ? date('Y-m-d H:i:s', $record['deletetime']) : null, ]); } /** * @name 计算年龄 * @desc 根据出生日期(YYYY-MM-DD)计算当前周岁年龄 * @lastUpdate v10.3.1 新增 (feat6) * @param string $birthday 出生日期 YYYY-MM-DD * @return array ['valid' => bool, 'age' => int] */ private function calculateAge($birthday) { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $birthday, $m)) { return ['valid' => false, 'age' => 0]; } $year = intval($m[1]); $month = intval($m[2]); $day = intval($m[3]); if (!checkdate($month, $day, $year)) { return ['valid' => false, 'age' => 0]; } $now = getdate(); $age = $now['year'] - $year; if ($now['mon'] < $month || ($now['mon'] == $month && $now['mday'] < $day)) { $age--; } return ['valid' => true, 'age' => $age]; } /** * @name 查询家长控制状态 * @desc 返回当前用户的未成年状态、年龄、家长控制配置(COPPA/GDPR-K合规) * @lastUpdate v10.3.1 新增 (feat6) */ public function parentalControlStatus() { $user = $this->auth->getUser(); $userRow = db('user')->where('id', $user->id)->find(); $birthday = $userRow['birthday'] ?? ''; $isMinor = intval($userRow['is_minor'] ?? 0); $age = null; if ($birthday) { $ageData = $this->calculateAge($birthday); if ($ageData['valid']) { $age = $ageData['age']; // 自动更新is_minor状态(若已成年需同步) $newIsMinor = ($age < 18) ? 1 : 0; if ($newIsMinor !== $isMinor) { try { db('user')->where('id', $user->id)->update(['is_minor' => $newIsMinor]); $isMinor = $newIsMinor; } catch (\Exception $e) {} } } } // 家长控制配置(默认值,后续可扩展为独立表存储) $parentalConfig = [ 'daily_limit_minutes' => $isMinor ? 120 : 0, // 未成年每日120分钟,成年无限制 'content_filter_level' => $isMinor ? 'strict' : 'off', // 内容过滤级别 'night_mode_enabled' => $isMinor ? true : false, // 夜间模式(22:00-06:00禁用) 'purchase_blocked' => $isMinor ? true : false, // 禁止消费 ]; $this->success('', [ 'has_birthday' => !empty($birthday), 'birthday' => $birthday, 'age' => $age, 'is_minor' => $isMinor, 'is_adult' => $isMinor ? 0 : 1, 'parental_control' => $parentalConfig, 'policy' => [ 'min_age' => 14, 'adult_age' => 18, 'consent_age' => 16, // GDPR-K家长同意年龄 'description' => '14岁以下禁止注册,14-17岁需家长同意,18岁及以上为完全民事行为能力人', ], ]); } /** * @name 更新出生日期 * @desc 已注册用户补充出生日期信息,自动更新未成年状态 * @lastUpdate v10.3.1 新增 (feat6) */ public function updateBirthday() { $this->checkRateLimit('changeemail'); $user = $this->auth->getUser(); $birthday = $this->request->post('birthday', '', 'trim'); if (!$birthday) { $this->error('出生日期不能为空'); } $ageData = $this->calculateAge($birthday); if (!$ageData['valid']) { $this->error('出生日期格式无效,应为 YYYY-MM-DD'); } if ($ageData['age'] < 0 || $ageData['age'] > 120) { $this->error('出生日期不合理'); } if ($ageData['age'] < 14) { $this->error('根据COPPA/GDPR-K要求,14岁以下用户不得使用本服务'); } $isMinor = ($ageData['age'] < 18) ? 1 : 0; try { db('user')->where('id', $user->id)->update([ 'birthday' => $birthday, 'is_minor' => $isMinor, 'updatetime'=> time(), ]); } catch (\Exception $e) { $this->error('保存失败,可能未执行数据库迁移: ' . $e->getMessage()); } $this->success('出生日期已更新', [ 'birthday' => $birthday, 'age' => $ageData['age'], 'is_minor' => $isMinor, ]); } // ============================================================ // v10.3.2 新增功能 // ============================================================ /** * @name SMTP配置状态检测 * @desc 检测服务器SMTP邮件配置是否完整可用(不暴露敏感信息) * @lastUpdate v10.3.2 新增 */ public function checkSmtpStatus() { $this->checkRateLimit('checkSmtpStatus'); $site = Config::get('site') ?: []; // 检查必要配置项 $host = isset($site['mail_smtp_host']) ? $site['mail_smtp_host'] : ''; $port = isset($site['mail_smtp_port']) ? $site['mail_smtp_port'] : ''; $user = isset($site['mail_smtp_user']) ? $site['mail_smtp_user'] : ''; $pass = isset($site['mail_smtp_pass']) ? $site['mail_smtp_pass'] : ''; $from = isset($site['mail_from']) ? $site['mail_from'] : ''; $mailType = isset($site['mail_type']) ? intval($site['mail_type']) : 0; $verifyType = isset($site['mail_verify_type']) ? intval($site['mail_verify_type']) : 0; $missing = []; if (!$host) $missing[] = 'SMTP主机'; if (!$port) $missing[] = 'SMTP端口'; if (!$user) $missing[] = 'SMTP用户名'; if (!$pass) $missing[] = 'SMTP密码'; if (!$from) $missing[] = '发件邮箱'; if ($mailType == 0) $missing[] = '邮件功能未开启(mail_type=0)'; $isConfigured = empty($missing); $secureMap = [0 => '无加密', 1 => 'TLS', 2 => 'SSL']; $secure = $secureMap[$verifyType] ?? '未知'; // 尝试TCP连接检测(不发送邮件,只检测端口可达性) $portReachable = false; $connectError = ''; if ($isConfigured && $host && $port) { // 使用fsockopen非阻塞检测 $fp = @fsockopen($host, intval($port), $errno, $errstr, 3); if ($fp) { $portReachable = true; fclose($fp); } else { $connectError = $errstr ?: '连接超时'; } } $this->success('', [ 'is_configured' => $isConfigured, 'is_enabled' => $mailType != 0, 'host' => $host ?: '(未配置)', 'port' => $port ?: '(未配置)', 'secure' => $secure, 'from_email' => $from ?: '(未配置)', // 不返回密码等敏感信息 'has_password' => !empty($pass), 'has_username' => !empty($user), 'port_reachable' => $portReachable, 'connect_error' => $connectError, 'missing_items' => $missing, 'summary' => $isConfigured ? ($portReachable ? '✅ SMTP配置完整且端口可达' : '⚠️ SMTP配置完整但端口不可达: ' . $connectError) : '❌ SMTP配置不完整, 缺失: ' . implode(', ', $missing), ]); } /** * @name 密保问题登录 * @desc 用户通过账号+密保问题+答案登录(无需密码) * @lastUpdate v10.3.2 新增 (支持忘记密码场景) */ public function secQuestionLogin() { $this->checkRateLimit('secQuestionLogin'); $account = $this->request->post('account', '', 'trim'); $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$account || !$secAnswer) { $this->error('账号和密保答案不能为空'); } $this->validateLength($account, '账号', 2, 100); $this->validateLength($secAnswer, '密保答案', 1, 100); $this->detectMaliciousInput($account); // 查找用户 $user = $this->findUserByAccount($account); if (!$user) { $this->error(__('User not found')); } if ($user->status != 'normal') { $this->error(__('Account is locked')); } // 验证密保答案 if (empty($user->sec_question) || empty($user->sec_answer)) { $this->error('该用户未设置密保问题,无法使用此登录方式'); } $inputHash = $this->hashSecAnswer($secAnswer); if ($inputHash !== $user->sec_answer) { $this->error('密保答案不正确'); } $ret = $this->auth->direct($user->id); if ($ret) { $this->recordLoginDevice($user->id); $this->updateOnlineStatus($user->id); $token = $this->auth->getToken(); $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 邮件验证码登录 * @desc 用户通过邮箱+验证码登录(无需密码),未注册自动创建账号 * @lastUpdate v10.3.2 新增 */ public function emailCodeLogin() { $this->checkRateLimit('emailCodeLogin'); $email = $this->request->post('email', '', 'trim'); $captcha = $this->request->post('captcha', '', 'trim'); if (!$email || !$captcha) { $this->error('邮箱和验证码不能为空'); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $this->error('邮箱格式不正确'); } $this->validateLength($captcha, '验证码', 4, 8); // 校验验证码 if (self::$testMode && $captcha === '888888') { $verified = true; } else { $verified = false; try { $verified = Ems::check($email, $captcha, 'emaillogin'); } catch (\Exception $e) { $this->error('验证码服务暂不可用'); } } if (!$verified) { $this->error('验证码不正确或已过期'); } $user = \app\common\model\User::getByEmail($email); if ($user) { if ($user->status != 'normal') { $this->error(__('Account is locked')); } $ret = $this->auth->direct($user->id); } else { // 邮箱未注册 - 自动注册 $username = 'user_' . substr(md5($email . time()), 0, 10); $ret = $this->auth->register($username, Random::alnum(10), $email, '', []); } if ($ret) { try { Ems::flush($email, 'emaillogin'); } catch (\Exception $e) {} $this->recordLoginDevice($this->auth->id); $this->updateOnlineStatus($this->auth->id); $token = $this->auth->getToken(); $data = ['userinfo' => $this->auth->getUserinfo(), 'token' => $token]; $this->success(__('Logged in successful'), $data); } else { $this->error($this->auth->getError()); } } /** * @name 发送注销回执码(忘记密码注销) * @desc 用户填写账号+邮箱/密保答案验证身份后,系统生成注销回执码发送到注册邮箱 * 用户凭回执码可提交注销(无需登录),用于忘记密码场景 * @lastUpdate v10.3.2 新增 */ public function sendDeletionCode() { $this->checkRateLimit('sendDeletionCode'); $account = $this->request->post('account', '', 'trim'); $verifyMethod = $this->request->post('verify_method', 'email', 'trim'); // email/sec_question $emailInput = $this->request->post('email', '', 'trim'); $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$account) { $this->error('账号不能为空'); } $this->validateLength($account, '账号', 2, 100); $this->detectMaliciousInput($account); $user = $this->findUserByAccount($account); if (!$user) { $this->error(__('User not found')); } // 验证身份 $userEmail = $user->email ?: ''; if ($verifyMethod === 'email') { // 邮箱验证: 用户填写的邮箱必须与注册邮箱一致 if (!$emailInput) { $this->error('请填写注册邮箱'); } if (strtolower($emailInput) !== strtolower($userEmail)) { $this->error('邮箱与账号不匹配'); } } elseif ($verifyMethod === 'sec_question') { // 密保验证: 校验密保答案 if (!$secAnswer) { $this->error('请填写密保答案'); } if (empty($user->sec_question) || empty($user->sec_answer)) { $this->error('该用户未设置密保问题'); } if ($this->hashSecAnswer($secAnswer) !== $user->sec_answer) { $this->error('密保答案不正确'); } } else { $this->error('不支持的验证方式'); } if (!$userEmail || !filter_var($userEmail, FILTER_VALIDATE_EMAIL)) { $this->error('该账号未绑定有效邮箱,无法发送回执码,请联系客服'); } // 检查是否已有进行中的注销申请 $existingDeletion = db('user_deletion') ->where('user_id', $user->id) ->where('status', 0) ->find(); if ($existingDeletion) { $this->error('该账号已有进行中的注销申请,无需重复提交'); } // 生成6位注销回执码(有效期30分钟) $deletionCode = sprintf('%06d', mt_rand(100000, 999999)); $cacheKey = 'deletion_code:' . $user->id; cache($cacheKey, json_encode([ 'user_id' => $user->id, 'account' => $account, 'email' => $userEmail, 'code' => $deletionCode, 'createtime' => time(), ]), 1800); // 30分钟过期 // 异步发送邮件 $username = $user->username ?? $user->nickname ?? '用户'; register_shutdown_function(function () use ($userEmail, $username, $deletionCode) { try { $subject = '【闲言APP】账号注销回执码 - ' . $deletionCode; $body = <<

🔐 账号注销回执码

亲爱的 {$username}

您正在进行账号注销操作。请使用以下回执码在注销页面完成注销提交:

{$deletionCode}

⚠️ 回执码有效期为30分钟,请尽快使用。

如非本人操作,请忽略此邮件,您的账号安全不受影响。


闲言APP隐私安全团队 · 此邮件由系统自动发送

HTML; $emailLib = \app\common\library\Email::instance(); $emailLib->to($userEmail)->subject($subject)->message($body, true)->send(); } catch (\Exception $e) { \think\Log::error('注销回执码邮件发送失败: ' . $e->getMessage()); } }); $this->success('注销回执码已发送到注册邮箱', [ 'email_masked' => $this->maskEmail($userEmail), 'expires_in' => 1800, ]); } /** * @name 凭回执码提交注销(无需登录) * @desc 用户使用收到的注销回执码提交注销申请,用于忘记密码场景 * @lastUpdate v10.3.2 新增 */ public function requestDeletionByReceipt() { $this->checkRateLimit('requestDeletionByReceipt'); $account = $this->request->post('account', '', 'trim'); $deletionCode = $this->request->post('deletion_code', '', 'trim'); $reason = $this->request->post('reason', '', 'trim'); if (!$account || !$deletionCode) { $this->error('账号和注销回执码不能为空'); } $this->validateLength($account, '账号', 2, 100); $this->validateLength($deletionCode, '回执码', 6, 8); $this->detectMaliciousInput($account); $user = $this->findUserByAccount($account); if (!$user) { $this->error(__('User not found')); } // 校验回执码 $cacheKey = 'deletion_code:' . $user->id; $cachedData = cache($cacheKey); if (!$cachedData) { $this->error('回执码已过期或不存在,请重新申请'); } $codeData = json_decode($cachedData, true); if (!$codeData || !isset($codeData['code']) || $codeData['code'] !== $deletionCode) { $this->error('回执码不正确'); } if (strtolower($codeData['email']) !== strtolower($user->email)) { $this->error('账号信息不匹配'); } // 检查是否已有进行中的注销申请 $existing = db('user_deletion') ->where('user_id', $user->id) ->where('status', 0) ->find(); if ($existing) { $this->error('该账号已有进行中的注销申请'); } // 创建注销申请 $autoDeleteDays = 15; $autoDeleteTime = time() + $autoDeleteDays * 86400; $deletionId = db('user_deletion')->insertGetId([ 'user_id' => $user->id, 'username' => $user->username ?: $account, 'email' => $user->email ?: '', 'reason' => $this->sanitizeString($reason, 500), 'status' => 0, 'auto_delete_time' => $autoDeleteTime, 'createtime' => time(), 'updatetime' => time(), 'source' => 'web_receipt', ]); // 清除回执码缓存 cache($cacheKey, null); // 异步发送确认邮件 $this->sendDeletionNotificationAsync($user, $deletionId, $autoDeleteTime, $reason); $this->success('注销申请已提交', [ 'deletion_id' => $deletionId, 'auto_delete_time_text'=> date('Y-m-d H:i:s', $autoDeleteTime), 'countdown_days' => $autoDeleteDays, 'can_cancel' => true, ]); } /** * @name 根据账号查找用户(内部辅助方法) * @desc 支持用户名/邮箱/手机号查找 * @lastUpdate v10.3.2 新增 */ private function findUserByAccount($account) { if (Validate::is($account, 'email')) { return \app\common\model\User::getByEmail($account); } elseif (Validate::regex($account, '^1[3-9]\\d{9}$')) { return \app\common\model\User::getByMobile($account); } else { return \app\common\model\User::getByUsername($account); } } /** * @name 邮箱脱敏 * @desc 将邮箱地址中间部分替换为星号 (如 a***@example.com) * @lastUpdate v10.3.2 新增 */ private function maskEmail($email) { if (!$email || strpos($email, '@') === false) { return $email; } list($local, $domain) = explode('@', $email, 2); $localLen = strlen($local); if ($localLen <= 1) { return $local . '***@' . $domain; } elseif ($localLen <= 3) { return $local[0] . '***@' . $domain; } else { return substr($local, 0, 2) . str_repeat('*', min($localLen - 2, 5)) . '@' . $domain; } } }