Skip to content
Open
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
38 changes: 25 additions & 13 deletions packages/global/openapi/support/user/team/invitationLink/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import z from 'zod';
import { ObjectIdSchema } from '../../../../../common/type/mongo';
import { TeamMemberNameSchema } from '../../../../../support/user/team/memberName';

const InvitationLinkIdSchema = z.string().min(1).meta({
example: 'V1StGXR8_Z5jdHi6B-myT',
Expand Down Expand Up @@ -44,23 +45,35 @@ const InvitationLinkBaseShape = {
example: '2026-08-26T00:00:00.000Z',
description: '邀请链接过期时间'
}),
description: z.string().meta({ description: '邀请链接描述' })
description: z.string().meta({ description: '邀请链接描述' }),
creatorUsername: z.string().optional().meta({ description: '邀请链接创建者用户名' })
};

/* ============================================================================
* API: 接受团队邀请链接
* Route: POST /api/proApi/support/user/team/invitationLink/accept
* API: 接受团队邀请链接并设置成员名
* Route: POST /api/proApi/support/user/team/invitationLink/acceptWithMemberName
* Method: POST
* Description: 当前用户接受邀请链接并加入对应团队
* Description: 当前用户设置目标团队成员名并接受邀请链接
* Tags: ['邀请链接管理', '团队管理', 'Write']
* ============================================================================ */

export const AcceptInvitationLinkBodySchema = z
export const AcceptInvitationWithMemberNameBodySchema = z
.object({
linkId: InvitationLinkIdSchema
linkId: InvitationLinkIdSchema,
memberName: TeamMemberNameSchema.meta({ description: '目标团队成员名', example: '张三' })
})
.meta({ example: { linkId: 'V1StGXR8_Z5jdHi6B-myT' } });
export type AcceptInvitationLinkBodyType = z.infer<typeof AcceptInvitationLinkBodySchema>;
.meta({ example: { linkId: 'V1StGXR8_Z5jdHi6B-myT', memberName: '张三' } });
export type AcceptInvitationWithMemberNameBodyType = z.infer<
typeof AcceptInvitationWithMemberNameBodySchema
>;

export const AcceptInvitationWithMemberNameResponseSchema = z.object({
teamId: ObjectIdSchema.meta({ description: '目标团队 ID' }),
tmbId: ObjectIdSchema.meta({ description: '目标团队成员 ID' })
});
export type AcceptInvitationWithMemberNameResponseType = z.infer<
typeof AcceptInvitationWithMemberNameResponseSchema
>;

/* ============================================================================
* API: 创建团队邀请链接
Expand Down Expand Up @@ -115,7 +128,7 @@ export type ForbidInvitationLinkBodyType = z.infer<typeof ForbidInvitationLinkBo
* API: 获取团队邀请链接信息
* Route: GET /api/proApi/support/user/team/invitationLink/info
* Method: GET
* Description: 获取邀请链接状态、所属团队和已加入成员信息
* Description: 获取有效邀请链接的状态、所属团队和当前用户加入状态
* Tags: ['邀请链接管理', '团队管理', 'Read']
* ============================================================================ */

Expand All @@ -129,11 +142,10 @@ export const GetInvitationLinkInfoResponseSchema = z
...InvitationLinkBaseShape,
members: z.array(ObjectIdSchema).meta({ description: '已通过邀请链接加入的成员 ID 列表' }),
teamAvatar: z.string().nullish().meta({ description: '团队头像' }),
teamName: z.string().meta({ description: '团队名称' })
teamName: z.string().meta({ description: '团队名称' }),
alreadyJoined: z.boolean().meta({ description: '当前用户是否已经加入该团队' })
})
// 当前用户已经是团队成员时,接口沿用历史行为返回空数据。
.optional()
.meta({ description: '邀请链接详情;当前用户已是团队成员时无返回数据' });
.meta({ description: '有效邀请链接详情' });
export type GetInvitationLinkInfoResponseType = z.infer<typeof GetInvitationLinkInfoResponseSchema>;

/* ============================================================================
Expand Down
18 changes: 12 additions & 6 deletions packages/global/openapi/support/user/team/invitationLink/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import {
AcceptInvitationLinkBodySchema,
AcceptInvitationWithMemberNameBodySchema,
AcceptInvitationWithMemberNameResponseSchema,
CreateInvitationLinkBodySchema,
CreateInvitationLinkResponseSchema,
ForbidInvitationLinkBodySchema,
Expand All @@ -13,21 +14,26 @@ import {
const TeamInvitationLinkTags = [DevApiTagsMap.teamInvitationLink];

export const TeamInvitationLinkPath: OpenAPIPath = {
'/proApi/support/user/team/invitationLink/accept': {
'/proApi/support/user/team/invitationLink/acceptWithMemberName': {
post: {
summary: '接受团队邀请链接',
description: '当前用户接受邀请链接并加入对应团队',
summary: '接受团队邀请链接并设置成员名',
description: '当前用户设置目标团队成员名并接受邀请链接',
tags: [...TeamInvitationLinkTags],
requestBody: {
content: {
'application/json': {
schema: AcceptInvitationLinkBodySchema
schema: AcceptInvitationWithMemberNameBodySchema
}
}
},
responses: {
200: {
description: '接受邀请成功'
description: '接受邀请成功并返回目标团队成员信息',
content: {
'application/json': {
schema: AcceptInvitationWithMemberNameResponseSchema
}
}
}
}
}
Expand Down
9 changes: 5 additions & 4 deletions packages/global/openapi/support/user/team/member/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ObjectIdSchema } from '../../../../../common/type/mongo';
import { GroupMemberRole } from '../../../../../support/permission/memberGroup/constant';
import { PermissionSchema } from '../../../../../support/permission/controller';
import { TeamMemberStatusEnum } from '../../../../../support/user/team/constant';
import { TeamMemberNameSchema } from '../../../../../support/user/team/memberName';
import { PaginationResponseSchema, PaginationSchema } from '../../../../api';

const TeamMemberIdSchema = ObjectIdSchema.meta({
Expand Down Expand Up @@ -173,9 +174,9 @@ export type UpdateTeamMemberInviteBodyType = z.infer<typeof UpdateTeamMemberInvi
* ============================================================================ */

export const UpdateTeamMemberNameBodySchema = z.object({
name: z.string().min(1).max(50).meta({
name: TeamMemberNameSchema.meta({
example: '张三',
description: '新的成员名称,不能为空,最多 50 个字符'
description: '新的成员名称,trim 后不能为空,最多 20 个字符且不能使用系统保留值'
})
});
export type UpdateTeamMemberNameBodyType = z.infer<typeof UpdateTeamMemberNameBodySchema>;
Expand All @@ -190,9 +191,9 @@ export type UpdateTeamMemberNameBodyType = z.infer<typeof UpdateTeamMemberNameBo

export const UpdateTeamMemberNameByManagerBodySchema = z.object({
tmbId: TeamMemberIdSchema.meta({ description: '需要修改名称的团队成员 ID' }),
name: z.string().min(1).max(50).meta({
name: TeamMemberNameSchema.meta({
example: '李四',
description: '新的成员名称,不能为空,最多 50 个字符'
description: '新的成员名称,trim 后不能为空,最多 20 个字符且不能使用系统保留值'
})
});
export type UpdateTeamMemberNameByManagerBodyType = z.infer<
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 UNSET_TEAM_MEMBER_NAME = '__FASTGPT_PENDING__';

export enum TeamMemberRoleEnum {
owner = 'owner'
Expand Down
23 changes: 23 additions & 0 deletions packages/global/support/user/team/memberName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import z from 'zod';
import { UNSET_TEAM_MEMBER_NAME } from './constant';

/**
* 成员名的统一交互式校验规则。
* 保留值只用于新成员的待补齐状态,不能被用户或可信外部数据直接提交。
*/
export const TeamMemberNameSchema = z
.string()
.trim()
.min(1, '成员名不能为空')
.max(20, '成员名长度不能超过 20 个字符')
.refine((value) => value !== UNSET_TEAM_MEMBER_NAME, '成员名非法!');

/** 将交互式成员名规范化并在非法输入时抛出参数错误。 */
export const normalizeTeamMemberName = (memberName: unknown) =>
TeamMemberNameSchema.parse(memberName);

/** 仅返回有效成员名,供注册和同步等非交互式入口选择明确的缺省值。 */
export const getValidTeamMemberName = (memberName: unknown) => {
const result = TeamMemberNameSchema.safeParse(memberName);
return result.success ? result.data : undefined;
};
41 changes: 37 additions & 4 deletions packages/global/test/openapi/support/user/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ import {
UpdateTeamCollaboratorBodySchema,
UpdateTeamCollaboratorOneBodySchema
} from '@fastgpt/global/openapi/support/user/team/collaborator/api';
import { GetInvitationLinkInfoResponseSchema } from '@fastgpt/global/openapi/support/user/team/invitationLink/api';
import {
AcceptInvitationWithMemberNameBodySchema,
GetInvitationLinkInfoResponseSchema
} from '@fastgpt/global/openapi/support/user/team/invitationLink/api';
import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import { InformLevelEnum } from '@fastgpt/global/support/user/inform/constants';
import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant';
Expand Down Expand Up @@ -79,8 +82,12 @@ describe('support user OpenAPI contracts', () => {
openAPIDocument.paths?.['/proApi/support/user/team/collaborator/updateOne']?.put?.tags
).toEqual([DevApiTagsMap.teamPermission]);
expect(
openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/accept']?.post?.tags
openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/acceptWithMemberName']?.post
?.tags
).toEqual([DevApiTagsMap.teamInvitationLink]);
expect(
openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/accept']
).toBeUndefined();
expect(
openAPIDocument.paths?.['/proApi/support/user/team/invitationLink/create']?.post?.tags
).toEqual([DevApiTagsMap.teamInvitationLink]);
Expand Down Expand Up @@ -334,8 +341,34 @@ describe('support user OpenAPI contracts', () => {
expect(UserSyncResponseSchema.parse(undefined)).toBeUndefined();
});

it('allows empty invitation details for existing team members', () => {
expect(GetInvitationLinkInfoResponseSchema.parse(undefined)).toBeUndefined();
it('parses invitation details and requires a valid member name when accepting', () => {
expect(
GetInvitationLinkInfoResponseSchema.parse({
_id: objectId,
linkId: 'invite-link-id',
teamId: secondObjectId,
expires: '2026-08-26T00:00:00.000Z',
description: '邀请加入团队',
creatorUsername: 'root',
members: [],
teamAvatar: null,
teamName: 'FastGPT 团队',
alreadyJoined: true
})
).toMatchObject({ alreadyJoined: true, creatorUsername: 'root' });

expect(
AcceptInvitationWithMemberNameBodySchema.parse({
linkId: 'invite-link-id',
memberName: ' 张三 '
})
).toEqual({ linkId: 'invite-link-id', memberName: '张三' });
expect(
AcceptInvitationWithMemberNameBodySchema.safeParse({
linkId: 'invite-link-id',
memberName: 'a'.repeat(21)
}).success
).toBe(false);
});

it('parses team management list and write contracts', () => {
Expand Down
26 changes: 26 additions & 0 deletions packages/global/test/support/user/team/memberName.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import {
getValidTeamMemberName,
normalizeTeamMemberName,
TeamMemberNameSchema
} from '@fastgpt/global/support/user/team/memberName';
import { UNSET_TEAM_MEMBER_NAME } from '@fastgpt/global/support/user/team/constant';

describe('TeamMemberNameSchema', () => {
it('trims and accepts names up to 20 characters', () => {
expect(TeamMemberNameSchema.parse(' Alice ')).toBe('Alice');
expect(TeamMemberNameSchema.parse('a'.repeat(20))).toBe('a'.repeat(20));
});

it('rejects empty, overlong, and reserved names', () => {
expect(TeamMemberNameSchema.safeParse(' ').success).toBe(false);
expect(TeamMemberNameSchema.safeParse('a'.repeat(21)).success).toBe(false);
expect(TeamMemberNameSchema.safeParse(UNSET_TEAM_MEMBER_NAME).success).toBe(false);
});

it('separates strict normalization from fallback validation', () => {
expect(normalizeTeamMemberName(' Bob ')).toBe('Bob');
expect(getValidTeamMemberName(UNSET_TEAM_MEMBER_NAME)).toBeUndefined();
expect(getValidTeamMemberName('')).toBeUndefined();
});
});
14 changes: 14 additions & 0 deletions packages/service/support/user/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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 withTeamLock = async <T>(teamId: string, fn: () => Promise<T>) =>
leaseCache.withLease({
key: `team:${String(teamId)}`,
label: 'team-operation',
ttlMs: lockTtlMs,
fn: () => fn()
});
4 changes: 2 additions & 2 deletions packages/service/support/user/team/invitationLink/type.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { type TeamMemberSchema } from '@fastgpt/global/support/user/team/type';

export type InvitationSchemaType = {
_id: string;
linkId: string;
Expand All @@ -8,6 +6,7 @@ export type InvitationSchemaType = {
forbidden?: boolean;
expires: Date;
description: string;
creatorUsername?: string;
members: string[];
};

Expand All @@ -34,4 +33,5 @@ export type InvitationLinkCreateType = {
export type InvitationInfoType = InvitationSchemaType & {
teamAvatar: string;
teamName: string;
alreadyJoined: boolean;
};
27 changes: 27 additions & 0 deletions packages/service/test/support/user/lock.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LeaseCache } from '@fastgpt/dal/redis/caches';
import { withTeamLock } from '@fastgpt/service/support/user/lock';

describe('team lock', () => {
let withLease: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
vi.restoreAllMocks();
withLease = vi.spyOn(LeaseCache.prototype, 'withLease');
withLease.mockImplementation(async ({ fn }) => fn({} as any));
});

it('holds a team-scoped lease around the callback and returns its result', async () => {
const callback = vi.fn().mockResolvedValue('done');

await expect(withTeamLock('team-1', callback)).resolves.toBe('done');

expect(withLease).toHaveBeenCalledWith({
key: 'team:team-1',
label: 'team-operation',
ttlMs: 600000,
fn: expect.any(Function)
});
expect(callback).toHaveBeenCalledOnce();
});
});
19 changes: 19 additions & 0 deletions packages/web/i18n/en/account_team.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@
"group": "group",
"group_name": "Group name",
"handle_invitation": "Handle Team invitation",
"team_invitation": "Team invitation",
Comment thread
shortlight5980 marked this conversation as resolved.
"set_member_name_title": "Set member name",
"member_name_label": "Team member name",
"member_name_placeholder": "Enter your team member name",
"invite_member_name_placeholder": "Set your name in this team (20 characters max)",
"member_name_required": "Enter a member name",
"member_name_limit": "Use 20 characters or fewer",
"join_team_success": "Joined the team successfully",
"already_joined": "You have already joined this team",
"switch_team_failed": "Team switch failed. Switch manually from the team entry later",
"invitation_rejected": "You rejected the invitation from {{source}}",
"force_member_name": "Set your team member name",
"set_member_name": "Confirm",
"confirm_member_name": "Confirm",
"cancel_member_name": "Cancel",
"reject_invitation": "Reject invitation",
"join_team": "Join team",
"invited_by": "Invited by {{source}}",
"invitation_creator_fallback": "team administrator",
"has_forbidden": "Forbidden",
"has_invited": "Invited",
"ignore": "Ignore",
Expand Down
4 changes: 2 additions & 2 deletions packages/web/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@
"confirm_update": "confirm_update",
"contact_business": "Contact Sales",
"contact_email": "Email us: {{email}}",
"contact_way": "Notification Received",
"contact_way": "Contact information",
"contribute_app_template": "Contribute Template",
"copy_successful": "Copied Successfully",
"copy_to_clipboard": "Copy to Clipboard",
Expand Down Expand Up @@ -1083,7 +1083,7 @@
"support.user.auth.get_code_again": "s Get Again",
"support.user.captcha_placeholder": "Please enter the verification code",
"support.user.info.bind_notification_error": "Abnormal binding notification account",
"support.user.info.bind_notification_hint": "Please bind the notification receiving account to ensure that you can receive important reminders such as account information and plan information in a timely manner.",
"support.user.info.bind_notification_hint": "Please bind your contact information to ensure that you can receive important information such as account and plan updates in a timely manner.",
"support.user.info.bind_notification_success": "Binding notification account successful",
"support.user.info.code_required": "Verification code cannot be empty",
"support.user.info.notification_receiving_hint": "Notification reception",
Expand Down
Loading
Loading