This commit is contained in:
wushumin
2026-05-11 15:28:27 +08:00
commit 9aac78b8da
289 changed files with 67193 additions and 0 deletions

View File

@@ -0,0 +1,441 @@
<?php
namespace app\support;
use Webman\Http\Request;
use support\think\Db;
class AppAuthService
{
public function __construct()
{
$this->ensurePasswordColumn();
$this->ensureTokenTable();
$this->ensureSmsCodeTable();
}
public function sendLoginCode(string $mobile, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法发送验证码');
}
$scene = 'login';
$now = time();
$latest = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->order('id', 'desc')
->find();
if ($latest) {
$retryAt = strtotime((string)$latest['created_at']) + 60;
if ($retryAt > $now) {
throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now)));
}
}
$todayStart = date('Y-m-d 00:00:00');
$todayCount = (int)Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->where('created_at', '>=', $todayStart)
->count();
if ($todayCount >= 20) {
throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试');
}
$code = (string)random_int(100000, 999999);
$nowText = date('Y-m-d H:i:s', $now);
$expireTime = date('Y-m-d H:i:s', $now + 300);
$sendResult = null;
$sendStatus = 'failed';
$failedReason = '';
try {
$sendResult = (new AppSmsService())->sendLoginCode($mobile, $code);
$sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success';
} catch (\Throwable $e) {
$failedReason = $this->truncateText($e->getMessage(), 250);
}
Db::name('sms_code_logs')->insert([
'mobile' => $mobile,
'scene' => $scene,
'code_hash' => $this->codeHash($mobile, $scene, $code),
'send_status' => $sendStatus,
'provider' => $sendResult['provider'] ?? 'aliyun_sms',
'template_code' => $this->systemConfig('sms', 'login_template_code'),
'request_id' => $sendResult['request_id'] ?? '',
'biz_id' => $sendResult['biz_id'] ?? '',
'failed_reason' => $failedReason,
'expire_time' => $expireTime,
'used_at' => null,
'send_ip' => $request->getRealIp(),
'created_at' => $nowText,
'updated_at' => $nowText,
]);
if ($sendStatus === 'failed') {
throw new \RuntimeException($failedReason ?: '验证码发送失败');
}
$payload = [
'mobile' => $mobile,
'scene' => $scene,
'expire_seconds' => 300,
'retry_after_seconds' => 60,
];
if (($sendResult['debug_code'] ?? null) !== null) {
$payload['debug_code'] = $sendResult['debug_code'];
}
return $payload;
}
public function loginByCode(string $mobile, string $code, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
$now = date('Y-m-d H:i:s');
Db::name('sms_code_logs')->where('id', $record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用');
}
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '安心验用户' . substr($mobile, -4),
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
}
$this->syncMobileAuth($userId, $mobile, $now);
return $this->issueToken($userId, $request, 'sms_code');
}
public function loginByPassword(string $mobile, string $password, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$password = trim($password);
if ($password === '') {
throw new \RuntimeException('密码不能为空');
}
$user = Db::name('users')->where('mobile', $mobile)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$passwordHash = (string)($user['password'] ?? '');
if ($passwordHash === '') {
throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录');
}
if (!password_verify($password, $passwordHash)) {
throw new \RuntimeException('手机号或密码错误');
}
$this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s'));
return $this->issueToken((int)$user['id'], $request, 'password');
}
public function logout(Request $request): void
{
$token = $this->extractToken($request);
if ($token === '') {
return;
}
Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
}
public function current(Request $request): ?array
{
$token = $this->extractToken($request);
if ($token === '') {
return null;
}
$record = Db::name('user_api_tokens')
->where('token_hash', hash('sha256', $token))
->find();
if (!$record) {
return null;
}
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
Db::name('user_api_tokens')->where('id', $record['id'])->delete();
return null;
}
$user = Db::name('users')->where('id', $record['user_id'])->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
return null;
}
Db::name('user_api_tokens')->where('id', $record['id'])->update([
'last_active_at' => date('Y-m-d H:i:s'),
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->userInfo((int)$user['id']);
}
public function savePassword(int $userId, string $currentPassword, string $newPassword): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$currentHash = (string)($user['password'] ?? '');
$hadPassword = $currentHash !== '';
if ($currentHash !== '') {
if ($currentPassword === '') {
throw new \RuntimeException('请输入当前密码');
}
if (!password_verify($currentPassword, $currentHash)) {
throw new \RuntimeException('当前密码错误');
}
}
$this->validateNewPassword($newPassword);
Db::name('users')->where('id', $userId)->update([
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
'updated_at' => date('Y-m-d H:i:s'),
]);
return [
'user_id' => $userId,
'password_set' => true,
'had_password' => $hadPassword,
];
}
private function issueToken(int $userId, Request $request, string $authType): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$token = bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $token);
$now = date('Y-m-d H:i:s');
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
Db::name('user_api_tokens')->insert([
'user_id' => $userId,
'token_hash' => $tokenHash,
'auth_type' => $authType,
'expire_time' => $expireTime,
'last_active_at' => $now,
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('users')->where('id', $userId)->update([
'last_login_at' => $now,
'updated_at' => $now,
]);
return [
'token' => $token,
'user_info' => $this->userInfo($userId),
];
}
private function userInfo(int $userId): array
{
$user = Db::name('users')->where('id', $userId)->find();
return [
'id' => (int)($user['id'] ?? 0),
'nickname' => $user['nickname'] ?: '安心验用户',
'mobile' => $user['mobile'] ?? '',
'avatar' => $user['avatar'] ?? '',
'status' => $user['status'] ?? 'enabled',
'password_set' => ((string)($user['password'] ?? '')) !== '',
];
}
private function syncMobileAuth(int $userId, string $mobile, string $now): void
{
$existing = Db::name('user_auths')
->where('auth_type', 'mobile')
->where('auth_key', $mobile)
->find();
$payload = [
'user_id' => $userId,
'auth_type' => 'mobile',
'auth_key' => $mobile,
'auth_open_id' => '',
'auth_union_id' => '',
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', $existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function normalizeMobile(string $mobile): string
{
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
if (!preg_match('/^1\d{10}$/', $mobile)) {
throw new \RuntimeException('请输入正确的手机号');
}
return $mobile;
}
private function validateNewPassword(string $password): void
{
if (mb_strlen($password) < 8) {
throw new \RuntimeException('密码长度不能少于 8 位');
}
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) {
throw new \RuntimeException('密码需同时包含字母和数字');
}
}
private function codeHash(string $mobile, string $scene, string $code): string
{
return hash('sha256', implode('|', [$mobile, $scene, $code]));
}
private function systemConfig(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function extractToken(Request $request): string
{
$authorization = trim((string)$request->header('authorization', ''));
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
return trim($matches[1]);
}
return '';
}
private function ensurePasswordColumn(): void
{
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
if ($column) {
return;
}
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
}
private function ensureTokenTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS user_api_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
auth_type VARCHAR(32) NOT NULL DEFAULT 'password',
expire_time DATETIME NOT NULL,
last_active_at DATETIME NULL DEFAULT NULL,
last_ip VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_api_tokens_token_hash (token_hash),
KEY idx_user_api_tokens_user_id (user_id),
KEY idx_user_api_tokens_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token';
SQL);
}
private function ensureSmsCodeTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS sms_code_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
mobile VARCHAR(32) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'login',
code_hash VARCHAR(64) NOT NULL,
send_status VARCHAR(32) NOT NULL DEFAULT 'success',
provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms',
template_code VARCHAR(64) NOT NULL DEFAULT '',
request_id VARCHAR(128) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
failed_reason VARCHAR(255) NOT NULL DEFAULT '',
expire_time DATETIME NOT NULL,
used_at DATETIME NULL DEFAULT NULL,
send_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_sms_code_logs_mobile_scene (mobile, scene),
KEY idx_sms_code_logs_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录';
SQL);
}
private function truncateText(string $value, int $maxLength): string
{
if (mb_strlen($value) <= $maxLength) {
return $value;
}
return mb_substr($value, 0, $maxLength);
}
}