Files
xianyan/docs/toolsapi/application/api/controller/UserSecurity.php
Developer 355191aaf6 feat(leisure): 新增闲情逸致模块与多项功能优化
本次提交完成多项核心更新:
1. 新增闲情逸致功能模块,包含时间线、收藏标注、季节主题等基础框架
2. 替换hive为社区维护的hive_ce包,修复依赖兼容问题
3. 统一替换"开发中"提示为"当前设备不支持",优化用户提示文案
4. 新增多项功能开关与特性标志,统一管理不可用功能提示
5. 完善用户账户洞察系统,新增头像审核中状态检测
6. 优化TTS语音朗读服务,修复Android端引擎初始化问题
7. 重构知识图谱缩放手势逻辑,解决缩放不跟手问题
8. 新增精灵头像组件,替换默认聊天头像样式
9. 新增外部链接跳转确认弹窗,提升使用安全性
10. 升级后端API接口,新增签到配置获取与补签积分规则动态读取
11. 完善多语言翻译覆盖率限制,非中文语言仅显示最高50%进度
12. 新增HTTP缓存拦截器,优化网络请求性能
13. 新增恢复出厂设置选项,完善数据管理功能

同时修复了多处代码细节问题:简化字符串拼接、优化布局代码、移除多余代码等。
2026-05-27 08:06:54 +08:00

1224 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\library\Receipt;
use app\common\library\Ems;
use app\common\library\Sms;
use fast\Random;
use think\Config;
use think\Validate;
/**
* 用户安全接口
* @time 2026-04-29
* @name UserSecurity
* @description 用户安全相关API含注册/登录/改密/改邮箱/重置密码/回执登录/二维码登录/密保问题等
* @lastUpdate v10.2.0 register注册赠送50积分+50金币; 补签积分不足提醒增强
*/
class UserSecurity extends Api
{
protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel', 'secQuestions'];
protected $noNeedRight = '*';
private static $rateLimitKey = 'api_rate_limit:';
private static $rateLimits = [
'login' => ['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],
];
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 = [
'/<script\b[^>]*>/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.2.0 新增注册赠送50积分+50金币
*/
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');
if (!$username || !$password || !$email) {
$this->error('用户名、密码和邮箱为必填项');
}
$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 = [];
if ($secQuestion > 0 && $secAnswer) {
if (!isset(self::$secQuestions[$secQuestion])) {
$this->error('密保问题编号无效(1-8)');
}
$this->validateLength($secAnswer, '密保答案', 1, 50);
$extend['sec_question'] = $secQuestion;
$extend['sec_answer'] = $this->hashSecAnswer($secAnswer);
}
$ret = $this->auth->register($username, $password, $email, $mobile, $extend);
if ($ret) {
$userId = $this->auth->id;
$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)]);
// 注册赠送50积分和50金币
$registerScore = 50;
$registerGold = 50;
$userBefore = db('user')->where('id', $userId)->find();
$scoreBefore = isset($userBefore['score']) ? intval($userBefore['score']) : 0;
$goldBefore = isset($userBefore['gold']) ? intval($userBefore['gold']) : 0;
db('user')->where('id', $userId)->setInc('score', $registerScore);
db('user')->where('id', $userId)->setInc('gold', $registerGold);
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) {}
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) {}
$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'];
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']) && !$userinfo) {
$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->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 查询当前用户的注销申请状态
* @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 取消二维码登录
* @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->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,
]);
}
}