360 lines
10 KiB
PHP
360 lines
10 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use App\Models\User;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Illuminate\Support\Str;
|
||
|
||
class WeChatService
|
||
{
|
||
private string $appId;
|
||
private string $appSecret;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->appId = config('services.wechat_open.app_id', env('WECHAT_OPEN_APP_ID', ''));
|
||
$this->appSecret = config('services.wechat_open.app_secret', env('WECHAT_OPEN_APP_SECRET', ''));
|
||
}
|
||
|
||
/**
|
||
* 获取微信登录二维码
|
||
*
|
||
* @return array ['success' => bool, 'data' => ['scene_str' => string, 'qr_url' => string, 'ticket' => string]]
|
||
*/
|
||
public function getLoginQr(): array
|
||
{
|
||
if (!$this->isConfigured()) {
|
||
// 未配置时返回模拟二维码
|
||
return $this->getMockQr();
|
||
}
|
||
|
||
try {
|
||
// 获取 Access Token (用于生成二维码)
|
||
$accessToken = $this->getAccessToken();
|
||
if (!$accessToken) {
|
||
return ['success' => false, 'message' => '获取access_token失败'];
|
||
}
|
||
|
||
// 创建二维码 ticket
|
||
$sceneStr = 'wx_' . Str::random(16);
|
||
$expireSeconds = 300; // 5分钟
|
||
|
||
$response = Http::post("https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token={$accessToken}", [
|
||
'expire_seconds' => $expireSeconds,
|
||
'action_name' => 'QR_STR',
|
||
'action_info' => [
|
||
'scene' => [
|
||
'scene_str' => $sceneStr
|
||
]
|
||
]
|
||
]);
|
||
|
||
$result = $response->json();
|
||
|
||
if (isset($result['ticket'])) {
|
||
$qrUrl = "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" . urlencode($result['ticket']);
|
||
|
||
// 缓存 ticket -> scene_str 映射,用于后续验证
|
||
Cache::put("wechat_qr:{$result['ticket']}", [
|
||
'scene_str' => $sceneStr,
|
||
'created_at' => time(),
|
||
], $expireSeconds);
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'scene_str' => $sceneStr,
|
||
'qr_url' => $qrUrl,
|
||
'ticket' => $result['ticket'],
|
||
]
|
||
];
|
||
}
|
||
|
||
return ['success' => false, 'message' => $result['errmsg'] ?? '创建二维码失败'];
|
||
|
||
} catch (\Exception $e) {
|
||
Log::error('微信获取二维码异常: ' . $e->getMessage());
|
||
return ['success' => false, 'message' => '微信服务异常'];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过 ticket 换取二维码链接
|
||
*/
|
||
public function getQrUrlByTicket(string $ticket): ?string
|
||
{
|
||
try {
|
||
$response = Http::get("https://mp.weixin.qq.com/cgi-bin/showqrcode", [
|
||
'ticket' => $ticket
|
||
]);
|
||
|
||
if ($response->status() === 200) {
|
||
return $response->header('Location') ?? null;
|
||
}
|
||
} catch (\Exception $e) {
|
||
Log::error('获取二维码URL失败: ' . $e->getMessage());
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 轮询检查扫码状态 (模拟 - 实际项目中微信需要使用被动回调)
|
||
*
|
||
* @param string $sceneStr 场景字符串
|
||
* @return array
|
||
*/
|
||
public function checkScanStatus(string $sceneStr): array
|
||
{
|
||
// 实际项目中,微信扫码后是回调到开发者服务器
|
||
// 这里提供模拟实现,用于开发和测试
|
||
|
||
// 模拟:检查是否有模拟登录标记
|
||
$mockScanKey = "wechat_mock_scan:{$sceneStr}";
|
||
$mockData = Cache::get($mockScanKey);
|
||
|
||
if ($mockData) {
|
||
Cache::forget($mockScanKey);
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'scanned' => true,
|
||
'openid' => $mockData['openid'],
|
||
'unionid' => $mockData['unionid'] ?? null,
|
||
'nickname' => $mockData['nickname'] ?? '微信用户',
|
||
]
|
||
];
|
||
}
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'scanned' => false,
|
||
]
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 模拟扫码(开发测试用)
|
||
*
|
||
* @param string $sceneStr 场景字符串
|
||
* @param string $openid 模拟的openid
|
||
*/
|
||
public function mockScan(string $sceneStr, string $openid = 'mock_openid_123456'): void
|
||
{
|
||
Cache::put("wechat_mock_scan:{$sceneStr}", [
|
||
'openid' => $openid,
|
||
'unionid' => 'mock_unionid_' . substr(md5($openid), 0, 16),
|
||
'nickname' => '测试用户',
|
||
], now()->addMinutes(5));
|
||
}
|
||
|
||
/**
|
||
* 处理微信开放平台回调
|
||
*
|
||
* @param string $code 授权码
|
||
* @return array ['success' => bool, 'data' => [...]]
|
||
*/
|
||
public function handleCallback(string $code): array
|
||
{
|
||
if (!$this->isConfigured()) {
|
||
return ['success' => false, 'message' => '微信未配置'];
|
||
}
|
||
|
||
try {
|
||
// 通过 code 获取 access_token 和 openid
|
||
$tokenResponse = Http::get("https://api.weixin.qq.com/sns/oauth2/access_token", [
|
||
'appid' => $this->appId,
|
||
'secret' => $this->appSecret,
|
||
'code' => $code,
|
||
'grant_type' => 'authorization_code',
|
||
]);
|
||
|
||
$tokenResult = $tokenResponse->json();
|
||
|
||
if (isset($tokenResult['errcode'])) {
|
||
return ['success' => false, 'message' => $tokenResult['errmsg'] ?? '获取access_token失败'];
|
||
}
|
||
|
||
$openid = $tokenResult['openid'];
|
||
$unionid = $tokenResult['unionid'] ?? null;
|
||
$accessToken = $tokenResult['access_token'];
|
||
|
||
// 获取用户信息
|
||
$userInfo = $this->getUserInfo($accessToken, $openid);
|
||
|
||
// 查找或创建用户
|
||
$user = $this->findOrCreateUser($openid, $unionid, $userInfo);
|
||
|
||
// 生成 token
|
||
$token = $user->createToken('wechat_auth')->plainTextToken;
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => [
|
||
'user' => $user,
|
||
'token' => $token,
|
||
'openid' => $openid,
|
||
'unionid' => $unionid,
|
||
]
|
||
];
|
||
|
||
} catch (\Exception $e) {
|
||
Log::error('微信回调处理异常: ' . $e->getMessage());
|
||
return ['success' => false, 'message' => '处理失败'];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用户信息
|
||
*/
|
||
public function getUserInfo(string $accessToken, string $openid): array
|
||
{
|
||
try {
|
||
$response = Http::get("https://api.weixin.qq.com/sns/userinfo", [
|
||
'access_token' => $accessToken,
|
||
'openid' => $openid,
|
||
]);
|
||
|
||
return $response->json();
|
||
} catch (\Exception $e) {
|
||
Log::error('获取微信用户信息失败: ' . $e->getMessage());
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查找或创建微信用户
|
||
*/
|
||
public function findOrCreateUser(string $openid, ?string $unionid, array $userInfo): User
|
||
{
|
||
// 优先通过 unionid 查找
|
||
if ($unionid) {
|
||
$user = User::where('wechat_unionid', $unionid)->first();
|
||
if ($user) {
|
||
// 更新 openid(一个 unionid 可能对应多个 openid)
|
||
$user->update(['wechat_openid' => $openid]);
|
||
return $user;
|
||
}
|
||
}
|
||
|
||
// 通过 openid 查找
|
||
$user = User::where('wechat_openid', $openid)->first();
|
||
if ($user) {
|
||
return $user;
|
||
}
|
||
|
||
// 创建新用户
|
||
$user = User::create([
|
||
'name' => $userInfo['nickname'] ?? ('微信用户_' . substr($openid, 0, 8)),
|
||
'email' => "wechat_{$openid}@wechat.local",
|
||
'password' => bcrypt(Str::random(16)),
|
||
'wechat_openid' => $openid,
|
||
'wechat_unionid' => $unionid,
|
||
'avatar' => $userInfo['headimgurl'] ?? null,
|
||
]);
|
||
|
||
return $user;
|
||
}
|
||
|
||
/**
|
||
* 绑定微信到已有账号
|
||
*/
|
||
public function bindToUser(User $user, string $openid, ?string $unionid): bool
|
||
{
|
||
// 检查是否已被其他账号绑定
|
||
$exists = User::where('wechat_openid', $openid)
|
||
->where('id', '!=', $user->id)
|
||
->exists();
|
||
|
||
if ($exists) {
|
||
return false;
|
||
}
|
||
|
||
$user->update([
|
||
'wechat_openid' => $openid,
|
||
'wechat_unionid' => $unionid,
|
||
]);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 解绑微信
|
||
*/
|
||
public function unbind(User $user): bool
|
||
{
|
||
$user->update([
|
||
'wechat_openid' => null,
|
||
'wechat_unionid' => null,
|
||
]);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取 Access Token
|
||
*/
|
||
private function getAccessToken(): ?string
|
||
{
|
||
$cacheKey = 'wechat_access_token';
|
||
|
||
$token = Cache::get($cacheKey);
|
||
if ($token) {
|
||
return $token;
|
||
}
|
||
|
||
try {
|
||
$response = Http::get("https://api.weixin.qq.com/cgi-bin/token", [
|
||
'grant_type' => 'client_credential',
|
||
'appid' => $this->appId,
|
||
'secret' => $this->appSecret,
|
||
]);
|
||
|
||
$result = $response->json();
|
||
|
||
if (isset($result['access_token'])) {
|
||
Cache::put($cacheKey, $result['access_token'], $result['expires_in'] - 200);
|
||
return $result['access_token'];
|
||
}
|
||
} catch (\Exception $e) {
|
||
Log::error('获取微信access_token失败: ' . $e->getMessage());
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 检查是否已配置
|
||
*/
|
||
public function isConfigured(): bool
|
||
{
|
||
return !empty($this->appId) && !empty($this->appSecret);
|
||
}
|
||
|
||
/**
|
||
* 获取模拟二维码(未配置时使用)
|
||
*/
|
||
private function getMockQr(): array
|
||
{
|
||
$sceneStr = 'mock_' . Str::random(16);
|
||
|
||
// 生成一个 data URI 格式的模拟二维码图片
|
||
// 这是一个简单的模拟二维码,实际项目中应该显示真实二维码
|
||
$mockQrData = [
|
||
'scene_str' => $sceneStr,
|
||
'qr_url' => null, // 前端可以自己生成二维码
|
||
'ticket' => null,
|
||
'mock' => true,
|
||
];
|
||
|
||
return [
|
||
'success' => true,
|
||
'data' => $mockQrData,
|
||
];
|
||
}
|
||
}
|