diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index b3065d2c942e..ce640badd85b 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -32,12 +32,16 @@ export enum TeamErrEnum { cannotDeleteNonEmptyOrg = 'cannotDeleteNonEmptyOrg', cannotDeleteDefaultGroup = 'cannotDeleteDefaultGroup', userNotActive = 'userNotActive', + userForbidden = 'userForbidden', invitationLinkInvalid = 'invitationLinkInvalid', youHaveBeenInTheTeam = 'youHaveBeenInTheTeam', tooManyInvitations = 'tooManyInvitations', unPermission = 'unPermission', accountCancellationPending = 'accountCancellationPending', - teamPluginInstallDisabled = 'teamPluginInstallDisabled' + teamPluginInstallDisabled = 'teamPluginInstallDisabled', + teamOwnerOverSize = 'teamOwnerOverSize', + onlyMultiTeam = 'onlyMultiTeam', + ownerTransferConflict = 'ownerTransferConflict' } const teamErr = [ @@ -115,6 +119,10 @@ const teamErr = [ statusText: TeamErrEnum.userNotActive, message: i18nT('common:code_error.team_error.user_not_active') }, + { + statusText: TeamErrEnum.userForbidden, + message: i18nT('common:code_error.team_error.user_forbidden') + }, { statusText: TeamErrEnum.orgMemberNotExist, message: i18nT('common:code_error.team_error.org_member_not_exist') @@ -223,6 +231,18 @@ const teamErr = [ statusText: TeamErrEnum.teamPluginInstallDisabled, message: i18nT('common:code_error.team_error.team_plugin_install_disabled'), httpStatus: 403 + }, + { + statusText: TeamErrEnum.teamOwnerOverSize, + message: i18nT('common:code_error.team_error.owner_team_over_size') + }, + { + statusText: TeamErrEnum.onlyMultiTeam, + message: i18nT('common:code_error.team_error.only_multi_team') + }, + { + statusText: TeamErrEnum.ownerTransferConflict, + message: i18nT('common:code_error.team_error.owner_transfer_conflict') } ]; diff --git a/packages/global/support/user/team/constant.ts b/packages/global/support/user/team/constant.ts index 625c8f3dabe7..b1f0c186bc52 100644 --- a/packages/global/support/user/team/constant.ts +++ b/packages/global/support/user/team/constant.ts @@ -1,5 +1,6 @@ export const TeamCollectionName = 'teams'; export const TeamMemberCollectionName = 'team_members'; +export const MAX_TEAM_OWNER_COUNT = 5; export enum TeamMemberRoleEnum { owner = 'owner' diff --git a/packages/service/support/user/account/cancellation/service.ts b/packages/service/support/user/account/cancellation/service.ts index 02ec1a656a12..b7d4681b0122 100644 --- a/packages/service/support/user/account/cancellation/service.ts +++ b/packages/service/support/user/account/cancellation/service.ts @@ -1,20 +1,13 @@ import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; import { isAccountCancellationMethod } from '@fastgpt/global/support/user/account/cancellation/utils'; import { deriveAccountCancellationSchedule } from '@fastgpt/global/support/user/account/cancellation/utils'; -import { - AccountCancellationCache, - LeaseCache, - RedisLeaseUnavailableError -} from '@fastgpt/dal/redis/caches'; +import { AccountCancellationCache, RedisLeaseUnavailableError } from '@fastgpt/dal/redis/caches'; import { getLogger, LogCategories } from '../../../../common/logger'; -import { getAccountCancellationAuthKey } from './formatter'; import { getActiveAccountCancellationByUserId } from './read'; +import { withTeamLock, withUserLock } from '../../lock'; import { MongoAccountCancellation } from './schema'; import { MongoTeam } from '../../team/teamSchema'; -const accountCancellationLockTtlMs = 10 * 60 * 1000; -const accountCancellationTeamLockTtlMs = 10 * 60 * 1000; -const leaseCache = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); const accountCancellationCache = new AccountCancellationCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); @@ -118,12 +111,7 @@ export const clearAccountCancellationCache = async ({ */ export const withAccountCancellationUserLock = async (userId: string, fn: () => Promise) => { try { - return await leaseCache.withLease({ - key: getAccountCancellationAuthKey(userId), - label: 'account-cancellation-user', - ttlMs: accountCancellationLockTtlMs, - fn: () => fn() - }); + return await withUserLock(userId, fn); } catch (error) { if (error instanceof RedisLeaseUnavailableError) { throw new Error('Account cancellation operation is busy'); @@ -132,18 +120,10 @@ export const withAccountCancellationUserLock = async (userId: string, fn: () } }; -/** - * 串行化团队删除、owner 转让和注销 finalizer 的团队部分。 - * 团队锁独立于用户锁,调用方需遵循“用户锁后团队锁”的顺序避免交叉等待。 - */ +/** 注销流程使用的团队锁兼容包装,底层复用通用团队锁并保留注销场景错误语义。 */ export const withAccountCancellationTeamLock = async (teamId: string, fn: () => Promise) => { try { - return await leaseCache.withLease({ - key: `accountCancellation:team:${String(teamId)}`, - label: 'account-cancellation-team', - ttlMs: accountCancellationTeamLockTtlMs, - fn: () => fn() - }); + return await withTeamLock(teamId, fn); } catch (error) { if (error instanceof RedisLeaseUnavailableError) { throw new Error('Account cancellation team operation is busy'); diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index 6ec33d72a97c..305b02a0f1ec 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -20,17 +20,23 @@ export async function authUserExist({ userId, username }: { userId?: string; use /** * 加载用户及团队详情;tmb 失效时回退到用户可用团队。 + * 只有显式传入创建回调的登录流程,才允许在没有可用团队时补建团队。 */ export async function getUserDetail({ tmbId, userId, isRoot = false, - session + session, + createTeamIfUnavailable }: { tmbId?: string; userId?: string; isRoot?: boolean; session?: ClientSession; + createTeamIfUnavailable?: (params: { + userId: string; + session?: ClientSession; + }) => Promise; }): Promise { const tmb = await (async () => { if (tmbId) { @@ -46,6 +52,13 @@ export async function getUserDetail({ }); if (fallback) return getTmbInfoByTmbId({ tmbId: fallback.tmbId, session }); } + if (userId && createTeamIfUnavailable) { + const fallbackTmbId = await createTeamIfUnavailable({ + userId: String(userId), + session + }); + if (fallbackTmbId) return getTmbInfoByTmbId({ tmbId: fallbackTmbId, session }); + } return Promise.reject(ERROR_ENUM.unAuthorization); })(); const query = MongoUser.findById(tmb.userId); diff --git a/packages/service/support/user/lock.ts b/packages/service/support/user/lock.ts new file mode 100644 index 000000000000..21a5d593925f --- /dev/null +++ b/packages/service/support/user/lock.ts @@ -0,0 +1,23 @@ +import { LeaseCache } from '@fastgpt/dal/redis/caches'; +import { getLogger, LogCategories } from '../../common/logger'; + +const lockTtlMs = 10 * 60 * 1000; +const leaseCache = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); + +/** 在用户维度串行化会修改用户、团队归属或账号状态的操作。 */ +export const withUserLock = async (userId: string, fn: () => Promise) => + leaseCache.withLease({ + key: `user:${String(userId)}`, + label: 'user-operation', + ttlMs: lockTtlMs, + fn: () => fn() + }); + +/** 在团队维度串行化会同时修改团队归属、成员关系或团队资源的操作。 */ +export const withTeamLock = async (teamId: string, fn: () => Promise) => + leaseCache.withLease({ + key: `team:${String(teamId)}`, + label: 'team-operation', + ttlMs: lockTtlMs, + fn: () => fn() + }); diff --git a/packages/service/support/user/team/controller.ts b/packages/service/support/user/team/controller.ts index 15c319cd2539..100462ab75c9 100644 --- a/packages/service/support/user/team/controller.ts +++ b/packages/service/support/user/team/controller.ts @@ -19,13 +19,25 @@ import { getAIApi } from '../../../core/ai/config'; import { createRootOrg } from '../../permission/org/controllers'; import { getS3AvatarSource } from '../../../common/s3/sources/avatar'; import { getLogger, LogCategories } from '../../../common/logger'; +import { LOGO_ICON } from '@fastgpt/global/common/system/constants'; +import { TeamDefaultPermissionVal } from '@fastgpt/global/support/permission/user/constant'; +import { initTeamFreePlan } from '../../wallet/sub/utils'; +import { getUserFallbackTeam } from './fallback'; import { + assertAccountCancellationUserCanOwnTeam, formatTeamAccountCancellationSummary, getActiveAccountCancellationsByTeamIds } from '../account/cancellation'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); +/** 根据登录用户名生成与多团队默认团队一致的团队名称。 */ +export const getUserDefaultTeamName = (username: string) => { + const splitUsername = username.split('-'); + const formatUsername = splitUsername.length > 1 ? splitUsername.slice(1).join('-') : username; + return `${formatUsername.slice(0, 10)} Team`; +}; + async function getTeamMember( match: Record, session?: ClientSession @@ -120,6 +132,70 @@ export async function getUserDefaultTeam({ ); } +/** + * 在登录事务中为没有任何可用团队的用户创建个人团队。 + * 调用方必须在覆盖完整登录事务生命周期的用户锁中调用;函数内部再次检查团队, + * 避免已提交的团队被重复创建。 + */ +export async function createUserLoginTeam({ + userId, + username, + memberName, + memberAvatar, + session +}: { + userId: string; + username: string; + memberName?: string; + memberAvatar?: string; + session: ClientSession; +}) { + await assertAccountCancellationUserCanOwnTeam(userId); + + const fallback = await getUserFallbackTeam({ userId, session }); + if (fallback) return fallback.tmbId; + + const [team] = await MongoTeam.create( + [ + { + ownerId: userId, + name: getUserDefaultTeamName(username), + avatar: LOGO_ICON, + defaultPermission: TeamDefaultPermissionVal + } + ], + { session, ordered: true } + ); + const [tmb] = await MongoTeamMember.create( + [ + { + teamId: team._id, + userId, + name: memberName ?? username, + role: TeamMemberRoleEnum.owner, + status: TeamMemberStatusEnum.active, + avatar: memberAvatar ?? LOGO_ICON + } + ], + { session, ordered: true } + ); + + await MongoMemberGroupModel.create( + [{ teamId: team._id, name: DefaultGroupName, avatar: LOGO_ICON }], + { session, ordered: true } + ); + await initTeamFreePlan({ teamId: String(team._id), session }); + await createRootOrg({ teamId: String(team._id), session }); + + logger.info('Login fallback team created', { + userId, + teamId: team._id, + tmbId: tmb._id + }); + + return String(tmb._id); +} + export async function createDefaultTeam({ userId, teamName = 'My Team', diff --git a/packages/service/support/user/team/delete/processor.ts b/packages/service/support/user/team/delete/processor.ts index b4e4d4751463..b27ac5392e47 100644 --- a/packages/service/support/user/team/delete/processor.ts +++ b/packages/service/support/user/team/delete/processor.ts @@ -22,13 +22,13 @@ import { onDelAllApp } from './utils'; import { deleteEvaluationsByTeamId } from '../../../../core/app/evaluation/delete'; import { MongoTeamSub } from '../../../../support/wallet/sub/schema'; import { getLogger, LogCategories } from '../../../../common/logger'; -import { withAccountCancellationTeamLock } from '../../account/cancellation'; +import { withTeamLock } from '../../lock'; import { MongoOutLink } from '../../../outLink/schema'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); export const teamDeleteProcessor: Processor = async (job) => - withAccountCancellationTeamLock(job.data.teamId, async () => { + withTeamLock(job.data.teamId, async () => { const { teamId } = job.data; const startTime = Date.now(); diff --git a/packages/service/test/support/user/account/cancellation/service.test.ts b/packages/service/test/support/user/account/cancellation/service.test.ts index f970cca86c34..e1ea7dfe754b 100644 --- a/packages/service/test/support/user/account/cancellation/service.test.ts +++ b/packages/service/test/support/user/account/cancellation/service.test.ts @@ -45,8 +45,8 @@ describe('account cancellation leases', () => { await expect(withAccountCancellationUserLock('user-1', fn)).resolves.toBe('done'); expect(withLease).toHaveBeenCalledWith({ - key: 'accountCancellation:user-1', - label: 'account-cancellation-user', + key: 'user:user-1', + label: 'user-operation', ttlMs: 600000, fn: expect.any(Function) }); @@ -55,15 +55,15 @@ describe('account cancellation leases', () => { it('uses a team-scoped lease and maps lease contention to a business error', async () => { withLease.mockRejectedValue( - new RedisLeaseUnavailableError({ key: 'accountCancellation:team:team-1', label: 'test' }) + new RedisLeaseUnavailableError({ key: 'team:team-1', label: 'test' }) ); await expect(withAccountCancellationTeamLock('team-1', vi.fn())).rejects.toThrow( 'Account cancellation team operation is busy' ); expect(withLease).toHaveBeenCalledWith({ - key: 'accountCancellation:team:team-1', - label: 'account-cancellation-team', + key: 'team:team-1', + label: 'team-operation', ttlMs: 600000, fn: expect.any(Function) }); diff --git a/packages/service/test/support/user/team/delete/processor.test.ts b/packages/service/test/support/user/team/delete/processor.test.ts index 4de9475d860a..2857bbf59ad2 100644 --- a/packages/service/test/support/user/team/delete/processor.test.ts +++ b/packages/service/test/support/user/team/delete/processor.test.ts @@ -14,8 +14,8 @@ const mocks = vi.hoisted(() => ({ loggerError: vi.fn() })); -vi.mock('@fastgpt/service/support/user/account/cancellation', () => ({ - withAccountCancellationTeamLock: mocks.withTeamLock +vi.mock('@fastgpt/service/support/user/lock', () => ({ + withTeamLock: mocks.withTeamLock })); vi.mock('@fastgpt/service/support/user/team/teamSchema', () => ({ diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index daf6604c2bc9..c6ee3cd34aa5 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -268,6 +268,10 @@ "select_new_owner": "Select new owner", "confirm_transfer": "Confirm transfer", "transfer_warning": "Warning: After transferring Team ownership, you will lose all administrator permissions. This action cannot be undone.", + "transfer_confirm_title": "Transfer Team Ownership", + "transfer_confirm_content_multi": "You are transferring {{teamName}} to {{memberName}}. After the transfer, the current account will become a regular member of this team. Please confirm!", + "transfer_confirm_content_wecom": "You are transferring {{teamName}} to {{memberName}}. After the transfer, the current account will become a regular member of this team. Please confirm!", + "transfer_confirm_input": "Confirm transfer", "type.Folder": "Folder", "type.Http plugin": "HTTP Plugin", "type.Workflow bot": "Workflow", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0729a61b27b6..81bff3450a62 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -214,6 +214,9 @@ "code_error.team_error.org_not_exist": "Organization does not exist", "code_error.team_error.org_parent_not_exist": "Parent organization does not exist", "code_error.team_error.over_size": "Team members exceed limit", + "code_error.team_error.owner_team_over_size": "The user has reached the maximum number of owned teams", + "code_error.team_error.only_multi_team": "Only multi-team mode supports ownership transfer", + "code_error.team_error.owner_transfer_conflict": "Team ownership changed. Please try again", "code_error.team_error.plugin_amount_not_enough": "Plugin Limit Reached", "code_error.team_error.team_plugin_install_disabled": "Team plugin installation is disabled", "code_error.team_error.re_rank_not_enough": "Search rearrangement cannot be used in the free version~", @@ -222,6 +225,7 @@ "code_error.team_error.too_many_invitations": "You have reached the maximum number of active invitation links, please clean up some links first", "code_error.team_error.un_auth": "Unauthorized to Operate This Team", "code_error.team_error.user_not_active": "The user did not accept or has left the team", + "code_error.team_error.user_forbidden": "This member has been disabled and cannot be transferred", "code_error.team_error.website_sync_not_enough": "The free version cannot be synchronized with the web site ~", "code_error.team_error.you_have_been_in_the_team": "You are already in this team", "code_error.token_error_code.403": "Invalid Login Status, Please Re-login", diff --git a/packages/web/i18n/ko-KR/account_team.json b/packages/web/i18n/ko-KR/account_team.json index 90f3a603cd6a..23a3ab52732e 100644 --- a/packages/web/i18n/ko-KR/account_team.json +++ b/packages/web/i18n/ko-KR/account_team.json @@ -260,6 +260,10 @@ "select_new_owner": "새 소유자 선택", "confirm_transfer": "이전 확인", "transfer_warning": "경고: 팀 소유권을 이전하면 모든 관리 권한을 잃게 되며, 이 작업은 되돌릴 수 없습니다. 신중하게 진행해 주세요.", + "transfer_confirm_title": "팀 소유권 이전", + "transfer_confirm_content_multi": "{{teamName}}을(를) {{memberName}}님에게 이전합니다. 이전 후 현재 계정은 이 팀의 일반 멤버로 전환됩니다. 확인해 주세요!", + "transfer_confirm_content_wecom": "{{teamName}}을(를) {{memberName}}님에게 이전합니다. 이전 후 현재 계정은 이 팀의 일반 멤버로 전환됩니다. 확인해 주세요!", + "transfer_confirm_input": "이전 확인", "type.Folder": "폴더", "type.Http plugin": "HTTP 플러그인", "type.Workflow bot": "워크플로", diff --git a/packages/web/i18n/ko-KR/common.json b/packages/web/i18n/ko-KR/common.json index a49d0ae247c5..ccc79fa24cec 100644 --- a/packages/web/i18n/ko-KR/common.json +++ b/packages/web/i18n/ko-KR/common.json @@ -212,6 +212,9 @@ "code_error.team_error.org_not_exist": "조직이 존재하지 않습니다", "code_error.team_error.org_parent_not_exist": "상위 조직이 존재하지 않습니다", "code_error.team_error.over_size": "팀 멤버 수가 한도를 초과했습니다", + "code_error.team_error.owner_team_over_size": "사용자가 소유할 수 있는 팀 수의 최대 한도에 도달했습니다", + "code_error.team_error.only_multi_team": "다중 팀 모드에서만 소유권을 이전할 수 있습니다", + "code_error.team_error.owner_transfer_conflict": "팀 소유권이 변경되었습니다. 다시 시도해 주세요", "code_error.team_error.plugin_amount_not_enough": "플러그인 한도 도달", "code_error.team_error.team_plugin_install_disabled": "팀 플러그인 설치 기능이 활성화되지 않았습니다", "code_error.team_error.re_rank_not_enough": "무료 버전에서는 검색 재정렬을 사용할 수 없습니다~", @@ -220,6 +223,7 @@ "code_error.team_error.too_many_invitations": "활성 초대 링크의 최대 개수에 도달했습니다. 먼저 일부 링크를 정리해 주세요", "code_error.team_error.un_auth": "이 팀을 조작할 권한이 없습니다", "code_error.team_error.user_not_active": "사용자가 수락하지 않았거나 팀을 탈퇴했습니다", + "code_error.team_error.user_forbidden": "이 멤버는 비활성화되어 이전할 수 없습니다", "code_error.team_error.website_sync_not_enough": "무료 버전은 웹사이트와 동기화할 수 없습니다~", "code_error.team_error.you_have_been_in_the_team": "이미 이 팀에 속해 있습니다", "code_error.token_error_code.403": "로그인 상태가 유효하지 않습니다. 다시 로그인해 주세요", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index aa8b164d9ffe..4a421edfc0d6 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -268,6 +268,10 @@ "select_new_owner": "选择新的所有者", "confirm_transfer": "确认转让", "transfer_warning": "警告:转让团队所有权后,您将失去所有管理权限,且此操作不可撤销。请谨慎操作。", + "transfer_confirm_title": "团队拥有者转移", + "transfer_confirm_content_multi": "正在将 {{teamName}} 转移给 {{memberName}}。转移后,当前账号会转为该团队的普通成员,请确认!", + "transfer_confirm_content_wecom": "正在将 {{teamName}} 转移给 {{memberName}},转移后,当前账号会转为该团队的普通成员,请确认!", + "transfer_confirm_input": "确认转移", "type.Folder": "文件夹", "type.Http plugin": "HTTP 插件", "type.Workflow bot": "工作流", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 9cff264ac8c2..a86c5212ec74 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -214,6 +214,9 @@ "code_error.team_error.org_not_exist": "部门不存在", "code_error.team_error.org_parent_not_exist": "父部门不存在", "code_error.team_error.over_size": "团队成员超出限制", + "code_error.team_error.owner_team_over_size": "用户拥有的团队数量已达上限", + "code_error.team_error.only_multi_team": "仅多团队模式支持转让所有权", + "code_error.team_error.owner_transfer_conflict": "团队所有权发生变化,请重试", "code_error.team_error.plugin_amount_not_enough": "插件数量已达上限~", "code_error.team_error.team_plugin_install_disabled": "团队插件安装能力未开启", "code_error.team_error.re_rank_not_enough": "免费版无法使用检索重排~", @@ -222,6 +225,7 @@ "code_error.team_error.too_many_invitations": "您的有效邀请链接数已达上限,请先清理链接", "code_error.team_error.un_auth": "无权操作该团队", "code_error.team_error.user_not_active": "用户未接受或已离开团队", + "code_error.team_error.user_forbidden": "该成员已被停用,无法转移", "code_error.team_error.website_sync_not_enough": "免费版无法使用Web站点同步~", "code_error.team_error.you_have_been_in_the_team": "你已经在该团队中", "code_error.token_error_code.403": "登录状态无效,请重新登录", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index f7868c56675f..de777c818de8 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -268,6 +268,10 @@ "select_new_owner": "選擇新的所有者", "confirm_transfer": "確認轉讓", "transfer_warning": "警告:轉讓團隊所有權後,您將失去所有管理權限,且此操作不可撤銷。請謹慎操作。", + "transfer_confirm_title": "團隊擁有者轉移", + "transfer_confirm_content_multi": "正在將 {{teamName}} 轉移給 {{memberName}}。轉移後,目前帳號會轉為該團隊的普通成員,請確認!", + "transfer_confirm_content_wecom": "正在將 {{teamName}} 轉移給 {{memberName}},轉移後,目前帳號會轉為該團隊的普通成員,請確認!", + "transfer_confirm_input": "確認轉移", "type.Folder": "資料夾", "type.Http plugin": "HTTP 外掛", "type.Workflow bot": "工作流程", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index c9f3cc8e3ade..a76cb6dacaf3 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -214,6 +214,9 @@ "code_error.team_error.org_not_exist": "組織不存在", "code_error.team_error.org_parent_not_exist": "父組織不存在", "code_error.team_error.over_size": "團隊成員超出限制", + "code_error.team_error.owner_team_over_size": "使用者擁有的團隊數量已達上限", + "code_error.team_error.only_multi_team": "僅多團隊模式支援轉讓所有權", + "code_error.team_error.owner_transfer_conflict": "團隊所有權已變更,請重試", "code_error.team_error.plugin_amount_not_enough": "已達外掛程式數量上限", "code_error.team_error.team_plugin_install_disabled": "團隊外掛安裝功能未開啟", "code_error.team_error.re_rank_not_enough": "免費版無法使用檢索重排~", @@ -222,6 +225,7 @@ "code_error.team_error.too_many_invitations": "您的有效邀請連結數已達上限,請先清理連結", "code_error.team_error.un_auth": "無權操作此團隊", "code_error.team_error.user_not_active": "使用者未接受或已離開團隊", + "code_error.team_error.user_forbidden": "該成員已被停用,無法轉移", "code_error.team_error.website_sync_not_enough": "免費版無法使用 Web 站點同步~", "code_error.team_error.you_have_been_in_the_team": "你已經在該團隊中", "code_error.token_error_code.403": "登入狀態無效,請重新登入", diff --git a/pro b/pro index 26544a3004ff..fdfc4a57cf98 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 26544a3004ff56b22bdc39d703a7a0340f5b2644 +Subproject commit fdfc4a57cf9868b3932188990545659a4059616a diff --git a/projects/app/src/pageComponents/account/team/MemberTable.tsx b/projects/app/src/pageComponents/account/team/MemberTable.tsx index 4c701c5daa4a..0a82e68ddd77 100644 --- a/projects/app/src/pageComponents/account/team/MemberTable.tsx +++ b/projects/app/src/pageComponents/account/team/MemberTable.tsx @@ -233,7 +233,7 @@ function MemberTable({ Tabs }: { Tabs: React.ReactNode }) { {t('account_team:user_team_invite_member')} )} - {userInfo?.team.permission.isOwner && !isSyncMode && isWecomTeam && ( + {userInfo?.team.permission.isOwner && !isSyncMode && feConfigs?.teamMode === 'multi' && ( + ); } diff --git a/projects/app/src/pages/api/support/user/account/loginByPassword.ts b/projects/app/src/pages/api/support/user/account/loginByPassword.ts index 8ccb2373fbd2..12c24f02626e 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -1,4 +1,7 @@ import { getUserDetail } from '@fastgpt/service/support/user/controller'; +import { createUserLoginTeam } from '@fastgpt/service/support/user/team/controller'; +import { withUserLock } from '@fastgpt/service/support/user/lock'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { NextAPI } from '@/service/middleware/entry'; import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; @@ -31,8 +34,8 @@ async function handler( bodySchema: LoginByPasswordBodySchema }).body; - const { user, userDetail, visitorIdentity } = - await passwordVerificationService.withVerifiedCredentials( + const runLogin = () => + passwordVerificationService.withVerifiedCredentials( { username, password, @@ -54,7 +57,16 @@ async function handler( tmbId: user?.lastLoginTmbId, userId: user._id, isRoot: username === 'root', - session + session, + createTeamIfUnavailable: + username !== 'root' && global.systemConfig?.teamMode === 'multi' + ? ({ userId, session }) => + createUserLoginTeam({ + userId, + username: user.username, + session: session as NonNullable + }) + : undefined }); user.lastLoginTmbId = userDetail.team.tmbId; @@ -71,6 +83,10 @@ async function handler( return { user, userDetail, visitorIdentity }; } ); + const userForLock = await MongoUser.findOne({ username }, { _id: 1 }).lean(); + const { user, userDetail, visitorIdentity } = userForLock + ? await withUserLock(String(userForLock._id), runLogin) + : await runLogin(); const token = await createUserSession({ userId: user._id, diff --git a/projects/app/test/api/support/user/account/loginByPassword.test.ts b/projects/app/test/api/support/user/account/loginByPassword.test.ts index 00bb645d587a..37f34715beb4 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as loginApi from '@/pages/api/support/user/account/loginByPassword'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; @@ -29,6 +29,7 @@ const saveLoginCode = (username: string, code = '123456') => ); describe('loginByPassword API', () => { + const originalSystemConfig = global.systemConfig; let testUser: any; let testTeam: any; let testTmb: any; @@ -66,6 +67,10 @@ describe('loginByPassword API', () => { vi.clearAllMocks(); }); + afterEach(() => { + global.systemConfig = originalSystemConfig; + }); + it('should login successfully with valid credentials', async () => { const res = await Call, any>(loginApi.default, { body: { @@ -199,6 +204,153 @@ describe('loginByPassword API', () => { expect(res.data.token).toEqual(expect.any(String)); }); + it('should create and use a personal team when multi-team login has no usable team', async () => { + global.systemConfig = { teamMode: 'multi' }; + const loginUser = await MongoUser.create({ + username: 'multi-orphan-user', + password: 'testpassword', + status: UserStatusEnum.active + }); + await saveLoginCode(loginUser.username); + + const res = await Call, any>(loginApi.default, { + body: { + username: loginUser.username, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); + + expect(res.code).toBe(200); + expect(res.data.user.team.teamName).toBe('orphan-use Team'); + expect(res.data.user.team.role).toBe('owner'); + await expect(MongoTeam.countDocuments({ ownerId: loginUser._id })).resolves.toBe(1); + await expect( + MongoTeamMember.countDocuments({ userId: loginUser._id, status: 'active' }) + ).resolves.toBe(1); + }); + + it('should keep the user lock until a fallback login consumes its verification material', async () => { + global.systemConfig = { teamMode: 'multi' }; + const loginUser = await MongoUser.create({ + username: 'concurrent-orphan-user', + password: 'testpassword', + status: UserStatusEnum.active + }); + await saveLoginCode(loginUser.username); + + let releaseFirstConsume!: () => void; + let firstConsumeReached!: () => void; + const firstConsume = new Promise((resolve) => { + firstConsumeReached = resolve; + }); + const consumeGate = new Promise((resolve) => { + releaseFirstConsume = resolve; + }); + const originalDeleteOne = MongoTmpData.deleteOne.bind(MongoTmpData); + let blockedFirstConsume = false; + const deleteOneSpy = vi + .spyOn(MongoTmpData, 'deleteOne') + .mockImplementation(async (...args: any[]) => { + if ( + !blockedFirstConsume && + args[0]?.dataId === + getDataId({ scene: 'login', type: 'password', key: loginUser.username }) + ) { + blockedFirstConsume = true; + firstConsumeReached(); + await consumeGate; + } + return originalDeleteOne(...args); + }); + + try { + const firstLogin = Call, any>( + loginApi.default, + { + body: { + username: loginUser.username, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + } + ); + await firstConsume; + + const secondLogin = await Call, any>( + loginApi.default, + { + body: { + username: loginUser.username, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + } + ); + + expect(secondLogin.code).not.toBe(200); + await expect(MongoTeam.countDocuments({ ownerId: loginUser._id })).resolves.toBe(1); + + releaseFirstConsume(); + await expect(firstLogin).resolves.toMatchObject({ code: 200 }); + } finally { + releaseFirstConsume(); + deleteOneSpy.mockRestore(); + } + }); + + it('should not create a team for a pending cancellation user without a usable team', async () => { + global.systemConfig = { teamMode: 'multi' }; + const loginUser = await MongoUser.create({ + username: 'pending-orphan-user', + password: 'testpassword', + status: UserStatusEnum.active + }); + await MongoAccountCancellation.create({ + userId: loginUser._id, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + }); + await saveLoginCode(loginUser.username); + + const res = await Call, any>(loginApi.default, { + body: { + username: loginUser.username, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); + + expect(res.code).toBe(500); + await expect(MongoTeam.countDocuments({ ownerId: loginUser._id })).resolves.toBe(0); + }); + + it('should keep single-team login failure when the user has no usable team', async () => { + global.systemConfig = { teamMode: 'single' }; + const loginUser = await MongoUser.create({ + username: 'single-orphan-user', + password: 'testpassword', + status: UserStatusEnum.active + }); + await saveLoginCode(loginUser.username); + + const res = await Call, any>(loginApi.default, { + body: { + username: loginUser.username, + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); + + expect(res.code).toBe(500); + await expect(MongoTeam.countDocuments({ ownerId: loginUser._id })).resolves.toBe(0); + }); + it('should reject a finalizing user before profile updates and session creation', async () => { await MongoAccountCancellation.create({ userId: testUser._id,