Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/global/common/error/code/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
}
];

Expand Down
1 change: 1 addition & 0 deletions packages/global/support/user/team/constant.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
30 changes: 5 additions & 25 deletions packages/service/support/user/account/cancellation/service.ts
Original file line number Diff line number Diff line change
@@ -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)
});
Expand Down Expand Up @@ -118,12 +111,7 @@ export const clearAccountCancellationCache = async ({
*/
export const withAccountCancellationUserLock = async <T>(userId: string, fn: () => Promise<T>) => {
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');
Expand All @@ -132,18 +120,10 @@ export const withAccountCancellationUserLock = async <T>(userId: string, fn: ()
}
};

/**
* 串行化团队删除、owner 转让和注销 finalizer 的团队部分。
* 团队锁独立于用户锁,调用方需遵循“用户锁后团队锁”的顺序避免交叉等待。
*/
/** 注销流程使用的团队锁兼容包装,底层复用通用团队锁并保留注销场景错误语义。 */
export const withAccountCancellationTeamLock = async <T>(teamId: string, fn: () => Promise<T>) => {
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');
Expand Down
15 changes: 14 additions & 1 deletion packages/service/support/user/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null | undefined>;
}): Promise<UserType> {
const tmb = await (async () => {
if (tmbId) {
Expand All @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions packages/service/support/user/lock.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(userId: string, fn: () => Promise<T>) =>
leaseCache.withLease({
key: `user:${String(userId)}`,
label: 'user-operation',
ttlMs: lockTtlMs,
fn: () => fn()
});

/** 在团队维度串行化会同时修改团队归属、成员关系或团队资源的操作。 */
export const withTeamLock = async <T>(teamId: string, fn: () => Promise<T>) =>
leaseCache.withLease({
key: `team:${String(teamId)}`,
label: 'team-operation',
ttlMs: lockTtlMs,
fn: () => fn()
});
76 changes: 76 additions & 0 deletions packages/service/support/user/team/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>,
session?: ClientSession
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions packages/service/support/user/team/delete/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TeamDeleteJobData> = async (job) =>
withAccountCancellationTeamLock(job.data.teamId, async () => {
withTeamLock(job.data.teamId, async () => {
const { teamId } = job.data;
const startTime = Date.now();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
Expand All @@ -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)
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
4 changes: 4 additions & 0 deletions packages/web/i18n/en/account_team.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/web/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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~",
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/web/i18n/ko-KR/account_team.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "워크플로",
Expand Down
4 changes: 4 additions & 0 deletions packages/web/i18n/ko-KR/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "무료 버전에서는 검색 재정렬을 사용할 수 없습니다~",
Expand All @@ -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": "로그인 상태가 유효하지 않습니다. 다시 로그인해 주세요",
Expand Down
4 changes: 4 additions & 0 deletions packages/web/i18n/zh-CN/account_team.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "工作流",
Expand Down
Loading
Loading