refactor: 重构多飞书应用绑定架构和数据库清理

- 新增 user_feishu_apps 表支持用户绑定多个飞书应用
- users 表添加唯一约束(公司内邮箱/手机唯一)
- 迁移现有飞书数据到 user_feishu_apps 表
- 清理冗余表(users_bak, user_feishu_binding)
- 废弃 users 表中的 feishu_user_id/feishu_open_id/tenant_key 字段
- 更新 FeishuNotificationService 使用新的数据获取方式
- User 模型新增 getFeishuUserId() 等便捷方法

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-26 16:27:33 +08:00
parent 04947ae52d
commit 0489da5e40
11 changed files with 942 additions and 45 deletions
+14 -10
View File
@@ -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}");
+11 -2
View File
@@ -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('测试消息发送失败,请查看日志');
+1
View File
@@ -11,6 +11,7 @@ class FeishuLoginSession extends Model
protected $fillable = [
'company_id',
'tenant_key',
'feishu_app_id',
'status',
'user_id',
'expires_at',
+73 -3
View File
@@ -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)
{
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace app\model;
use support\Model;
/**
* 用户飞书应用绑定模型
*/
class UserFeishuApp extends Model
{
protected $table = 'user_feishu_apps';
protected $fillable = [
'user_id',
'company_id',
'feishu_app_id',
'feishu_user_id',
'feishu_open_id',
'tenant_key',
'is_default',
'last_login_at',
];
protected $casts = [
'user_id' => '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);
}
}
@@ -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;
+61 -11
View File
@@ -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
{
@@ -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;
@@ -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;
@@ -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;
+389
View File
@@ -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 的数据库迁移和代码修改?**