2360 lines
90 KiB
PHP
2360 lines
90 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.3.2 新增SMTP检测/密保登录/邮箱验证码登录/回执码注销(忘记密码场景); accountLookup返回昵称
|
||
*/
|
||
class UserSecurity extends Api
|
||
{
|
||
protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel', 'secQuestions', 'accountLookup', 'checkSmtpStatus', 'secQuestionLogin', 'emailCodeLogin', 'sendDeletionCode', 'requestDeletionByReceipt'];
|
||
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],
|
||
'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 = [
|
||
'/<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.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 = <<<HTML
|
||
<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"></head><body style="font-family:-apple-system,sans-serif;max-width:560px;margin:0 auto;padding:20px;color:#333;">
|
||
<h2 style="color:#FF3B30;">⚠️ 账号注销申请确认</h2>
|
||
<p>您好 <strong>{$username}</strong>:</p>
|
||
<p>我们收到了针对您账号的注销申请。如果这是<strong style="color:#FF3B30;">您本人的操作</strong>,请忽略此邮件。</p>
|
||
<p>如果<strong style="color:#FF3B30;">不是您本人操作</strong>,请立即登录App或访问以下链接取消注销:</p>
|
||
<p style="margin:16px 0;"><a href="{$cancelUrl}" style="display:inline-block;background:#6C5CE7;color:#fff;padding:10px 24px;border-radius:8px;text-decoration:none;">取消注销申请</a></p>
|
||
<table style="width:100%;border-collapse:collapse;margin:20px 0;font-size:14px;">
|
||
<tr><td style="padding:8px 12px;border:1px solid #eee;background:#f9f9f9;">申请时间</td><td style="padding:8px 12px;border:1px solid #eee;">{$requestTime}</td></tr>
|
||
<tr><td style="padding:8px 12px;border:1px solid #eee;background:#f9f9f9;">注销原因</td><td style="padding:8px 12px;border:1px solid #eee;">{$reasonText}</td></tr>
|
||
<tr><td style="padding:8px 12px;border:1px solid #eee;background:#f9f9f9;">IP地址</td><td style="padding:8px 12px;border:1px solid #eee;">{$ip}</td></tr>
|
||
<tr><td style="padding:8px 12px;border:1px solid #eee;background:#f9f9f9;">自动注销时间</td><td style="padding:8px 12px;border:1px solid #eee;color:#FF3B30;">{$autoDeleteText}(3天后)</td></tr>
|
||
</table>
|
||
<div style="background:#FFF8E1;border-left:3px solid #FF9500;padding:12px 16px;margin:16px 0;border-radius:4px;">
|
||
<p style="margin:0;"><strong>⚠️ 注销后果:</strong>所有收藏、笔记、签到记录、积分、文章、会员权益等数据将被<strong>永久删除且不可恢复</strong>。</p>
|
||
</div>
|
||
<hr style="border:none;border-top:1px solid #eee;margin:20px 0;">
|
||
<p style="font-size:12px;color:#999;">此邮件由系统自动发送,请勿回复。如有疑问请联系 ad@avefs.com</p>
|
||
<p style="font-size:12px;color:#999;">© 2026 闲言APP · 弥勒市朋普镇微风暴网络科技工作室</p>
|
||
</body></html>
|
||
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 = <<<HTML
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<body style="font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#F2F2F7;padding:20px;">
|
||
<div style="max-width:520px;margin:0 auto;background:#FFF;border-radius:12px;padding:24px;">
|
||
<h2 style="color:#6C5CE7;margin:0 0 12px;">🔐 账号注销回执码</h2>
|
||
<p style="color:#1C1C1E;font-size:14px;">亲爱的 <strong>{$username}</strong>:</p>
|
||
<p style="color:#8E8E93;font-size:14px;">您正在进行账号注销操作。请使用以下回执码在注销页面完成注销提交:</p>
|
||
<div style="background:rgba(108,92,231,0.08);border-radius:8px;padding:16px;text-align:center;margin:16px 0;">
|
||
<span style="font-size:28px;font-weight:700;color:#6C5CE7;letter-spacing:4px;">{$deletionCode}</span>
|
||
</div>
|
||
<p style="color:#FF3B30;font-size:12px;">⚠️ 回执码有效期为30分钟,请尽快使用。</p>
|
||
<p style="color:#8E8E93;font-size:12px;">如非本人操作,请忽略此邮件,您的账号安全不受影响。</p>
|
||
<hr style="border:none;border-top:1px solid #E5E5EA;margin:16px 0;">
|
||
<p style="color:#AEAEB2;font-size:11px;">闲言APP隐私安全团队 · 此邮件由系统自动发送</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
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;
|
||
}
|
||
}
|
||
}
|