初始化本地仓库

This commit is contained in:
2026-08-31 11:33:59 +08:00
commit 663f74b633
2390 changed files with 402918 additions and 0 deletions
@@ -0,0 +1,705 @@
<?php
declare(strict_types=1);
namespace app\service\api\auth;
use app\dao\member\MemberDao;
use app\enum\common\EnabledStatus;
use app\model\member\Member;
use app\service\admin\system\ConfigService;
use core\base\BaseService;
use core\email\MailService;
use core\jwt\JwtToken;
use support\Container;
use support\Redis;
/**
* 认证服务
*/
class AuthService extends BaseService
{
public function __construct(MemberDao $dao){
$this->dao = $dao;
}
/**
* 用户登录
*
* @throws \Exception
*/
public function login(array $data): array
{
// 验证参数
if (empty($data['username']) || empty($data['password'])) {
throw new \Exception('用户名和密码不能为空', 400);
}
// 查找用户(支持用户名、手机号、邮箱登录)
$member = $this->dao->query()
->where(function ($query) use ($data) {
$query->where('username', $data['username'])
->orWhere('phone', $data['username'])
->orWhere('email', $data['username']);
})->first();
if (!$member) {
throw new \Exception('用户不存在', 401);
}
// 验证用户状态
if ($member->enabled !== EnabledStatus::ENABLED->value) {
throw new \Exception('用户已被禁用', 401);
}
// 验证密码
if (!$member->verifyPassword($data['password'])) {
throw new \Exception('密码错误', 401);
}
// 更新登录信息
$this->updateLoginInfo($member);
$userInfo = [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'client' => 'web',
'access_exp' => 7200,
'refresh_exp' => 604800,
];
// 使用新的 JwtToken 生成 token
$jwt = new JwtToken();
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
$token = [
'access_token' => $tokenObj->accessToken,
'refresh_token' => $tokenObj->refreshToken,
'expires_in' => $tokenObj->expiresIn
];
return array_merge($token, ['user_info' => $userInfo]);
}
/**
* 手机验证码登录
*/
public function loginWithMobile(array $data): array
{
if (empty($data['mobile']) || empty($data['code'])) {
throw new \Exception('手机号和验证码不能为空', 400);
}
// 验证验证码
$this->verifySmsCode($data['mobile'], $data['code']);
// 查找用户
$member = $this->dao->query()->where('phone', $data['mobile'])->first();
if (!$member) {
// 自动注册
$member = $this->autoRegisterWithMobile($data['mobile']);
}
// 验证用户状态
if ($member->enabled !== EnabledStatus::ENABLED->value) {
throw new \Exception('用户已被禁用', 401);
}
// 更新登录信息
$this->updateLoginInfo($member);
// 生成token
$userInfo = [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'client' => 'web',
'access_exp' => 7200,
'refresh_exp' => 604800,
];
// 使用新的 JwtToken 生成 token
$jwt = new JwtToken();
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
$token = [
'access_token' => $tokenObj->accessToken,
'refresh_token' => $tokenObj->refreshToken,
'expires_in' => $tokenObj->expiresIn
];
return [
'token' => $token,
'user_info' => [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'points' => $member->points,
'balance' => $member->balance,
],
];
}
/**
* 用户退出登录
*/
public function logout(): array
{
$request = \request();
$token = $request->header('Authorization');
if ($token) {
// 处理 token 前缀
if (str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7);
}
// 使用新的 JwtToken 实现退出登录
try {
(new JwtToken())->logout($token);
} catch (\Exception $e) {
// 忽略异常,确保退出成功
}
}
return [
'message' => '退出成功',
];
}
/**
* 用户注册
*/
public function register(array $data): array
{
// 创建用户
$memberData = [
'username' => $data['username'],
'password' => $data['password'],
'nickname' => $data['username'],
'phone' => $data['mobile'] ?? '',
'email' => $data['email'] ?? null,
'enabled' => EnabledStatus::ENABLED->value,
];
$member = $this->dao->save($memberData);
// 更新登录信息
$this->updateLoginInfo($member);
$userInfo = [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'client' => 'web',
'access_exp' => 7200,
'refresh_exp' => 604800,
];
// 使用新的 JwtToken 生成 token
$jwt = new JwtToken();
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
$token = [
'access_token' => $tokenObj->accessToken,
'refresh_token' => $tokenObj->refreshToken,
'expires_in' => $tokenObj->expiresIn
];
return array_merge($token, [
'user_info' => [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'points' => $member->points,
'balance' => $member->balance,
],
]);
}
/**
* 手机号注册
*/
public function registerWithMobile(array $data): array
{
if (empty($data['mobile']) || empty($data['code']) || empty($data['password'])) {
throw new \Exception('手机号、验证码和密码不能为空', 400);
}
// 验证验证码
$this->verifySmsCode($data['mobile'], $data['code']);
// 检查手机号是否已存在
if ($this->dao->query()->where('phone', $data['mobile'])->exists()) {
throw new \Exception('手机号已存在', 400);
}
// 创建用户
$memberData = [
'username' => 'user_' . substr($data['mobile'], -4),
'password' => $data['password'],
'nickname' => '用户_' . substr($data['mobile'], -4),
'phone' => $data['mobile'],
'enabled' => EnabledStatus::ENABLED->value,
];
$member = $this->dao->create($memberData);
return [
'user_id' => $member->id,
];
}
/**
* 绑定手机号
*/
public function bindMobile(array $data): array
{
if (empty($data['mobile']) || empty($data['code'])) {
throw new \Exception('手机号和验证码不能为空', 400);
}
// 验证验证码
$this->verifySmsCode($data['mobile'], $data['code']);
// 获取当前用户
$memberId = $this->getCurrentMemberId();
$member = $this->dao->find($memberId);
if (!$member) {
throw new \Exception('用户不存在', 401);
}
// 检查手机号是否已被其他用户绑定
$existingMember = $this->dao->query()
->where('phone', $data['mobile'])
->where('id', '!=', $memberId)
->first();
if ($existingMember) {
throw new \Exception('手机号已被其他用户绑定', 400);
}
// 更新手机号
$member->phone = $data['mobile'];
$member->save();
return [
'message' => '绑定成功',
];
}
/**
* 更新登录信息
*/
private function updateLoginInfo(Member $member): void
{
$member->last_login_time = time();
$member->last_login_ip = \request()->getRealIp();
$member->login_count = $member->login_count + 1;
$member->save();
}
/**
* 生成token
*/
private function generateToken(int $memberId): string
{
$token = md5(uniqid() . $memberId . microtime(true));
$expireTime = 7 * 24 * 3600; // 7天过期
// 存储token到Redis
Redis::setex('user_token:' . md5($token), $expireTime, json_encode([
'member_id' => $memberId,
'login_time' => time(),
]));
return $token;
}
/**
* 验证短信验证码
*/
private function verifySmsCode(string $mobile, string $code): void
{
$cacheKey = 'sms_code:' . $mobile;
$cachedCode = Redis::get($cacheKey);
if (!$cachedCode || $cachedCode !== $code) {
throw new \Exception('验证码错误或已过期', 400);
}
// 验证成功后删除验证码
Redis::del($cacheKey);
}
/**
* 验证邮箱和验证码(忘记密码第一步)
*
* @throws \Exception
*/
public function verifyEmail(array $data): array
{
// 验证参数
if (empty($data['email']) || empty($data['code'])) {
throw new \Exception('邮箱和验证码不能为空', 400);
}
// 验证验证码
if (isset($data['captcha_key']) && isset($data['captcha_code'])) {
$this->verifyCaptcha($data['captcha_key'], $data['captcha_code']);
}
// 检查邮箱是否存在
$member = $this->dao->query()->where('email', $data['email'])->first();
// 生成临时token用于第二步密码重置
$resetToken = $this->generateResetToken($member->id);
return [
'reset_token' => $resetToken,
'email' => $member->email,
];
}
/**
* 重置密码(忘记密码第二步)
*
* @throws \Exception
*/
public function forgetPassword(array $data): void
{
// 验证参数
if (empty($data['email']) || empty($data['code']) || empty($data['new_password'])) {
throw new \Exception('邮箱、验证码和新密码不能为空', 400);
}
// 验证新密码和确认密码是否一致
if (isset($data['confirm_password']) && $data['new_password'] !== $data['confirm_password']) {
throw new \Exception('两次输入的密码不一致', 400);
}
// 验证重置token(如果使用)
if (isset($data['reset_token'])) {
$this->validateResetToken($data['reset_token']);
}
// 查找用户
$member = $this->dao->query()->where('email', $data['email'])->first();
if (!$member) {
throw new \Exception('用户不存在', 404);
}
// 验证用户状态
if ($member->enabled !== EnabledStatus::ENABLED->value) {
throw new \Exception('用户已被禁用', 401);
}
// 验证邮箱验证码
$cacheKey = 'email_code:' . $data['email'];
$cachedCode = Redis::get($cacheKey);
if (!$cachedCode || $cachedCode !== $data['code']) {
throw new \Exception('邮箱验证码错误或已过期', 400);
}
// 更新密码
$member->password = $data['new_password'];
$member->save();
// 清除验证码
Redis::del($cacheKey);
}
/**
* 验证图片验证码
*
* @throws \Exception
*/
private function verifyCaptcha(string $key, string $code): void
{
$cachedCode = Redis::get('captcha:' . $key);
if (!$cachedCode || strtoupper($cachedCode) !== strtoupper($code)) {
throw new \Exception('验证码错误', -1);
}
// 验证成功后删除验证码
Redis::del('captcha:' . $key);
}
/**
* 生成密码重置token
*/
private function generateResetToken(int $memberId): string
{
$token = md5(uniqid() . $memberId . microtime(true));
// 存储token到Redis,有效期30分钟
Redis::setex('password_reset:' . $token, 1800, json_encode([
'member_id' => $memberId,
'expire' => time() + 1800,
]));
return $token;
}
/**
* 验证密码重置token
*
* @throws \Exception
*/
private function validateResetToken(string $token): void
{
$tokenData = Redis::get('password_reset:' . $token);
if (!$tokenData) {
throw new \Exception('重置链接已过期,请重新获取', 400);
}
$data = json_decode($tokenData, true);
if ($data['expire'] < time()) {
Redis::del('password_reset:' . $token);
throw new \Exception('重置链接已过期,请重新获取', 400);
}
}
/**
* 发送邮箱验证码
*
* @throws \Exception
*/
public function sendEmailCode(string $email): void
{
// 验证邮箱格式
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \Exception('邮箱格式不正确', 400);
}
// 检查邮箱是否存在
$member = $this->dao->query()->where('email', $email)->first();
if (!$member) {
throw new \Exception('该邮箱未注册', 404);
}
// 检查是否频繁发送
$cacheKey = 'email_code_limit:' . $email;
$lastSend = Redis::get($cacheKey);
if ($lastSend && (time() - $lastSend) < 60) {
throw new \Exception('发送过于频繁,请稍后再试', 400);
}
// 生成验证码
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// 存储验证码到Redis,有效期5分钟
Redis::setex('email_code:' . $email, 300, $code);
// 记录发送时间
Redis::setex('email_code_limit:' . $email, 60, time());
// 获取系统配置
$configService = Container::make(ConfigService::class);
$siteConfig = $configService->config('web_site_setting');
$siteName = $siteConfig['site_name'] ?? 'madong.tech';
// 构建邮件主题和内容
$subject = $siteName . '社区找回密码';
$content = $this->buildResetPasswordEmailTemplate($code, $member->nickname ?? $member->username);
// 发送邮件
/** @var MailService $mailService */
$mailService = Container::make(MailService::class);
$mailService->send($email, $subject, $content);
}
/**
* 构建密码重置邮件模板
*
* @param string $code 验证码
* @param string $nickname 用户昵称
*
* @return string HTML邮件内容
*/
private function buildResetPasswordEmailTemplate(string $code, string $nickname): string
{
// 获取系统配置
$configService = Container::make(ConfigService::class);
$siteConfig = $configService->config('web_site_setting');
$siteName = $siteConfig['site_name'] ?? "madong.tech";
$year = date('Y');
return <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>密码重置验证码</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f5f5f5;
margin: 0;
padding: 20px;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.email-header {
background-color: #f8f9fa;
padding: 30px 40px;
border-bottom: 1px solid #e9ecef;
}
.email-header h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #2c3e50;
}
.email-body {
padding: 40px;
}
.greeting {
font-size: 16px;
margin-bottom: 20px;
color: #333333;
}
.message {
font-size: 14px;
margin-bottom: 30px;
line-height: 1.6;
color: #555555;
}
.code-section {
background-color: #f8f9fa;
border-left: 4px solid #4a90e2;
padding: 20px;
margin-bottom: 30px;
border-radius: 0 4px 4px 0;
}
.code-label {
font-size: 14px;
color: #666666;
margin-bottom: 10px;
font-weight: 500;
}
.code {
font-size: 28px;
font-weight: 700;
color: #2c3e50;
font-family: 'Courier New', monospace;
letter-spacing: 2px;
}
.expiry-notice {
font-size: 12px;
color: #999999;
margin-top: 10px;
}
.security-tip {
background-color: #e8f4f8;
padding: 15px;
border-radius: 4px;
margin-bottom: 30px;
}
.security-tip h3 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 600;
color: #2c3e50;
}
.security-tip ul {
margin: 0;
padding-left: 20px;
font-size: 12px;
color: #666666;
line-height: 1.5;
}
.email-footer {
background-color: #f8f9fa;
padding: 30px 40px;
border-top: 1px solid #e9ecef;
font-size: 12px;
color: #999999;
}
.site-name {
font-weight: 600;
color: #4a90e2;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
<h1>密码重置验证</h1>
</div>
<div class="email-body">
<div class="greeting">尊敬的 {$nickname},</div>
<div class="message">
您正在请求重置 <span class="site-name">{$siteName}</span> 账户的密码。请使用以下验证码完成身份验证:
</div>
<div class="code-section">
<div class="code-label">验证码</div>
<div class="code">{$code}</div>
<div class="expiry-notice">验证码有效期为 5 分钟</div>
</div>
<div class="security-tip">
<h3>安全提示</h3>
<ul>
<li>请勿将此验证码透露给任何第三方</li>
<li>如果您没有发起此请求,请忽略此邮件</li>
<li>为了账户安全,建议定期更换密码</li>
</ul>
</div>
</div>
<div class="email-footer">
<p>此邮件由 <span class="site-name">{$siteName}</span> 系统自动发送,请勿直接回复</p>
<p>© {$year} {$siteName}. All rights reserved.</p>
</div>
</div>
</body>
</html>
HTML;
}
/**
* 通过手机号自动注册
*/
private function autoRegisterWithMobile(string $mobile): Member
{
$memberData = [
'username' => 'user_' . substr($mobile, -4),
'password' => substr(md5(uniqid()), 0, 8),
'nickname' => '用户_' . substr($mobile, -4),
'phone' => $mobile,
'enabled' => EnabledStatus::ENABLED->value,
];
return $this->dao->create($memberData);
}
/**
* 获取当前用户ID
*/
private function getCurrentMemberId(): int
{
try {
// 使用新的 JwtToken 获取当前用户ID
$jwt = new JwtToken();
$mid = $jwt->id();
return $mid ?? 0;
} catch (\Exception $e) {
throw new \Exception('登录已过期', 401);
}
}
}
@@ -0,0 +1,672 @@
<?php
declare(strict_types=1);
namespace app\service\api\auth;
use app\api\CurrentMember;
use app\dao\member\MemberThirdPartyDao;
use app\enum\member\ThirdPartyPlatform;
use core\base\BaseService;
use support\Container;
use support\Redis;
/**
* 第三方认证服务
*/
class ThirdPartyAuthService extends BaseService
{
public function __construct(private readonly MemberThirdPartyDao $thirdPartyDao)
{
}
/**
* 获取第三方绑定列表
*/
public function getThirdPartyList(): array
{
$currentMember = Container::make(CurrentMember::class);
$memberId = $currentMember->id();
if (empty($memberId)) {
throw new \Exception('用户未登录', 401);
}
$thirdParties = $this->thirdPartyDao->getMemberThirdParties($memberId);
$platforms = [
[
'type' => 'qq',
'name' => 'QQ',
'platform' => ThirdPartyPlatform::QQ->value,
'binded' => false,
],
[
'type' => 'wechat',
'name' => '微信',
'platform' => ThirdPartyPlatform::WECHAT->value,
'binded' => false,
],
[
'type' => 'douyin',
'name' => '抖音',
'platform' => ThirdPartyPlatform::DOUYIN->value,
'binded' => false,
],
[
'type' => 'weibo',
'name' => '微博',
'platform' => ThirdPartyPlatform::WEIBO->value,
'binded' => false,
],
];
foreach ($thirdParties as $party) {
foreach ($platforms as &$platform) {
if ($platform['platform'] == $party['platform']) {
$platform['binded'] = true;
$platform['info'] = [
'account' => $party['nickname'] ?? $party['openid'] ?? '',
'avatar' => $party['avatar'] ?? '',
'gender' => $party['gender'] ?? 0,
'openid' => $party['openid'] ?? '',
];
$bindTime = $party['updated_at'] ?? '';
if ($bindTime) {
$platform['bind_time'] = date('Y-m-d H:i:s', strtotime($bindTime));
} else {
$platform['bind_time'] = '';
}
break;
}
}
}
return $platforms;
}
/**
* 绑定第三方账号
*
* @throws \Exception
*/
public function bindThirdParty(array $data): void
{
if (empty($data['platform']) || empty($data['openid'])) {
throw new \Exception('平台和OpenID不能为空', 400);
}
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
if (!$platform) {
throw new \Exception('无效的平台类型', 400);
}
$currentMember = Container::make(CurrentMember::class);
$memberId = $currentMember->id();
if (empty($memberId)) {
throw new \Exception('用户未登录', 401);
}
if ($this->thirdPartyDao->isBound($platform->value, $data['openid'])) {
throw new \Exception('该第三方账号已被其他用户绑定', 400);
}
$bindData = [
'openid' => $data['openid'],
'unionid' => $data['unionid'] ?? '',
'nickname' => $data['nickname'] ?? '',
'avatar' => $data['avatar'] ?? '',
'gender' => $data['gender'] ?? 0,
'country' => $data['country'] ?? '',
'province' => $data['province'] ?? '',
'city' => $data['city'] ?? '',
'access_token' => $data['access_token'] ?? '',
'refresh_token' => $data['refresh_token'] ?? '',
];
$this->thirdPartyDao->bind($memberId, $platform->value, $data['openid'], $bindData);
}
/**
* 解绑第三方账号
*
* @throws \Exception
*/
public function unbindThirdParty(int $platform): void
{
$platformEnum = ThirdPartyPlatform::tryFrom($platform);
if (!$platformEnum) {
throw new \Exception('无效的平台类型', 400);
}
$currentMember = Container::make(CurrentMember::class);
$memberId = $currentMember->id();
if (empty($memberId)) {
throw new \Exception('用户未登录', 401);
}
$thirdParty = $this->thirdPartyDao->findByMemberAndPlatform($memberId, $platform);
if (!$thirdParty) {
throw new \Exception('未找到绑定记录', 404);
}
$this->thirdPartyDao->unbind($thirdParty);
}
/**
* 生成第三方绑定二维码
*
* @throws \Exception
*/
public function generateBindQrCode(array $data): array
{
if (empty($data['platform'])) {
throw new \Exception('平台不能为空');
}
$platform = ThirdPartyPlatform::tryFrom((int)$data['platform']);
if (!$platform) {
throw new \Exception('无效的平台类型');
}
$currentMember = Container::make(CurrentMember::class);
$memberId = $currentMember->id();
if (empty($memberId)) {
throw new \Exception('用户未登录', 401);
}
$sceneId = 'bind_' . $platform->value . '_' . $memberId . '_' . time() . '_' . mt_rand(1000, 9999);
$bindUrl = $this->buildBindUrl($platform->value, $sceneId);
$bindInfo = [
'scene_id' => $sceneId,
'member_id' => $memberId,
'platform' => $platform->value,
'status' => 'pending',
'created_at' => time(),
];
Redis::setex('third_party_bind:' . $sceneId, 300, json_encode($bindInfo));
return [
'scene_id' => $sceneId,
'qr_code' => $bindUrl,
'expires_in' => 300,
];
}
/**
* 检查绑定二维码状态
*
* @throws \Exception
*/
public function checkBindQrStatus(string $sceneId): array
{
if (empty($sceneId)) {
throw new \Exception('场景ID不能为空', 400);
}
$cacheKey = 'third_party_bind:' . $sceneId;
$bindInfo = Redis::get($cacheKey);
if (!$bindInfo) {
throw new \Exception('二维码已过期或不存在', 400);
}
$bindInfo = json_decode($bindInfo, true);
return [
'scene_id' => $sceneId,
'status' => $bindInfo['status'] ?? 'pending',
'platform' => $bindInfo['platform'] ?? 0,
];
}
/**
* 构建绑定URL
*/
private function buildBindUrl(int $platform, string $sceneId): string
{
$baseUrl = config('app.url', 'http://localhost');
$bindUrls = [
1 => 'https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=' . config('third_party.qq.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/qq/callback?scene=' . $sceneId),
2 => 'https://open.weixin.qq.com/connect/qrconnect?appid=' . config('third_party.wechat.app_id', '') . '&redirect_uri=' . urlencode($baseUrl . '/api/auth/wechat/callback?scene=' . $sceneId) . '&response_type=code&scope=snsapi_login',
3 => 'https://api.weibo.com/oauth2/authorize?client_id=' . config('third_party.weibo.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/weibo/callback?scene=' . $sceneId),
4 => 'https://open.douyin.com/oauth2/authorize?client_key=' . config('third_party.douyin.app_id', '') . '&response_type=code&redirect_uri=' . urlencode($baseUrl . '/api/auth/douyin/callback?scene=' . $sceneId) . '&scope=user_info',
];
$authUrl = $bindUrls[$platform] ?? '';
if (empty($authUrl)) {
return '';
}
return 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' . urlencode($authUrl);
}
/**
* 处理QQ回调
*/
public function handleQqCallback(string $scene, string $code): void
{
$bindInfo = $this->getBindInfo($scene);
if (!$bindInfo) {
throw new \Exception('二维码已过期或不存在', 400);
}
$userInfo = $this->getQqUserInfo($code);
if (!$userInfo) {
throw new \Exception('获取QQ用户信息失败', 400);
}
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::QQ->value, $userInfo);
$this->updateBindStatus($scene, 'success');
}
/**
* 处理微信回调
*/
public function handleWechatCallback(string $scene, string $code): void
{
$bindInfo = $this->getBindInfo($scene);
if (!$bindInfo) {
throw new \Exception('二维码已过期或不存在', 400);
}
$userInfo = $this->getWechatUserInfo($code);
if (!$userInfo) {
throw new \Exception('获取微信用户信息失败', 400);
}
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::WECHAT->value, $userInfo);
$this->updateBindStatus($scene, 'success');
}
/**
* 处理微博回调
*/
public function handleWeiboCallback(string $scene, string $code): void
{
$bindInfo = $this->getBindInfo($scene);
if (!$bindInfo) {
throw new \Exception('二维码已过期或不存在', 400);
}
$userInfo = $this->getWeiboUserInfo($code);
if (!$userInfo) {
throw new \Exception('获取微博用户信息失败', 400);
}
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::WEIBO->value, $userInfo);
$this->updateBindStatus($scene, 'success');
}
/**
* 处理抖音回调
*/
public function handleDouyinCallback(string $scene, string $code): void
{
$bindInfo = $this->getBindInfo($scene);
if (!$bindInfo) {
throw new \Exception('二维码已过期或不存在', 400);
}
$userInfo = $this->getDouyinUserInfo($code);
if (!$userInfo) {
throw new \Exception('获取抖音用户信息失败', 400);
}
$this->saveThirdPartyBind($bindInfo['member_id'], ThirdPartyPlatform::DOUYIN->value, $userInfo);
$this->updateBindStatus($scene, 'success');
}
/**
* 获取绑定信息
*/
private function getBindInfo(string $scene): ?array
{
$cacheKey = 'third_party_bind:' . $scene;
$bindInfo = Redis::get($cacheKey);
if (!$bindInfo) {
return null;
}
return json_decode($bindInfo, true);
}
/**
* 更新绑定状态
*/
private function updateBindStatus(string $scene, string $status): void
{
$cacheKey = 'third_party_bind:' . $scene;
$bindInfo = Redis::get($cacheKey);
if ($bindInfo) {
$bindInfo = json_decode($bindInfo, true);
$bindInfo['status'] = $status;
Redis::setex($cacheKey, 300, json_encode($bindInfo));
}
}
/**
* 保存第三方绑定信息
*/
private function saveThirdPartyBind(int $memberId, int $platform, array $userInfo): void
{
$bindData = [
'openid' => $userInfo['openid'] ?? '',
'unionid' => $userInfo['unionid'] ?? '',
'nickname' => $userInfo['nickname'] ?? '',
'avatar' => $userInfo['avatar'] ?? '',
'gender' => $userInfo['gender'] ?? 0,
'country' => $userInfo['country'] ?? '',
'province' => $userInfo['province'] ?? '',
'city' => $userInfo['city'] ?? '',
'access_token' => $userInfo['access_token'] ?? '',
'refresh_token' => $userInfo['refresh_token'] ?? '',
];
$this->thirdPartyDao->bind($memberId, $platform, $bindData['openid'], $bindData);
}
/**
* 获取QQ用户信息
*/
private function getQqUserInfo(string $code): ?array
{
$appId = config('third_party.qq.app_id', '');
$appSecret = config('third_party.qq.app_secret', '');
$redirectUri = config('third_party.qq.redirect_uri', '');
if (empty($appId) || empty($appSecret)) {
throw new \Exception('QQ配置不完整', 400);
}
$tokenUrl = "https://graph.qq.com/oauth2.0/token";
$params = [
'grant_type' => 'authorization_code',
'client_id' => $appId,
'client_secret' => $appSecret,
'code' => $code,
'redirect_uri' => $redirectUri,
];
$tokenResponse = $this->httpGet($tokenUrl, $params);
if (strpos($tokenResponse, 'access_token') === false) {
return null;
}
parse_str($tokenResponse, $tokenData);
$accessToken = $tokenData['access_token'] ?? '';
$openIdUrl = "https://graph.qq.com/oauth2.0/me?access_token={$accessToken}";
$openIdResponse = $this->httpGet($openIdUrl);
if (strpos($openIdResponse, 'openid') === false) {
return null;
}
$openIdData = json_decode(str_replace('callback(', '', str_replace(');', '', $openIdResponse)), true);
$openid = $openIdData['openid'] ?? '';
$userInfoUrl = "https://graph.qq.com/user/get_user_info";
$userParams = [
'access_token' => $accessToken,
'oauth_consumer_key' => $appId,
'openid' => $openid,
];
$userResponse = $this->httpGet($userInfoUrl, $userParams);
$userData = json_decode($userResponse, true);
if ($userData['ret'] != 0) {
return null;
}
return [
'openid' => $openid,
'unionid' => '',
'nickname' => $userData['nickname'] ?? '',
'avatar' => $userData['figureurl_qq_2'] ?? '',
'gender' => $this->convertGender($userData['gender'] ?? ''),
'country' => '',
'province' => $userData['province'] ?? '',
'city' => $userData['city'] ?? '',
'access_token' => $accessToken,
'refresh_token' => '',
];
}
/**
* 获取微信用户信息
*/
private function getWechatUserInfo(string $code): ?array
{
$appId = config('third_party.wechat.app_id', '');
$appSecret = config('third_party.wechat.app_secret', '');
if (empty($appId) || empty($appSecret)) {
throw new \Exception('微信配置不完整', 400);
}
$tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token";
$params = [
'appid' => $appId,
'secret' => $appSecret,
'code' => $code,
'grant_type' => 'authorization_code',
];
$tokenResponse = $this->httpGet($tokenUrl, $params);
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['errcode'])) {
return null;
}
$accessToken = $tokenData['access_token'] ?? '';
$openid = $tokenData['openid'] ?? '';
$unionid = $tokenData['unionid'] ?? '';
$userInfoUrl = "https://api.weixin.qq.com/sns/userinfo";
$userParams = [
'access_token' => $accessToken,
'openid' => $openid,
];
$userResponse = $this->httpGet($userInfoUrl, $userParams);
$userData = json_decode($userResponse, true);
if (isset($userData['errcode'])) {
return null;
}
return [
'openid' => $openid,
'unionid' => $unionid,
'nickname' => $userData['nickname'] ?? '',
'avatar' => $userData['headimgurl'] ?? '',
'gender' => $userData['sex'] ?? 0,
'country' => $userData['country'] ?? '',
'province' => $userData['province'] ?? '',
'city' => $userData['city'] ?? '',
'access_token' => $accessToken,
'refresh_token' => $tokenData['refresh_token'] ?? '',
];
}
/**
* 获取微博用户信息
*/
private function getWeiboUserInfo(string $code): ?array
{
$appId = config('third_party.weibo.app_id', '');
$appSecret = config('third_party.weibo.app_secret', '');
$redirectUri = config('third_party.weibo.redirect_uri', '');
if (empty($appId) || empty($appSecret)) {
throw new \Exception('微博配置不完整', 400);
}
$tokenUrl = "https://api.weibo.com/oauth2/access_token";
$params = [
'client_id' => $appId,
'client_secret' => $appSecret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri,
];
$tokenResponse = $this->httpPost($tokenUrl, $params);
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['error'])) {
return null;
}
$accessToken = $tokenData['access_token'] ?? '';
$uid = $tokenData['uid'] ?? '';
$userInfoUrl = "https://api.weibo.com/2/users/show.json";
$userParams = [
'access_token' => $accessToken,
'uid' => $uid,
];
$userResponse = $this->httpGet($userInfoUrl, $userParams);
$userData = json_decode($userResponse, true);
if (isset($userData['error'])) {
return null;
}
return [
'openid' => $uid,
'unionid' => '',
'nickname' => $userData['screen_name'] ?? '',
'avatar' => $userData['avatar_large'] ?? '',
'gender' => $this->convertGender($userData['gender'] ?? ''),
'country' => '',
'province' => $userData['province'] ?? '',
'city' => $userData['city'] ?? '',
'access_token' => $accessToken,
'refresh_token' => '',
];
}
/**
* 获取抖音用户信息
*/
private function getDouyinUserInfo(string $code): ?array
{
$appId = config('third_party.douyin.app_id', '');
$appSecret = config('third_party.douyin.app_secret', '');
if (empty($appId) || empty($appSecret)) {
throw new \Exception('抖音配置不完整', 400);
}
$tokenUrl = "https://open.douyin.com/oauth/access_token/";
$params = [
'client_key' => $appId,
'client_secret' => $appSecret,
'code' => $code,
'grant_type' => 'authorization_code',
];
$tokenResponse = $this->httpPost($tokenUrl, $params);
$tokenData = json_decode($tokenResponse, true);
if (isset($tokenData['error_code'])) {
return null;
}
$accessToken = $tokenData['access_token'] ?? '';
$openId = $tokenData['open_id'] ?? '';
$userInfoUrl = "https://open.douyin.com/oauth/userinfo/";
$userParams = [
'access_token' => $accessToken,
'open_id' => $openId,
];
$userResponse = $this->httpGet($userInfoUrl, $userParams);
$userData = json_decode($userResponse, true);
if (isset($userData['error_code'])) {
return null;
}
return [
'openid' => $openId,
'unionid' => $userData['union_id'] ?? '',
'nickname' => $userData['nickname'] ?? '',
'avatar' => $userData['avatar'] ?? '',
'gender' => $userData['gender'] ?? 0,
'country' => $userData['country'] ?? '',
'province' => $userData['province'] ?? '',
'city' => $userData['city'] ?? '',
'access_token' => $accessToken,
'refresh_token' => $tokenData['refresh_token'] ?? '',
];
}
/**
* 转换性别
*/
private function convertGender(string $gender): int
{
$genderMap = [
'男' => 1,
'女' => 2,
'm' => 1,
'f' => 2,
'male' => 1,
'female' => 2,
];
return $genderMap[$gender] ?? 0;
}
/**
* HTTP GET请求
*/
private function httpGet(string $url, array $params = []): string
{
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
curl_close($ch);
return $response ?: '';
}
/**
* HTTP POST请求
*/
private function httpPost(string $url, array $params = []): string
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
$response = curl_exec($ch);
curl_close($ch);
return $response ?: '';
}
}
@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace app\service\api\auth;
use app\model\member\Member;
use app\model\member\MemberThirdParty;
use app\enum\common\EnabledStatus;
use app\enum\member\ThirdPartyType;
use core\base\BaseService;
use core\jwt\JwtToken;
use support\Redis;
/**
* 微信认证服务
*/
class WechatService extends BaseService
{
/**
* 微信授权登录
*/
public function wechatLogin(array $data): array
{
if (empty($data['code'])) {
throw new \Exception('授权码不能为空', 400);
}
// 获取微信用户信息
$wechatUserInfo = $this->getWechatUserInfo($data['code'], $data['encrypted_data'] ?? '', $data['iv'] ?? '');
// 查找或创建用户
$member = $this->findOrCreateMemberByWechat($wechatUserInfo);
// 验证用户状态
if ($member->enabled !== EnabledStatus::ENABLED->value) {
throw new \Exception('用户已被禁用', 401);
}
// 更新登录信息
$this->updateLoginInfo($member);
// 生成token
$token = $this->generateToken($member->id);
return [
'code' => 200,
'msg' => '登录成功',
'data' => [
'token' => $token,
'user_info' => [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'points' => $member->points,
'balance' => $member->balance,
],
],
];
}
/**
* 微信小程序登录
*/
public function weappLogin(array $data): array
{
if (empty($data['code'])) {
throw new \Exception('登录code不能为空', 400);
}
// 获取小程序用户信息
$weappUserInfo = $this->getWeappUserInfo($data['code'], $data['encrypted_data'] ?? '', $data['iv'] ?? '');
// 查找或创建用户
$member = $this->findOrCreateMemberByWeapp($weappUserInfo);
// 验证用户状态
if ($member->enabled !== EnabledStatus::ENABLED->value) {
throw new \Exception('用户已被禁用', 401);
}
// 更新登录信息
$this->updateLoginInfo($member);
// 生成token
$token = $this->generateToken($member->id);
return [
'code' => 200,
'msg' => '登录成功',
'data' => [
'token' => $token,
'user_info' => [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'points' => $member->points,
'balance' => $member->balance,
],
],
];
}
/**
* 生成微信扫码登录二维码
*/
public function generateWechatQrCode(): array
{
$sceneId = uniqid('wechat_scan_');
$qrCodeUrl = $this->generateQrCodeUrl($sceneId);
// 存储扫码状态
Redis::setex('wechat_scan:' . $sceneId, 300, json_encode([
'status' => 'waiting',
'created_at' => time(),
]));
return [
'scene_id' => $sceneId,
'qr_code_url' => $qrCodeUrl,
'expire_time' => 300,
];
}
/**
* 检查微信扫码状态
*/
public function checkWechatScanStatus(string $sceneId): array
{
if (empty($sceneId)) {
throw new \Exception('场景ID不能为空');
}
$scanData = Redis::get('wechat_scan:' . $sceneId);
if (!$scanData) {
throw new \Exception('二维码已过期');
}
$data = json_decode($scanData, true);
if ($data['status'] === 'scanned') {
// 扫码成功,返回用户信息
$memberId = $data['member_id'] ?? 0;
$member = Member::find($memberId);
if ($member) {
// 更新登录信息
$this->updateLoginInfo($member);
// 生成token
$userInfo = [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
'phone' => $member->phone,
'email' => $member->email,
'client' => 'web',
'access_exp' => 7200,
'refresh_exp' => 604800,
];
// 使用新的 JwtToken 生成 token
$jwt = new JwtToken();
$tokenObj = $jwt->generate((string)$member->id, 'api', $userInfo);
$token = [
'access_token' => $tokenObj->accessToken,
'refresh_token' => $tokenObj->refreshToken,
'expires_in' => $tokenObj->expiresIn
];
// 删除扫码记录
Redis::del('wechat_scan:' . $sceneId);
$msg = '扫描成功';
$data = [
'status' => 'success',
'token' => $token,
'user_info' => [
'id' => $member->id,
'username' => $member->username,
'nickname' => $member->nickname,
'avatar' => $member->avatar,
],
];
return compact('msg', 'data');
}
}
$msg = '查询成功';
$data = [
'status' => $data['status'],
];
return compact('msg', 'data');
}
/**
* 检查微信登录可用性
*/
public function checkWechatAvailability(): array
{
$config = config('wechat');
return [
'wechat_enabled' => !empty($config['app_id']) && !empty($config['app_secret']),
'weapp_enabled' => !empty($config['mini_program']['app_id']) && !empty($config['mini_program']['app_secret']),
];
}
/**
* 获取微信用户信息
*/
private function getWechatUserInfo(string $code, string $encryptedData = '', string $iv = ''): array
{
$config = config('wechat');
if (empty($config['app_id']) || empty($config['app_secret'])) {
throw new \Exception('微信配置未完成', 500);
}
// 获取access_token
$accessToken = $this->getWechatAccessToken($code);
// 获取用户信息
$userInfo = $this->getWechatUserInfoByToken($accessToken);
return [
'openid' => $userInfo['openid'] ?? '',
'unionid' => $userInfo['unionid'] ?? '',
'nickname' => $userInfo['nickname'] ?? '',
'avatar' => $userInfo['headimgurl'] ?? '',
'gender' => $userInfo['sex'] ?? 0,
'type' => ThirdPartyType::WECHAT->value,
];
}
/**
* 获取小程序用户信息
*/
private function getWeappUserInfo(string $code, string $encryptedData = '', string $iv = ''): array
{
$config = config('wechat.mini_program');
if (empty($config['app_id']) || empty($config['app_secret'])) {
throw new \Exception('小程序配置未完成', 500);
}
// 获取session_key
$sessionInfo = $this->getWeappSession($code);
// 解密用户信息
$userInfo = $this->decryptWeappData($encryptedData, $sessionInfo['session_key'], $iv);
return [
'openid' => $sessionInfo['openid'] ?? '',
'unionid' => $sessionInfo['unionid'] ?? '',
'nickname' => $userInfo['nickName'] ?? '',
'avatar' => $userInfo['avatarUrl'] ?? '',
'gender' => $userInfo['gender'] ?? 0,
'type' => ThirdPartyType::WEAPP->value,
];
}
/**
* 根据微信信息查找或创建用户
*/
private function findOrCreateMemberByWechat(array $wechatInfo): Member
{
// 查找第三方绑定
$thirdParty = MemberThirdParty::where('openid', $wechatInfo['openid'])
->where('type', ThirdPartyType::WECHAT->value)
->first();
if ($thirdParty) {
// 返回已绑定的用户
return Member::find($thirdParty->member_id);
}
// 创建新用户
$memberData = [
'username' => 'wx_' . substr($wechatInfo['openid'], -8),
'password' => substr(md5(uniqid()), 0, 8),
'nickname' => $wechatInfo['nickname'] ?? '微信用户',
'avatar' => $wechatInfo['avatar'] ?? '',
'enabled' => EnabledStatus::ENABLED->value,
];
$member = Member::create($memberData);
// 创建第三方绑定
MemberThirdParty::create([
'member_id' => $member->id,
'type' => ThirdPartyType::WECHAT->value,
'openid' => $wechatInfo['openid'],
'unionid' => $wechatInfo['unionid'] ?? '',
'nickname' => $wechatInfo['nickname'] ?? '',
'avatar' => $wechatInfo['avatar'] ?? '',
]);
return $member;
}
/**
* 根据小程序信息查找或创建用户
*/
private function findOrCreateMemberByWeapp(array $weappInfo): Member
{
// 查找第三方绑定
$thirdParty = MemberThirdParty::where('openid', $weappInfo['openid'])
->where('type', ThirdPartyType::WEAPP->value)
->first();
if ($thirdParty) {
// 返回已绑定的用户
return Member::find($thirdParty->member_id);
}
// 创建新用户
$memberData = [
'username' => 'wxapp_' . substr($weappInfo['openid'], -8),
'password' => substr(md5(uniqid()), 0, 8),
'nickname' => $weappInfo['nickname'] ?? '小程序用户',
'avatar' => $weappInfo['avatar'] ?? '',
'enabled' => EnabledStatus::ENABLED->value,
];
$member = Member::create($memberData);
// 创建第三方绑定
MemberThirdParty::create([
'member_id' => $member->id,
'type' => ThirdPartyType::WEAPP->value,
'openid' => $weappInfo['openid'],
'unionid' => $weappInfo['unionid'] ?? '',
'nickname' => $weappInfo['nickname'] ?? '',
'avatar' => $weappInfo['avatar'] ?? '',
]);
return $member;
}
/**
* 生成二维码URL
*/
private function generateQrCodeUrl(string $sceneId): string
{
$config = config('wechat');
$appId = $config['app_id'] ?? '';
// 这里应该调用微信API生成二维码,暂时返回模拟URL
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={$sceneId}";
}
/**
* 获取微信access_token
*/
private function getWechatAccessToken(string $code): string
{
// 这里应该调用微信API获取access_token
// 暂时返回模拟数据
return 'mock_access_token_' . $code;
}
/**
* 根据token获取微信用户信息
*/
private function getWechatUserInfoByToken(string $accessToken): array
{
// 这里应该调用微信API获取用户信息
// 暂时返回模拟数据
return [
'openid' => 'mock_openid_' . uniqid(),
'nickname' => '微信用户',
'headimgurl' => '',
'sex' => 0,
];
}
/**
* 获取小程序session
*/
private function getWeappSession(string $code): array
{
// 这里应该调用微信小程序API获取session
// 暂时返回模拟数据
return [
'openid' => 'mock_weapp_openid_' . uniqid(),
'session_key' => 'mock_session_key',
];
}
/**
* 解密小程序数据
*/
private function decryptWeappData(string $encryptedData, string $sessionKey, string $iv): array
{
// 这里应该解密小程序数据
// 暂时返回模拟数据
return [
'nickName' => '小程序用户',
'avatarUrl' => '',
'gender' => 0,
];
}
/**
* 更新登录信息
*/
private function updateLoginInfo(Member $member): void
{
$member->last_login_time = date('Y-m-d H:i:s');
$member->last_login_ip = \request()->getRealIp();
$member->login_count = $member->login_count + 1;
$member->save();
}
/**
* 生成token
*/
private function generateToken(int $memberId): string
{
$token = md5(uniqid() . $memberId . microtime(true));
$expireTime = 7 * 24 * 3600; // 7天过期
// 存储token到Redis
Redis::setex('user_token:' . md5($token), $expireTime, json_encode([
'member_id' => $memberId,
'login_time' => time(),
]));
return $token;
}
}