erp-backend/app/Services/WeChatService.php
2026-04-01 17:07:04 +08:00

360 lines
10 KiB
PHP
Raw Permalink 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\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,
];
}
}