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