|
|
@@ -0,0 +1,1132 @@
|
|
|
+# 飞书登录集成指南
|
|
|
+
|
|
|
+> 完整的飞书登录解决方案,支持客户端免登录、二维码扫码登录和账号密码登录
|
|
|
+
|
|
|
+## 目录
|
|
|
+
|
|
|
+- [概述](#概述)
|
|
|
+- [功能特性](#功能特性)
|
|
|
+- [架构设计](#架构设计)
|
|
|
+- [快速开始](#快速开始)
|
|
|
+- [详细配置](#详细配置)
|
|
|
+- [使用指南](#使用指南)
|
|
|
+- [API 参考](#api-参考)
|
|
|
+- [常见问题](#常见问题)
|
|
|
+- [最佳实践](#最佳实践)
|
|
|
+
|
|
|
+## 概述
|
|
|
+
|
|
|
+本项目实现了完整的飞书登录功能,支持三种登录方式:
|
|
|
+
|
|
|
+1. **飞书客户端内免登录**:在飞书 App 内自动获取用户信息
|
|
|
+2. **飞书二维码扫码登录**:在浏览器中使用飞书扫码登录
|
|
|
+3. **账号密码登录**:传统的用户名密码登录方式
|
|
|
+
|
|
|
+所有登录方式都与 Parse 用户系统无缝集成,提供统一的用户认证体验。
|
|
|
+
|
|
|
+## 功能特性
|
|
|
+
|
|
|
+- ✅ 环境自动检测(飞书客户端 vs 浏览器)
|
|
|
+- ✅ 三种登录方式无缝切换
|
|
|
+- ✅ Parse 用户系统集成
|
|
|
+- ✅ 会话自动保存和恢复
|
|
|
+- ✅ 路由守卫保护
|
|
|
+- ✅ 统一的 OAuth2 接口
|
|
|
+- ✅ 优雅的 UI 和动画效果
|
|
|
+- ✅ 响应式设计
|
|
|
+- ✅ TypeScript 类型支持
|
|
|
+
|
|
|
+## 架构设计
|
|
|
+
|
|
|
+### 核心组件
|
|
|
+
|
|
|
+```
|
|
|
+src/app/
|
|
|
+├── core/
|
|
|
+│ ├── services/
|
|
|
+│ │ ├── feishu-auth.service.ts # 飞书认证服务
|
|
|
+│ │ └── auth.service.ts # 应用认证服务
|
|
|
+│ └── guards/
|
|
|
+│ └── auth.guard.ts # 路由守卫
|
|
|
+└── modules/
|
|
|
+ └── login/
|
|
|
+ ├── login.component.ts # 登录组件
|
|
|
+ ├── login.component.html # 登录模板
|
|
|
+ └── login.component.scss # 登录样式
|
|
|
+```
|
|
|
+
|
|
|
+### 登录流程
|
|
|
+
|
|
|
+#### 1. 飞书客户端内免登录
|
|
|
+
|
|
|
+```
|
|
|
+用户在飞书 App 内打开应用
|
|
|
+ ↓
|
|
|
+检测到飞书环境
|
|
|
+ ↓
|
|
|
+调用飞书 JSSDK 获取授权码
|
|
|
+ ↓
|
|
|
+发送授权码到后端 API
|
|
|
+ ↓
|
|
|
+后端返回用户信息和 sessionToken
|
|
|
+ ↓
|
|
|
+使用 sessionToken 登录 Parse
|
|
|
+ ↓
|
|
|
+更新 AuthService 认证状态
|
|
|
+ ↓
|
|
|
+跳转到首页
|
|
|
+```
|
|
|
+
|
|
|
+#### 2. 飞书二维码扫码登录
|
|
|
+
|
|
|
+```
|
|
|
+用户在浏览器中打开应用
|
|
|
+ ↓
|
|
|
+显示飞书二维码
|
|
|
+ ↓
|
|
|
+用户使用飞书扫码
|
|
|
+ ↓
|
|
|
+二维码 SDK 返回 tmp_code
|
|
|
+ ↓
|
|
|
+重定向到飞书授权页面(携带 tmp_code)
|
|
|
+ ↓
|
|
|
+飞书服务器处理后重定向回应用(携带 code)
|
|
|
+ ↓
|
|
|
+发送 code 到后端 API
|
|
|
+ ↓
|
|
|
+后端返回用户信息和 sessionToken
|
|
|
+ ↓
|
|
|
+使用 sessionToken 登录 Parse
|
|
|
+ ↓
|
|
|
+更新 AuthService 认证状态
|
|
|
+ ↓
|
|
|
+跳转到首页
|
|
|
+```
|
|
|
+
|
|
|
+#### 3. 账号密码登录
|
|
|
+
|
|
|
+```
|
|
|
+用户输入用户名和密码
|
|
|
+ ↓
|
|
|
+调用 Parse.User.logIn()
|
|
|
+ ↓
|
|
|
+登录成功,获取 Parse User
|
|
|
+ ↓
|
|
|
+更新 AuthService 认证状态
|
|
|
+ ↓
|
|
|
+跳转到首页
|
|
|
+```
|
|
|
+
|
|
|
+## SDK 详细说明
|
|
|
+
|
|
|
+### 概述
|
|
|
+
|
|
|
+`feishu-auth.service.ts` 依赖三个核心 SDK 来实现完整的登录功能:
|
|
|
+
|
|
|
+1. **飞书客户端 JSSDK** - 用于飞书 App 内免登录
|
|
|
+2. **飞书二维码登录 SDK** - 用于浏览器扫码登录
|
|
|
+3. **Parse SDK** - 用于用户系统集成
|
|
|
+
|
|
|
+### 1. 飞书客户端 JSSDK
|
|
|
+
|
|
|
+#### SDK 信息
|
|
|
+
|
|
|
+- **CDN 地址**: `https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.44.js`
|
|
|
+- **全局对象**: `window.tt`
|
|
|
+- **用途**: 在飞书客户端内获取用户授权码
|
|
|
+- **版本**: 1.5.44
|
|
|
+
|
|
|
+#### 加载方式
|
|
|
+
|
|
|
+```typescript
|
|
|
+private loadFeishuSDK(): Promise<void> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ // 检查飞书客户端 JSSDK 是否已加载
|
|
|
+ if (window['tt']) {
|
|
|
+ resolve();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const script = document.createElement('script');
|
|
|
+ script.src = 'https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.44.js';
|
|
|
+ script.onload = () => resolve();
|
|
|
+ script.onerror = () => reject(new Error('Failed to load Feishu SDK'));
|
|
|
+ document.head.appendChild(script);
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 核心方法
|
|
|
+
|
|
|
+##### tt.requestAccess()
|
|
|
+
|
|
|
+获取飞书客户端内的用户授权码。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ appID: string; // 飞书应用 ID
|
|
|
+ scopeList: string[]; // 权限范围列表(可为空)
|
|
|
+ success: (res: any) => void; // 成功回调
|
|
|
+ fail: (error: any) => void; // 失败回调
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+window.tt.requestAccess({
|
|
|
+ appID: 'cli_a9253658eef99cd2',
|
|
|
+ scopeList: [],
|
|
|
+ success: (res) => {
|
|
|
+ const code = res.code; // 获取授权码
|
|
|
+ console.log('授权码:', code);
|
|
|
+ },
|
|
|
+ fail: (error) => {
|
|
|
+ console.error('获取授权码失败:', error);
|
|
|
+ }
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ code: string; // 授权码,用于后端换取用户信息
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 环境检测
|
|
|
+
|
|
|
+```typescript
|
|
|
+isInFeishuApp(): boolean {
|
|
|
+ // 检查 window.tt 是否存在
|
|
|
+ if (typeof window['tt'] !== 'undefined' && window['tt'].requestAccess) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查 User Agent
|
|
|
+ const ua = navigator.userAgent.toLowerCase();
|
|
|
+ return ua.includes('lark') || ua.includes('feishu');
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 飞书二维码登录 SDK
|
|
|
+
|
|
|
+#### SDK 信息
|
|
|
+
|
|
|
+- **CDN 地址**: `https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js`
|
|
|
+- **全局函数**: `window.QRLogin`
|
|
|
+- **用途**: 在浏览器中生成飞书登录二维码
|
|
|
+- **版本**: 1.0.3
|
|
|
+
|
|
|
+#### 加载方式
|
|
|
+
|
|
|
+```typescript
|
|
|
+private loadQRCodeSDK(): Promise<void> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ // 检查二维码 SDK 是否已加载
|
|
|
+ if (window['QRLogin']) {
|
|
|
+ resolve();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const script = document.createElement('script');
|
|
|
+ script.src = 'https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js';
|
|
|
+ script.onload = () => resolve();
|
|
|
+ script.onerror = () => reject(new Error('Failed to load Feishu QRCode SDK'));
|
|
|
+ document.head.appendChild(script);
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 核心方法
|
|
|
+
|
|
|
+##### QRLogin()
|
|
|
+
|
|
|
+初始化并显示飞书登录二维码。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ id: string; // 二维码容器的 DOM ID
|
|
|
+ goto: string; // 授权跳转 URL
|
|
|
+ width: string; // 二维码宽度(如 "300")
|
|
|
+ height: string; // 二维码高度(如 "300")
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 构造授权 URL
|
|
|
+const goto = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&state=success_login`;
|
|
|
+
|
|
|
+// 初始化二维码
|
|
|
+const QRLoginObj = window.QRLogin({
|
|
|
+ id: 'qrcode-container',
|
|
|
+ goto: goto,
|
|
|
+ width: "300",
|
|
|
+ height: "300"
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ matchOrigin: (origin: string) => boolean; // 验证消息来源
|
|
|
+ matchData: (data: any) => boolean; // 验证消息数据
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 监听扫码结果
|
|
|
+
|
|
|
+二维码 SDK 通过 `postMessage` 发送扫码结果:
|
|
|
+
|
|
|
+```typescript
|
|
|
+window.addEventListener('message', (event: MessageEvent) => {
|
|
|
+ // 验证消息来源和数据
|
|
|
+ if (QRLoginObj.matchOrigin(event.origin) &&
|
|
|
+ QRLoginObj.matchData(event.data)) {
|
|
|
+
|
|
|
+ const tmp_code = event.data.tmp_code; // 临时码
|
|
|
+
|
|
|
+ // 使用 tmp_code 构造重定向 URL
|
|
|
+ const redirectUrl = `${goto}&tmp_code=${tmp_code}`;
|
|
|
+ window.location.href = redirectUrl;
|
|
|
+ }
|
|
|
+}, false);
|
|
|
+```
|
|
|
+
|
|
|
+**重要说明:**
|
|
|
+
|
|
|
+- `tmp_code` 不是最终的授权码,只是临时码
|
|
|
+- 需要用 `tmp_code` 重定向到飞书服务器
|
|
|
+- 飞书服务器处理后会重定向回应用,携带真正的授权码 `code`
|
|
|
+
|
|
|
+#### 授权 URL 格式
|
|
|
+
|
|
|
+```
|
|
|
+https://passport.feishu.cn/suite/passport/oauth/authorize
|
|
|
+ ?client_id=<APP_ID>
|
|
|
+ &redirect_uri=<REDIRECT_URI>
|
|
|
+ &response_type=code
|
|
|
+ &state=<STATE>
|
|
|
+```
|
|
|
+
|
|
|
+**参数说明:**
|
|
|
+
|
|
|
+- `client_id`: 飞书应用 ID
|
|
|
+- `redirect_uri`: 授权回调地址(需 URL 编码)
|
|
|
+- `response_type`: 固定为 `code`
|
|
|
+- `state`: 状态参数,用于区分登录类型(如 `success_login` 表示二维码登录)
|
|
|
+
|
|
|
+### 3. Parse SDK
|
|
|
+
|
|
|
+#### SDK 信息
|
|
|
+
|
|
|
+- **导入方式**: `import { FmodeParse as Parse } from 'fmode-ng/core/parse'`
|
|
|
+- **用途**: 用户系统集成和会话管理
|
|
|
+- **版本**: 由 fmode-ng 提供
|
|
|
+
|
|
|
+#### 初始化
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { FmodeParse as Parse } from 'fmode-ng/core/parse';
|
|
|
+
|
|
|
+// 动态获取 Parse 实例
|
|
|
+const Parse = FmodeParse.with(PARSE_APP_ID);
|
|
|
+```
|
|
|
+
|
|
|
+#### 核心方法
|
|
|
+
|
|
|
+##### Parse.User.become()
|
|
|
+
|
|
|
+使用 sessionToken 恢复用户会话。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+sessionToken: string // Parse 会话令牌
|
|
|
+```
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+async loginWithSessionToken(sessionToken: string): Promise<any> {
|
|
|
+ try {
|
|
|
+ // 使用 sessionToken 成为当前用户
|
|
|
+ const user = await Parse.User.become(sessionToken);
|
|
|
+
|
|
|
+ console.log('Parse 用户登录成功:', user.id);
|
|
|
+
|
|
|
+ return {
|
|
|
+ parseUser: user,
|
|
|
+ sessionToken: sessionToken,
|
|
|
+ userId: user.id,
|
|
|
+ username: user.get('username')
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Parse 用户登录失败:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### Parse.User.logIn()
|
|
|
+
|
|
|
+使用用户名和密码登录。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+username: string // 用户名
|
|
|
+password: string // 密码
|
|
|
+```
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+async webLogin(username: string, password: string): Promise<AuthResult> {
|
|
|
+ try {
|
|
|
+ // 使用 Parse.User.logIn() 进行登录
|
|
|
+ const user = await Parse.User.logIn(username, password);
|
|
|
+
|
|
|
+ // 获取用户 sessionToken
|
|
|
+ const sessionToken = user.getSessionToken();
|
|
|
+
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ userInfo: {
|
|
|
+ userId: user.id,
|
|
|
+ username: user.get('username'),
|
|
|
+ sessionToken: sessionToken
|
|
|
+ },
|
|
|
+ loginType: 'web'
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ error: error.message,
|
|
|
+ loginType: 'web'
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### Parse.User.logOut()
|
|
|
+
|
|
|
+退出登录,清除当前用户会话。
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+async logout(): Promise<void> {
|
|
|
+ // 使用 Parse SDK 登出
|
|
|
+ await Parse.User.logOut();
|
|
|
+
|
|
|
+ // 清除本地存储的用户信息
|
|
|
+ localStorage.removeItem('feishu_user_info');
|
|
|
+ localStorage.removeItem('feishu_session_token');
|
|
|
+ localStorage.removeItem('feishu_login_time');
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### Parse.User.current()
|
|
|
+
|
|
|
+获取当前登录的用户。
|
|
|
+
|
|
|
+**使用示例:**
|
|
|
+
|
|
|
+```typescript
|
|
|
+async getCurrentParseUser(): Promise<any> {
|
|
|
+ try {
|
|
|
+ return Parse.User.current();
|
|
|
+ } catch (error) {
|
|
|
+ console.warn('获取当前 Parse 用户失败:', error);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### SDK 类型声明
|
|
|
+
|
|
|
+为了在 TypeScript 中使用这些 SDK,需要添加全局类型声明:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 飞书 SDK 全局类型声明
|
|
|
+declare global {
|
|
|
+ interface Window {
|
|
|
+ tt?: any; // 飞书客户端 JSSDK
|
|
|
+ QRLogin?: any; // 飞书二维码登录 SDK
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### SDK 加载时机
|
|
|
+
|
|
|
+#### 自动加载
|
|
|
+
|
|
|
+`FeishuAuth` 构造函数会自动加载飞书客户端 JSSDK:
|
|
|
+
|
|
|
+```typescript
|
|
|
+constructor(config: FeishuAuthConfig) {
|
|
|
+ this.config = {
|
|
|
+ appId: config.appId,
|
|
|
+ redirectUri: config.redirectUri,
|
|
|
+ apiOauthWeb: config.apiOauthWeb || 'http://localhost:3000/api/feishu-v2/oauth2/login'
|
|
|
+ };
|
|
|
+
|
|
|
+ // 自动加载飞书 SDK
|
|
|
+ this.loadFeishuSDK();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 按需加载
|
|
|
+
|
|
|
+二维码 SDK 只在需要时加载:
|
|
|
+
|
|
|
+```typescript
|
|
|
+initQRCodeLogin(containerId: string, onSuccess, onError): void {
|
|
|
+ // 确保 SDK 已加载
|
|
|
+ this.loadQRCodeSDK().then(() => {
|
|
|
+ // 初始化二维码
|
|
|
+ const QRLoginObj = window.QRLogin({...});
|
|
|
+ });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### SDK 错误处理
|
|
|
+
|
|
|
+#### SDK 加载失败
|
|
|
+
|
|
|
+```typescript
|
|
|
+try {
|
|
|
+ await this.loadFeishuSDK();
|
|
|
+} catch (error) {
|
|
|
+ console.error('加载飞书 SDK 失败:', error);
|
|
|
+ // 降级处理:使用网页登录
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### API 调用失败
|
|
|
+
|
|
|
+```typescript
|
|
|
+try {
|
|
|
+ window.tt.requestAccess({
|
|
|
+ appID: this.config.appId,
|
|
|
+ scopeList: [],
|
|
|
+ success: (res) => {
|
|
|
+ // 处理成功
|
|
|
+ },
|
|
|
+ fail: (error) => {
|
|
|
+ console.error('获取授权码失败:', error);
|
|
|
+ // 错误处理
|
|
|
+ }
|
|
|
+ });
|
|
|
+} catch (error) {
|
|
|
+ console.error('飞书 JSSDK 调用失败:', error);
|
|
|
+ // 降级处理
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### SDK 兼容性
|
|
|
+
|
|
|
+#### 浏览器兼容性
|
|
|
+
|
|
|
+- **飞书客户端 JSSDK**: 仅在飞书 App 内可用
|
|
|
+- **飞书二维码登录 SDK**: 支持所有现代浏览器(Chrome, Firefox, Safari, Edge)
|
|
|
+- **Parse SDK**: 支持所有现代浏览器
|
|
|
+
|
|
|
+#### 移动端支持
|
|
|
+
|
|
|
+- ✅ 飞书客户端内免登录:支持 iOS 和 Android
|
|
|
+- ✅ 二维码扫码登录:支持移动浏览器
|
|
|
+- ✅ 账号密码登录:支持所有平台
|
|
|
+
|
|
|
+## 快速开始
|
|
|
+
|
|
|
+### 1. 安装依赖
|
|
|
+
|
|
|
+确保项目已安装以下依赖:
|
|
|
+
|
|
|
+```bash
|
|
|
+npm install fmode-ng
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 配置飞书应用
|
|
|
+
|
|
|
+在飞书开发者后台配置:
|
|
|
+
|
|
|
+1. **应用凭证**:
|
|
|
+ - 获取 App ID
|
|
|
+ - 获取 App Secret
|
|
|
+
|
|
|
+2. **安全设置**:
|
|
|
+ - 配置重定向 URL:`https://your-domain.com/login`
|
|
|
+ - 配置 H5 可信域名:`your-domain.com`
|
|
|
+
|
|
|
+3. **权限配置**:
|
|
|
+ - 开通 `auth:user.id:read`
|
|
|
+ - 开通 `auth:user:email:read`(如需邮箱)
|
|
|
+
|
|
|
+### 3. 复制核心文件
|
|
|
+
|
|
|
+将以下文件复制到你的项目:
|
|
|
+
|
|
|
+```
|
|
|
+src/app/core/services/feishu-auth.service.ts
|
|
|
+src/app/modules/login/login.component.ts
|
|
|
+src/app/modules/login/login.component.html
|
|
|
+src/app/modules/login/login.component.scss
|
|
|
+```
|
|
|
+
|
|
|
+### 4. 配置应用
|
|
|
+
|
|
|
+在 `login.component.ts` 中配置飞书应用信息:
|
|
|
+
|
|
|
+```typescript
|
|
|
+this.feishuAuth = new FeishuAuth({
|
|
|
+ appId: 'YOUR_FEISHU_APP_ID',
|
|
|
+ redirectUri: window.location.origin + '/login',
|
|
|
+ apiOauthWeb: 'https://your-backend.com/api/feishu-v2/oauth2/login'
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 5. 配置路由
|
|
|
+
|
|
|
+在 `app.routes.ts` 中添加登录路由:
|
|
|
+
|
|
|
+```typescript
|
|
|
+export const routes: Routes = [
|
|
|
+ {
|
|
|
+ path: 'login',
|
|
|
+ component: LoginComponent
|
|
|
+ },
|
|
|
+ {
|
|
|
+ path: 'homepage',
|
|
|
+ component: HomepageComponent,
|
|
|
+ canActivate: [authGuard]
|
|
|
+ }
|
|
|
+];
|
|
|
+```
|
|
|
+
|
|
|
+## 详细配置
|
|
|
+
|
|
|
+### FeishuAuthConfig 接口
|
|
|
+
|
|
|
+```typescript
|
|
|
+export interface FeishuAuthConfig {
|
|
|
+ appId: string; // 飞书应用 App ID(必填)
|
|
|
+ redirectUri: string; // 回调地址(必填)
|
|
|
+ apiOauthWeb?: string; // OAuth2 登录接口(可选)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 默认配置
|
|
|
+
|
|
|
+如果不指定 `apiOauthWeb`,将使用默认值:
|
|
|
+
|
|
|
+```typescript
|
|
|
+apiOauthWeb: 'http://localhost:3000/api/feishu-v2/oauth2/login'
|
|
|
+```
|
|
|
+
|
|
|
+### 后端 API 接口
|
|
|
+
|
|
|
+后端需要提供以下接口:
|
|
|
+
|
|
|
+#### POST /api/feishu-v2/oauth2/login
|
|
|
+
|
|
|
+**请求参数:**
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "appId": "cli_a9253658eef99cd2",
|
|
|
+ "code": "授权码",
|
|
|
+ "redirect_uri": "https://your-domain.com/login"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**响应格式:**
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "code": 1,
|
|
|
+ "data": {
|
|
|
+ "userInfo": {
|
|
|
+ "objectId": "用户ID",
|
|
|
+ "username": "用户名",
|
|
|
+ "nickname": "昵称",
|
|
|
+ "avatar": "头像URL",
|
|
|
+ "email": "邮箱",
|
|
|
+ "mobile": "手机号",
|
|
|
+ "openId": "飞书 OpenID",
|
|
|
+ "unionId": "飞书 UnionID"
|
|
|
+ },
|
|
|
+ "sessionToken": "Parse Session Token"
|
|
|
+ },
|
|
|
+ "mess": "登录成功"
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 使用指南
|
|
|
+
|
|
|
+### 在登录组件中使用
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { Component, OnInit } from '@angular/core';
|
|
|
+import { Router, ActivatedRoute } from '@angular/router';
|
|
|
+import { AuthService } from '../../core/services/auth.service';
|
|
|
+import { FeishuAuth, AuthResult } from '../../core/services/feishu-auth.service';
|
|
|
+
|
|
|
+@Component({
|
|
|
+ selector: 'app-login',
|
|
|
+ templateUrl: './login.component.html',
|
|
|
+ styleUrls: ['./login.component.scss']
|
|
|
+})
|
|
|
+export class LoginComponent implements OnInit {
|
|
|
+ private feishuAuth: FeishuAuth;
|
|
|
+
|
|
|
+ constructor(
|
|
|
+ private authService: AuthService,
|
|
|
+ private router: Router,
|
|
|
+ private activatedRoute: ActivatedRoute
|
|
|
+ ) {
|
|
|
+ // 初始化飞书认证服务
|
|
|
+ this.feishuAuth = new FeishuAuth({
|
|
|
+ appId: 'YOUR_FEISHU_APP_ID',
|
|
|
+ redirectUri: window.location.origin + '/login'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async ngOnInit(): Promise<void> {
|
|
|
+ // 检查是否有回调参数
|
|
|
+ const code = this.activatedRoute.snapshot.queryParamMap.get('code');
|
|
|
+ const state = this.activatedRoute.snapshot.queryParamMap.get('state');
|
|
|
+
|
|
|
+ if (code) {
|
|
|
+ await this.handleAuthCallback(code, state || '');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理认证回调
|
|
|
+ private async handleAuthCallback(code: string, state: string): Promise<void> {
|
|
|
+ const result = await this.feishuAuth.handleAuthCallback(code, state);
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ await this.handleLoginSuccess(result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理登录成功
|
|
|
+ private async handleLoginSuccess(result: AuthResult): Promise<void> {
|
|
|
+ // 保存用户信息
|
|
|
+ if (result.userInfo) {
|
|
|
+ this.feishuAuth.saveUserInfo(result.userInfo);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 刷新认证状态
|
|
|
+ await this.authService.refreshAuthState();
|
|
|
+
|
|
|
+ // 跳转到首页
|
|
|
+ await this.router.navigate(['/homepage']);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 环境检测
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 检查是否在飞书客户端内
|
|
|
+if (this.feishuAuth.isInFeishuApp()) {
|
|
|
+ console.log('在飞书客户端内');
|
|
|
+} else {
|
|
|
+ console.log('在浏览器中');
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 飞书客户端内免登录
|
|
|
+
|
|
|
+```typescript
|
|
|
+async handleFeishuLogin(): Promise<void> {
|
|
|
+ const result = await this.feishuAuth.getFeishuAuthCode();
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ await this.handleLoginSuccess(result);
|
|
|
+ } else {
|
|
|
+ console.error('登录失败:', result.error);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 二维码登录
|
|
|
+
|
|
|
+```typescript
|
|
|
+initQRCodeLogin(): void {
|
|
|
+ this.feishuAuth.initQRCodeLogin(
|
|
|
+ 'qrcode-container',
|
|
|
+ async (result: AuthResult) => {
|
|
|
+ if (result.success) {
|
|
|
+ await this.handleLoginSuccess(result);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ (error: string) => {
|
|
|
+ console.error('二维码登录失败:', error);
|
|
|
+ }
|
|
|
+ );
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 账号密码登录
|
|
|
+
|
|
|
+```typescript
|
|
|
+async handleWebLogin(username: string, password: string): Promise<void> {
|
|
|
+ const result = await this.feishuAuth.webLogin(username, password);
|
|
|
+
|
|
|
+ if (result.success) {
|
|
|
+ await this.handleLoginSuccess(result);
|
|
|
+ } else {
|
|
|
+ console.error('登录失败:', result.error);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## API 参考
|
|
|
+
|
|
|
+### FeishuAuth 类
|
|
|
+
|
|
|
+#### 构造函数
|
|
|
+
|
|
|
+```typescript
|
|
|
+constructor(config: FeishuAuthConfig)
|
|
|
+```
|
|
|
+
|
|
|
+#### 方法
|
|
|
+
|
|
|
+##### isInFeishuApp(): boolean
|
|
|
+
|
|
|
+检查是否在飞书客户端环境内。
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+- `true`:在飞书客户端内
|
|
|
+- `false`:在浏览器中
|
|
|
+
|
|
|
+##### getFeishuAuthCode(): Promise<AuthResult>
|
|
|
+
|
|
|
+飞书客户端内免登录,获取授权码并完成登录。
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ success: boolean;
|
|
|
+ userInfo?: UserInfo;
|
|
|
+ error?: string;
|
|
|
+ loginType: 'feishu' | 'web' | 'qrcode';
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### initQRCodeLogin(containerId, onSuccess, onError): void
|
|
|
+
|
|
|
+初始化二维码登录。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+- `containerId: string` - 二维码容器的 DOM ID
|
|
|
+- `onSuccess: (result: AuthResult) => void` - 登录成功回调
|
|
|
+- `onError: (error: string) => void` - 登录失败回调
|
|
|
+
|
|
|
+##### handleAuthCallback(code, state): Promise<AuthResult>
|
|
|
+
|
|
|
+处理飞书授权回调。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+- `code: string` - 授权码
|
|
|
+- `state: string` - 状态参数
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ success: boolean;
|
|
|
+ userInfo?: UserInfo;
|
|
|
+ error?: string;
|
|
|
+ loginType: 'feishu' | 'web' | 'qrcode';
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### webLogin(username, password): Promise<AuthResult>
|
|
|
+
|
|
|
+账号密码登录。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+- `username: string` - 用户名
|
|
|
+- `password: string` - 密码
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ success: boolean;
|
|
|
+ userInfo?: UserInfo;
|
|
|
+ error?: string;
|
|
|
+ loginType: 'web';
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+##### saveUserInfo(userInfo): void
|
|
|
+
|
|
|
+保存用户信息到 localStorage。
|
|
|
+
|
|
|
+**参数:**
|
|
|
+- `userInfo: any` - 用户信息对象
|
|
|
+
|
|
|
+##### getUserInfo(): any
|
|
|
+
|
|
|
+从 localStorage 获取用户信息。
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+- 用户信息对象,如果未登录则返回 `null`
|
|
|
+
|
|
|
+##### isLoggedIn(): boolean
|
|
|
+
|
|
|
+检查用户是否已登录(基于 localStorage)。
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+- `true`:已登录且未过期(24小时内)
|
|
|
+- `false`:未登录或已过期
|
|
|
+
|
|
|
+##### logout(): Promise<void>
|
|
|
+
|
|
|
+退出登录,清除所有本地存储和 Parse 会话。
|
|
|
+
|
|
|
+##### validateConfig(): { isValid: boolean; errors: string[] }
|
|
|
+
|
|
|
+验证飞书应用配置。
|
|
|
+
|
|
|
+**返回值:**
|
|
|
+```typescript
|
|
|
+{
|
|
|
+ isValid: boolean;
|
|
|
+ errors: string[];
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### AuthResult 接口
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface AuthResult {
|
|
|
+ success: boolean;
|
|
|
+ code?: string;
|
|
|
+ userInfo?: {
|
|
|
+ userId: string;
|
|
|
+ username: string;
|
|
|
+ avatar: string;
|
|
|
+ nickname?: string;
|
|
|
+ email?: string;
|
|
|
+ mobile?: string;
|
|
|
+ sessionToken?: string;
|
|
|
+ openId?: string;
|
|
|
+ unionId?: string;
|
|
|
+ rawUserInfo?: any;
|
|
|
+ parseUser?: any;
|
|
|
+ };
|
|
|
+ error?: string;
|
|
|
+ loginType: 'feishu' | 'web' | 'qrcode';
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 常见问题
|
|
|
+
|
|
|
+### Q1: 二维码登录后无法跳转到首页?
|
|
|
+
|
|
|
+**原因:** `AuthService` 的认证状态没有更新,路由守卫拒绝跳转。
|
|
|
+
|
|
|
+**解决方案:** 在登录成功后调用 `authService.refreshAuthState()`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+private async handleLoginSuccess(result: AuthResult): Promise<void> {
|
|
|
+ if (result.userInfo) {
|
|
|
+ this.feishuAuth.saveUserInfo(result.userInfo);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 刷新认证状态
|
|
|
+ await this.authService.refreshAuthState();
|
|
|
+
|
|
|
+ await this.router.navigate(['/homepage']);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### Q2: 二维码登录报错 4401?
|
|
|
+
|
|
|
+**原因:** 使用了旧版登录流程的 API 端点。
|
|
|
+
|
|
|
+**解决方案:** 确保使用旧版 goto URL(当前实现已正确):
|
|
|
+
|
|
|
+```typescript
|
|
|
+const goto = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${redirectUri}&response_type=code&state=success_login`;
|
|
|
+```
|
|
|
+
|
|
|
+### Q3: 如何区分二维码登录和客户端登录?
|
|
|
+
|
|
|
+**解决方案:** 通过 `state` 参数判断:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// state 为 'success_login' 表示二维码登录
|
|
|
+const loginType = state === 'success_login' ? 'qrcode' : 'feishu';
|
|
|
+```
|
|
|
+
|
|
|
+### Q4: 如何自定义后端 API 地址?
|
|
|
+
|
|
|
+**解决方案:** 在初始化时指定 `apiOauthWeb`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+this.feishuAuth = new FeishuAuth({
|
|
|
+ appId: 'YOUR_FEISHU_APP_ID',
|
|
|
+ redirectUri: window.location.origin + '/login',
|
|
|
+ apiOauthWeb: 'https://your-backend.com/api/feishu-v2/oauth2/login'
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### Q5: 如何处理登录过期?
|
|
|
+
|
|
|
+**解决方案:** `FeishuAuth` 内置了 24 小时过期检查:
|
|
|
+
|
|
|
+```typescript
|
|
|
+if (!this.feishuAuth.isLoggedIn()) {
|
|
|
+ // 登录已过期,跳转到登录页
|
|
|
+ this.router.navigate(['/login']);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+## 最佳实践
|
|
|
+
|
|
|
+### 1. 环境配置管理
|
|
|
+
|
|
|
+使用环境变量管理不同环境的配置:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// environment.ts
|
|
|
+export const environment = {
|
|
|
+ production: false,
|
|
|
+ feishu: {
|
|
|
+ appId: 'dev_app_id',
|
|
|
+ apiOauthWeb: 'http://localhost:3000/api/feishu-v2/oauth2/login'
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// environment.prod.ts
|
|
|
+export const environment = {
|
|
|
+ production: true,
|
|
|
+ feishu: {
|
|
|
+ appId: 'prod_app_id',
|
|
|
+ apiOauthWeb: 'https://api.your-domain.com/api/feishu-v2/oauth2/login'
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 使用
|
|
|
+this.feishuAuth = new FeishuAuth({
|
|
|
+ appId: environment.feishu.appId,
|
|
|
+ redirectUri: window.location.origin + '/login',
|
|
|
+ apiOauthWeb: environment.feishu.apiOauthWeb
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 2. 错误处理
|
|
|
+
|
|
|
+统一处理登录错误:
|
|
|
+
|
|
|
+```typescript
|
|
|
+private async handleLoginError(error: string): Promise<void> {
|
|
|
+ console.error('登录失败:', error);
|
|
|
+
|
|
|
+ // 显示错误提示
|
|
|
+ this.errorMessage.set(error);
|
|
|
+
|
|
|
+ // 记录错误日志
|
|
|
+ this.logError('login_failed', { error });
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 3. 加载状态管理
|
|
|
+
|
|
|
+使用 signal 管理加载状态:
|
|
|
+
|
|
|
+```typescript
|
|
|
+loading = signal(false);
|
|
|
+
|
|
|
+async handleFeishuLogin(): Promise<void> {
|
|
|
+ this.loading.set(true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const result = await this.feishuAuth.getFeishuAuthCode();
|
|
|
+ // 处理结果
|
|
|
+ } finally {
|
|
|
+ this.loading.set(false);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 4. 路由守卫配置
|
|
|
+
|
|
|
+确保所有需要登录的路由都配置了守卫:
|
|
|
+
|
|
|
+```typescript
|
|
|
+export const routes: Routes = [
|
|
|
+ {
|
|
|
+ path: 'login',
|
|
|
+ component: LoginComponent
|
|
|
+ },
|
|
|
+ {
|
|
|
+ path: '',
|
|
|
+ canActivate: [authGuard],
|
|
|
+ children: [
|
|
|
+ { path: 'homepage', component: HomepageComponent },
|
|
|
+ { path: 'profile', component: ProfileComponent }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+];
|
|
|
+```
|
|
|
+
|
|
|
+### 5. 会话恢复
|
|
|
+
|
|
|
+在应用启动时自动恢复会话:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// app.component.ts
|
|
|
+async ngOnInit(): Promise<void> {
|
|
|
+ // AuthService 会自动从 Parse 恢复会话
|
|
|
+ await this.authService.refreshAuthState();
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 6. 安全最佳实践
|
|
|
+
|
|
|
+- 不要在前端代码中硬编码 App Secret
|
|
|
+- 使用 HTTPS 传输敏感信息
|
|
|
+- 定期更新 sessionToken
|
|
|
+- 实现登录失败次数限制
|
|
|
+- 记录登录日志用于安全审计
|
|
|
+
|
|
|
+### 7. 用户体验优化
|
|
|
+
|
|
|
+- 根据环境自动选择登录方式
|
|
|
+- 提供清晰的错误提示
|
|
|
+- 添加加载动画
|
|
|
+- 支持记住登录状态
|
|
|
+- 提供退出登录功能
|
|
|
+
|
|
|
+## 技术栈
|
|
|
+
|
|
|
+- Angular 17+
|
|
|
+- TypeScript 5.0+
|
|
|
+- Parse SDK
|
|
|
+- 飞书 JSSDK
|
|
|
+- 飞书二维码登录 SDK
|
|
|
+
|
|
|
+## 许可证
|
|
|
+
|
|
|
+MIT
|
|
|
+
|
|
|
+## 支持
|
|
|
+
|
|
|
+如有问题或建议,请联系开发团队。
|