diff --git a/backend/app/controller/AuthController.php b/backend/app/controller/AuthController.php index a9e5c05..c33330d 100644 --- a/backend/app/controller/AuthController.php +++ b/backend/app/controller/AuthController.php @@ -86,7 +86,7 @@ class AuthController extends BaseController } $companyId = $feishuApp->company_id; - error_log("✅ 找到飞书应用配置: company_id={$companyId}"); + error_log("✅ 找到飞书应用配置: company_id={$companyId}, feishu_app_id={$feishuApp->id}"); $feishuService->setCompany($companyId); } @@ -111,8 +111,11 @@ class AuthController extends BaseController return $this->error('未配置飞书应用'); } - error_log("✅ 找到公司配置: company_id={$companyId}"); + error_log("✅ 找到公司配置: company_id={$companyId}, feishu_app_id={$feishuApp->id}"); $feishuService->setCompany($companyId); + + // 设置应用ID + $appId = $feishuApp->id; } else { return $this->error('缺少 app_id 或 tenant_key 参数'); } @@ -121,15 +124,15 @@ class AuthController extends BaseController $feishuData = $feishuService->getUserInfo($authCode); error_log("👤 获取到用户信息: " . json_encode($feishuData)); - // 创建或更新用户 - $user = $feishuService->createOrUpdateUser($feishuData, $companyId); + // 创建或更新用户(传递应用ID) + $user = $feishuService->createOrUpdateUser($feishuData, $companyId, $appId); // 生成 JWT Token $token = JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, - 'union_id' => $user->feishu_union_id, + 'union_id' => $user->union_id, ]); error_log("✅ 登录成功: user_id={$user->id}, company_id={$user->company_id}"); @@ -222,6 +225,7 @@ class AuthController extends BaseController $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分钟过期 ]); @@ -279,7 +283,7 @@ class AuthController extends BaseController 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, - 'union_id' => $user->feishu_union_id, + 'union_id' => $user->union_id, ]); // 清除会话 @@ -346,7 +350,7 @@ class AuthController extends BaseController return $this->error('二维码已过期'); } - error_log("🔄 Processing login for company_id: {$session->company_id}"); + error_log("🔄 Processing login for company_id: {$session->company_id}, feishu_app_id: {$session->feishu_app_id}"); // 使用 session 的公司信息 $feishuService = new \app\service\FeishuService(); @@ -356,8 +360,8 @@ class AuthController extends BaseController $feishuData = $feishuService->getUserInfo($authCode); error_log("👤 Got user info: " . json_encode($feishuData)); - // 创建或更新用户 - $user = $feishuService->createOrUpdateUser($feishuData, $session->company_id); + // 创建或更新用户(传递飞书应用ID) + $user = $feishuService->createOrUpdateUser($feishuData, $session->company_id, $session->feishu_app_id); error_log("✅ User created/updated: user_id={$user->id}"); // 更新会话状态 @@ -371,7 +375,7 @@ class AuthController extends BaseController 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, - 'union_id' => $user->feishu_union_id, + 'union_id' => $user->union_id, ]); error_log("🎫 Generated token for user: {$user->id}"); diff --git a/backend/app/controller/TestController.php b/backend/app/controller/TestController.php index 337172f..0c6c252 100644 --- a/backend/app/controller/TestController.php +++ b/backend/app/controller/TestController.php @@ -18,13 +18,18 @@ class TestController extends BaseController try { $notification = new FeishuNotificationService(); - // 获取用户的company_id + // 获取用户的company_id和feishu_app_id $user = \app\model\User::find($userId); if (!$user) { return $this->error('用户不存在'); } + if (!$user->feishu_app_id) { + return $this->error('用户未绑定飞书应用'); + } + $notification->setCompany($user->company_id); + $notification->setApp($user->feishu_app_id); // 发送测试消息 $result = $notification->sendApprovalNotification([ @@ -37,11 +42,15 @@ class TestController extends BaseController ]); if ($result) { + // 获取用户在该应用下的feishu_user_id + $feishuUserId = $user->getFeishuUserId($user->feishu_app_id); + return $this->success([ 'message' => '测试消息发送成功', 'user_id' => $userId, 'user_name' => $user->name, - 'feishu_user_id' => $user->feishu_user_id, + 'feishu_user_id' => $feishuUserId, + 'feishu_app_id' => $user->feishu_app_id, ]); } else { return $this->error('测试消息发送失败,请查看日志'); diff --git a/backend/app/model/FeishuLoginSession.php b/backend/app/model/FeishuLoginSession.php index eb121c1..59ba869 100644 --- a/backend/app/model/FeishuLoginSession.php +++ b/backend/app/model/FeishuLoginSession.php @@ -11,6 +11,7 @@ class FeishuLoginSession extends Model protected $fillable = [ 'company_id', 'tenant_key', + 'feishu_app_id', 'status', 'user_id', 'expires_at', diff --git a/backend/app/model/User.php b/backend/app/model/User.php index 6881595..87fb166 100644 --- a/backend/app/model/User.php +++ b/backend/app/model/User.php @@ -8,6 +8,14 @@ class User extends Model { protected $table = 'users'; + /** + * 可填充字段 + * + * 注意:以下字段已废弃,保留仅用于向后兼容 + * @deprecated feishu_user_id - 请使用 UserFeishuApp 模型获取 + * @deprecated feishu_open_id - 请使用 UserFeishuApp 模型获取 + * @deprecated tenant_key - 请使用 UserFeishuApp 模型获取 + */ protected $fillable = [ 'company_id', 'sn', @@ -21,9 +29,12 @@ class User extends Model 'status', 'user_type', 'union_id', - 'feishu_user_id', - 'feishu_open_id', - 'tenant_key', + // 废弃字段 - 请使用 UserFeishuApp 模型 + 'feishu_user_id', // @deprecated + 'feishu_open_id', // @deprecated + 'tenant_key', // @deprecated + // 保留字段:记录最后登录的应用,用于快速获取默认应用 + 'feishu_app_id', 'position', 'sign_img', ]; @@ -66,6 +77,62 @@ class User extends Model return $this->hasMany(User::class, 'report_to'); } + /** + * 用户飞书应用绑定 + */ + public function feishuApps() + { + return $this->hasMany(UserFeishuApp::class, 'user_id'); + } + + /** + * 最后登录的飞书应用 + */ + public function lastFeishuApp() + { + return $this->belongsTo(CompanyFeishuApp::class, 'feishu_app_id'); + } + + /** + * 获取用户在当前公司的默认飞书应用 + */ + public function getDefaultFeishuApp() + { + return $this->hasOne(UserFeishuApp::class, 'user_id') + ->where('company_id', $this->company_id) + ->where('is_default', 1); + } + + /** + * 获取用户在指定飞书应用下的绑定信息 + */ + public function feishuAppBinding(int $feishuAppId) + { + return $this->hasOne(UserFeishuApp::class, 'user_id') + ->where('company_id', $this->company_id) + ->where('feishu_app_id', $feishuAppId); + } + + /** + * 获取用户在指定飞书应用下的 feishu_user_id + */ + public function getFeishuUserId(int $feishuAppId): ?string + { + $binding = $this->feishuAppBinding($feishuAppId)->first(); + return $binding ? $binding->feishu_user_id : null; + } + + /** + * 获取用户在最后登录应用下的 feishu_user_id + */ + public function getLastFeishuUserId(): ?string + { + if (!$this->feishu_app_id) { + return null; + } + return $this->getFeishuUserId($this->feishu_app_id); + } + /** * 获取启用的用户 */ @@ -84,6 +151,7 @@ class User extends Model /** * 根据飞书用户ID查找 + * @deprecated 请通过 UserFeishuApp 模型查询 */ public function scopeByFeishuUserId($query, ?string $feishuUserId) { @@ -95,6 +163,7 @@ class User extends Model /** * 根据飞书 UnionID 查找 + * union_id 用于跨应用识别用户,保留此方法 */ public function scopeByFeishuUnionId($query, ?string $feishuUnionId) { @@ -106,6 +175,7 @@ class User extends Model /** * 根据OpenID查找 + * @deprecated 请通过 UserFeishuApp 模型查询 */ public function scopeByFeishuOpenId($query, ?string $openId) { diff --git a/backend/app/model/UserFeishuApp.php b/backend/app/model/UserFeishuApp.php new file mode 100644 index 0000000..b438558 --- /dev/null +++ b/backend/app/model/UserFeishuApp.php @@ -0,0 +1,78 @@ + 'integer', + 'company_id' => 'integer', + 'feishu_app_id' => 'integer', + 'is_default' => 'boolean', + 'last_login_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * 关联用户 + */ + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * 关联公司 + */ + public function company() + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * 关联飞书应用配置 + */ + public function feishuApp() + { + return $this->belongsTo(CompanyFeishuApp::class, 'feishu_app_id'); + } + + /** + * 获取启用的绑定 + */ + public function scopeActive($query) + { + return $query->whereHas('feishuApp', function ($q) { + $q->where('status', 1); + }); + } + + /** + * 获取用户在某公司的默认应用 + */ + public function scopeDefaultApp($query, int $userId, int $companyId) + { + return $query->where('user_id', $userId) + ->where('company_id', $companyId) + ->where('is_default', 1); + } +} diff --git a/backend/app/service/FeishuNotificationService.php b/backend/app/service/FeishuNotificationService.php index 0089f27..32650b9 100644 --- a/backend/app/service/FeishuNotificationService.php +++ b/backend/app/service/FeishuNotificationService.php @@ -27,7 +27,7 @@ class FeishuNotificationService } /** - * 设置飞书应用ID(支持多应用) + * 设置飞书应用ID(必须指定) */ public function setApp(int $appId): self { @@ -36,7 +36,29 @@ class FeishuNotificationService } /** - * 获取飞书应用配置 + * 根据用户ID自动设置其最后登录的应用 + */ + public function setAppByUser(int $userId): self + { + $user = \app\model\User::find($userId); + if (!$user) { + throw new Exception('用户不存在'); + } + + $this->setCompany($user->company_id); + + // 使用用户最后登录的应用 + if ($user->feishu_app_id) { + $this->appId = $user->feishu_app_id; + } else { + throw new Exception('用户未绑定飞书应用,请先通过飞书登录'); + } + + return $this; + } + + /** + * 获取飞书应用配置(必须先通过 setApp 或 setAppByUser 指定) */ private function getAppConfig(): array { @@ -44,22 +66,17 @@ class FeishuNotificationService throw new Exception('未设置公司ID'); } - if ($this->appId) { - $app = \app\model\CompanyFeishuApp::find($this->appId); - if (!$app || $app->status !== 1) { - throw new Exception('飞书应用配置不存在'); - } - return $app->toArray(); + if (!$this->appId) { + throw new Exception('未指定飞书应用,请通过 setApp() 或 setAppByUser() 指定'); } $app = \app\model\CompanyFeishuApp::where('company_id', $this->companyId) - ->where('system_id', 1) + ->where('id', $this->appId) ->where('status', 1) - ->orderBy('sort') ->first(); if (!$app) { - throw new Exception('未配置飞书应用'); + throw new Exception('指定的飞书应用不存在或未启用'); } return $app->toArray(); @@ -74,6 +91,23 @@ class FeishuNotificationService return FeishuUtil::getAppAccessToken($app['id']); } + /** + * 获取用户在指定飞书应用下的 feishu_user_id + */ + private function getFeishuUserId(int $userId, int $companyId): ?string + { + if (!$this->appId) { + throw new Exception('未指定飞书应用,请先调用 setApp() 或 setAppByUser()'); + } + + $binding = \app\model\UserFeishuApp::where('user_id', $userId) + ->where('company_id', $companyId) + ->where('feishu_app_id', $this->appId) + ->first(); + + return $binding ? $binding->feishu_user_id : null; + } + /** * 发送审批通知 * @@ -98,13 +132,20 @@ class FeishuNotificationService try { $user = User::find($userId); - if (!$user || !$user->feishu_user_id) { - error_log("❌ sendApprovalNotification: 用户不存在或未绑定飞书, user_id={$userId}"); + if (!$user) { + error_log("❌ sendApprovalNotification: 用户不存在, user_id={$userId}"); + return false; + } + + // 从 user_feishu_apps 获取指定应用下的 feishu_user_id + $feishuUserId = $this->getFeishuUserId($userId, $user->company_id); + if (!$feishuUserId) { + error_log("❌ sendApprovalNotification: 用户未绑定飞书应用, user_id={$userId}"); return false; } $card = $this->buildApprovalCard($params); - return $this->sendMessage($user->feishu_user_id, $card); + return $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log('❌ sendApprovalNotification 失败: ' . $e->getMessage()); return false; @@ -133,13 +174,20 @@ class FeishuNotificationService foreach ($userIds as $userId) { try { $user = User::find($userId); - if (!$user || !$user->feishu_user_id) { + if (!$user) { + $results[$userId] = false; + continue; + } + + // 从 user_feishu_apps 获取指定应用下的 feishu_user_id + $feishuUserId = $this->getFeishuUserId($userId, $user->company_id); + if (!$feishuUserId) { $results[$userId] = false; continue; } $card = $this->buildSystemCard($params); - $results[$userId] = $this->sendMessage($user->feishu_user_id, $card); + $results[$userId] = $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log("❌ sendSystemNotification 失败: user_id={$userId}, error=" . $e->getMessage()); $results[$userId] = false; @@ -170,13 +218,20 @@ class FeishuNotificationService try { $user = User::find($userId); - if (!$user || !$user->feishu_user_id) { - error_log("❌ sendReminderNotification: 用户不存在或未绑定飞书, user_id={$userId}"); + if (!$user) { + error_log("❌ sendReminderNotification: 用户不存在, user_id={$userId}"); + return false; + } + + // 从 user_feishu_apps 获取指定应用下的 feishu_user_id + $feishuUserId = $this->getFeishuUserId($userId, $user->company_id); + if (!$feishuUserId) { + error_log("❌ sendReminderNotification: 用户未绑定飞书应用, user_id={$userId}"); return false; } $card = $this->buildReminderCard($params); - return $this->sendMessage($user->feishu_user_id, $card); + return $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log('❌ sendReminderNotification 失败: ' . $e->getMessage()); return false; diff --git a/backend/app/service/FeishuService.php b/backend/app/service/FeishuService.php index 27c6074..c12358c 100644 --- a/backend/app/service/FeishuService.php +++ b/backend/app/service/FeishuService.php @@ -123,11 +123,17 @@ class FeishuService /** * 创建或更新用户 * 注意:同一用户在不同企业的账号是独立的,通过 company_id + union_id 组合查找 + * + * @param array $feishuData 飞书用户数据 + * @param int $companyId 公司ID + * @param int $appId 飞书应用配置ID + * @return User */ - public function createOrUpdateUser(array $feishuData, int $companyId): User + public function createOrUpdateUser(array $feishuData, int $companyId, int $appId): User { $unionId = $feishuData['union_id'] ?? null; - $userId = $feishuData['user_id'] ?? null; + $feishuUserId = $feishuData['user_id'] ?? null; + $openId = $feishuData['open_id'] ?? null; $name = $feishuData['name'] ?? '未知'; $avatar = $feishuData['avatar_url'] ?? null; $email = $feishuData['email'] ?? null; @@ -136,8 +142,17 @@ class FeishuService throw new Exception('缺少 union_id'); } + // 获取飞书应用配置 + $app = CompanyFeishuApp::where('company_id', $companyId) + ->where('id', $appId) + ->where('status', 1) + ->first(); + + if (!$app) { + throw new Exception('飞书应用配置不存在或未启用'); + } + // ⭐ 关键:通过 company_id + union_id 组合查找用户 - // 这样同一个 union_id 在不同公司的账号是独立的 $user = User::where('company_id', $companyId) ->where('union_id', $unionId) ->first(); @@ -146,16 +161,16 @@ class FeishuService error_log("✨ 创建新用户: company={$companyId}, union_id={$unionId}, name={$name}"); // 初始密码使用 sn(工号),如果没有 sn 则使用 union_id 的前6位 - $initialPassword = $feishuData['sn'] ?? substr($unionId, 0, 6); + $initialPassword = $feishuData['employee_no'] ?? substr($unionId, 0, 6); $user = User::create([ 'company_id' => $companyId, - 'feishu_user_id' => $userId, 'union_id' => $unionId, 'name' => $name, 'email' => $email, 'avatar' => $avatar, 'password' => password_hash($initialPassword, PASSWORD_DEFAULT), // 初始密码加密 + 'feishu_app_id' => $appId, // 记录登录来源应用 'status' => 1, // 新用户默认在职 ]); error_log("🔑 初始密码设置为 sn: {$initialPassword}"); @@ -168,22 +183,57 @@ class FeishuService throw new Exception('该员工已离职,无法登录系统'); } - // 更新用户信息(在职员工) - $updateData = [ - 'feishu_user_id' => $userId, + // 更新用户信息和登录来源应用 + $user->update([ 'name' => $name, 'avatar' => $avatar, 'email' => $email, - ]; - - $user->update($updateData); + 'feishu_app_id' => $appId, + ]); } + // ⭐ 保存或更新用户飞书应用绑定关系 + $this->saveUserFeishuBinding($user->id, $companyId, $appId, $feishuUserId, $openId, $app->tenant_key); + return $user; } + /** + * 保存或更新用户飞书应用绑定关系 + */ + private function saveUserFeishuBinding(int $userId, int $companyId, int $appId, ?string $feishuUserId, ?string $openId, ?string $tenantKey): void + { + $binding = \app\model\UserFeishuApp::where('user_id', $userId) + ->where('company_id', $companyId) + ->where('feishu_app_id', $appId) + ->first(); + + $data = [ + 'feishu_user_id' => $feishuUserId, + 'feishu_open_id' => $openId, + 'tenant_key' => $tenantKey, + 'last_login_at' => date('Y-m-d H:i:s'), + ]; + + if ($binding) { + // 更新现有绑定 + $binding->update($data); + error_log("🔄 更新飞书绑定: user_id={$userId}, app_id={$appId}, feishu_user_id={$feishuUserId}"); + } else { + // 创建新绑定 + \app\model\UserFeishuApp::create([ + 'user_id' => $userId, + 'company_id' => $companyId, + 'feishu_app_id' => $appId, + 'is_default' => 0, // 后续可由用户或管理员设置 + ] + $data); + error_log("✨ 创建飞书绑定: user_id={$userId}, app_id={$appId}, feishu_user_id={$feishuUserId}"); + } + } + /** * 发送审批消息通知 + * @deprecated 请使用 FeishuNotificationService::sendApprovalNotification() 代替 */ public function sendApprovalMessage(int $userId, string $title, string $url): bool { diff --git a/backend/database/migrations/010_add_user_feishu_apps.sql b/backend/database/migrations/010_add_user_feishu_apps.sql new file mode 100644 index 0000000..64db7d6 --- /dev/null +++ b/backend/database/migrations/010_add_user_feishu_apps.sql @@ -0,0 +1,118 @@ +-- X-Server 用户多飞书应用绑定功能 +-- 执行方式:在数据库中直接执行此SQL + +USE xserver; + +-- ===================================== +-- 1. 添加 users 表唯一约束 +-- ===================================== + +-- 删除可能存在的旧索引 +ALTER TABLE users DROP INDEX IF EXISTS idx_email; +ALTER TABLE users DROP INDEX IF EXISTS idx_phone; + +-- 添加 email 唯一索引(同一公司内邮箱唯一) +CREATE UNIQUE INDEX uk_users_company_email ON users(company_id, email); + +-- 添加 phone 唯一索引(同一公司内手机号唯一) +CREATE UNIQUE INDEX uk_users_company_phone ON users(company_id, phone); + +-- ===================================== +-- 2. 创建用户飞书应用绑定表 +-- ===================================== + +DROP TABLE IF EXISTS user_feishu_apps; + +CREATE TABLE `user_feishu_apps` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT "ID", + `user_id` bigint unsigned NOT NULL COMMENT "用户ID", + `company_id` bigint unsigned NOT NULL COMMENT "公司ID", + `feishu_app_id` int unsigned NOT NULL COMMENT "飞书应用配置ID", + + -- 该应用中的飞书用户信息 + `feishu_user_id` varchar(60) DEFAULT NULL COMMENT "该应用的飞书用户ID", + `feishu_open_id` varchar(60) DEFAULT NULL COMMENT "该应用的飞书OpenID", + `tenant_key` varchar(60) DEFAULT NULL COMMENT "该应用的租户Key", + + -- 绑定信息 + `is_default` tinyint NOT NULL DEFAULT 0 COMMENT "是否为默认应用: 1=是, 0=否", + `last_login_at` timestamp NULL DEFAULT NULL COMMENT "最后一次通过此应用登录时间", + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT "绑定时间", + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT "更新时间", + + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_app` (`user_id`, `company_id`, `feishu_app_id`), + KEY `idx_user_default` (`user_id`, `company_id`, `is_default`), + KEY `idx_feishu_user_id` (`feishu_user_id`), + KEY `idx_feishu_open_id` (`feishu_open_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT="用户飞书应用绑定表"; + +-- ===================================== +-- 3. 修改 users 表,移除不需要的飞书字段 +-- ===================================== + +-- 注意:为了兼容性,先保留现有字段,后续可考虑迁移 +-- ALTER TABLE users DROP COLUMN feishu_user_id; +-- ALTER TABLE users DROP COLUMN feishu_open_id; +-- ALTER TABLE users DROP COLUMN tenant_key; + +-- 临时方案:添加 feishu_app_id 记录最后登录的应用 +ALTER TABLE users +ADD COLUMN `feishu_app_id` int unsigned DEFAULT NULL +COMMENT '最后登录的飞书应用ID' +AFTER `tenant_key`; + +ALTER TABLE users +ADD INDEX `idx_feishu_app_id` (`feishu_app_id`); + +-- ===================================== +-- 4. 迁移现有用户飞书绑定数据(如果有的话) +-- ===================================== + +-- 将现有 users 表中的飞书信息迁移到绑定表 +-- 只处理有 feishu_user_id 的用户 +INSERT INTO user_feishu_apps ( + user_id, + company_id, + feishu_app_id, + feishu_user_id, + feishu_open_id, + tenant_key, + is_default, + last_login_at +) +SELECT + u.id as user_id, + u.company_id, + u.feishu_app_id as feishu_app_id, + u.feishu_user_id, + u.feishu_open_id, + u.tenant_key, + 1 as is_default, + NOW() as last_login_at +FROM users u +WHERE u.feishu_user_id IS NOT NULL + AND u.feishu_app_id IS NOT NULL +ON DUPLICATE KEY UPDATE + last_login_at = NOW(); + +-- ===================================== +-- 验证结果 +-- ===================================== + +SELECT "=== 用户表唯一约束 ===" as ""; +SHOW INDEX FROM users WHERE Key_name LIKE 'uk_users_company%'; + +SELECT "=== 飞书应用绑定表结构 ===" as ""; +DESCRIBE user_feishu_apps; + +SELECT "=== 现有用户飞书绑定数据 ===" as ""; +SELECT + u.id as user_id, + u.name, + u.company_id, + u.feishu_app_id, + COUNT(ufa.id) as app_count +FROM users u +LEFT JOIN user_feishu_apps ufa ON u.id = ufa.user_id +GROUP BY u.id, u.name, u.company_id, u.feishu_app_id; diff --git a/backend/database/migrations/011_migrate_users_feishu_data.sql b/backend/database/migrations/011_migrate_users_feishu_data.sql new file mode 100644 index 0000000..9f3f638 --- /dev/null +++ b/backend/database/migrations/011_migrate_users_feishu_data.sql @@ -0,0 +1,92 @@ +-- X-Server 用户表飞书数据迁移 +-- 将 users 表中的飞书字段数据迁移到 user_feishu_apps 表 + +USE xserver; + +-- ===================================== +-- 1. 迁移现有数据到 user_feishu_apps +-- ===================================== + +-- 将 users 表中的飞书信息迁移到 user_feishu_apps 表 +-- 使用每个公司的默认飞书应用 (system_id=1) +INSERT INTO user_feishu_apps ( + user_id, + company_id, + feishu_app_id, + feishu_user_id, + feishu_open_id, + tenant_key, + is_default, + last_login_at, + created_at, + updated_at +) +SELECT + u.id as user_id, + u.company_id, + cfa.id as feishu_app_id, + u.feishu_user_id, + u.feishu_open_id, + u.tenant_key, + 1 as is_default, + NOW() as last_login_at, + u.created_at, + u.updated_at +FROM users u +INNER JOIN company_feishu_apps cfa ON cfa.company_id = u.company_id +WHERE u.feishu_user_id IS NOT NULL + AND cfa.system_id = 1 + AND cfa.status = 1 +ON DUPLICATE KEY UPDATE + feishu_user_id = VALUES(feishu_user_id), + feishu_open_id = VALUES(feishu_open_id), + tenant_key = VALUES(tenant_key), + last_login_at = NOW(); + +-- ===================================== +-- 2. 更新 users 表的 feishu_app_id +-- ===================================== + +UPDATE users u +INNER JOIN company_feishu_apps cfa ON cfa.company_id = u.company_id +SET u.feishu_app_id = cfa.id +WHERE u.feishu_user_id IS NOT NULL + AND cfa.system_id = 1 + AND cfa.status = 1; + +-- ===================================== +-- 3. 验证迁移结果 +-- ===================================== + +SELECT "=== 迁移前 users 表数据 ===" as ""; +SELECT + company_id, + COUNT(*) as total_users, + SUM(CASE WHEN feishu_user_id IS NOT NULL THEN 1 ELSE 0 END) as with_feishu_id, + SUM(CASE WHEN feishu_app_id IS NOT NULL THEN 1 ELSE 0 END) as with_app_id +FROM users +GROUP BY company_id; + +SELECT "=== 迁移后 user_feishu_apps 表数据 ===" as ""; +SELECT + company_id, + COUNT(DISTINCT user_id) as users_count, + COUNT(*) as bindings_count +FROM user_feishu_apps +GROUP BY company_id; + +-- ===================================== +-- 4. 标记冗余字段(不删除,保证向后兼容) +-- ===================================== + +-- 注意:以下字段保留但不推荐使用 +-- - feishu_user_id (冗余,应从 user_feishu_apps 获取) +-- - feishu_open_id (冗余,应从 user_feishu_apps 获取) +-- - tenant_key (冗余,应从 user_feishu_apps 获取) +-- +-- 推荐使用方式: +-- $binding = UserFeishuApp::where('user_id', $userId) +-- ->where('company_id', $companyId) +-- ->where('feishu_app_id', $appId) +-- ->first(); +-- $feishuUserId = $binding ? $binding->feishu_user_id : null; diff --git a/backend/database/migrations/012_cleanup_redundant_tables.sql b/backend/database/migrations/012_cleanup_redundant_tables.sql new file mode 100644 index 0000000..b70b56b --- /dev/null +++ b/backend/database/migrations/012_cleanup_redundant_tables.sql @@ -0,0 +1,31 @@ +-- X-Server 清理冗余表 +-- 执行前请确认已备份数据库 + +USE xserver; + +-- ===================================== +-- 1. 删除 users_bak 表(用户表旧版备份) +-- ===================================== + +DROP TABLE IF EXISTS users_bak; + +-- ===================================== +-- 2. 删除 user_feishu_binding 表(已被 user_feishu_apps 替代) +-- ===================================== + +DROP TABLE IF EXISTS user_feishu_binding; + +-- ===================================== +-- 验证结果 +-- ===================================== + +SELECT "=== 当前表列表 ===" as ""; +SHOW TABLES; + +SELECT "=== user_feishu_apps 统计 ===" as ""; +SELECT + company_id, + COUNT(DISTINCT user_id) as users_count, + COUNT(*) as bindings_count +FROM user_feishu_apps +GROUP BY company_id; diff --git a/docs/database-analysis.md b/docs/database-analysis.md new file mode 100644 index 0000000..79bf5ae --- /dev/null +++ b/docs/database-analysis.md @@ -0,0 +1,389 @@ +# X-Server Mobile 项目数据库与多对多关系分析报告 + +## 一、项目概述 + +**项目名称**: X-Server Mobile +**项目类型**: 企业内部H5移动工具平台 +**核心功能**: 审批流程引擎、多租户架构、飞书集成 + +## 二、数据库表结构分析 + +### 2.1 核心表关系图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 多对多关系架构 │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ │ +│ │ Person(人) │ ← 物理概念: 通过 union_id 识别 │ +│ │ union_id │ │ +│ └──────────────┘ │ +│ │ │ +│ ├────────────────────────────────────────────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 公司A Users │ │ 公司B Users │ │ +│ │ company_id=1 │ │ company_id=2 │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ │ │ +│ │ 每个 User 有对应的飞书身份 │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 公司飞书应用 (company_feishu_apps) │ │ +│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ +│ │ │ OA系统应用 │ │ 考勤系统应用 │ │ 审批系统应用 │ │ │ +│ │ │ app_id=aaa │ │ app_id=bbb │ │ app_id=ccc │ │ │ +│ │ └────────────┘ └────────────┘ └────────────┘ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 表结构详解 + +#### 1. companies (公司表) +```sql +CREATE TABLE `companies` ( + id BIGINT PRIMARY KEY, + name VARCHAR(100), -- 公司名称 + tenant_key VARCHAR(36), -- 飞书企业标识 + feishu_enabled TINYINT, -- 是否启用飞书 + ... +); +``` + +#### 2. users (用户表) ⚠️ 关键 +```sql +CREATE TABLE `users` ( + id BIGINT PRIMARY KEY, + company_id BIGINT, -- 所属公司 (单对一) + union_id VARCHAR(60), -- 飞书UnionID (全局唯一) + feishu_user_id VARCHAR(60), -- 该公司的飞书用户ID + feishu_open_id VARCHAR(60), -- 飞书OpenID + tenant_key VARCHAR(60), -- 当前企业标识 + ... +); +``` + +**设计特点**: +- ✅ 同一个人在不同公司 = 不同的 User 记录 +- ✅ 通过 `union_id + company_id` 唯一识别 +- ❌ 每个 User 只能存储一个公司的飞书身份 + +#### 3. company_feishu_apps (飞书应用配置表) +```sql +CREATE TABLE `company_feishu_apps` ( + id INT PRIMARY KEY, + company_id INT, -- 所属公司 + app_id VARCHAR(60), -- 飞书应用ID + tenant_key VARCHAR(60), -- 飞书租户key + system_id INT, -- 系统ID (1=X-Server) + ... +); +``` + +**设计特点**: +- ✅ 一个公司可以有多个飞书应用 +- ✅ 每个应用有独立的 app_id 和 tenant_key + +#### 4. user_feishu_binding (用户飞书绑定表) +```sql +CREATE TABLE `user_feishu_binding` ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + company_id BIGINT, + feishu_open_id VARCHAR(100), + UNIQUE KEY uk_user_company (user_id, company_id) +); +``` + +**状态**: ⚠️ **此表未被使用** + +## 三、多对多关系分析 + +### 3.1 当前实现方式 + +**用户 ↔ 公司**: 通过 `union_id + company_id` 组合实现 +- 同一个人在不同公司有不同的 User 记录 +- 每个 User 记录存储该公司的飞书身份信息 + +**公司 ↔ 飞书应用**: 一对多 +- 一个公司可以有多个飞书应用配置 +- 每个应用独立配置 app_id, app_secret + +### 3.2 现有设计的问题 + +#### 问题1: User 表的飞书字段只能存储一个值 + +```sql +users 表: +- feishu_user_id VARCHAR(60) -- 只能存一个值 +- feishu_open_id VARCHAR(60) -- 只能存一个值 +- tenant_key VARCHAR(60) -- 只能存一个值 +``` + +**场景问题**: +如果公司A有2个飞书应用(OA、考勤),张三在两个应用中都有账号,但 User 表只能存一个 feishu_user_id。 + +#### 问题2: 发送通知时的应用选择 + +当前 `FeishuNotificationService` 使用默认应用(system_id=1),没有考虑: +- 业务场景应该用哪个应用发送? +- 接收人是否在该应用中存在? + +#### 问题3: 登录来源应用未记录 + +用户通过不同飞书应用登录时,没有记录登录来源,后续发送通知时无法知道用哪个应用。 + +## 四、解决方案设计 + +### 方案A: 扩展 User 表 (推荐 ⭐) + +```sql +ALTER TABLE users ADD COLUMN feishu_app_id INT DEFAULT NULL COMMENT '登录来源飞书应用ID'; +``` + +**优点**: +- 最小改动,只需加一个字段 +- 记录用户登录时使用的飞书应用 +- 发送通知时优先使用登录来源的应用 + +**发送通知逻辑**: +1. 检查用户的 feishu_app_id +2. 如果有,使用该应用发送 +3. 如果没有,使用公司的默认应用 + +### 方案B: 创建用户飞书应用绑定表 + +```sql +CREATE TABLE user_feishu_apps ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + company_feishu_app_id INT NOT NULL, + feishu_user_id VARCHAR(60), + feishu_open_id VARCHAR(60), + is_default TINYINT DEFAULT 0, + created_at TIMESTAMP, + UNIQUE KEY uk_user_app (user_id, company_feishu_app_id), + KEY idx_user_id (user_id), + KEY idx_default (user_id, is_default) +); +``` + +**优点**: +- 支持一个用户在同一公司关联多个飞书应用 +- 完整记录用户在不同应用的飞书身份 +- 灵活设置默认应用 + +**缺点**: +- 需要创建新表 +- 需要修改较多代码 + +### 方案C: 保持当前设计 + 业务规则 + +不修改数据库,通过业务规则处理: + +**发送通知逻辑**: +```php +// 1. 获取审批单所属公司的飞书应用 +// 2. 优先使用 system_id=1 的应用(默认应用) +// 3. 如果该应用发送失败,尝试其他应用 +``` + +**优点**: +- 无需修改数据库 +- 当前代码已基本支持 + +**缺点**: +- 灵活性较低 +- 无法记录用户登录来源 + +## 五、推荐方案 + +### 采用 **方案A**: 扩展 User 表 + +**理由**: +1. 改动最小,只需添加一个字段 +2. 满足当前业务需求(记录登录来源) +3. 为后续扩展保留空间 + +**实施步骤**: + +#### 步骤1: 数据库迁移 + +```sql +-- 添加 feishu_app_id 字段 +ALTER TABLE users +ADD COLUMN feishu_app_id INT DEFAULT NULL COMMENT '登录来源飞书应用ID' +AFTER tenant_key; + +-- 添加索引 +ALTER TABLE users +ADD INDEX idx_feishu_app_id (feishu_app_id); +``` + +#### 步骤2: 修改 User 模型 + +```php +class User extends Model +{ + protected $fillable = [ + // ... 现有字段 + 'feishu_app_id', // 新增 + ]; + + // 关联飞书应用 + public function feishuApp() + { + return $this->belongsTo(CompanyFeishuApp::class, 'feishu_app_id'); + } +} +``` + +#### 步骤3: 修改登录逻辑 + +```php +// FeishuService.php - createOrUpdateUser 方法 +public function createOrUpdateUser(array $feishuData, int $companyId, int $appId): User +{ + // ... 现有逻辑 + + $user->update([ + 'feishu_user_id' => $userId, + 'feishu_app_id' => $appId, // 记录登录来源应用 + // ... 其他字段 + ]); +} +``` + +#### 步骤4: 修改通知发送逻辑 + +```php +// FeishuNotificationService.php +private function getAppConfig(): array +{ + $app = null; + + // 1. 如果指定了应用,使用指定的应用 + if ($this->appId) { + $app = CompanyFeishuApp::find($this->appId); + } + + // 2. 否则使用公司的默认应用 + if (!$app) { + $app = CompanyFeishuApp::where('company_id', $this->companyId) + ->where('system_id', 1) + ->where('status', 1) + ->orderBy('sort') + ->first(); + } + + if (!$app || $app->status !== 1) { + throw new Exception('未找到可用的飞书应用'); + } + + return $app->toArray(); +} + +// 新增:通过用户获取其登录来源应用 +public function setAppByUser(int $userId): self +{ + $user = User::find($userId); + if (!$user) { + throw new Exception('用户不存在'); + } + + $this->setCompany($user->company_id); + + // 如果用户有登录来源应用,使用该应用 + if ($user->feishu_app_id) { + $this->appId = $user->feishu_app_id; + } + + return $this; +} +``` + +## 六、业务流程说明 + +### 6.1 用户登录流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 飞书登录流程 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. 用户通过飞书应用A登录 │ +│ ├─ 获取 app_id = cli_aaa │ +│ ├─ 获取用户信息 (union_id, feishu_user_id) │ +│ └─ 创建/更新 User 记录 │ +│ - company_id: 1 │ +│ - union_id: on_abc123 │ +│ - feishu_user_id: user_a │ +│ - feishu_app_id: 1 (应用A的ID) ✨ 新增 │ +│ │ +│ 2. 同一用户通过飞书应用B登录 (同一公司) │ +│ ├─ 获取 app_id = cli_bbb │ +│ ├─ 获取用户信息 (union_id 相同, feishu_user_id 不同) │ +│ └─ 更新 User 记录 │ +│ - feishu_user_id: user_b │ +│ - feishu_app_id: 2 (应用B的ID) ✨ 新增 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 6.2 发送通知流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 发送通知流程 │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 场景:公司A 的用户张三提交出差申请,需要通知审批人李四 │ +│ │ +│ 1. 获取审批人李四的 User 记录 │ +│ └─ company_id: 1, feishu_app_id: 1 │ +│ │ +│ 2. 创建通知服务 │ +│ $notification = new FeishuNotificationService(); │ +│ $notification->setAppByUser($李四的user_id); ✨ 新增 │ +│ │ +│ 3. 发送通知 │ +│ └─ 使用李四登录来源的应用A发送消息 │ +│ └─ 如果应用A发送失败,降级使用默认应用 │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 七、总结 + +### 当前数据库设计评估 + +| 设计点 | 评估 | 说明 | +|--------|------|------| +| 用户 ↔ 公司 | ✅ 满足 | 通过 union_id + company_id 实现 | +| 公司 ↔ 飞书应用 | ✅ 满足 | company_feishu_apps 表 | +| 用户 ↔ 飞书应用 | ⚠️ 需改进 | 缺少登录来源记录 | + +### 建议改进 + +**必须添加**: +- `users.feishu_app_id` 字段 - 记录用户登录来源的飞书应用 + +**可选添加**: +- 如果需要支持用户在同一公司关联多个飞书应用,可创建 `user_feishu_apps` 关联表 + +### 飞书应用选择策略 + +```php +// 发送通知时的应用选择优先级: +// 1. 用户登录来源的应用 (feishu_app_id) +// 2. 公司的默认应用 (system_id=1) +// 3. 公司的第一个启用应用 +``` + +--- + +**是否需要我实施方案 A 的数据库迁移和代码修改?**