Files
x-server-mobile/backend/app/controller/AuthController.php
T
root b1ca2c58b7 feat: 飞书登录支持多租户和用户工号管理
后端改动:
- FeishuService: 从飞书用户信息获取 tenant_key 和手机号
- FeishuService: 新增 user_sn 表工号分配机制
- FeishuService: 优先使用 enterprise_email,支持手机号
- AuthController: 飞书免登支持 company 参数

前端改动:
- FeishuAutoLogin: 支持多租户 company 参数
- login/index: 支持 redirect 参数和已登录检测
- QRCodeLogin: 支持多租户参数传递
- router: 新增路由守卫和参数处理
- api.ts: 优化飞书登录 API 调用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:57:49 +08:00

563 lines
20 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
$redirectUri = urlencode("https://xsm.dev.hnxinyouhe.com/login/callback");
$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());
}
}
}