Files
x-server-mobile/backend/app/controller/AuthController.php
T
2026-03-06 13:51:25 +08:00

748 lines
26 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\controller;
use app\service\FeishuService;
use app\util\JwtUtil;
use app\model\User;
use app\model\Company;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 用户登录(邮箱/手机号 + 密码)
*/
public function login(Request $request): Response
{
$username = $request->post('username');
$password = $request->post('password');
if (!$username || !$password) {
return $this->error('用户名或密码不能为空');
}
// 通过邮箱或手机号查找用户
$user = User::where(function ($query) use ($username) {
$query->where('email', $username)
->orWhere('phone', $username);
})->where('status', 1)
->first();
if (!$user) {
return $this->error('用户不存在或已被禁用');
}
// 验证密码(如果没有设置密码,提示使用飞书登录)
if (!$user->password) {
return $this->error('该账号未设置密码,请使用飞书登录');
}
if (!password_verify($password, $user->password)) {
return $this->error('密码错误');
}
// 生成 token
$token = JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'username' => $user->name,
]);
return $this->success([
'token' => $token,
'user' => $user->load('company'),
]);
}
/**
* 飞书免登
*/
public function feishuLogin(Request $request): Response
{
$authCode = $request->post('auth_code');
$appId = $request->post('app_id');
$tenantKey = $request->post('tenant_key');
$companyParam = $request->post('company'); // 新增:company 参数
if (!$authCode) {
return $this->error('缺少授权码');
}
try {
$feishuService = new FeishuService();
$companyId = null;
$feishuApp = null;
// 方式1: 根据 company 参数查找公司配置(新增,用于多公司支持)
if ($companyParam) {
error_log("🔍 使用 company 参数查找配置: {$companyParam}");
// company 参数是 company_id
$company = \app\model\Company::find($companyParam);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
$tenantKey = $company->tenant_key; // 从公司表获取 tenant_key
// 获取该公司的默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('该公司未配置飞书应用');
}
error_log("✅ 通过 company 找到配置: company_id={$companyId}, tenant_key={$tenantKey}, feishu_app_id={$feishuApp->id}, app_id={$feishuApp->app_id}");
$feishuService->setCompany($companyId);
$appId = $feishuApp->id;
}
// 方式2: 根据 app_id 查找飞书应用配置
elseif ($appId) {
error_log("🔍 使用 app_id 查找配置: {$appId}");
// 根据 app_id 查找飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('app_id', $appId)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('飞书应用配置不存在');
}
$companyId = $feishuApp->company_id;
error_log("✅ 找到飞书应用配置: company_id={$companyId}, feishu_app_id={$feishuApp->id}");
$feishuService->setCompany($companyId);
}
// 方式3: 根据 tenant_key 查找(兼容旧版)
elseif ($tenantKey) {
error_log("🔍 使用 tenant_key 查找配置: {$tenantKey}");
$company = $feishuService->getCompanyByTenantKey($tenantKey);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
// 获取默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('未配置飞书应用');
}
error_log("✅ 找到公司配置: company_id={$companyId}, feishu_app_id={$feishuApp->id}");
$feishuService->setCompany($companyId);
// 设置应用ID
$appId = $feishuApp->id;
} else {
return $this->error('缺少 company、app_id 或 tenant_key 参数');
}
// 获取用户信息
$feishuData = $feishuService->getUserInfo($authCode);
error_log("👤 获取到用户信息: " . json_encode($feishuData));
// 创建或更新用户(传递应用ID)
$user = $feishuService->createOrUpdateUser($feishuData, $companyId, $appId);
// 生成 JWT Token
$token = JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_app_id' => $user->feishu_app_id,
'union_id' => $user->union_id,
]);
error_log("✅ 登录成功: user_id={$user->id}, company_id={$user->company_id}");
return $this->success([
'token' => $token,
'user' => $user->load('company'),
]);
} catch (\Exception $e) {
error_log("❌ 登录失败: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 获取当前用户信息
*/
public function me(Request $request): Response
{
$userId = $this->getUserId($request);
if (!$userId) {
return $this->error('未登录', 401);
}
$user = User::with('company')->find($userId);
if (!$user) {
return $this->error('用户不存在');
}
return $this->success($user);
}
/**
* 生成飞书扫码登录二维码
*/
public function qrcodeLogin(Request $request): Response
{
$tenantKey = $request->post('tenant_key');
$appId = $request->post('app_id');
$companyParam = $request->post('company'); // 新增:company 参数
error_log("📱 qrcodeLogin - tenant_key: {$tenantKey}, app_id: {$appId}, company: {$companyParam}");
try {
$feishuService = new \app\service\FeishuService();
$companyId = null;
$feishuApp = null;
// 方式1: 根据 company 参数查找公司配置(新增,用于多公司支持)
if ($companyParam) {
error_log("🔍 使用 company 参数查找配置: {$companyParam}");
// company 参数是 company_id
$company = \app\model\Company::find($companyParam);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
$tenantKey = $company->tenant_key; // 从公司表获取 tenant_key
// 获取该公司的默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('该公司未配置飞书应用');
}
error_log("✅ 通过 company 找到配置: company_id={$companyId}, tenant_key={$tenantKey}, feishu_app_id={$feishuApp->id}, app_id={$feishuApp->app_id}");
}
// 方式2: 根据 app_id 查找飞书应用配置(推荐)
elseif ($appId) {
$feishuApp = \app\model\CompanyFeishuApp::where('app_id', $appId)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('飞书应用配置不存在');
}
$companyId = $feishuApp->company_id;
error_log("✅ 通过 app_id 找到配置: company_id={$companyId}");
}
// 方式3: 根据 tenant_key 查找(兼容旧版)
elseif ($tenantKey) {
$company = $feishuService->getCompanyByTenantKey($tenantKey);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
// 获取默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('未配置飞书应用');
}
error_log("✅ 通过 tenant_key 找到配置: company_id={$companyId}");
} else {
return $this->error('缺少 company、app_id 或 tenant_key 参数');
}
$feishuService->setCompany($companyId);
// 生成登录会话
$loginSession = \app\model\FeishuLoginSession::create([
'company_id' => $companyId,
'tenant_key' => $tenantKey,
'feishu_app_id' => $feishuApp->id,
'status' => 'pending',
'expires_at' => date('Y-m-d H:i:s', time() + 300), // 5分钟过期
]);
// 构建扫码登录 URL(使用数据库中配置的回调地址)
if ($feishuApp->mobile_callback_url) {
$callbackUrl = $feishuApp->mobile_callback_url;
} else {
// 检查反向代理的协议头
$scheme = request()->header('x-forwarded-proto') ?? request()->header('x-scheme') ?? 'https';
$callbackUrl = $scheme . '://' . request()->host() . '/login/callback';
}
$redirectUri = urlencode($callbackUrl);
$authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?client_id={$feishuApp->app_id}&redirect_uri={$redirectUri}&response_type=code&state={$loginSession->id}";
error_log("✅ 生成二维码会话: session_id={$loginSession->id}");
return $this->success([
'session_id' => $loginSession->id,
'auth_url' => $authUrl,
'expires_at' => $loginSession->expires_at,
]);
} catch (\Exception $e) {
error_log("❌ qrcodeLogin 失败: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 轮询扫码登录状态
*/
public function qrcodeLoginStatus(Request $request): Response
{
$sessionId = $request->get('session_id');
if (!$sessionId) {
return $this->error('缺少会话ID');
}
$session = \app\model\FeishuLoginSession::find($sessionId);
if (!$session) {
return $this->error('登录会话不存在');
}
// 检查是否过期
if (strtotime($session->expires_at) < time()) {
$session->status = 'expired';
$session->save();
return $this->error('二维码已过期,请刷新重试');
}
// 返回当前状态
if ($session->status === 'success') {
// 生成 token
$user = \app\model\User::find($session->user_id);
if (!$user) {
return $this->error('用户不存在');
}
$token = \app\util\JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->union_id,
]);
// 清除会话
$session->delete();
return $this->success([
'status' => 'success',
'token' => $token,
'user' => $user->load('company'),
]);
} else if ($session->status === 'expired') {
return $this->error('二维码已过期,请刷新重试');
} else {
// pending 或 scanning
return $this->success([
'status' => $session->status,
]);
}
}
/**
* 飞书扫码登录回调(飞书授权后的回调)
*/
public function qrcodeLoginCallback(Request $request): Response
{
// 从 state 参数获取 session_id
$sessionId = $request->get('state');
// OAuth 2.0 标准参数是 code
$authCode = $request->get('code');
error_log("🔔 QRCode callback received - session_id: {$sessionId}, code: " . substr($authCode ?? 'null', 0, 20) . "...");
// 如果没有参数,直接返回空页面(可能是 QR SDK 的预检请求)
if (!$sessionId && !$authCode) {
error_log("ℹ️ Callback called without parameters, returning empty page");
return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], '');
}
if (!$sessionId) {
error_log("❌ Callback missing session_id");
return $this->error('缺少会话ID');
}
if (!$authCode) {
error_log("❌ Callback missing auth_code");
return $this->error('缺少授权码');
}
try {
$session = \app\model\FeishuLoginSession::find($sessionId);
error_log("🔍 Session found: " . ($session ? "id={$session->id}, status={$session->status}" : "null"));
if (!$session) {
error_log("❌ Session not found for id: {$sessionId}");
return $this->error('登录会话不存在');
}
// 检查是否过期
if (strtotime($session->expires_at) < time()) {
error_log("⏰ Session expired at: {$session->expires_at}");
$session->status = 'expired';
$session->save();
return $this->error('二维码已过期');
}
error_log("🔄 Processing login for company_id: {$session->company_id}, feishu_app_id: {$session->feishu_app_id}");
// 使用 session 的公司信息
$feishuService = new \app\service\FeishuService();
$feishuService->setCompany($session->company_id);
// 获取用户信息
$feishuData = $feishuService->getUserInfo($authCode);
error_log("👤 Got user info: " . json_encode($feishuData));
// 创建或更新用户(传递飞书应用ID)
$user = $feishuService->createOrUpdateUser($feishuData, $session->company_id, $session->feishu_app_id);
error_log("✅ User created/updated: user_id={$user->id}");
// 更新会话状态
$session->status = 'success';
$session->user_id = $user->id;
$saved = $session->save();
error_log("💾 Session save result: " . ($saved ? 'success' : 'failed') . ", new status: {$session->status}");
// 生成 JWT Token
$token = \app\util\JwtUtil::encode([
'user_id' => $user->id,
'company_id' => $user->company_id,
'feishu_user_id' => $user->feishu_user_id,
'union_id' => $user->union_id,
]);
error_log("🎫 Generated token for user: {$user->id}");
// 返回成功页面(包含 token,前端会保存到 localStorage)
$userName = htmlspecialchars($user->name ?? '用户');
// 准备用户数据JSON
$userJson = json_encode([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar' => $user->avatar,
'phone' => $user->phone,
'company_id' => $user->company_id,
'status' => $user->status,
], JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE);
$html = <<<HTML
<!DOCTYPE html>
<html>
<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', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255,255,255,0.1);
border-radius: 20px;
backdrop-filter: blur(10px);
}
.icon {
font-size: 60px;
margin-bottom: 20px;
}
h1 {
margin: 0 0 10px 0;
font-size: 24px;
}
p {
margin: 10px 0;
opacity: 0.9;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="icon">✅</div>
<h1>登录成功!</h1>
<p>欢迎回来,{$userName}</p>
<p>正在跳转到应用...</p>
<div class="spinner"></div>
</div>
<script>
// 保存 token 到 localStorage
try {
localStorage.setItem('token', '$token');
localStorage.setItem('i18nextLng', 'zh');
console.log('✅ Token saved to localStorage');
// 保存用户信息到 zustand persist storage(正确的格式)
const userState = {
state: {
user: $userJson,
token: '$token',
currentCompany: null,
isLoggedIn: true
},
version: 0
};
localStorage.setItem('user-storage', JSON.stringify(userState));
console.log('✅ User state saved to localStorage:', userState);
// 通知父窗口登录成功(如果在弹窗中)
if (window.opener) {
window.opener.postMessage({
type: 'feishu-login-success',
data: { token: '$token', user: $userJson }
}, '*');
setTimeout(function() {
window.close();
}, 500);
} else {
// 否则跳转到首页
setTimeout(function() {
window.location.href = '/dashboard';
}, 1500);
}
} catch (e) {
console.error('❌ Failed to save data:', e);
// 如果 localStorage 不可用,使用 sessionStorage
sessionStorage.setItem('token', '$token');
setTimeout(function() {
window.location.href = '/dashboard';
}, 1500);
}
</script>
</body>
</html>
HTML;
return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html);
} catch (\Exception $e) {
return $this->error($e->getMessage());
}
}
/**
* 获取飞书配置(用于前端免登)
* 根据 company 参数返回对应的 appId
*/
public function feishuConfig(Request $request): Response
{
$companyParam = $request->get('company');
if (!$companyParam) {
return $this->error('缺少 company 参数');
}
try {
// company 参数是 company_id
$company = \app\model\Company::find($companyParam);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
// 获取该公司的默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('该公司未配置飞书应用');
}
error_log("✅ feishuConfig - company_id={$companyId}, app_id={$feishuApp->app_id}");
return $this->success([
'app_id' => $feishuApp->app_id,
'company_id' => $companyId,
]);
} catch (\Exception $e) {
error_log("❌ feishuConfig 失败: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 获取飞书 JS API 鉴权签名
* 用于前端初始化飞书 JS SDK
*
* company 参数可选,如果未提供则从登录 token 中获取用户所属公司
*/
public function feishuJsapiSignature(Request $request): Response
{
$url = $request->get('url');
$companyParam = $request->get('company');
if (!$url) {
return $this->error('缺少 url 参数');
}
try {
$companyId = null;
// 优先使用传入的 company 参数
if ($companyParam) {
$company = \app\model\Company::find($companyParam);
if (!$company) {
return $this->error('公司不存在');
}
$companyId = $company->id;
error_log("✅ 使用参数中的 company: {$companyId}");
}
// 从 token 中获取用户所属公司
else {
$companyId = $this->getCompanyId($request);
if (!$companyId) {
return $this->error('未登录且未指定公司', 401);
}
error_log("✅ 从 token 获取 company: {$companyId}");
}
// 获取该公司的默认飞书应用
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $companyId)
->where('system_id', 1)
->where('status', 1)
->first();
if (!$feishuApp) {
return $this->error('该公司未配置飞书应用');
}
// 生成 JS API 签名
$signatureData = $this->generateJsapiSignature($feishuApp->id, $url);
return $this->success([
'app_id' => $feishuApp->app_id,
'company_id' => $companyId,
'jsapi_ticket' => $signatureData['jsapi_ticket'],
'noncestr' => $signatureData['noncestr'],
'timestamp' => $signatureData['timestamp'],
'url' => $signatureData['url'],
'signature' => $signatureData['signature'],
]);
} catch (\Exception $e) {
error_log("❌ feishuJsapiSignature 失败: " . $e->getMessage());
return $this->error($e->getMessage());
}
}
/**
* 生成 JS API 鉴权签名
*
* @param int $feishuAppId 飞书应用配置ID
* @param string $url 当前页面URL
* @return array 签名数据
*/
private function generateJsapiSignature(int $feishuAppId, string $url): array
{
// 获取 jsapi_ticket
$jsapiTicket = \app\util\FeishuUtil::getJsapiTicket($feishuAppId);
// 清理 URL:移除 # 和 ? 后面的参数
$url = $this->cleanUrl($url);
// 生成随机字符串
$noncestr = $this->generateNonceStr();
// 获取毫秒级时间戳
$timestamp = (int)round(microtime(true) * 1000);
// 拼接签名字符串
$verifyStr = "jsapi_ticket={$jsapiTicket}&noncestr={$noncestr}&timestamp={$timestamp}&url={$url}";
// 生成 SHA1 签名
$signature = sha1($verifyStr);
error_log("✅ JS API 签名生成成功: url={$url}");
return [
'jsapi_ticket' => $jsapiTicket,
'noncestr' => $noncestr,
'timestamp' => $timestamp,
'url' => $url,
'signature' => $signature,
];
}
/**
* 清理 URL,移除 # 和 ? 后面的参数
*/
private function cleanUrl(string $url): string
{
// 移除 # 后面的内容
$pos = strpos($url, '#');
if ($pos !== false) {
$url = substr($url, 0, $pos);
}
// 移除 ? 后面的内容
$pos = strpos($url, '?');
if ($pos !== false) {
$url = substr($url, 0, $pos);
}
return $url;
}
/**
* 生成随机字符串(由数字、字母组成)
*/
private function generateNonceStr(int $length = 16): string
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$noncestr = '';
for ($i = 0; $i < $length; $i++) {
$noncestr .= $chars[mt_rand(0, strlen($chars) - 1)];
}
return $noncestr;
}
}