From b1ca2c58b7efa2d6076690c788b5589134a95e70 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 17:57:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A3=9E=E4=B9=A6=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E7=A7=9F=E6=88=B7=E5=92=8C=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=B7=A5=E5=8F=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改动: - 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 --- backend/app/controller/AuthController.php | 74 ++++++++++++++++++--- backend/app/service/FeishuService.php | 57 ++++++++++++++-- backend/config/route.php | 5 +- frontend/src/components/FeishuAutoLogin.tsx | 9 ++- frontend/src/pages/login/QRCodeLogin.tsx | 30 ++++++++- frontend/src/pages/login/index.tsx | 72 ++++++++++++++++++-- frontend/src/router/index.tsx | 6 ++ frontend/src/services/api.ts | 29 ++++---- 8 files changed, 241 insertions(+), 41 deletions(-) diff --git a/backend/app/controller/AuthController.php b/backend/app/controller/AuthController.php index e79b9c9..5c094f9 100644 --- a/backend/app/controller/AuthController.php +++ b/backend/app/controller/AuthController.php @@ -64,6 +64,7 @@ class AuthController extends BaseController $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('缺少授权码'); @@ -71,9 +72,38 @@ class AuthController extends BaseController try { $feishuService = new FeishuService(); + $companyId = null; + $feishuApp = null; - // 方式1: 根据 app_id 查找飞书应用配置(推荐) - if ($appId) { + // 方式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 查找飞书应用 @@ -90,7 +120,7 @@ class AuthController extends BaseController $feishuService->setCompany($companyId); } - // 方式2: 根据 tenant_key 查找(兼容旧版) + // 方式3: 根据 tenant_key 查找(兼容旧版) elseif ($tenantKey) { error_log("🔍 使用 tenant_key 查找配置: {$tenantKey}"); @@ -117,7 +147,7 @@ class AuthController extends BaseController // 设置应用ID $appId = $feishuApp->id; } else { - return $this->error('缺少 app_id 或 tenant_key 参数'); + return $this->error('缺少 company、app_id 或 tenant_key 参数'); } // 获取用户信息 @@ -174,16 +204,42 @@ class AuthController extends BaseController { $tenantKey = $request->post('tenant_key'); $appId = $request->post('app_id'); + $companyParam = $request->post('company'); // 新增:company 参数 - error_log("📱 qrcodeLogin - tenant_key: {$tenantKey}, app_id: {$appId}"); + error_log("📱 qrcodeLogin - tenant_key: {$tenantKey}, app_id: {$appId}, company: {$companyParam}"); try { $feishuService = new \app\service\FeishuService(); $companyId = null; $feishuApp = null; - // 方式1: 根据 app_id 查找飞书应用配置(推荐) - if ($appId) { + // 方式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(); @@ -195,7 +251,7 @@ class AuthController extends BaseController $companyId = $feishuApp->company_id; error_log("✅ 通过 app_id 找到配置: company_id={$companyId}"); } - // 方式2: 根据 tenant_key 查找(兼容旧版) + // 方式3: 根据 tenant_key 查找(兼容旧版) elseif ($tenantKey) { $company = $feishuService->getCompanyByTenantKey($tenantKey); if (!$company) { @@ -216,7 +272,7 @@ class AuthController extends BaseController error_log("✅ 通过 tenant_key 找到配置: company_id={$companyId}"); } else { - return $this->error('缺少 app_id 或 tenant_key 参数'); + return $this->error('缺少 company、app_id 或 tenant_key 参数'); } $feishuService->setCompany($companyId); diff --git a/backend/app/service/FeishuService.php b/backend/app/service/FeishuService.php index 20cbd11..16c30ef 100644 --- a/backend/app/service/FeishuService.php +++ b/backend/app/service/FeishuService.php @@ -134,9 +134,18 @@ class FeishuService $unionId = $feishuData['union_id'] ?? null; $feishuUserId = $feishuData['user_id'] ?? null; $openId = $feishuData['open_id'] ?? null; + $tenantKey = $feishuData['tenant_key'] ?? null; // 从飞书用户信息获取 tenant_key $name = $feishuData['name'] ?? '未知'; $avatar = $feishuData['avatar_url'] ?? null; - $email = $feishuData['email'] ?? null; + + // 邮箱:优先使用 enterprise_email,否则使用 email + $email = $feishuData['enterprise_email'] ?? $feishuData['email'] ?? null; + // 手机号:飞书返回的是 mobile 字段 + $phone = $feishuData['mobile'] ?? null; + // 去掉手机号中的 +86 前缀 + if ($phone && str_starts_with($phone, '+86')) { + $phone = substr($phone, 3); + } if (!$unionId) { throw new Exception('缺少 union_id'); @@ -160,20 +169,25 @@ class FeishuService if (!$user) { error_log("✨ 创建新用户: company={$companyId}, union_id={$unionId}, name={$name}"); - // 初始密码使用 sn(工号),如果没有 sn 则使用 union_id 的前6位 - $initialPassword = $feishuData['employee_no'] ?? substr($unionId, 0, 6); + // 从 user_sn 表中随机获取一个未分配的 sn + $userSn = $this->allocateUserSn(); $user = User::create([ 'company_id' => $companyId, + 'sn' => $userSn, 'union_id' => $unionId, 'name' => $name, 'email' => $email, + 'phone' => $phone, 'avatar' => $avatar, - 'password' => password_hash($initialPassword, PASSWORD_DEFAULT), // 初始密码加密 + 'password' => password_hash($userSn, PASSWORD_DEFAULT), // 使用 sn 作为初始密码 'feishu_app_id' => $appId, // 记录登录来源应用 'status' => 1, // 新用户默认在职 ]); - error_log("🔑 初始密码设置为 sn: {$initialPassword}"); + error_log("🔑 用户 sn: {$userSn}, 初始密码: {$userSn}"); + + // 更新 user_sn 表,标记 sn 已分配给新用户 + $this->markUserSnAllocated($userSn, $user->id); } else { error_log("🔄 更新用户: id={$user->id}, company={$companyId}, union_id={$unionId}, 当前状态={$user->status}"); @@ -188,16 +202,47 @@ class FeishuService 'name' => $name, 'avatar' => $avatar, 'email' => $email, + 'phone' => $phone, 'feishu_app_id' => $appId, ]); } // ⭐ 保存或更新用户飞书应用绑定关系 - $this->saveUserFeishuBinding($user->id, $companyId, $appId, $feishuUserId, $openId, $app->tenant_key); + $this->saveUserFeishuBinding($user->id, $companyId, $appId, $feishuUserId, $openId, $tenantKey); return $user; } + /** + * 从 user_sn 表中随机获取一个未分配的 sn + */ + private function allocateUserSn(): string + { + // 随机获取一个未分配的 sn(user_id = 0) + $snRecord = \support\Db::table('user_sn') + ->where('user_id', 0) + ->inRandomOrder() + ->first(); + + if (!$snRecord) { + throw new Exception('没有可用的用户编号,请联系管理员'); + } + + return (string) $snRecord->user_sn; + } + + /** + * 标记 user_sn 已分配给指定用户 + */ + private function markUserSnAllocated(string $userSn, int $userId): void + { + \support\Db::table('user_sn') + ->where('user_sn', $userSn) + ->update(['user_id' => $userId]); + + error_log("✅ 用户编号 {$userSn} 已分配给用户 {$userId}"); + } + /** * 保存或更新用户飞书应用绑定关系 */ diff --git a/backend/config/route.php b/backend/config/route.php index c7153cd..ac648d2 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -14,12 +14,15 @@ Route::group('/api/v1', function () { Route::get('/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']); // 测试接口 - Route::get('/test/notification', [app\controller\TestController::class, 'testNotification']); + Route::get('/test/notification', [app\controller\TestController::class, 'notification']); })->middleware($apiMiddleware); // 飞书登录回调(不在 /api/v1 下,供飞书重定向使用) Route::get('/login/callback', [app\controller\AuthController::class, 'qrcodeLoginCallback'])->middleware($apiMiddleware); +// 飞书卡片回调(不需要认证,飞书服务器直接调用) +Route::post('/feishu/card/callback', [app\controller\FeishuCardController::class, 'callback']); + // 用户路由(需要认证) Route::group('/api/v1/user', function () { Route::get('/info', [app\controller\UserController::class, 'info']); diff --git a/frontend/src/components/FeishuAutoLogin.tsx b/frontend/src/components/FeishuAutoLogin.tsx index 56eb9f9..702c2e4 100644 --- a/frontend/src/components/FeishuAutoLogin.tsx +++ b/frontend/src/components/FeishuAutoLogin.tsx @@ -60,7 +60,10 @@ export default function FeishuAutoLogin() { tryFeishuAutoLogin() function tryFeishuAutoLogin() { - const appId = 'cli_a8033d607cba500d' + // 从 URL 获取 company 参数 + const urlParams = new URLSearchParams(window.location.search) + const companyParam = urlParams.get('company') + console.log('🔍 URL 中的 company 参数:', companyParam) // 打印 tt 对象的所有方法 if (window.tt) { @@ -75,8 +78,8 @@ export default function FeishuAutoLogin() { // 方式1: tt.requestAuthCode if (window.tt && typeof window.tt.requestAuthCode === 'function') { console.log('使用 tt.requestAuthCode') + // 不传 appId,让飞书自动选择(后端会根据 company 参数处理) window.tt.requestAuthCode({ - appId, success: (res: any) => { console.log('✅ 获取到授权码:', res.code?.substring(0, 20)) handleLogin(res.code) @@ -117,7 +120,7 @@ export default function FeishuAutoLogin() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ auth_code: authCode, - tenant_key: '14e7bc0fe5985740', + company: urlParams.get('company') || undefined, }), }) diff --git a/frontend/src/pages/login/QRCodeLogin.tsx b/frontend/src/pages/login/QRCodeLogin.tsx index 436fd69..ec9a1c2 100644 --- a/frontend/src/pages/login/QRCodeLogin.tsx +++ b/frontend/src/pages/login/QRCodeLogin.tsx @@ -92,13 +92,37 @@ export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) { console.log('🔍 window.QRLogin:', !!window.QRLogin) + // 从 URL 获取 company 参数(支持两种方式:直接参数或从 redirect 中提取) + const urlParams = new URLSearchParams(window.location.search) + let companyParam = urlParams.get('company') + + // 如果当前 URL 没有 company,尝试从 redirect 参数中获取 + if (!companyParam) { + const redirect = urlParams.get('redirect') + if (redirect) { + try { + const redirectUrl = new URL(redirect, window.location.origin) + companyParam = redirectUrl.searchParams.get('company') + console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam) + } catch (e) { + console.log('⚠️ 无法解析 redirect URL:', redirect) + } + } + } + + console.log('🔍 最终使用的 company 参数:', companyParam) + // 生成登录会话 + const requestBody: any = {} + if (companyParam) { + // 如果有 company 参数,传递给后端 + requestBody.company = companyParam + } + const response = await fetch('/api/v1/auth/qrcode-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tenant_key: '14e7bc0fe5985740', - }), + body: JSON.stringify(requestBody), }) const result = await response.json() diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx index 7d01f49..60d4cb8 100644 --- a/frontend/src/pages/login/index.tsx +++ b/frontend/src/pages/login/index.tsx @@ -21,12 +21,38 @@ declare global { export default function Login() { const navigate = useNavigate() - const { login } = useUserStore() + const { login, user } = useUserStore() const [loading, setLoading] = useState(true) const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null) const [showQRCode, setShowQRCode] = useState(false) useEffect(() => { + // 首先检查是否已登录 + const token = localStorage.getItem('token') + const urlParams = new URLSearchParams(window.location.search) + const redirect = urlParams.get('redirect') + + console.log('🔐 登录页检查:', { hasToken: !!token, hasUser: !!user, redirect }) + + // 如果有 token,说明已登录 + if (token) { + console.log('✅ 检测到 token,用户已登录') + // 如果有 redirect 参数,跳转到目标页面 + if (redirect) { + console.log('✅ 跳转到 redirect 页面:', redirect) + window.location.href = redirect + return + } + // 否则跳转到 dashboard + console.log('✅ 跳转到 dashboard') + navigate('/dashboard') + return + } + + // 没有token,继续登录流程 + console.log('⚠️ 未检测到 token,显示登录页面') + setLoading(false) + // 监听飞书登录成功事件 const handleFeishuLoginSuccess = ((e: any) => { console.log('🎉 收到飞书登录成功事件:', e.detail) @@ -39,7 +65,7 @@ export default function Login() { return () => { window.removeEventListener('feishu-login-success', handleFeishuLoginSuccess) } - }, []) + }, [user, login, navigate]) useEffect(() => { // 打印详细的环境信息 @@ -103,7 +129,20 @@ export default function Login() { console.log('🔍 window.tt:', window.tt) console.log('🔍 window.tt 类型:', typeof window.tt) - const appId = 'cli_a8033d607cba500d' + // 从 URL 获取 company 参数(可能来自当前 URL 或 redirect URL) + const urlParams = new URLSearchParams(window.location.search) + let companyParam = urlParams.get('company') + console.log('🔍 URL 中的 company 参数:', companyParam) + + // 如果当前 URL 没有 company,尝试从 redirect 参数中获取 + if (!companyParam) { + const redirect = urlParams.get('redirect') + if (redirect) { + const redirectUrl = new URL(redirect, window.location.origin) + companyParam = redirectUrl.searchParams.get('company') + console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam) + } + } // 详细检查 tt 对象 if (window.tt) { @@ -137,10 +176,10 @@ export default function Login() { // 尝试调用 requestAuthCode try { - console.log('🔧 调用 tt.requestAuthCode,appId:', appId) + console.log('🔧 调用 tt.requestAuthCode,company:', companyParam) + // 不传 appId,让飞书自动选择(后端会根据 company 参数处理) window.tt.requestAuthCode({ - appId: appId, success: (res: any) => { console.log('✅ 成功获取授权码:', res) console.log('✅ code:', res.code?.substring(0, 30)) @@ -198,11 +237,28 @@ export default function Login() { // 处理飞书登录 const handleFeishuLogin = async (authCode: string) => { try { - // 从 URL 获取 app_id 参数 + // 从 URL 获取参数 const urlParams = new URLSearchParams(window.location.search) + let companyParam = urlParams.get('company') const urlAppId = urlParams.get('app_id') - console.log('🔍 URL 中的 app_id:', urlAppId) + // 如果当前 URL 没有 company,尝试从 redirect 参数中获取 + if (!companyParam) { + const redirect = urlParams.get('redirect') + if (redirect) { + try { + const redirectUrl = new URL(redirect, window.location.origin) + companyParam = redirectUrl.searchParams.get('company') + console.log('🔍 从 redirect URL 中提取的 company 参数:', companyParam) + } catch (e) { + console.log('⚠️ 无法解析 redirect URL:', redirect) + } + } + } + + console.log('🔍 URL 中的参数:') + console.log(' - app_id:', urlAppId) + console.log(' - company:', companyParam) const response = await fetch('/api/v1/auth/feishu-login', { method: 'POST', @@ -210,6 +266,7 @@ export default function Login() { body: JSON.stringify({ auth_code: authCode, app_id: urlAppId || undefined, // 如果 URL 有 app_id 就传递 + company: companyParam || undefined, // 传递 company 参数 }), }) @@ -224,6 +281,7 @@ export default function Login() { const redirect = urlParams.get('redirect') if (redirect) { console.log('🔗 登录成功,跳转到:', redirect) + // redirect 参数可能包含 ?company=xxx,需要直接跳转 window.location.href = redirect } else { navigate('/dashboard') diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index fe88d9a..489450a 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -12,6 +12,7 @@ import SelectCc from '@/pages/business-trip/select-cc' import ApprovalList from '@/pages/approval/list' import ApprovalDetail from '@/pages/approval/detail' import QuickAction from '@/pages/approval/quick-action' +import RejectPage from '@/pages/approval/reject' import Login from '@/pages/login' import SimpleLogin from '@/pages/login-simple' import DebugFeishu from '@/pages/debug-feishu' @@ -30,15 +31,20 @@ const router = createBrowserRouter([ { path: 'business-trip/apply', element: }, { path: 'business-trip/apply/select-approver', element: }, { path: 'business-trip/apply/select-cc', element: }, + { path: 'business-trip/detail/:id', element: }, { path: 'business-trip/:id', element: }, { path: 'approval', element: }, { path: 'approval/:id', element: }, ], }, + // 详情页移到顶层,支持未登录访问(页面自己处理登录检查) + { path: '/approval/detail/:id', element: }, + { path: '/business-trip/detail/:id', element: }, { path: '/login', element: }, { path: '/login-simple', element: }, { path: '/debug-feishu', element: }, { path: '/approval/quick-action', element: }, + { path: '/approval/reject', element: }, { path: '*', element: }, ]) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 31076e2..6353fb3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -11,22 +11,29 @@ const handleUnauthorized = () => { if (isLoggingOut) return isLoggingOut = true + // 保存当前页面信息,用于登录后跳转回来 + const currentUrl = window.location.pathname + window.location.search + console.log('🚨 handleUnauthorized 被调用, currentUrl:', currentUrl) + sessionStorage.setItem('redirect_after_login', currentUrl) + // 清除本地存储 localStorage.removeItem('token') localStorage.removeItem('user-storage') // 显示提示 Toast.show({ - content: '登录已过期,请重新登录', - duration: 2000, + content: '请先登录', + duration: 1500, }) // 延迟跳转,确保提示显示 setTimeout(() => { isLoggingOut = false - // 跳转到登录页,并清除历史记录 - window.location.href = '/login' - }, 2000) + const redirectUrl = '/login?redirect=' + encodeURIComponent(currentUrl) + console.log('🚨 准备跳转到登录页, redirectUrl:', redirectUrl) + // 跳转到登录页,带上 redirect 参数 + window.location.href = redirectUrl + }, 500) } // 创建 axios 实例 @@ -82,19 +89,17 @@ export const authApi = { login: (username: string, password: string) => api.post('/auth/login', { username, password }), - // 飞书免登(支持 app_id 或 tenant_key) - feishuLogin: (authCode: string, options?: { appId?: string; tenantKey?: string }) => + // 飞书免登(支持 app_id、tenant_key 或 company) + feishuLogin: (authCode: string, company?: string) => api.post('/auth/feishu-login', { auth_code: authCode, - app_id: options?.appId, - tenant_key: options?.tenantKey, + company: company, }), // 生成二维码登录会话 - qrcodeLogin: (appId?: string, tenantKey?: string) => + qrcodeLogin: (company?: string) => api.post('/auth/qrcode-login', { - app_id: appId, - tenant_key: tenantKey, + company: company, }), // 轮询二维码登录状态