|
|
@@ -0,0 +1,507 @@
|
|
|
+/**
|
|
|
+ * 飞书Express路由模块
|
|
|
+ *
|
|
|
+ * 功能说明:
|
|
|
+ * - 提供所有飞书相关的HTTP API路由
|
|
|
+ * - 支持OAuth2登录、用户信息获取等功能
|
|
|
+ * - 自动处理token管理和用户会话
|
|
|
+ *
|
|
|
+ * @module router
|
|
|
+ * @author fmode
|
|
|
+ * @date 2026
|
|
|
+ */
|
|
|
+
|
|
|
+import express from 'express';
|
|
|
+import type { Request, Response } from 'express';
|
|
|
+import type { FeishuAppConfig } from './config.ts';
|
|
|
+import { FeishuConfig } from './config.ts';
|
|
|
+import { FeishuTokenManager } from './token-manager.ts';
|
|
|
+import { FeishuUserManager } from './user-manager.ts';
|
|
|
+import { FeishuClient } from './client.ts';
|
|
|
+
|
|
|
+/**
|
|
|
+ * 飞书路由配置接口
|
|
|
+ */
|
|
|
+export interface FeishuRouterConfig {
|
|
|
+ [appId: string]: FeishuAppConfig;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 创建飞书路由实例
|
|
|
+ *
|
|
|
+ * @param config - 飞书应用配置(支持多个应用)
|
|
|
+ * @returns Express Router实例
|
|
|
+ */
|
|
|
+export function createFeishuRouter(config: FeishuRouterConfig): express.Router {
|
|
|
+ const router = express.Router();
|
|
|
+
|
|
|
+ // 创建配置管理器实例
|
|
|
+ const feishuConfig = new FeishuConfig();
|
|
|
+ feishuConfig.setAppConfigs(config);
|
|
|
+
|
|
|
+ // 创建Token管理器实例(传入配置管理器)
|
|
|
+ const feishuTokenManager = new FeishuTokenManager(feishuConfig);
|
|
|
+
|
|
|
+ // 创建用户管理器实例(传入配置管理器)
|
|
|
+ const feishuUserManager = new FeishuUserManager(feishuConfig);
|
|
|
+
|
|
|
+ console.log(`加载飞书 API 路由,配置了 ${Object.keys(config).length} 个应用`);
|
|
|
+
|
|
|
+ /**
|
|
|
+ * GET /test
|
|
|
+ * 测试端点,验证飞书模块是否正常加载
|
|
|
+ */
|
|
|
+ router.get('/test', (req: Request, res: Response) => {
|
|
|
+ const apps = Object.keys(config).map(appId => ({
|
|
|
+ appId,
|
|
|
+ company: config[appId].company,
|
|
|
+ enabled: config[appId].enabled
|
|
|
+ }));
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ message: "飞书 API 模块已加载",
|
|
|
+ version: "1.0.0",
|
|
|
+ timestamp: new Date().toISOString(),
|
|
|
+ apps: apps,
|
|
|
+ endpoints: {
|
|
|
+ oauth2Login: "POST /oauth2/login - OAuth2扫码登录",
|
|
|
+ oauth2Feishu: "POST /oauth2/feishu - 网页应用免登录",
|
|
|
+ oauth2Refresh: "POST /oauth2/refresh_token - 刷新令牌",
|
|
|
+ userSync: "POST /user/sync - 同步用户信息",
|
|
|
+ forward: "POST /forward - 转发API请求",
|
|
|
+ tokenStatus: "POST /token/status - 获取Token状态",
|
|
|
+ tokenRefresh: "POST /token/refresh - 强制刷新Token",
|
|
|
+ callback: "ALL /callback - 事件回调"
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 通用错误响应函数
|
|
|
+ */
|
|
|
+ function goWrong(response: Response, msg: string): void {
|
|
|
+ response.status(500);
|
|
|
+ response.json({
|
|
|
+ code: 500,
|
|
|
+ mess: msg
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 记录认证事件到OpenEvent表
|
|
|
+ */
|
|
|
+ async function recordAuthEvent(appId: string, action: string, data: any): Promise<void> {
|
|
|
+ try {
|
|
|
+ const Parse = (global as any).Parse;
|
|
|
+ if (!Parse) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const OpenEvent = Parse.Object.extend('OpenEvent');
|
|
|
+ const eventRecord = new OpenEvent();
|
|
|
+
|
|
|
+ eventRecord.set('provider', 'feishu');
|
|
|
+ eventRecord.set('eventType', 'auth_event');
|
|
|
+ eventRecord.set('appId', appId);
|
|
|
+ eventRecord.set('action', action);
|
|
|
+ eventRecord.set('status', 'success');
|
|
|
+ eventRecord.set('eventData', data);
|
|
|
+ eventRecord.set('createdAt', new Date());
|
|
|
+ eventRecord.set('updatedAt', new Date());
|
|
|
+
|
|
|
+ await eventRecord.save();
|
|
|
+ console.log(`记录认证事件到OpenEvent: ${appId}, action: ${action}`);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('记录认证事件失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * OAuth2扫码登录
|
|
|
+ */
|
|
|
+ router.post('/oauth2/login', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId, code } = req.body;
|
|
|
+
|
|
|
+ if (!appId || !code) {
|
|
|
+ goWrong(res, "缺少appId或code参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const appConfig = feishuConfig.getAppConfig(appId);
|
|
|
+ console.log(`使用应用配置: ${appId}`);
|
|
|
+
|
|
|
+ if (typeof code !== 'string' || code.length < 10) {
|
|
|
+ goWrong(res, "授权码格式无效");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`开始OAuth2登录流程,appId: ${appId}, code: ${code.substring(0, 8)}...`);
|
|
|
+
|
|
|
+ const tokenInfo = await FeishuClient.getUserAccessToken(
|
|
|
+ appConfig.appId,
|
|
|
+ appConfig.appSecret,
|
|
|
+ code,
|
|
|
+ 'authorization_code'
|
|
|
+ );
|
|
|
+
|
|
|
+ console.log(`成功获取OAuth2令牌,有效期: ${tokenInfo.expiresIn}秒`);
|
|
|
+
|
|
|
+ const userInfo = await FeishuClient.getUserInfo(tokenInfo.accessToken);
|
|
|
+
|
|
|
+ console.log(`成功获取用户信息,用户: ${userInfo.name} (${userInfo.unionId})`);
|
|
|
+
|
|
|
+ const userSessionInfo = await feishuUserManager.getUserInfoSessionToken(userInfo, appId);
|
|
|
+
|
|
|
+ console.log(`用户登录处理完成,用户ID: ${userSessionInfo.user.id}`);
|
|
|
+
|
|
|
+ await recordAuthEvent('oauth2_' + appId, 'oauth2_login', {
|
|
|
+ appId: appId,
|
|
|
+ code: code.substring(0, 8) + '...',
|
|
|
+ userInfo: {
|
|
|
+ name: userInfo.name,
|
|
|
+ unionId: userInfo.unionId,
|
|
|
+ openId: userInfo.openId
|
|
|
+ },
|
|
|
+ tokenExpireIn: tokenInfo.expiresIn,
|
|
|
+ userId: userSessionInfo.user.id,
|
|
|
+ username: userSessionInfo.user.get('username')
|
|
|
+ });
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "登录成功",
|
|
|
+ data: {
|
|
|
+ userInfo: {
|
|
|
+ ...userInfo,
|
|
|
+ objectId: userSessionInfo.user.id,
|
|
|
+ username: userSessionInfo.user.get('username'),
|
|
|
+ nickname: userSessionInfo.user.get('nickname'),
|
|
|
+ mobile: userSessionInfo.user.get('mobile'),
|
|
|
+ email: userSessionInfo.user.get('email'),
|
|
|
+ avatar: userSessionInfo.user.get('avatar')
|
|
|
+ },
|
|
|
+ tokenInfo: {
|
|
|
+ accessToken: tokenInfo.accessToken,
|
|
|
+ refreshToken: tokenInfo.refreshToken,
|
|
|
+ expiresIn: tokenInfo.expiresIn
|
|
|
+ },
|
|
|
+ sessionToken: userSessionInfo.sessionToken.get('sessionToken')
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('OAuth2登录失败:', error);
|
|
|
+
|
|
|
+ if (error.message?.includes('invalid_grant')) {
|
|
|
+ goWrong(res, "授权码已过期或无效,请重新授权");
|
|
|
+ } else if (error.message?.includes('invalid_client')) {
|
|
|
+ goWrong(res, "应用信息无效,请检查appId和appSecret");
|
|
|
+ } else {
|
|
|
+ goWrong(res, error.message || "OAuth2登录失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * OAuth2刷新访问令牌
|
|
|
+ */
|
|
|
+ router.post('/oauth2/refresh_token', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId, refreshToken } = req.body;
|
|
|
+
|
|
|
+ if (!appId || !refreshToken) {
|
|
|
+ goWrong(res, "缺少appId或refreshToken参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const appConfig = feishuConfig.getAppConfig(appId);
|
|
|
+
|
|
|
+ console.log(`开始刷新OAuth2令牌,appId: ${appId}`);
|
|
|
+
|
|
|
+ const tokenInfo = await FeishuClient.refreshUserAccessToken(
|
|
|
+ appConfig.appId,
|
|
|
+ appConfig.appSecret,
|
|
|
+ refreshToken
|
|
|
+ );
|
|
|
+
|
|
|
+ console.log(`成功刷新OAuth2令牌,新有效期: ${tokenInfo.expiresIn}秒`);
|
|
|
+
|
|
|
+ await recordAuthEvent('oauth2_' + appId, 'oauth2_refresh', {
|
|
|
+ appId: appId,
|
|
|
+ newExpireIn: tokenInfo.expiresIn
|
|
|
+ });
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "令牌刷新成功",
|
|
|
+ data: {
|
|
|
+ accessToken: tokenInfo.accessToken,
|
|
|
+ refreshToken: tokenInfo.refreshToken,
|
|
|
+ expiresIn: tokenInfo.expiresIn
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('刷新OAuth2令牌失败:', error);
|
|
|
+
|
|
|
+ if (error.message?.includes('invalid_grant')) {
|
|
|
+ goWrong(res, "刷新令牌已过期或无效,请重新登录");
|
|
|
+ } else {
|
|
|
+ goWrong(res, error.message || "刷新令牌失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 飞书网页免登OAuth2接口
|
|
|
+ */
|
|
|
+ router.post('/oauth2/feishu', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId, code } = req.body;
|
|
|
+
|
|
|
+ if (!appId || !code) {
|
|
|
+ goWrong(res, "缺少appId或code参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const appConfig = feishuConfig.getAppConfig(appId);
|
|
|
+ console.log(`使用应用配置: ${appId}`);
|
|
|
+
|
|
|
+ const tenantAccessToken = await feishuTokenManager.getTenantAccessToken(appId);
|
|
|
+
|
|
|
+ if (typeof code !== 'string' || code.length < 10) {
|
|
|
+ goWrong(res, "授权码格式无效");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`开始网页免登OAuth2流程,appId: ${appId}, code: ${code.substring(0, 8)}...`);
|
|
|
+
|
|
|
+ const loginInfo = await FeishuClient.getLoginUserInfo(tenantAccessToken, code);
|
|
|
+
|
|
|
+ console.log(`成功获取登录用户身份,access_token有效期: ${loginInfo.expiresIn}秒`);
|
|
|
+
|
|
|
+ const userInfo = await FeishuClient.getUserInfo(loginInfo.accessToken);
|
|
|
+
|
|
|
+ console.log(`成功获取飞书用户信息,unionId: ${userInfo.unionId}, userId: ${userInfo.userId}`);
|
|
|
+
|
|
|
+ const userSessionInfo = await feishuUserManager.getUserInfoSessionToken(userInfo, appId);
|
|
|
+
|
|
|
+ console.log(`用户登录处理完成,用户ID: ${userSessionInfo.user.id}`);
|
|
|
+
|
|
|
+ await recordAuthEvent('weblogin_' + appId, 'weblogin_oauth2', {
|
|
|
+ appId: appId,
|
|
|
+ code: code.substring(0, 8) + '...',
|
|
|
+ userInfo: {
|
|
|
+ name: userInfo.name,
|
|
|
+ unionId: userInfo.unionId,
|
|
|
+ openId: userInfo.openId
|
|
|
+ },
|
|
|
+ userId: userSessionInfo.user.id,
|
|
|
+ username: userSessionInfo.user.get('username')
|
|
|
+ });
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "登录成功",
|
|
|
+ data: {
|
|
|
+ userInfo: {
|
|
|
+ ...userInfo,
|
|
|
+ objectId: userSessionInfo.user.id,
|
|
|
+ username: userSessionInfo.user.get('username'),
|
|
|
+ nickname: userSessionInfo.user.get('nickname'),
|
|
|
+ mobile: userSessionInfo.user.get('mobile'),
|
|
|
+ email: userSessionInfo.user.get('email'),
|
|
|
+ avatar: userSessionInfo.user.get('avatar')
|
|
|
+ },
|
|
|
+ sessionToken: userSessionInfo.sessionToken.get('sessionToken')
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('飞书网页免登失败:', error);
|
|
|
+
|
|
|
+ if (error.message?.includes('code')) {
|
|
|
+ goWrong(res, "授权码已过期或无效,请重新授权");
|
|
|
+ } else if (error.message?.includes('应用配置中缺少company信息')) {
|
|
|
+ goWrong(res, "应用配置不完整,请联系管理员");
|
|
|
+ } else {
|
|
|
+ goWrong(res, error.message || "网页免登失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 同步飞书用户信息
|
|
|
+ */
|
|
|
+ router.post('/user/sync', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { userInfo, appId } = req.body;
|
|
|
+
|
|
|
+ if (!userInfo || !appId) {
|
|
|
+ goWrong(res, "缺少userInfo或appId参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!userInfo.unionId || !userInfo.openId) {
|
|
|
+ goWrong(res, "userInfo中缺少必要的unionId或openId");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(`开始同步飞书用户,unionId: ${userInfo.unionId}, openId: ${userInfo.openId}, appId: ${appId}`);
|
|
|
+
|
|
|
+ const userSessionInfo = await feishuUserManager.getUserInfoSessionToken(userInfo, appId);
|
|
|
+
|
|
|
+ console.log(`用户同步完成,用户ID: ${userSessionInfo.user.id}`);
|
|
|
+
|
|
|
+ await recordAuthEvent('sync_' + appId, 'user_sync', {
|
|
|
+ appId: appId,
|
|
|
+ userInfo: {
|
|
|
+ unionId: userInfo.unionId,
|
|
|
+ openId: userInfo.openId,
|
|
|
+ name: userInfo.name
|
|
|
+ },
|
|
|
+ userId: userSessionInfo.user.id,
|
|
|
+ username: userSessionInfo.user.get('username')
|
|
|
+ });
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "用户同步成功",
|
|
|
+ data: {
|
|
|
+ userInfo: userSessionInfo.userInfo,
|
|
|
+ sessionToken: userSessionInfo.sessionToken.get('sessionToken')
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('同步飞书用户失败:', error);
|
|
|
+ goWrong(res, error.message || "用户同步失败");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 转发请求到飞书API
|
|
|
+ */
|
|
|
+ router.post('/forward', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId, path, method = 'GET', query, body } = req.body;
|
|
|
+
|
|
|
+ if (!appId || !path) {
|
|
|
+ goWrong(res, "缺少appId或path参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const tenantAccessToken = await feishuTokenManager.getTenantAccessToken(appId);
|
|
|
+
|
|
|
+ const result = await FeishuClient.forwardRequest(tenantAccessToken, {
|
|
|
+ path,
|
|
|
+ method,
|
|
|
+ query,
|
|
|
+ body
|
|
|
+ });
|
|
|
+
|
|
|
+ await recordAuthEvent(appId, 'api_forward', {
|
|
|
+ path: path,
|
|
|
+ method: method,
|
|
|
+ success: result.code === 0
|
|
|
+ });
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "成功",
|
|
|
+ data: { result }
|
|
|
+ });
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('转发请求失败:', error);
|
|
|
+ goWrong(res, error.message || "转发请求失败");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取token状态
|
|
|
+ */
|
|
|
+ router.post('/token/status', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId } = req.body;
|
|
|
+
|
|
|
+ if (!appId) {
|
|
|
+ goWrong(res, "缺少appId参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const status = await feishuTokenManager.getTokenStatus(appId);
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "成功",
|
|
|
+ data: status
|
|
|
+ });
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('获取token状态失败:', error);
|
|
|
+ goWrong(res, error.message || "获取token状态失败");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 强制刷新token
|
|
|
+ */
|
|
|
+ router.post('/token/refresh', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const { appId } = req.body;
|
|
|
+
|
|
|
+ if (!appId) {
|
|
|
+ goWrong(res, "缺少appId参数");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const tenantAccessToken = await feishuTokenManager.forceRefreshToken(appId);
|
|
|
+
|
|
|
+ res.json({
|
|
|
+ code: 1,
|
|
|
+ mess: "成功",
|
|
|
+ data: { tenantAccessToken }
|
|
|
+ });
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('刷新token失败:', error);
|
|
|
+ goWrong(res, error.message || "刷新token失败");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 飞书事件回调
|
|
|
+ */
|
|
|
+ router.all('/callback', async function (req: Request, res: Response) {
|
|
|
+ try {
|
|
|
+ const Parse = (global as any).Parse;
|
|
|
+
|
|
|
+ if (Parse) {
|
|
|
+ const openEvent = new Parse.Object('OpenEvent');
|
|
|
+ openEvent.set('provider', 'feishu');
|
|
|
+ openEvent.set('eventType', 'callback');
|
|
|
+ openEvent.set('receivedAt', new Date());
|
|
|
+ openEvent.set('requestData', {
|
|
|
+ method: req.method,
|
|
|
+ query: req.query,
|
|
|
+ body: req.body,
|
|
|
+ headers: req.headers
|
|
|
+ });
|
|
|
+
|
|
|
+ await openEvent.save();
|
|
|
+ console.log('飞书回调事件已记录到OpenEvent表');
|
|
|
+ }
|
|
|
+
|
|
|
+ res.send('success');
|
|
|
+ } catch (error) {
|
|
|
+ console.error('处理飞书回调失败:', error);
|
|
|
+ res.status(500).send('error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ return router;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 默认导出创建路由的工厂函数
|
|
|
+ */
|
|
|
+export default createFeishuRouter;
|