Files
xianyan/docs/toolsapi/application/api/controller/UserSecurity.php
2026-06-27 04:57:00 +08:00

2360 lines
90 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\api\controller;
use app\common\controller\Api;
use app\common\library\Receipt;
use app\common\library\Ems;
use app\common\library\Sms;
use fast\Random;
use think\Config;
use think\Validate;
/**
* 用户安全接口
* @time 2026-04-29
* @name UserSecurity
* @description 用户安全相关API含注册/登录/改密/改邮箱/重置密码/回执登录/二维码登录/密保问题等
* @lastUpdate v10.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;
}
}
}