diff --git a/packages/dal/redis/adapter.ts b/packages/dal/redis/adapter.ts index ea43aae69619..ba6dcbe91328 100644 --- a/packages/dal/redis/adapter.ts +++ b/packages/dal/redis/adapter.ts @@ -755,6 +755,40 @@ export class RedisCacheAdapter { } }); + /** 局部更新 hash 字段且保留现有 TTL。 */ + updateHashFields = ({ + key, + fields + }: { + key: RedisLogicalKey; + fields: Record; + }) => { + const operation = 'hash.updateFields'; + if ( + !fields || + Object.keys(fields).length === 0 || + Object.values(fields).some((value) => typeof value !== 'string') + ) { + throw new RedisInvalidArgumentError({ + operation, + message: 'hash fields must contain at least one string value' + }); + } + + return this.operationExecutor.uncertainWrite({ + operation, + execute: async () => { + const result = await this.getCommandClient().hmset(toPhysicalRedisKey(key), fields); + if (result !== 'OK') { + throw new RedisInvalidResponseError({ + operation, + message: 'Redis HMSET returned an unsupported response' + }); + } + } + }); + }; + /** 在一个事务中写入 hash 并设置 TTL,避免 hash 无过期时间。 */ setHashWithTtl = ({ key, diff --git a/packages/dal/redis/bullmq/index.ts b/packages/dal/redis/bullmq/index.ts index bc70f66f31a7..4854bd99ce6b 100644 --- a/packages/dal/redis/bullmq/index.ts +++ b/packages/dal/redis/bullmq/index.ts @@ -2,7 +2,8 @@ export { bullMQ, BullMQBinding } from './binding'; export { getConfiguredRedisBullMQRuntime, getRedisBullMQRuntime } from './context'; export { QueueNames } from './names'; export { RedisBullMQRuntime } from './runtime'; -export { UnrecoverableError } from 'bullmq'; +export { addOrRequeueFailedJob } from './job-recovery'; +export { DelayedError, UnrecoverableError } from 'bullmq'; export * from './services'; export type { BullMQRuntimeState, diff --git a/packages/dal/redis/bullmq/job-recovery.ts b/packages/dal/redis/bullmq/job-recovery.ts new file mode 100644 index 000000000000..7588e6bb7ebc --- /dev/null +++ b/packages/dal/redis/bullmq/job-recovery.ts @@ -0,0 +1,78 @@ +import { LeaseCache } from '../caches'; +import { bullMQ } from './binding'; +import type { Queue } from './types'; + +const FAILED_JOB_RECOVERY_LEASE_TTL_MS = 30 * 1000; + +/** + * 添加稳定 ID 任务;若同 ID 历史任务已失败,则在分布式租约内刷新数据并手动重试。 + * 其它状态继续复用现有任务,避免并发生产者制造重复工作。 + */ +export async function addOrRequeueFailedJob({ + queue, + name, + data, + opts +}: { + queue: Queue; + name: Parameters['add']>[0]; + data: Parameters['add']>[1]; + opts: NonNullable['add']>[2]> & { jobId: string }; +}) { + /** unknown 可能来自 retention cleanup;二次读取确认不存在后才允许重建。 */ + const getJobWithConfirmedState = async () => { + const job = await queue.getJob(opts.jobId); + if (!job) return; + + const state = await job.getState(); + if (state !== 'unknown') return { job, state }; + + const latestJob = await queue.getJob(opts.jobId); + if (!latestJob) return; + + const latestState = await latestJob.getState(); + if (latestState === 'unknown') { + throw new Error(`BullMQ job is in an unknown state: ${queue.name}/${opts.jobId}`); + } + return { job: latestJob, state: latestState }; + }; + + const existing = await getJobWithConfirmedState(); + if (existing) { + if (existing.state !== 'failed') return existing.job; + + const leaseCache = new LeaseCache({ logger: bullMQ.getLogger() }); + return leaseCache.withLease({ + key: `bullmq:failed-job-recovery:${queue.name}:${opts.jobId}`, + label: 'bullmq-failed-job-recovery', + ttlMs: FAILED_JOB_RECOVERY_LEASE_TTL_MS, + fn: async () => { + const current = await getJobWithConfirmedState(); + if (!current) return queue.add(name, data, opts); + if (current.state !== 'failed') return current.job; + const currentJob = current.job; + + try { + await currentJob.updateData(data as DataType); + } catch (error) { + const latest = await getJobWithConfirmedState(); + if (!latest) return queue.add(name, data, opts); + if (latest.state !== 'failed') return latest.job; + throw error; + } + + try { + await currentJob.retry('failed'); + return currentJob; + } catch (error) { + const latest = await getJobWithConfirmedState(); + if (!latest) return queue.add(name, data, opts); + if (latest.state !== 'failed') return latest.job; + throw error; + } + } + }); + } + + return queue.add(name, data, opts); +} diff --git a/packages/dal/redis/bullmq/names.ts b/packages/dal/redis/bullmq/names.ts index e120b884e3ce..25363b80b095 100644 --- a/packages/dal/redis/bullmq/names.ts +++ b/packages/dal/redis/bullmq/names.ts @@ -11,6 +11,7 @@ export enum QueueNames { appDelete = 'appDelete', agentSkillDelete = 'agentSkillDelete', teamDelete = 'teamDelete', + accountCancellation = 'accountCancellation', // Publish wechatPoll = 'wechatPoll', diff --git a/packages/dal/redis/bullmq/services/teamDelete.ts b/packages/dal/redis/bullmq/services/teamDelete.ts index 902539bc2548..cb08fd55b919 100644 --- a/packages/dal/redis/bullmq/services/teamDelete.ts +++ b/packages/dal/redis/bullmq/services/teamDelete.ts @@ -1,4 +1,5 @@ import { bullMQ, type BullMQBinding } from '../binding'; +import { addOrRequeueFailedJob } from '../job-recovery'; import { QueueNames } from '../names'; import type { Processor, Queue, Worker } from '../types'; @@ -40,9 +41,14 @@ export class TeamDeleteMQService { /** 投递幂等的 Team 删除任务,并延迟一秒让请求先完成。 */ addJob(data: TeamDeleteJobData) { - return this.getQueue().add('delete_team', data, { - jobId: String(data.teamId), - delay: 1000 + return addOrRequeueFailedJob({ + queue: this.getQueue(), + name: 'delete_team', + data, + opts: { + jobId: String(data.teamId), + delay: 1000 + } }); } } diff --git a/packages/dal/redis/caches/session.ts b/packages/dal/redis/caches/session.ts index c137e8093c06..4dd43ccc4113 100644 --- a/packages/dal/redis/caches/session.ts +++ b/packages/dal/redis/caches/session.ts @@ -123,6 +123,21 @@ export class SessionCache { await this.redis.delete(this.getKey(sessionId)); } + /** 更新 Session 的团队上下文,不刷新原有过期时间。 */ + updateTeam = ({ + sessionId, + teamId, + tmbId + }: { + sessionId: string; + teamId: string; + tmbId: string; + }) => + this.redis.updateHashFields({ + key: this.getKey(sessionId), + fields: { teamId, tmbId } + }); + /** 分页扫描某个用户的全部 typed session,损坏记录会被尽力清理。 */ async listByUser(userId: string): Promise { const records: SessionRecord[] = []; diff --git a/packages/dal/test/redis/caches/session.test.ts b/packages/dal/test/redis/caches/session.test.ts index 1f2be76a39d0..07fd3a2e1951 100644 --- a/packages/dal/test/redis/caches/session.test.ts +++ b/packages/dal/test/redis/caches/session.test.ts @@ -16,7 +16,8 @@ describe('SessionCache', () => { deleteMany: vi.fn(), getHashAll: vi.fn(), iterateByPrefix: vi.fn(), - setHashWithTtl: vi.fn() + setHashWithTtl: vi.fn(), + updateHashFields: vi.fn() }; beforeEach(() => { @@ -33,6 +34,7 @@ describe('SessionCache', () => { }); redis.iterateByPrefix.mockReturnValue(createKeyBatches([])); redis.setHashWithTtl.mockResolvedValue(undefined); + redis.updateHashFields.mockResolvedValue(undefined); }); it('decodes a complete session hash and normalizes isRoot/createdAt', async () => { @@ -188,6 +190,22 @@ describe('SessionCache', () => { ]); }); + it('updates only the team context so the existing TTL is preserved', async () => { + const cache = new SessionCache({ redis: redis as any, logger }); + + await cache.updateTeam({ + sessionId: 'user-1:token-1', + teamId: 'team-2', + tmbId: 'tmb-2' + }); + + expect(redis.updateHashFields).toHaveBeenCalledWith({ + key: 'session:user-1:token-1', + fields: { teamId: 'team-2', tmbId: 'tmb-2' } + }); + expect(redis.setHashWithTtl).not.toHaveBeenCalled(); + }); + it('scans all user pages and returns only valid typed sessions', async () => { redis.iterateByPrefix.mockReturnValue( createKeyBatches([ @@ -252,6 +270,7 @@ describe('SessionCache adapter integration', () => { isRoot: '0', createdAt: '1000' }), + hmset: vi.fn().mockResolvedValue('OK'), multi: vi.fn(), scan: vi.fn(), set: vi.fn() @@ -282,6 +301,11 @@ describe('SessionCache adapter integration', () => { createdAt: 1000 } }); + await cache.updateTeam({ + sessionId: 'user-1:token-1', + teamId: 'team-2', + tmbId: 'tmb-2' + }); expect(commandClient.hgetall).toHaveBeenCalledWith('fastgpt:session:user-1:token-1'); expect(multi.hmset).toHaveBeenCalledWith('fastgpt:session:user-1:token-1', { @@ -295,5 +319,9 @@ describe('SessionCache adapter integration', () => { 'fastgpt:session:user-1:token-1', SESSION_TTL_SECONDS ); + expect(commandClient.hmset).toHaveBeenCalledWith('fastgpt:session:user-1:token-1', { + teamId: 'team-2', + tmbId: 'tmb-2' + }); }); }); diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index 92ee7ad6eaea..809e1fac0076 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -35,7 +35,8 @@ export enum TeamErrEnum { invitationLinkInvalid = 'invitationLinkInvalid', youHaveBeenInTheTeam = 'youHaveBeenInTheTeam', tooManyInvitations = 'tooManyInvitations', - unPermission = 'unPermission' + unPermission = 'unPermission', + accountCancellationPending = 'accountCancellationPending' } const teamErr = [ @@ -47,6 +48,10 @@ const teamErr = [ statusText: TeamErrEnum.unPermission, message: i18nT('common:error_un_permission') }, + { + statusText: TeamErrEnum.accountCancellationPending, + message: i18nT('common:code_error.team_error.account_cancellation_pending') + }, { statusText: TeamErrEnum.teamOverSize, message: i18nT('common:code_error.team_error.over_size') diff --git a/packages/global/common/error/code/user.ts b/packages/global/common/error/code/user.ts index 135e04ec95e1..c036baddf954 100644 --- a/packages/global/common/error/code/user.ts +++ b/packages/global/common/error/code/user.ts @@ -11,6 +11,7 @@ export enum UserErrEnum { sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently', verifyCodeTooFrequently = 'verifyCodeTooFrequently', invalidAccount = 'invalidAccount', + accountCancellationPending = 'accountCancellationPending', registrationMethodNotSupported = 'registrationMethodNotSupported' } const errList = [ @@ -49,6 +50,10 @@ const errList = [ statusText: UserErrEnum.invalidAccount, message: i18nT('common:code_error.invalid_account') }, + { + statusText: UserErrEnum.accountCancellationPending, + message: i18nT('common:code_error.account_cancellation_pending') + }, { statusText: UserErrEnum.registrationMethodNotSupported, message: i18nT('common:error.registration_method_not_supported'), diff --git a/packages/global/common/middle/tracks/constants.ts b/packages/global/common/middle/tracks/constants.ts index 98f8267a192d..a4993e006a23 100644 --- a/packages/global/common/middle/tracks/constants.ts +++ b/packages/global/common/middle/tracks/constants.ts @@ -13,6 +13,9 @@ export enum TrackEnum { teamChatQPM = 'teamChatQPM', enterpriseAuthStart = 'enterpriseAuthStart', enterpriseAuthBenefitGrant = 'enterpriseAuthBenefitGrant', + accountCancellationSubmitSuccess = 'account_cancellation_submit_success', + accountCancellationCancelSuccess = 'account_cancellation_cancel_success', + accountCancellationFinalizeSuccess = 'account_cancellation_finalize_success', // Admin cron job tracks subscriptionDeleted = 'subscriptionDeleted', diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index fe32821b50fc..ec65ee7c6b46 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -1,4 +1,5 @@ import type { SubPlanType } from '../../../support/wallet/sub/type'; +import type { AccountCancellationVerificationCapabilities } from '../../../support/user/account/cancellation/type'; import type { LLMModelItemType, EmbeddingModelItemType, @@ -75,6 +76,13 @@ export type FastGPTFeConfigsType = { show_enterprise_auth?: boolean; showWecomConfig?: boolean; wecomLoginAutoRedirect?: boolean; + accountCancellation?: { + enabled?: boolean; + }; + /** 仅暴露注销验证的布尔能力,不包含任何 Provider 密钥。 */ + accountVerification?: { + accountCancellation?: AccountCancellationVerificationCapabilities; + }; show_dataset_feishu?: boolean; show_dataset_yuque?: boolean; diff --git a/packages/global/openapi/support/user/account/cancellation/api.ts b/packages/global/openapi/support/user/account/cancellation/api.ts new file mode 100644 index 000000000000..8d6cf4042b59 --- /dev/null +++ b/packages/global/openapi/support/user/account/cancellation/api.ts @@ -0,0 +1,238 @@ +import { z } from 'zod'; +import { + AccountCancellationAllowedMethodSchema, + AccountCancellationUnavailableReasonSchema +} from '../../../../../support/user/account/cancellation/type'; + +/* ============================================================================ + * API: 账号注销 + * Route: /proApi/support/user/account/cancellation/* + * Method: GET/POST/DELETE + * Description: 查询、验证、提交和取消当前登录账号的注销申请 + * Tags: ['Account Cancellation', 'Account Verification'] + * ============================================================================ */ + +const DateTimeSchema = z.iso.datetime({ offset: true }); + +export const AccountCancellationStatusResponseSchema = z + .discriminatedUnion('status', [ + z + .object({ + status: z.literal('none').meta({ description: '当前没有注销申请', example: 'none' }), + canRequestCancellation: z.boolean().meta({ + description: '是否允许发起注销申请', + example: true + }), + maskedAccount: z + .string() + .meta({ description: '当前账号脱敏值', example: 'us***@example.com' }), + unavailableReason: AccountCancellationUnavailableReasonSchema.optional().meta({ + description: '不可申请原因', + example: 'password_verification_not_allowed' + }) + }) + .strict(), + z + .object({ + status: z + .literal('pending') + .meta({ description: '等待期或最终清理中', example: 'pending' }), + maskedAccount: z + .string() + .meta({ description: '当前账号脱敏值', example: 'us***@example.com' }), + requestedAt: DateTimeSchema.meta({ + description: '注销申请时间(UTC)', + example: '2026-07-01T10:00:00.000Z' + }), + scheduledCancelAt: DateTimeSchema.optional().meta({ + description: '派生的计划清理时间(UTC)', + example: '2026-07-16T16:00:00.000Z' + }), + canCancelCancellation: z.boolean().meta({ description: '当前是否允许取消', example: true }) + }) + .strict() + ]) + .meta({ description: '账号注销公开状态' }); +export type AccountCancellationStatusResponse = z.infer< + typeof AccountCancellationStatusResponseSchema +>; + +const CodeVerificationCreateSchema = z + .object({ + method: z.literal('code').meta({ description: '邮箱或手机验证码', example: 'code' }), + payload: z + .object({ + captcha: z + .string() + .min(1) + .max(64) + .meta({ description: '图片验证码答案', example: 'A1B2C3' }) + }) + .strict() + }) + .strict(); + +const WechatVerificationCreateSchema = z + .object({ + method: z.literal('wechat').meta({ description: '微信扫码验证', example: 'wechat' }), + payload: z.object({}).strict() + }) + .strict(); + +const OAuthCreatePayloadSchema = z + .object({ + callbackUrl: z + .url() + .max(2048) + .meta({ description: 'OAuth 回调地址', example: 'https://example.com/login/provider' }), + isWecomWorkTerminal: z.boolean().optional().meta({ description: '是否来自企业微信工作台' }) + }) + .strict(); + +const OAuthCreateMethodSchemas = [ + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export const CreateAccountCancellationVerificationBodySchema = z.discriminatedUnion('method', [ + CodeVerificationCreateSchema, + WechatVerificationCreateSchema, + ...OAuthCreateMethodSchemas.map((method) => + z + .object({ + method: z.literal(method).meta({ description: 'OAuth 验证方式', example: method }), + payload: OAuthCreatePayloadSchema + }) + .strict() + ) +] as [typeof CodeVerificationCreateSchema, typeof WechatVerificationCreateSchema, ...any[]]); +export type CreateAccountCancellationVerificationBody = z.infer< + typeof CreateAccountCancellationVerificationBodySchema +>; + +export const CreateAccountCancellationVerificationResponseSchema = z.discriminatedUnion('method', [ + z + .object({ + method: z.literal('code'), + sent: z.literal(true), + maskedTarget: z + .string() + .meta({ description: '验证码接收目标脱敏值', example: 'us***@example.com' }) + }) + .strict(), + z + .object({ + method: z.literal('wechat'), + code: z.string().min(16).meta({ description: '微信扫码 scene', example: 'scene-code' }), + codeUrl: z + .url() + .meta({ description: '微信二维码地址', example: 'https://mp.weixin.qq.com/...' }), + expiredAt: DateTimeSchema.optional().meta({ description: '二维码过期时间' }) + }) + .strict(), + ...OAuthCreateMethodSchemas.map((method) => + z + .object({ + method: z.literal(method), + state: z.string().min(16).meta({ description: '一次性 OAuth state', example: 'state' }), + url: z + .url() + .meta({ description: 'Provider 授权地址', example: 'https://provider.example/authorize' }) + }) + .strict() + ) +] as [any, any, ...any[]]); +export type CreateAccountCancellationVerificationResponse = z.infer< + typeof CreateAccountCancellationVerificationResponseSchema +>; + +const CodeSubmitSchema = z + .object({ + method: z.literal('code'), + payload: z + .object({ + code: z.string().min(1).max(32).meta({ description: '验证码', example: '123456' }) + }) + .strict() + }) + .strict(); +const WechatSubmitSchema = z + .object({ + method: z.literal('wechat'), + payload: z + .object({ + code: z + .string() + .min(1) + .max(128) + .meta({ description: '微信扫码 scene', example: 'scene-code' }) + }) + .strict() + }) + .strict(); +const OAuthPropsSchema = z + .record( + z + .string() + .regex(/^[A-Za-z0-9_.-]+$/) + .max(64), + z.string().max(4096) + ) + .refine((props) => Object.keys(props).length <= 20, { + message: 'OAuth props contain too many keys' + }); +const OAuthSubmitPayloadSchema = z.object({ + callbackUrl: z + .url() + .max(2048) + .meta({ description: 'OAuth 回调地址', example: 'https://example.com/login/provider' }), + code: z + .string() + .min(1) + .max(4096) + .meta({ description: 'Provider 授权 code', example: 'provider-code' }), + state: z + .string() + .min(16) + .max(256) + .optional() + .meta({ description: '一次性 state;仅旧 SSO 可省略' }), + props: OAuthPropsSchema.optional().meta({ description: 'SSO 附加属性' }) +}); + +export const SubmitAccountCancellationBodySchema = z.discriminatedUnion('method', [ + CodeSubmitSchema, + WechatSubmitSchema, + ...OAuthCreateMethodSchemas.map((method) => + z.object({ method: z.literal(method), payload: OAuthSubmitPayloadSchema }).strict() + ) +] as [typeof CodeSubmitSchema, typeof WechatSubmitSchema, ...any[]]); +export type SubmitAccountCancellationBody = z.infer; + +export const SubmitAccountCancellationResponseSchema = z.discriminatedUnion('status', [ + z.object({ status: z.literal('verificationPending') }).strict(), + z.object({ status: z.literal('verificationExpired') }).strict(), + z + .object({ + status: z.literal('pending'), + requestedAt: DateTimeSchema, + scheduledCancelAt: DateTimeSchema, + canCancelCancellation: z.literal(true) + }) + .strict() +]); +export type SubmitAccountCancellationResponse = z.infer< + typeof SubmitAccountCancellationResponseSchema +>; + +export const CancelAccountCancellationResponseSchema = z + .undefined() + .meta({ description: '取消成功' }); +export type CancelAccountCancellationResponse = z.infer< + typeof CancelAccountCancellationResponseSchema +>; + +export { AccountCancellationAllowedMethodSchema }; diff --git a/packages/global/openapi/support/user/account/cancellation/index.ts b/packages/global/openapi/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..565dcc1d187e --- /dev/null +++ b/packages/global/openapi/support/user/account/cancellation/index.ts @@ -0,0 +1,90 @@ +import z from 'zod'; +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { + AccountCancellationStatusResponseSchema, + CancelAccountCancellationResponseSchema, + CreateAccountCancellationVerificationBodySchema, + CreateAccountCancellationVerificationResponseSchema, + SubmitAccountCancellationBodySchema, + SubmitAccountCancellationResponseSchema +} from './api'; + +export const AccountCancellationPath: OpenAPIPath = { + '/proApi/support/user/account/cancellation/status': { + get: { + summary: '获取账号注销状态', + description: '获取当前登录账号的注销状态和申请资格', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + responses: { + 200: { + description: '注销状态', + content: { 'application/json': { schema: AccountCancellationStatusResponseSchema } } + } + } + } + }, + '/proApi/support/user/account/cancellation/verification/create': { + post: { + summary: '创建账号注销验证材料', + description: '创建绑定当前登录账号和 accountCancellation scene 的短期验证材料', + tags: [DevApiTagsMap.userLogin, 'Account Verification', 'Account Cancellation'], + requestBody: { + content: { 'application/json': { schema: CreateAccountCancellationVerificationBodySchema } } + }, + responses: { + 200: { + description: '验证材料已创建', + content: { + 'application/json': { schema: CreateAccountCancellationVerificationResponseSchema } + } + }, + 400: { + description: '请求参数或图片验证码错误', + content: { 'application/json': { schema: z.null() } } + }, + 429: { + description: '验证码发送过于频繁', + content: { 'application/json': { schema: z.null() } } + } + } + } + }, + '/proApi/support/user/account/cancellation/submit': { + post: { + summary: '提交账号注销申请', + description: '在同一请求中消费注销验证材料并创建注销等待期记录', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + requestBody: { + content: { 'application/json': { schema: SubmitAccountCancellationBodySchema } } + }, + responses: { + 200: { + description: '验证进行中或已进入注销等待期', + content: { 'application/json': { schema: SubmitAccountCancellationResponseSchema } } + }, + 400: { + description: '请求参数或验证码错误', + content: { 'application/json': { schema: z.null() } } + }, + 429: { + description: '验证码校验过于频繁', + content: { 'application/json': { schema: z.null() } } + } + } + } + }, + '/proApi/support/user/account/cancellation/cancel': { + delete: { + summary: '取消账号注销', + description: '在最终清理开始前取消当前账号的注销申请', + tags: [DevApiTagsMap.userLogin, 'Account Cancellation'], + responses: { + 200: { + description: '取消成功', + content: { 'application/json': { schema: CancelAccountCancellationResponseSchema } } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/account/index.ts b/packages/global/openapi/support/user/account/index.ts index 6b5d98b5a4ad..643775a5c0fa 100644 --- a/packages/global/openapi/support/user/account/index.ts +++ b/packages/global/openapi/support/user/account/index.ts @@ -3,6 +3,7 @@ import { LoginPath } from './login'; import { RegisterPath } from './register'; import { PasswordPath } from './password'; import { CaptchaPath } from './captcha'; +import { AccountCancellationPath } from './cancellation'; import { UpdateUserAccountPath } from './update'; export const UserAccountPath: OpenAPIPath = { @@ -10,5 +11,6 @@ export const UserAccountPath: OpenAPIPath = { ...RegisterPath, ...PasswordPath, ...CaptchaPath, + ...AccountCancellationPath, ...UpdateUserAccountPath }; diff --git a/packages/global/support/user/account/cancellation/constants.ts b/packages/global/support/user/account/cancellation/constants.ts new file mode 100644 index 000000000000..f1fa1ba04e94 --- /dev/null +++ b/packages/global/support/user/account/cancellation/constants.ts @@ -0,0 +1,45 @@ +export const accountCancellationWaitDays = 15; +export const accountCancellationTimezone = 'Asia/Shanghai'; + +export const AccountCancellationStatus = { + pending: 'pending', + finalizing: 'finalizing', + completed: 'completed' +} as const; + +export const accountCancellationActiveStatuses = [ + AccountCancellationStatus.pending, + AccountCancellationStatus.finalizing +] as const; + +export const accountCancellationAllowedMethods = [ + 'code', + 'wechat', + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export const AccountCancellationReminder = { + sevenDays: '7d', + oneDay: '1d', + today: 'today' +} as const; + +export const AccountCancellationUnavailableReason = { + featureDisabled: 'feature_disabled', + unsupportedTeamMode: 'unsupported_team_mode', + rootAccount: 'root_account', + accountForbidden: 'account_forbidden', + emptyUsername: 'empty_username', + verificationUnavailable: 'verification_unavailable', + passwordVerificationNotAllowed: 'password_verification_not_allowed' +} as const; + +export const accountCancellationStatusMap = { + [AccountCancellationStatus.pending]: { label: 'Pending' }, + [AccountCancellationStatus.finalizing]: { label: 'Finalizing' }, + [AccountCancellationStatus.completed]: { label: 'Completed' } +}; diff --git a/packages/global/support/user/account/cancellation/index.ts b/packages/global/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..330de6dc9cae --- /dev/null +++ b/packages/global/support/user/account/cancellation/index.ts @@ -0,0 +1,13 @@ +export { + AccountCancellationReminder, + AccountCancellationStatus, + AccountCancellationUnavailableReason, + accountCancellationActiveStatuses, + accountCancellationAllowedMethods, + accountCancellationStatusMap, + accountCancellationTimezone, + accountCancellationWaitDays +} from './constants'; +export * from './type'; +export * from './utils'; +export * from './resolver'; diff --git a/packages/global/support/user/account/cancellation/resolver.ts b/packages/global/support/user/account/cancellation/resolver.ts new file mode 100644 index 000000000000..00aab01189d7 --- /dev/null +++ b/packages/global/support/user/account/cancellation/resolver.ts @@ -0,0 +1,29 @@ +import { resolveAccountVerificationByUsername } from '../verification/utils'; +import type { AccountCancellationResolveResult, AccountCancellationResolverInput } from './type'; + +/** 将统一 resolver 的结果收窄为注销允许的非密码验证方式。 */ +export const resolveAccountCancellationByUsername = ({ + username, + capabilities +}: AccountCancellationResolverInput): AccountCancellationResolveResult => { + const result = resolveAccountVerificationByUsername({ + username: username ?? '', + capabilities + }); + + if (result.status === 'unsupported') return result; + + if (result.method === 'oldPassword') { + return { + status: 'unsupported', + accountKind: result.accountKind, + unsupportedReason: 'password_verification_not_allowed' + }; + } + + return { + status: 'supported', + accountKind: result.accountKind, + method: result.method + }; +}; diff --git a/packages/global/support/user/account/cancellation/type.ts b/packages/global/support/user/account/cancellation/type.ts new file mode 100644 index 000000000000..25acfabbafdd --- /dev/null +++ b/packages/global/support/user/account/cancellation/type.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { + AccountCancellationStatus as AccountCancellationStatusValues, + AccountCancellationReminder as AccountCancellationReminderValues, + AccountCancellationUnavailableReason as AccountCancellationUnavailableReasonValues, + accountCancellationAllowedMethods +} from './constants'; + +export const AccountCancellationStatusSchema = z.enum(AccountCancellationStatusValues); +export type AccountCancellationStatus = z.infer; + +export const TeamAccountCancellationStatusSchema = AccountCancellationStatusSchema.exclude([ + AccountCancellationStatusValues.completed +]); +export type TeamAccountCancellationStatus = z.infer; + +export const AccountCancellationAllowedMethodSchema = z.enum(accountCancellationAllowedMethods); +export type AccountCancellationAllowedMethod = z.infer< + typeof AccountCancellationAllowedMethodSchema +>; + +export const AccountCancellationReminderSchema = z.enum(AccountCancellationReminderValues); +export type AccountCancellationReminder = z.infer; + +export const AccountCancellationUnavailableReasonSchema = z.enum( + AccountCancellationUnavailableReasonValues +); + +export type AccountCancellationSchedule = { + requestedAt: Date; + waitEndsAt: Date; + cleanupLocalDate: string; + sevenDayReminderAt: Date; + oneDayReminderAt: Date; + finalNoticeAt: Date; + scheduledCancelAt: Date; + timezone: string; +}; +export type TeamAccountCancellationSummary = { + status: TeamAccountCancellationStatus; + scheduledCancelAt?: Date | string; +}; + +export type AccountCancellationOAuthProvider = 'github' | 'google' | 'microsoft' | 'wecom' | 'sso'; + +export type AccountCancellationVerificationCapabilities = { + emailCode: boolean; + phoneCode: boolean; + accountCancellation?: boolean; + wechat: boolean; + oauth: Record; +}; + +export type AccountCancellationResolverInput = { + username?: string | null; + capabilities: AccountCancellationVerificationCapabilities; +}; + +export type AccountCancellationResolveResult = + | { + status: 'supported'; + method: AccountCancellationAllowedMethod; + accountKind: string; + unsupportedReason?: undefined; + } + | { + status: 'unsupported'; + method?: undefined; + accountKind: 'invalid' | string; + unsupportedReason: + | 'empty_username' + | 'password_verification_not_allowed' + | 'verification_unavailable'; + }; + +export type AccountCancellationAccessPreset = + | 'normal' + | 'selfCancellation' + | 'teamEscape' + | 'tokenLogin'; + +export type AccountCancellationVerificationMethod = AccountCancellationAllowedMethod; diff --git a/packages/global/support/user/account/cancellation/utils.ts b/packages/global/support/user/account/cancellation/utils.ts new file mode 100644 index 000000000000..647060e443aa --- /dev/null +++ b/packages/global/support/user/account/cancellation/utils.ts @@ -0,0 +1,254 @@ +import { + accountCancellationTimezone, + accountCancellationWaitDays, + AccountCancellationReminder as AccountCancellationReminderValues, + accountCancellationAllowedMethods +} from './constants'; +import type { AccountCancellationReminder, AccountCancellationSchedule } from './type'; + +const dayInMilliseconds = 24 * 60 * 60 * 1000; +const accountCancellationAnonymizedUsernameReg = /-[a-z][a-zA-Z0-9]{7}-delete$/; +const legacyAccountCancellationUsernameRegs = [/-deleted$/, /^deleted-[a-f0-9]{32}$/]; + +type LocalDateParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +const getFormatter = (timeZone: string) => + new Intl.DateTimeFormat('en-US', { + timeZone, + calendar: 'gregory', + numberingSystem: 'latn', + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + +const parseDateParts = (date: Date, timeZone: string): LocalDateParts => { + const values = Object.fromEntries( + getFormatter(timeZone) + .formatToParts(date) + .filter(({ type }) => type !== 'literal') + .map(({ type, value }) => [type, Number(value)]) + ) as Record; + + return { + year: values.year, + month: values.month, + day: values.day, + hour: values.hour === 24 ? 0 : values.hour, + minute: values.minute, + second: values.second + }; +}; + +const assertValidTimeZone = (timeZone: string) => { + try { + getFormatter(timeZone).format(); + } catch { + throw new Error(`Invalid account cancellation timezone: ${timeZone}`); + } +}; + +const getTimeZoneOffset = (date: Date, timeZone: string) => { + const parts = parseDateParts(date, timeZone); + const localAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + return localAsUtc - Math.floor(date.getTime() / 1000) * 1000; +}; + +/** 将指定时区的墙上时间转换为 UTC,避免依赖进程机器时区。 */ +const localDateTimeToUtc = ( + parts: Omit & { second?: number }, + timeZone: string +) => { + const localAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second ?? 0 + ); + let candidate = localAsUtc; + + for (let attempt = 0; attempt < 3; attempt++) { + const offset = getTimeZoneOffset(new Date(candidate), timeZone); + const next = localAsUtc - offset; + if (next === candidate) break; + candidate = next; + } + + return new Date(candidate); +}; + +const addLocalDays = ( + { year, month, day }: Pick, + days: number +) => { + const date = new Date(Date.UTC(year, month - 1, day + days)); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate() + }; +}; + +const formatLocalDate = ({ year, month, day }: LocalDateParts) => + `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + +const atLocalTime = (date: ReturnType, hour: number, timeZone: string) => + localDateTimeToUtc({ ...date, hour, minute: 0, second: 0 }, timeZone); + +/** 返回目标时区指定相对日期的 UTC 半开区间。 */ +const getLocalDayWindow = ({ + now, + daysFromToday, + timeZone +}: { + now: Date; + daysFromToday: number; + timeZone: string; +}) => { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) { + throw new Error('Invalid account cancellation current time'); + } + assertValidTimeZone(timeZone); + + const targetDate = addLocalDays(parseDateParts(now, timeZone), daysFromToday); + return { + start: atLocalTime(targetDate, 0, timeZone), + end: atLocalTime(addLocalDays(targetDate, 1), 0, timeZone) + }; +}; + +/** + * 从唯一持久化时间推导注销等待期的全部时间点。 + * waitEndsAt 使用完整的 UTC 24 小时周期,提醒和最终清理则使用显式配置时区的自然日。 + */ +export const deriveAccountCancellationSchedule = ( + requestedAt: Date, + timeZone = accountCancellationTimezone +): AccountCancellationSchedule => { + if (!(requestedAt instanceof Date) || Number.isNaN(requestedAt.getTime())) { + throw new Error('Invalid account cancellation requestedAt'); + } + assertValidTimeZone(timeZone); + + const normalizedRequestedAt = new Date(requestedAt.getTime()); + const waitEndsAt = new Date( + normalizedRequestedAt.getTime() + accountCancellationWaitDays * dayInMilliseconds + ); + const waitEndsLocal = parseDateParts(waitEndsAt, timeZone); + const cleanupDate = { + year: waitEndsLocal.year, + month: waitEndsLocal.month, + day: waitEndsLocal.day + }; + const cleanupLocalDate = formatLocalDate(waitEndsLocal); + + return { + requestedAt: normalizedRequestedAt, + waitEndsAt, + cleanupLocalDate, + sevenDayReminderAt: atLocalTime(addLocalDays(waitEndsLocal, -7), 10, timeZone), + oneDayReminderAt: atLocalTime(addLocalDays(waitEndsLocal, -1), 10, timeZone), + finalNoticeAt: atLocalTime(cleanupDate, 10, timeZone), + scheduledCancelAt: atLocalTime(addLocalDays(waitEndsLocal, 1), 0, timeZone), + timezone: timeZone + }; +}; + +export const getAccountCancellationReminderAt = ({ + requestedAt, + reminder, + timeZone = accountCancellationTimezone +}: { + requestedAt: Date; + reminder: AccountCancellationReminder; + timeZone?: string; +}) => { + const schedule = deriveAccountCancellationSchedule(requestedAt, timeZone); + if (reminder === AccountCancellationReminderValues.sevenDays) return schedule.sevenDayReminderAt; + if (reminder === AccountCancellationReminderValues.oneDay) return schedule.oneDayReminderAt; + return schedule.finalNoticeAt; +}; + +/** + * 反推出指定自然日应发送某类提醒的 requestedAt 半开区间,供数据库范围查询使用。 + * 区间按配置时区的自然日计算,避免受服务进程时区影响。 + */ +export const getAccountCancellationReminderRequestedAtWindow = ({ + now, + reminder, + timeZone = accountCancellationTimezone +}: { + now: Date; + reminder: AccountCancellationReminder; + timeZone?: string; +}) => { + const reminderDaysBeforeCleanup = (() => { + if (reminder === AccountCancellationReminderValues.sevenDays) return 7; + if (reminder === AccountCancellationReminderValues.oneDay) return 1; + return 0; + })(); + const cleanupDayWindow = getLocalDayWindow({ + now, + daysFromToday: reminderDaysBeforeCleanup, + timeZone + }); + const waitPeriodMs = accountCancellationWaitDays * dayInMilliseconds; + + return { + start: new Date(cleanupDayWindow.start.getTime() - waitPeriodMs), + end: new Date(cleanupDayWindow.end.getTime() - waitPeriodMs) + }; +}; + +/** + * 返回到期 pending 的 requestedAt 排他上界。 + * 当前自然日开始前已进入计划清理时间的记录满足 requestedAt < cutoff。 + */ +export const getAccountCancellationPendingDueCutoff = ({ + now, + timeZone = accountCancellationTimezone +}: { + now: Date; + timeZone?: string; +}) => { + const todayStart = getLocalDayWindow({ + now, + daysFromToday: 0, + timeZone + }).start; + + return new Date(todayStart.getTime() - accountCancellationWaitDays * dayInMilliseconds); +}; + +export const isAccountCancellationCancelable = (requestedAt: Date, now = new Date()) => + now.getTime() < deriveAccountCancellationSchedule(requestedAt).scheduledCancelAt.getTime(); + +export const isAccountCancellationMethod = (method: string) => + (accountCancellationAllowedMethods as readonly string[]).includes(method); + +/** + * 判断用户名是否由账号注销流程生成,同时兼容已落库的历史匿名用户名格式。 + */ +export const isAccountCancellationAnonymizedUsername = (username: string) => + accountCancellationAnonymizedUsernameReg.test(username) || + legacyAccountCancellationUsernameRegs.some((reg) => reg.test(username)); diff --git a/packages/global/support/user/account/verification/constants.ts b/packages/global/support/user/account/verification/constants.ts index 73860a29f86b..97f6b3a44f2a 100644 --- a/packages/global/support/user/account/verification/constants.ts +++ b/packages/global/support/user/account/verification/constants.ts @@ -2,5 +2,29 @@ export enum VerificationCodeTypeEnum { register = 'register', findPassword = 'findPassword', + unsubscribe = 'unsubscribe', bindNotification = 'bindNotification' } + +export const accountVerificationMethods = [ + 'code', + 'oldPassword', + 'wechat', + 'oauth/github', + 'oauth/google', + 'oauth/microsoft', + 'oauth/wecom', + 'oauth/sso' +] as const; + +export const recognizedAccountKinds = [ + 'email', + 'phone', + 'local', + 'wechat', + 'github', + 'google', + 'microsoft', + 'wecom', + 'sso' +] as const; diff --git a/packages/global/support/user/account/verification/type.ts b/packages/global/support/user/account/verification/type.ts index 0c78673afad8..2d804df2d715 100644 --- a/packages/global/support/user/account/verification/type.ts +++ b/packages/global/support/user/account/verification/type.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; -import { VerificationCodeTypeEnum } from './constants'; +import { + accountVerificationMethods, + recognizedAccountKinds, + VerificationCodeTypeEnum +} from './constants'; export const ACCOUNT_VERIFICATION_PURPOSES = [ 'login', @@ -30,8 +34,8 @@ export type VerificationType = (typeof VERIFICATION_TYPES)[number]; export const VERIFICATION_SCENES_BY_TYPE = { password: ['login'], - code: ['register', 'forgetPassword', 'bindNotification'], - captcha: ['register', 'forgetPassword', 'bindNotification'], + code: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'], + captcha: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'], // The callback adapter discovers the scene from all active QR materials. wechat: ACCOUNT_VERIFICATION_PURPOSES, oauth: ['login'] @@ -79,6 +83,7 @@ export type VerificationMaterialMatch = Partial<{ export const VERIFICATION_CODE_TYPES = [ VerificationCodeTypeEnum.register, VerificationCodeTypeEnum.findPassword, + VerificationCodeTypeEnum.unsubscribe, VerificationCodeTypeEnum.bindNotification ] as const; export const VerificationCodeTypeSchema = z.enum(VERIFICATION_CODE_TYPES); @@ -89,10 +94,11 @@ export const CodeVerificationPurposeSchema = AccountVerificationPurposeSchema.ex ); export type CodeVerificationPurpose = z.infer; -/** Each public code template has exactly one account-verification purpose. */ +/** Each verification code type has exactly one account-verification purpose. */ export const VERIFICATION_CODE_PURPOSES_BY_TYPE = { [VerificationCodeTypeEnum.register]: 'register', [VerificationCodeTypeEnum.findPassword]: 'forgetPassword', + [VerificationCodeTypeEnum.unsubscribe]: 'unsubscribe', [VerificationCodeTypeEnum.bindNotification]: 'bindNotification' } as const satisfies Record; @@ -121,7 +127,10 @@ export const PasswordVerificationPurposeSchema = AccountVerificationPurposeSchem ); export type PasswordVerificationPurpose = z.infer; -export const WechatPurposeSchema = AccountVerificationPurposeSchema.extract(['login']); +export const WechatPurposeSchema = AccountVerificationPurposeSchema.extract([ + 'login', + 'unsubscribe' +]); export type WechatPurpose = z.infer; export const ShortAuthStringSchema = z.string().trim().min(1).max(100); @@ -142,3 +151,39 @@ export const AccountLoginUsernameSchema = z.union([ AccountContactUsernameSchema, AccountUsernameSchema ]); + +export const AccountVerificationMethodSchema = z.enum(accountVerificationMethods); +export type AccountVerificationMethod = z.infer; + +export const AccountVerificationCapabilitiesSchema = z.object({ + emailCode: z.boolean(), + phoneCode: z.boolean(), + wechat: z.boolean(), + oauth: z.object({ + github: z.boolean(), + google: z.boolean(), + microsoft: z.boolean(), + wecom: z.boolean(), + sso: z.boolean() + }) +}); +export type AccountVerificationCapabilities = z.infer; + +export const RecognizedAccountKindSchema = z.enum(recognizedAccountKinds); +export type RecognizedAccountKind = z.infer; + +export const AccountVerificationResolutionSchema = z.discriminatedUnion('status', [ + z.object({ + status: z.literal('supported'), + accountKind: RecognizedAccountKindSchema, + method: AccountVerificationMethodSchema, + unsupportedReason: z.undefined().optional() + }), + z.object({ + status: z.literal('unsupported'), + accountKind: z.literal('invalid'), + method: z.undefined().optional(), + unsupportedReason: z.literal('empty_username') + }) +]); +export type AccountVerificationResolution = z.infer; diff --git a/packages/global/support/user/account/verification/utils.ts b/packages/global/support/user/account/verification/utils.ts new file mode 100644 index 000000000000..0ed4ee94d48a --- /dev/null +++ b/packages/global/support/user/account/verification/utils.ts @@ -0,0 +1,105 @@ +import { + AccountEmailUsernameSchema, + AccountPhoneUsernameSchema, + type AccountVerificationCapabilities, + type AccountVerificationMethod, + type AccountVerificationResolution, + type RecognizedAccountKind +} from './type'; + +/** + * 根据持久化 username 和部署能力推导唯一验证方式。 + * 当账号没有可用的外部验证方式时,统一降级为密码验证。 + */ +export const resolveAccountVerificationByUsername = ({ + username, + capabilities +}: { + username: string; + capabilities: AccountVerificationCapabilities; +}): AccountVerificationResolution => { + const normalizedUsername = username.trim(); + if (!normalizedUsername) { + return { + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }; + } + + const hasPrefix = (prefix: string) => + normalizedUsername.startsWith(`${prefix}-`) && normalizedUsername.length > prefix.length + 1; + const firstSeparatorIndex = normalizedUsername.indexOf('-'); + const hasSsoPrefix = + firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1; + + // 联系方式优先于通用 SSO 前缀,避免带连字符的合法邮箱被误判。 + const accountKind = (() => { + if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email'; + if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone'; + if (hasPrefix('wechat')) return 'wechat'; + if (hasPrefix('git')) return 'github'; + if (hasPrefix('google')) return 'google'; + if (hasPrefix('microsoft')) return 'microsoft'; + if (hasPrefix('wecom')) return 'wecom'; + if (capabilities.oauth.sso && hasSsoPrefix) return 'sso'; + return 'local'; + })() satisfies RecognizedAccountKind; + + type ExternalVerificationMethod = Exclude; + + const candidateMethods: readonly ExternalVerificationMethod[] = (() => { + switch (accountKind) { + case 'email': + case 'phone': + return ['code']; + case 'wechat': + return ['wechat']; + case 'github': + return ['oauth/github']; + case 'google': + return ['oauth/google']; + case 'microsoft': + return ['oauth/microsoft']; + case 'wecom': + return ['oauth/sso', 'oauth/wecom']; + case 'sso': + return ['oauth/sso']; + case 'local': + return []; + default: { + const exhaustiveAccountKind: never = accountKind; + return exhaustiveAccountKind; + } + } + })(); + + const isMethodAvailable = (method: ExternalVerificationMethod) => { + switch (method) { + case 'code': + return accountKind === 'email' ? capabilities.emailCode : capabilities.phoneCode; + case 'wechat': + return capabilities.wechat; + case 'oauth/github': + return capabilities.oauth.github; + case 'oauth/google': + return capabilities.oauth.google; + case 'oauth/microsoft': + return capabilities.oauth.microsoft; + case 'oauth/wecom': + return capabilities.oauth.wecom; + case 'oauth/sso': + return capabilities.oauth.sso; + default: { + const exhaustiveMethod: never = method; + return exhaustiveMethod; + } + } + }; + + return { + status: 'supported', + accountKind, + method: candidateMethods.find(isMethodAvailable) ?? 'oldPassword' + }; +}; diff --git a/packages/global/support/user/audit/constants.ts b/packages/global/support/user/audit/constants.ts index f48ac0cd6776..e5e7d8cfe7fa 100644 --- a/packages/global/support/user/audit/constants.ts +++ b/packages/global/support/user/audit/constants.ts @@ -5,6 +5,7 @@ export enum AdminAuditEventEnum { ADMIN_ADD_USER = 'ADMIN_ADD_USER', ADMIN_UPDATE_USER = 'ADMIN_UPDATE_USER', + ADMIN_DELETE_USER = 'ADMIN_DELETE_USER', ADMIN_UPDATE_TEAM = 'ADMIN_UPDATE_TEAM', ADMIN_ADD_PLAN = 'ADMIN_ADD_PLAN', ADMIN_UPDATE_PLAN = 'ADMIN_UPDATE_PLAN', @@ -87,6 +88,9 @@ export enum AuditEventEnum { CHANGE_PASSWORD = 'CHANGE_PASSWORD', CHANGE_NOTIFICATION_SETTINGS = 'CHANGE_NOTIFICATION_SETTINGS', CHANGE_MEMBER_NAME_ACCOUNT = 'CHANGE_MEMBER_NAME_ACCOUNT', + ACCOUNT_CANCELLATION_SUBMIT = 'ACCOUNT_CANCELLATION_SUBMIT', + ACCOUNT_CANCELLATION_CANCEL = 'ACCOUNT_CANCELLATION_CANCEL', + ACCOUNT_CANCELLATION_FINALIZE = 'ACCOUNT_CANCELLATION_FINALIZE', PURCHASE_PLAN = 'PURCHASE_PLAN', EXPORT_BILL_RECORDS = 'EXPORT_BILL_RECORDS', CREATE_INVOICE = 'CREATE_INVOICE', diff --git a/packages/global/support/user/audit/type.ts b/packages/global/support/user/audit/type.ts index 052fcd123111..8d4df811bea6 100644 --- a/packages/global/support/user/audit/type.ts +++ b/packages/global/support/user/audit/type.ts @@ -1,6 +1,5 @@ import type { SourceMemberType } from '../type'; import type { AuditEventEnum } from './constants'; - export type TeamAuditSchemaType = { _id: string; tmbId: string; @@ -9,7 +8,6 @@ export type TeamAuditSchemaType = { event: `${AuditEventEnum}`; metadata?: Record; }; - export type TeamAuditListItemType = { _id: string; sourceMember: SourceMemberType; diff --git a/packages/global/support/user/inform/constants.ts b/packages/global/support/user/inform/constants.ts index 206628a927ee..94ff76cbd5cc 100644 --- a/packages/global/support/user/inform/constants.ts +++ b/packages/global/support/user/inform/constants.ts @@ -20,6 +20,11 @@ export enum SendInformTemplateCodeEnum { REGISTER = 'REGISTER', // 注册 RESET_PASSWORD = 'RESET_PASSWORD', // 重置密码 BIND_NOTIFICATION = 'BIND_NOTIFICATION', // 绑定通知 + ACCOUNT_CANCELLATION_CODE = 'ACCOUNT_CANCELLATION_CODE', // 账号注销验证码 + ACCOUNT_CANCELLATION_TEAM_SUBMITTED = 'ACCOUNT_CANCELLATION_TEAM_SUBMITTED', // 团队注销通知 + ACCOUNT_CANCELLATION_7D = 'ACCOUNT_CANCELLATION_7D', + ACCOUNT_CANCELLATION_1D = 'ACCOUNT_CANCELLATION_1D', + ACCOUNT_CANCELLATION_TODAY = 'ACCOUNT_CANCELLATION_TODAY', EXPIRE_SOON = 'EXPIRE_SOON', // 即将过期 EXPIRED = 'EXPIRED', // 已过期 diff --git a/packages/global/support/user/team/type.ts b/packages/global/support/user/team/type.ts index edb15a644689..32dd8c1a3769 100644 --- a/packages/global/support/user/team/type.ts +++ b/packages/global/support/user/team/type.ts @@ -3,6 +3,7 @@ import { TeamMemberRoleEnum, TeamMemberStatusEnum } from './constant'; import type { GroupMemberRole } from '../../permission/memberGroup/constant'; import { TeamPermission } from '../../permission/user/controller'; import { z } from 'zod'; +import { TeamAccountCancellationStatusSchema } from '../account/cancellation/type'; export const OpenaiAccountSchema = z.object({ key: z.string(), @@ -62,7 +63,14 @@ export const TeamTmbItemSchema = ThidPartyAccountSchema.extend({ status: z.enum(TeamMemberStatusEnum), notificationAccount: z.string().optional(), permission: z.instanceof(TeamPermission), - isWecomTeam: z.boolean().optional() + isWecomTeam: z.boolean().optional(), + accountCancellation: z + .object({ + status: TeamAccountCancellationStatusSchema, + scheduledCancelAt: z.union([z.date(), z.iso.datetime({ offset: true })]).optional() + }) + .strict() + .optional() }); export type TeamTmbItemType = z.infer; diff --git a/packages/global/test/openapi/support/user/account/cancellation/api.test.ts b/packages/global/test/openapi/support/user/account/cancellation/api.test.ts new file mode 100644 index 000000000000..b94824bde887 --- /dev/null +++ b/packages/global/test/openapi/support/user/account/cancellation/api.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAccountCancellationVerificationBodySchema, + SubmitAccountCancellationBodySchema, + SubmitAccountCancellationResponseSchema +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; + +describe('account cancellation API contracts', () => { + it('accepts only the non-password create methods', () => { + expect( + CreateAccountCancellationVerificationBodySchema.parse({ + method: 'code', + payload: { captcha: 'A1B2C3' } + }).method + ).toBe('code'); + expect(() => + CreateAccountCancellationVerificationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'secret' } + }) + ).toThrow(); + }); + + it('does not accept username, scene, or extra submit fields', () => { + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'code', + payload: { code: '123456' }, + username: 'user@example.com' + }) + ).toThrow(); + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'oldPassword', + payload: { password: 'secret' } + }) + ).toThrow(); + }); + + it('bounds SSO callback props', () => { + const props = Object.fromEntries( + Array.from({ length: 21 }, (_, index) => [`key${index}`, 'value']) + ); + expect(() => + SubmitAccountCancellationBodySchema.parse({ + method: 'oauth/sso', + payload: { + callbackUrl: 'https://fastgpt.example.com/login/provider', + code: 'provider-code', + props + } + }) + ).toThrow(); + }); + + it('exposes WeChat verification expiry as a polling result', () => { + expect( + SubmitAccountCancellationResponseSchema.parse({ status: 'verificationExpired' }) + ).toEqual({ status: 'verificationExpired' }); + }); +}); diff --git a/packages/global/test/openapi/support/user/inform/api.test.ts b/packages/global/test/openapi/support/user/inform/api.test.ts index 5d916d9f1fb5..39d85c1c970f 100644 --- a/packages/global/test/openapi/support/user/inform/api.test.ts +++ b/packages/global/test/openapi/support/user/inform/api.test.ts @@ -33,8 +33,8 @@ describe('SendAuthCodeBodySchema', () => { expect(() => SendAuthCodeBodySchema.parse({ ...common, - type: 'login', - purpose: 'login' + type: VerificationCodeTypeEnum.unsubscribe, + purpose: 'unsubscribe' }) ).toThrow(); }); diff --git a/packages/global/test/support/user/account/cancellation/resolver.test.ts b/packages/global/test/support/user/account/cancellation/resolver.test.ts new file mode 100644 index 000000000000..101851649985 --- /dev/null +++ b/packages/global/test/support/user/account/cancellation/resolver.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import type { AccountCancellationVerificationCapabilities } from '@fastgpt/global/support/user/account/cancellation/type'; +import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation/resolver'; + +const capabilities: AccountCancellationVerificationCapabilities = { + emailCode: true, + phoneCode: true, + wechat: true, + oauth: { + github: true, + google: true, + microsoft: true, + wecom: true, + sso: true + } +}; + +describe('resolveAccountCancellationByUsername', () => { + it('allows the shared non-password resolver result', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'user@example.com', + capabilities + }) + ).toEqual({ status: 'supported', accountKind: 'email', method: 'code' }); + }); + + it('turns the shared old-password fallback into an unsupported result', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'local', + capabilities + }) + ).toEqual({ + status: 'unsupported', + accountKind: 'local', + unsupportedReason: 'password_verification_not_allowed' + }); + }); + + it.each([' ', null])('preserves the shared empty-username result for %s', (username) => { + expect( + resolveAccountCancellationByUsername({ + username, + capabilities + }) + ).toEqual({ + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }); + }); + + it('does not expose a provider that is unavailable', () => { + expect( + resolveAccountCancellationByUsername({ + username: 'git-octocat', + capabilities: { + ...capabilities, + oauth: { ...capabilities.oauth, github: false } + } + }) + ).toMatchObject({ + status: 'unsupported', + accountKind: 'github', + unsupportedReason: 'password_verification_not_allowed' + }); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/type.test.ts b/packages/global/test/support/user/account/cancellation/type.test.ts new file mode 100644 index 000000000000..1f5d9c8ab276 --- /dev/null +++ b/packages/global/test/support/user/account/cancellation/type.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + AccountCancellationAllowedMethodSchema, + AccountCancellationReminderSchema, + AccountCancellationStatusSchema, + AccountCancellationUnavailableReasonSchema, + TeamAccountCancellationStatusSchema +} from '@fastgpt/global/support/user/account/cancellation/type'; +import { + AccountCancellationReminder, + AccountCancellationStatus, + AccountCancellationUnavailableReason, + accountCancellationAllowedMethods +} from '@fastgpt/global/support/user/account/cancellation/constants'; + +describe('account cancellation schemas', () => { + it('accepts and rejects cancellation statuses', () => { + expect( + AccountCancellationStatusSchema.safeParse(AccountCancellationStatus.pending).success + ).toBe(true); + expect( + AccountCancellationStatusSchema.safeParse(AccountCancellationStatus.finalizing).success + ).toBe(true); + expect( + AccountCancellationStatusSchema.safeParse(AccountCancellationStatus.completed).success + ).toBe(true); + expect(AccountCancellationStatusSchema.safeParse('unknown').success).toBe(false); + expect( + TeamAccountCancellationStatusSchema.safeParse(AccountCancellationStatus.completed).success + ).toBe(false); + }); + + it('covers reminders, unavailable reasons, and allowed methods', () => { + expect( + Object.values(AccountCancellationReminder).every( + (value) => AccountCancellationReminderSchema.safeParse(value).success + ) + ).toBe(true); + expect( + Object.values(AccountCancellationUnavailableReason).every( + (value) => AccountCancellationUnavailableReasonSchema.safeParse(value).success + ) + ).toBe(true); + expect( + accountCancellationAllowedMethods.every( + (value) => AccountCancellationAllowedMethodSchema.safeParse(value).success + ) + ).toBe(true); + expect(AccountCancellationAllowedMethodSchema.safeParse('oauth/unknown').success).toBe(false); + }); +}); diff --git a/packages/global/test/support/user/account/cancellation/utils.test.ts b/packages/global/test/support/user/account/cancellation/utils.test.ts new file mode 100644 index 000000000000..be51db4a9e17 --- /dev/null +++ b/packages/global/test/support/user/account/cancellation/utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { AccountCancellationReminder } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { + deriveAccountCancellationSchedule, + getAccountCancellationPendingDueCutoff, + getAccountCancellationReminderAt, + getAccountCancellationReminderRequestedAtWindow, + isAccountCancellationAnonymizedUsername, + isAccountCancellationCancelable +} from '@fastgpt/global/support/user/account/cancellation/utils'; + +describe('deriveAccountCancellationSchedule', () => { + it('derives a complete wait period and local-day reminders', () => { + const requestedAt = new Date('2026-07-01T10:20:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + + expect(schedule.waitEndsAt.toISOString()).toBe('2026-07-16T10:20:00.000Z'); + expect(schedule.cleanupLocalDate).toBe('2026-07-16'); + expect(schedule.sevenDayReminderAt.toISOString()).toBe('2026-07-09T02:00:00.000Z'); + expect(schedule.oneDayReminderAt.toISOString()).toBe('2026-07-15T02:00:00.000Z'); + expect(schedule.finalNoticeAt.toISOString()).toBe('2026-07-16T02:00:00.000Z'); + expect(schedule.scheduledCancelAt.toISOString()).toBe('2026-07-16T16:00:00.000Z'); + }); + + it('uses the configured timezone for a DST transition', () => { + const requestedAt = new Date('2026-03-01T17:00:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt, 'America/New_York'); + + expect(schedule.cleanupLocalDate).toBe('2026-03-16'); + expect(schedule.finalNoticeAt.toISOString()).toBe('2026-03-16T14:00:00.000Z'); + expect(schedule.scheduledCancelAt.toISOString()).toBe('2026-03-17T04:00:00.000Z'); + }); + + it('shares reminder calculation with the schedule helper', () => { + const requestedAt = new Date('2026-07-01T00:00:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + expect( + getAccountCancellationReminderAt({ + requestedAt, + reminder: AccountCancellationReminder.today + }) + ).toEqual(schedule.finalNoticeAt); + }); + + it('derives requestedAt query windows for reminders from the configured local day', () => { + const now = new Date('2026-07-09T02:00:00.000Z'); + + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminder.sevenDays + }) + ).toEqual({ + start: new Date('2026-06-30T16:00:00.000Z'), + end: new Date('2026-07-01T16:00:00.000Z') + }); + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminder.oneDay + }) + ).toEqual({ + start: new Date('2026-06-24T16:00:00.000Z'), + end: new Date('2026-06-25T16:00:00.000Z') + }); + expect( + getAccountCancellationReminderRequestedAtWindow({ + now, + reminder: AccountCancellationReminder.today + }) + ).toEqual({ + start: new Date('2026-06-23T16:00:00.000Z'), + end: new Date('2026-06-24T16:00:00.000Z') + }); + }); + + it('derives the exclusive pending due cutoff from the configured local day', () => { + expect( + getAccountCancellationPendingDueCutoff({ + now: new Date('2026-07-16T16:00:00.000Z') + }) + ).toEqual(new Date('2026-07-01T16:00:00.000Z')); + }); + + it('keeps requestedAt query windows aligned with the fixed wait period across DST', () => { + const window = getAccountCancellationReminderRequestedAtWindow({ + now: new Date('2026-03-09T14:00:00.000Z'), + reminder: AccountCancellationReminder.today, + timeZone: 'America/New_York' + }); + + expect(window).toEqual({ + start: new Date('2026-02-22T04:00:00.000Z'), + end: new Date('2026-02-23T04:00:00.000Z') + }); + expect( + deriveAccountCancellationSchedule(window.start, 'America/New_York').finalNoticeAt + ).toEqual(new Date('2026-03-09T14:00:00.000Z')); + }); + + it('allows cancellation only before the derived local midnight', () => { + const requestedAt = new Date('2026-07-01T10:20:00.000Z'); + const schedule = deriveAccountCancellationSchedule(requestedAt); + expect(isAccountCancellationCancelable(requestedAt, new Date('2026-07-16T15:59:59.999Z'))).toBe( + true + ); + expect(isAccountCancellationCancelable(requestedAt, schedule.scheduledCancelAt)).toBe(false); + }); + + it('rejects invalid dates and timezones', () => { + expect(() => deriveAccountCancellationSchedule(new Date('invalid'))).toThrow(); + expect(() => deriveAccountCancellationSchedule(new Date(), 'invalid/zone')).toThrow(); + }); +}); + +describe('isAccountCancellationAnonymizedUsername', () => { + it('matches the current username-random-delete format', () => { + expect(isAccountCancellationAnonymizedUsername('user@example.com-a1B2c3D4-delete')).toBe(true); + }); + + it('keeps historical anonymized usernames recognizable', () => { + expect(isAccountCancellationAnonymizedUsername('user@example.com-deleted')).toBe(true); + expect(isAccountCancellationAnonymizedUsername(`deleted-${'a'.repeat(32)}`)).toBe(true); + }); + + it('does not treat ordinary delete-like usernames as anonymized', () => { + expect(isAccountCancellationAnonymizedUsername('user-delete')).toBe(false); + expect(isAccountCancellationAnonymizedUsername('user-12345678-delete')).toBe(false); + expect(isAccountCancellationAnonymizedUsername('user-a1B2c3D4-delete-suffix')).toBe(false); + expect(isAccountCancellationAnonymizedUsername(`deleted-${'g'.repeat(32)}`)).toBe(false); + }); +}); diff --git a/packages/global/test/support/user/account/verification/utils.test.ts b/packages/global/test/support/user/account/verification/utils.test.ts new file mode 100644 index 000000000000..b80bb68d00e9 --- /dev/null +++ b/packages/global/test/support/user/account/verification/utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type'; +import { resolveAccountVerificationByUsername } from '@fastgpt/global/support/user/account/verification/utils'; + +const capabilities: AccountVerificationCapabilities = { + emailCode: true, + phoneCode: true, + wechat: true, + oauth: { + github: true, + google: true, + microsoft: true, + wecom: true, + sso: true + } +}; + +describe('resolveAccountVerificationByUsername', () => { + it.each([ + ['user@example.com', 'email', 'code'], + ['13800138000', 'phone', 'code'], + ['wechat-openid', 'wechat', 'wechat'], + ['git-octocat', 'github', 'oauth/github'], + ['google-user', 'google', 'oauth/google'], + ['microsoft-user', 'microsoft', 'oauth/microsoft'], + ['tenant-user', 'sso', 'oauth/sso'] + ] as const)('resolves %s to %s verification', (username, accountKind, method) => { + expect(resolveAccountVerificationByUsername({ username, capabilities })).toEqual({ + status: 'supported', + accountKind, + method + }); + }); + + it('prefers SSO over the standalone WeCom provider', () => { + expect( + resolveAccountVerificationByUsername({ username: 'wecom-user', capabilities }) + ).toMatchObject({ method: 'oauth/sso' }); + }); + + it('falls back to the standalone WeCom provider when SSO is unavailable', () => { + expect( + resolveAccountVerificationByUsername({ + username: 'wecom-user', + capabilities: { ...capabilities, oauth: { ...capabilities.oauth, sso: false } } + }) + ).toMatchObject({ accountKind: 'wecom', method: 'oauth/wecom' }); + }); + + it.each([ + ['local', capabilities], + ['user@example.com', { ...capabilities, emailCode: false }], + ['git-octocat', { ...capabilities, oauth: { ...capabilities.oauth, github: false } }] + ] as const)('falls back to password verification for %s', (username, currentCapabilities) => { + expect( + resolveAccountVerificationByUsername({ username, capabilities: currentCapabilities }) + ).toMatchObject({ status: 'supported', method: 'oldPassword' }); + }); + + it('rejects an empty username', () => { + expect(resolveAccountVerificationByUsername({ username: ' ', capabilities })).toEqual({ + status: 'unsupported', + accountKind: 'invalid', + unsupportedReason: 'empty_username' + }); + }); +}); diff --git a/packages/service/common/middle/tracks/utils.ts b/packages/service/common/middle/tracks/utils.ts index c8ddad49a2c9..27af4f629c2e 100644 --- a/packages/service/common/middle/tracks/utils.ts +++ b/packages/service/common/middle/tracks/utils.ts @@ -18,6 +18,24 @@ import type { StandardSubLevelEnum } from '@fastgpt/global/support/wallet/sub/co const logger = getLogger(LogCategories.EVENT.TRACK); const dailyActiveDedupeCache = new DailyActiveDedupeCache({ logger }); +type AccountCancellationTrackData = { + uid?: string; + teamId?: string; + tmbId?: string; + userId: string; + operatorUserId: string; + operatorType: 'self' | 'system' | 'admin'; + requestSource: 'self' | 'admin'; + requestedAt: Date; + scheduledCancelAt: Date; + finalizedAt?: Date; + verificationMethod?: string; + verificationProvider?: string; + affectedTeamIds?: string[]; + requestId?: string; + cronExecutionId?: string; +}; + const createTrack = ({ event, data }: { event: TrackEnum; data: Record }) => { if (!global.feConfigs?.isPlus) return; logger.debug('Enqueue track event', { @@ -196,6 +214,40 @@ export const pushTrack = { data }); }, + accountCancellationSubmitSuccess: ( + data: AccountCancellationTrackData & { + operatorType: 'self'; + requestSource: 'self'; + verificationMethod: string; + affectedTeamIds: string[]; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationSubmitSuccess, + data + }); + }, + accountCancellationCancelSuccess: ( + data: AccountCancellationTrackData & { + operatorType: 'self'; + requestSource: 'self'; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationCancelSuccess, + data + }); + }, + accountCancellationFinalizeSuccess: ( + data: AccountCancellationTrackData & { + finalizedAt: Date; + } + ) => { + return createTrack({ + event: TrackEnum.accountCancellationFinalizeSuccess, + data + }); + }, // Admin cron job tracks subscriptionDeleted: (data: { diff --git a/packages/service/common/system/cron.ts b/packages/service/common/system/cron.ts index 7b5e32513e90..ec0e477e3e43 100644 --- a/packages/service/common/system/cron.ts +++ b/packages/service/common/system/cron.ts @@ -1,6 +1,6 @@ -import nodeCron from 'node-cron'; +import nodeCron, { type ScheduleOptions } from 'node-cron'; -export const setCron = (time: string, cb: () => void) => { +export const setCron = (time: string, cb: () => void, options?: ScheduleOptions) => { // second minute hour day month week - return nodeCron.schedule(time, cb); + return nodeCron.schedule(time, cb, options); }; diff --git a/packages/service/common/system/timerLock/constants.ts b/packages/service/common/system/timerLock/constants.ts index d17f720f914b..64cef4c9acd6 100644 --- a/packages/service/common/system/timerLock/constants.ts +++ b/packages/service/common/system/timerLock/constants.ts @@ -18,6 +18,8 @@ export enum TimerIdEnum { archiveInactiveSandboxes = 'archiveInactiveSandboxes', recoverStaleSandboxOperations = 'recoverStaleSandboxOperations', enterpriseAuthTaskCleanup = 'enterpriseAuthTaskCleanup', + accountCancellationReminder = 'accountCancellationReminder', + accountCancellationFinalize = 'accountCancellationFinalize', /** 纠正长时间卡在 generating 的会话状态 */ cleanStaleGeneratingChat = 'cleanStaleGeneratingChat' } diff --git a/packages/service/core/app/evaluation/delete.ts b/packages/service/core/app/evaluation/delete.ts new file mode 100644 index 000000000000..7804df48840b --- /dev/null +++ b/packages/service/core/app/evaluation/delete.ts @@ -0,0 +1,19 @@ +import { MongoEvaluation } from './evalSchema'; +import { MongoEvalItem } from './evalItemSchema'; + +/** + * 删除团队下的评估及其评估项。 + * 评估项只保存 evalId,因此必须先保留父记录 ID 并删除子项,再删除父记录,确保失败重试时仍可定位子项。 + */ +export const deleteEvaluationsByTeamId = async (teamId: string) => { + const evaluations = await MongoEvaluation.find({ teamId }, '_id').lean(); + const evalIds = evaluations.map((evaluation) => evaluation._id); + + if (evalIds.length > 0) { + await MongoEvalItem.deleteMany({ + evalId: { $in: evalIds } + }); + } + + await MongoEvaluation.deleteMany({ teamId }); +}; diff --git a/packages/service/core/dataset/delete/processor.ts b/packages/service/core/dataset/delete/processor.ts index 8ef7f4b1a121..cd83c5d3bf4a 100644 --- a/packages/service/core/dataset/delete/processor.ts +++ b/packages/service/core/dataset/delete/processor.ts @@ -46,6 +46,11 @@ export const deleteTeamAllDatasets = async (teamId: string) => { datasetIds: datasets.map((d) => d._id) }); + const datasetIdSet = new Set(datasets.map((dataset) => String(dataset._id))); + const deleteRootDatasets = datasets.filter( + (dataset) => !dataset.parentId || !datasetIdSet.has(String(dataset.parentId)) + ); + await mongoSessionRun(async (session) => { await MongoDataset.updateMany( { @@ -61,9 +66,7 @@ export const deleteTeamAllDatasets = async (teamId: string) => { } ); await Promise.all( - datasets.map((dataset) => { - // 有 parentId 的忽略,只需要删 root 下的即可。 - if (dataset.parentId) return; + deleteRootDatasets.map((dataset) => { return addDatasetDeleteJob({ teamId, datasetId: dataset._id diff --git a/packages/service/support/outLink/guard.ts b/packages/service/support/outLink/guard.ts new file mode 100644 index 000000000000..f9ea5c9390e5 --- /dev/null +++ b/packages/service/support/outLink/guard.ts @@ -0,0 +1,12 @@ +import { assertAccountUsable } from '../user/account/cancellation/guard'; + +/** 分享链接没有用户Session,使用发布链接绑定的 tmb/team 校验账号可用性。 */ +export const assertOutLinkTeamUsable = async ({ + teamId, + tmbId +}: { + teamId: string; + tmbId: string; +}) => { + await assertAccountUsable({ teamId, tmbId }); +}; diff --git a/packages/service/support/outLink/runtime/utils.ts b/packages/service/support/outLink/runtime/utils.ts index 5f8a8eb90e09..707eeb1a6fb7 100644 --- a/packages/service/support/outLink/runtime/utils.ts +++ b/packages/service/support/outLink/runtime/utils.ts @@ -46,6 +46,7 @@ import { MongoChat } from '../../../core/chat/chatSchema'; import { buildChatSourceQuery, type ChatSourceParams } from '../../../core/chat/source'; import { getNanoid } from '@fastgpt/global/common/string/tools'; import { MongoChatItem } from '../../../core/chat/chatItemSchema'; +import { assertOutLinkTeamUsable } from '../guard'; const logger = getLogger(LogCategories.MODULE.OUTLINK); @@ -127,6 +128,10 @@ export async function outlinkInvokeChat({ onStreamChunk, streamId }: outLinkInvokeChatProps) { + await assertOutLinkTeamUsable({ + teamId: String(outLinkConfig.teamId), + tmbId: String(outLinkConfig.tmbId) + }); const roundState = { preparedRound: undefined as PreChatRoundResult | undefined, sourceId: '', diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts index 51b32c1e6cbf..46af0df1613f 100644 --- a/packages/service/support/permission/auth/common.ts +++ b/packages/service/support/permission/auth/common.ts @@ -4,10 +4,14 @@ import { SERVICE_LOCAL_HOST } from '../../../common/system/tools'; import type { NodeHttpRequest, NodeHttpResponse } from '../../../types/http'; import Cookie from 'cookie'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; -import { authUserSession } from '../../../support/user/session'; +import { authUserSession, resolveUserSessionTeam } from '../../../support/user/session'; import { authOpenApiKey } from '../../../support/openapi/auth'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import { serviceEnv } from '../../../env'; +import { resolveAccountCancellationAccess } from '../../user/account/cancellation/access'; +import { getActiveAccountCancellationsByUserIds } from '../../user/account/cancellation/read'; +import { assertAccountUsable } from '../../user/account/cancellation/guard'; +import { resolveAuthContext } from './context'; export const authCert = async (props: AuthModeType) => { const result = await parseHeaderCert(props); @@ -30,7 +34,8 @@ export async function parseHeaderCert({ req, authToken = false, authRoot = false, - authApiKey = false + authApiKey = false, + accountCancellationAccess = 'normal' }: AuthModeType) { // parse jwt async function authCookieToken(cookie?: string, token?: string) { @@ -152,14 +157,62 @@ export async function parseHeaderCert({ return Promise.reject(ERROR_ENUM.unAuthorization); })(); - if (!authRoot && (!teamId || !tmbId)) { + const authContext = await (async () => { + if (authRoot) return undefined; + if (!teamId || !tmbId) return null; + + const currentContext = await resolveAuthContext({ + userId: uid ? String(uid) : undefined, + teamId: String(teamId), + tmbId: String(tmbId) + }); + if (currentContext) return currentContext; + + // 只对已有 Session 保留团队失效后的原地 fallback;API Key 失效必须直接拒绝。 + if (authType === AuthUserTypeEnum.token && uid && sessionId) { + const sessionTeam = await resolveUserSessionTeam({ + userId: String(uid), + teamId: String(teamId), + tmbId: String(tmbId), + sessionId + }); + return resolveAuthContext({ + userId: String(uid), + teamId: sessionTeam.teamId, + tmbId: sessionTeam.tmbId + }); + } + return null; + })(); + + if (!authRoot && !authContext) { return Promise.reject(ERROR_ENUM.unAuthorization); } + const accountCancellationGuard = resolveAccountCancellationAccess({ + req, + accountCancellationAccess + }); + const cancellations = authContext + ? await getActiveAccountCancellationsByUserIds({ + userId: authContext.userId, + ownerId: authContext.ownerId + }) + : undefined; + await assertAccountUsable({ + authContext: authContext ?? undefined, + cancellations, + ...accountCancellationGuard + }); + + const resolvedUserId = authContext?.userId ?? String(uid); + const resolvedTeamId = authContext?.teamId ?? ''; + const resolvedTmbId = authContext?.tmbId ?? ''; + return { - userId: String(uid), - teamId: String(teamId), - tmbId: String(tmbId), + userId: resolvedUserId, + teamId: String(resolvedTeamId), + tmbId: String(resolvedTmbId), appId, authType, sourceName, diff --git a/packages/service/support/permission/auth/context.ts b/packages/service/support/permission/auth/context.ts new file mode 100644 index 000000000000..a599db324057 --- /dev/null +++ b/packages/service/support/permission/auth/context.ts @@ -0,0 +1,101 @@ +import { + TeamCollectionName, + TeamMemberStatusEnum +} from '@fastgpt/global/support/user/team/constant'; +import { Types } from 'mongoose'; +import { MongoTeamMember } from '../../user/team/teamMemberSchema'; + +export type AuthContext = { + userId: string; + teamId: string; + tmbId: string; + ownerId?: string; +}; + +type AuthContextAggregationResult = { + userId: Types.ObjectId; + teamId: Types.ObjectId; + tmbId: Types.ObjectId; + ownerId?: Types.ObjectId; +}; + +const toObjectId = (value?: string) => { + if (!value || !Types.ObjectId.isValid(value)) return undefined; + return new Types.ObjectId(value); +}; + +/** + * 构造中心鉴权使用的成员/团队聚合。聚合同时确认 active member、未删除团队和当前 owner, + * 让后续注销 guard 不需要再次读取成员与团队,也让 API Key 使用数据库中的真实成员身份。 + */ +export const buildAuthContextPipeline = ({ + userId, + teamId, + tmbId +}: { + userId?: string; + teamId: string; + tmbId: string; +}) => { + const objectTeamId = toObjectId(teamId); + const objectTmbId = toObjectId(tmbId); + const objectUserId = toObjectId(userId); + if (!objectTeamId || !objectTmbId || (userId && !objectUserId)) return null; + + return [ + { + $match: { + _id: objectTmbId, + teamId: objectTeamId, + status: TeamMemberStatusEnum.active, + ...(objectUserId ? { userId: objectUserId } : {}) + } + }, + { + $lookup: { + from: TeamCollectionName, + localField: 'teamId', + foreignField: '_id', + as: 'team' + } + }, + { $unwind: '$team' }, + { + $match: { + $or: [{ 'team.deleteTime': { $exists: false } }, { 'team.deleteTime': null }] + } + }, + { + $project: { + _id: 0, + userId: 1, + teamId: 1, + tmbId: '$_id', + ownerId: '$team.ownerId' + } + } + ]; +}; + +/** + * 解析并验证当前请求的 auth-context。返回 null 表示成员、团队或身份已失效,调用方负责决定 + * 是否进入 Session fallback;API Key 调用方应直接拒绝,不得复用其它 Session。 + */ +export const resolveAuthContext = async (props: { + userId?: string; + teamId: string; + tmbId: string; +}): Promise => { + const pipeline = buildAuthContextPipeline(props); + if (!pipeline) return null; + + const [result] = await MongoTeamMember.aggregate(pipeline); + if (!result) return null; + + return { + userId: String(result.userId), + teamId: String(result.teamId), + tmbId: String(result.tmbId), + ...(result.ownerId ? { ownerId: String(result.ownerId) } : {}) + }; +}; diff --git a/packages/service/support/permission/publish/authLink.ts b/packages/service/support/permission/publish/authLink.ts index 0e0688990049..f95657881f2f 100644 --- a/packages/service/support/permission/publish/authLink.ts +++ b/packages/service/support/permission/publish/authLink.ts @@ -6,6 +6,7 @@ import { OwnerPermissionVal } from '@fastgpt/global/support/permission/constant' import { authAppByTmbId } from '../app/auth'; import { type AuthModeType, type AuthResponseType } from '../type'; import { parseHeaderCert } from '../auth/common'; +import { assertOutLinkTeamUsable } from '../../outLink/guard'; /* crud outlink permission */ export async function authOutLinkCrud({ @@ -68,6 +69,11 @@ export async function authOutLinkValid({ return Promise.reject(OutLinkErrEnum.linkUnInvalid); } + await assertOutLinkTeamUsable({ + teamId: String(outLinkConfig.teamId), + tmbId: String(outLinkConfig.tmbId) + }); + return { appId: outLinkConfig.appId, outLinkConfig: outLinkConfig diff --git a/packages/service/support/permission/type.ts b/packages/service/support/permission/type.ts index 7b680c0bac35..ca76699c387c 100644 --- a/packages/service/support/permission/type.ts +++ b/packages/service/support/permission/type.ts @@ -3,6 +3,7 @@ import type { PermissionValueType } from '@fastgpt/global/support/permission/typ import type { RequireAtLeastOne } from '@fastgpt/global/common/type/utils'; import type { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; import type { NodeHttpRequest } from '../../types/http'; +import type { AccountCancellationAccessPreset } from '@fastgpt/global/support/user/account/cancellation/type'; export type ReqHeaderAuthType = { cookie?: string; @@ -19,6 +20,7 @@ type authModeType = { authRoot?: boolean; authApiKey?: boolean; per?: PermissionValueType; + accountCancellationAccess?: AccountCancellationAccessPreset; }; export type AuthModeType = RequireAtLeastOne; diff --git a/packages/service/support/user/account/cancellation/access.ts b/packages/service/support/user/account/cancellation/access.ts new file mode 100644 index 000000000000..26f1d2372d47 --- /dev/null +++ b/packages/service/support/user/account/cancellation/access.ts @@ -0,0 +1,137 @@ +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import type { AccountCancellationAccessPreset } from '@fastgpt/global/support/user/account/cancellation/type'; +import type { AssertAccountUsableProps } from './guard'; + +type AccessRequest = { + method?: string; + url?: string; +}; + +type AccessPreset = { + apis: string[]; + options: Required< + Pick< + AssertAccountUsableProps, + | 'allowUserAccountCancellationPending' + | 'allowUserAccountCancellationFinalizing' + | 'allowCurrentUserOwnedTeamAccountCancellationPending' + | 'allowCurrentUserOwnedTeamAccountCancellationFinalizing' + | 'allowCurrentSessionTeamAccountCancellationPending' + | 'allowCurrentSessionTeamAccountCancellationFinalizing' + > + >; +}; + +export const accountCancellationAccessPresets: Record< + AccountCancellationAccessPreset, + AccessPreset +> = { + normal: { + apis: [], + options: { + allowUserAccountCancellationPending: false, + allowUserAccountCancellationFinalizing: false, + allowCurrentUserOwnedTeamAccountCancellationPending: false, + allowCurrentUserOwnedTeamAccountCancellationFinalizing: false, + allowCurrentSessionTeamAccountCancellationPending: false, + allowCurrentSessionTeamAccountCancellationFinalizing: false + } + }, + selfCancellation: { + apis: [ + 'GET /proApi/support/user/account/cancellation/status', + 'POST /proApi/support/user/account/cancellation/verification/create', + 'POST /proApi/support/user/account/cancellation/submit', + 'DELETE /proApi/support/user/account/cancellation/cancel' + ], + options: { + allowUserAccountCancellationPending: true, + allowUserAccountCancellationFinalizing: true, + allowCurrentUserOwnedTeamAccountCancellationPending: true, + allowCurrentUserOwnedTeamAccountCancellationFinalizing: true, + allowCurrentSessionTeamAccountCancellationPending: true, + allowCurrentSessionTeamAccountCancellationFinalizing: true + } + }, + teamEscape: { + apis: [ + 'GET /proApi/support/user/team/list', + 'POST /proApi/support/user/team/switch', + 'PUT /proApi/support/user/team/switch' + ], + options: { + allowUserAccountCancellationPending: false, + allowUserAccountCancellationFinalizing: false, + allowCurrentUserOwnedTeamAccountCancellationPending: false, + allowCurrentUserOwnedTeamAccountCancellationFinalizing: false, + allowCurrentSessionTeamAccountCancellationPending: true, + allowCurrentSessionTeamAccountCancellationFinalizing: true + } + }, + tokenLogin: { + apis: [ + 'GET /api/support/user/account/tokenLogin', + 'GET /proApi/support/user/account/tokenLogin', + 'GET /api/support/user/team/plan/getTeamPlanStatus' + ], + options: { + allowUserAccountCancellationPending: true, + allowUserAccountCancellationFinalizing: false, + allowCurrentUserOwnedTeamAccountCancellationPending: true, + allowCurrentUserOwnedTeamAccountCancellationFinalizing: true, + allowCurrentSessionTeamAccountCancellationPending: true, + allowCurrentSessionTeamAccountCancellationFinalizing: true + } + } +}; + +const requestKeys = ({ method, url }: AccessRequest) => { + const normalizedMethod = method?.toUpperCase(); + if (!normalizedMethod || !url) return []; + let pathname = url; + try { + pathname = new URL(url, 'http://fastgpt.local').pathname; + } catch { + pathname = url.split('?')[0] ?? ''; + } + + const paths = new Set([pathname]); + if (pathname.startsWith('/api/proApi/')) paths.add(pathname.replace('/api/proApi', '/proApi')); + if (pathname.startsWith('/api/') && !pathname.startsWith('/api/proApi/')) { + paths.add(pathname.replace('/api', '/proApi')); + } + return Array.from(paths, (path) => `${normalizedMethod} ${path}`); +}; + +/** 将访问 preset 收窄为 guard flags,并强制校验当前请求路径。 */ +export const resolveAccountCancellationAccess = ({ + req, + accountCancellationAccess = 'normal' +}: { + req?: AccessRequest; + accountCancellationAccess?: AccountCancellationAccessPreset; +}) => { + const preset = accountCancellationAccessPresets[accountCancellationAccess]; + const keys = requestKeys(req ?? {}); + if (accountCancellationAccess !== 'normal') { + const allowed = keys.some((key) => preset.apis.includes(key)); + if (!allowed) throw new Error(ERROR_ENUM.unAuthorization); + } + if ( + accountCancellationAccess === 'selfCancellation' && + keys.some((key) => + [ + 'POST /proApi/support/user/account/cancellation/verification/create', + 'POST /proApi/support/user/account/cancellation/submit' + ].includes(key) + ) + ) { + // 状态查询和取消注销必须可恢复;仅阻止成员借 pending 团队发起新的注销申请。 + return { + ...preset.options, + allowCurrentSessionTeamAccountCancellationPending: false, + allowCurrentSessionTeamAccountCancellationFinalizing: false + }; + } + return preset.options; +}; diff --git a/packages/service/support/user/account/cancellation/formatter.ts b/packages/service/support/user/account/cancellation/formatter.ts new file mode 100644 index 000000000000..479bd169e3c0 --- /dev/null +++ b/packages/service/support/user/account/cancellation/formatter.ts @@ -0,0 +1,62 @@ +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { deriveAccountCancellationSchedule } from '@fastgpt/global/support/user/account/cancellation/utils'; +import type { TeamAccountCancellationSummary } from '@fastgpt/global/support/user/account/cancellation/type'; +import type { AccountCancellationSchemaType } from './schema'; + +export const getAccountCancellationAuthKey = (userId: string) => + `accountCancellation:${String(userId)}`; + +export const maskAccount = (account?: string) => { + if (!account) return ''; + const at = account.indexOf('@'); + if (at > 1) return `${account.slice(0, 2)}***${account.slice(at)}`; + if (/^1\d{10}$/.test(account)) return `${account.slice(0, 3)}****${account.slice(-4)}`; + if (account.length <= 4) return `${account.slice(0, 1)}***`; + return `${account.slice(0, 2)}***${account.slice(-2)}`; +}; + +/** 将内部 pending/finalizing 记录转换为公开注销状态。 */ +export const formatAccountCancellationPendingResponse = ( + record: Pick, + now = new Date() +) => { + if ( + !record.requestedAt || + (record.status !== AccountCancellationStatus.pending && + record.status !== AccountCancellationStatus.finalizing) + ) { + throw new Error('Invalid account cancellation active record'); + } + + const schedule = deriveAccountCancellationSchedule(record.requestedAt); + const isPending = record.status === AccountCancellationStatus.pending; + return { + status: 'pending' as const, + requestedAt: record.requestedAt, + ...(isPending && now < schedule.scheduledCancelAt + ? { scheduledCancelAt: schedule.scheduledCancelAt } + : {}), + canCancelCancellation: isPending && now < schedule.scheduledCancelAt + }; +}; + +export const formatTeamAccountCancellationSummary = ( + record: Pick +): TeamAccountCancellationSummary => { + formatAccountCancellationPendingResponse(record); + + if (record.status === AccountCancellationStatus.pending) { + return { + status: AccountCancellationStatus.pending, + scheduledCancelAt: deriveAccountCancellationSchedule(record.requestedAt).scheduledCancelAt + }; + } + + if (record.status === AccountCancellationStatus.finalizing) { + return { + status: AccountCancellationStatus.finalizing + }; + } + + throw new Error('Invalid team account cancellation active record'); +}; diff --git a/packages/service/support/user/account/cancellation/guard.ts b/packages/service/support/user/account/cancellation/guard.ts new file mode 100644 index 000000000000..39dbd21d4752 --- /dev/null +++ b/packages/service/support/user/account/cancellation/guard.ts @@ -0,0 +1,136 @@ +import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; +import type { AccountCancellationStatus as AccountCancellationStatusType } from '@fastgpt/global/support/user/account/cancellation/type'; +import type { AuthContext } from '../../../permission/auth/context'; +import { resolveAuthContext } from '../../../permission/auth/context'; +import type { AccountCancellationSchemaType } from './schema'; +import { + getActiveAccountCancellationByTeamId, + getActiveAccountCancellationByUserId, + getActiveAccountCancellationsByUserIds +} from './read'; + +export type AssertAccountUsableProps = { + userId?: string; + teamId?: string; + tmbId?: string; + authContext?: AuthContext; + cancellations?: Pick[]; + allowUserAccountCancellationPending?: boolean; + allowUserAccountCancellationFinalizing?: boolean; + allowCurrentUserOwnedTeamAccountCancellationPending?: boolean; + allowCurrentUserOwnedTeamAccountCancellationFinalizing?: boolean; + allowCurrentSessionTeamAccountCancellationPending?: boolean; + allowCurrentSessionTeamAccountCancellationFinalizing?: boolean; +}; + +/** + * 按用户和团队注销生命周期阻断业务访问。中心鉴权传入已验证的 auth-context 和注销记录时, + * 本函数不再查询 member/team;保留无上下文调用给邀请链接等只知道目标 team 的业务。 + */ +export const assertAccountUsable = async ({ + userId, + teamId, + tmbId, + authContext, + cancellations, + allowUserAccountCancellationPending = false, + allowUserAccountCancellationFinalizing = false, + allowCurrentUserOwnedTeamAccountCancellationPending = false, + allowCurrentUserOwnedTeamAccountCancellationFinalizing = false, + allowCurrentSessionTeamAccountCancellationPending = false, + allowCurrentSessionTeamAccountCancellationFinalizing = false +}: AssertAccountUsableProps) => { + const context = + authContext ?? + (tmbId && teamId + ? await resolveAuthContext({ + userId, + teamId, + tmbId + }) + : undefined); + + if (tmbId && teamId && !context) { + throw new Error(ERROR_ENUM.unAuthorization); + } + + const currentUserId = context?.userId ?? userId; + const records = context + ? (cancellations ?? + (await getActiveAccountCancellationsByUserIds({ + userId: context.userId, + ownerId: context.ownerId + }))) + : []; + const userCancellation = context + ? records.find((record) => String(record.userId) === String(context.userId)) + : userId + ? await getActiveAccountCancellationByUserId(userId) + : undefined; + const teamCancellation = context + ? context.ownerId + ? records.find((record) => String(record.userId) === String(context.ownerId)) + : undefined + : teamId + ? await getActiveAccountCancellationByTeamId(teamId) + : undefined; + + const isAllowedStatus = ({ + status, + allowPending, + allowFinalizing + }: { + status: AccountCancellationStatusType; + allowPending: boolean; + allowFinalizing: boolean; + }) => + (status === AccountCancellationStatus.pending && allowPending) || + (status === AccountCancellationStatus.finalizing && allowFinalizing); + + if ( + userCancellation && + !isAllowedStatus({ + status: userCancellation.status, + allowPending: allowUserAccountCancellationPending, + allowFinalizing: allowUserAccountCancellationFinalizing + }) + ) { + throw new Error(UserErrEnum.accountCancellationPending); + } + + if (!teamCancellation) return; + + const isOwnTeam = + !!currentUserId && + String(teamCancellation.userId) === String(currentUserId) && + isAllowedStatus({ + status: teamCancellation.status, + allowPending: allowCurrentUserOwnedTeamAccountCancellationPending, + allowFinalizing: allowCurrentUserOwnedTeamAccountCancellationFinalizing + }); + const isCurrentSessionTeam = isAllowedStatus({ + status: teamCancellation.status, + allowPending: allowCurrentSessionTeamAccountCancellationPending, + allowFinalizing: allowCurrentSessionTeamAccountCancellationFinalizing + }); + if (!isOwnTeam && !isCurrentSessionTeam) { + throw new Error(TeamErrEnum.accountCancellationPending); + } +}; + +/** 登录前只允许本人处于 pending;finalizing 用户不能更新偏好或创建新的 Session。 */ +export const assertUserCanLogin = async (userId: string) => { + const cancellation = await getActiveAccountCancellationByUserId(userId); + if (cancellation?.status === AccountCancellationStatus.finalizing) { + throw new Error(UserErrEnum.accountCancellationPending); + } +}; + +/** 创建团队或转让 owner 前调用,避免注销中的用户重新获得 owner 资源。 */ +export const assertAccountCancellationUserCanOwnTeam = async (userId?: string) => { + const cancellation = await getActiveAccountCancellationByUserId(userId); + if (cancellation) throw new Error(UserErrEnum.accountCancellationPending); +}; diff --git a/packages/service/support/user/account/cancellation/index.ts b/packages/service/support/user/account/cancellation/index.ts new file mode 100644 index 000000000000..ff4709a8abb2 --- /dev/null +++ b/packages/service/support/user/account/cancellation/index.ts @@ -0,0 +1,6 @@ +export * from './schema'; +export * from './read'; +export * from './formatter'; +export * from './service'; +export * from './guard'; +export * from './access'; diff --git a/packages/service/support/user/account/cancellation/read.ts b/packages/service/support/user/account/cancellation/read.ts new file mode 100644 index 000000000000..23e1ee3492c6 --- /dev/null +++ b/packages/service/support/user/account/cancellation/read.ts @@ -0,0 +1,105 @@ +import { + AccountCancellationStatus, + accountCancellationActiveStatuses +} from '@fastgpt/global/support/user/account/cancellation/constants'; +import { Types } from 'mongoose'; +import { MongoTeam } from '../../team/teamSchema'; +import { MongoAccountCancellation, type AccountCancellationSchemaType } from './schema'; + +export const accountCancellationActiveStatusFilter = { + $in: accountCancellationActiveStatuses +} as const; + +export const getAccountCancellationByUserId = (userId?: string) => { + if (!userId) return null; + return MongoAccountCancellation.findOne({ userId }).lean(); +}; + +export const getActiveAccountCancellationByUserId = (userId?: string) => { + if (!userId) return null; + return MongoAccountCancellation.findOne({ + userId, + status: accountCancellationActiveStatusFilter + }).lean(); +}; + +/** 一次读取当前成员本人和当前团队 owner 的 active 注销记录,供中心鉴权复用。 */ +export const getActiveAccountCancellationsByUserIds = async ({ + userId, + ownerId +}: { + userId: string; + ownerId?: string; +}) => { + const userIds = Array.from(new Set([userId, ownerId].filter(Boolean))); + return MongoAccountCancellation.find({ + userId: { $in: userIds }, + status: accountCancellationActiveStatusFilter + }).lean(); +}; + +/** + * 通过团队当前 owner 动态关联注销记录,生命周期集合不保存 ownerTeamIds 快照。 + */ +export const getActiveAccountCancellationByTeamId = async (teamId?: string) => { + if (!teamId || !Types.ObjectId.isValid(teamId)) return null; + const team = await MongoTeam.findById(teamId, { ownerId: 1 }).lean(); + if (!team?.ownerId) return null; + + return MongoAccountCancellation.findOne({ + userId: team.ownerId, + status: accountCancellationActiveStatusFilter + }).lean(); +}; + +/** + * 批量读取已查询团队 owner 的注销状态,避免 fallback 为了注销检查重复读取团队。 + * knownCancellations 用于复用调用方已经读取的 owner 注销记录。 + */ +export const getActiveAccountCancellationsByTeams = async ( + teams: { _id: unknown; ownerId?: unknown }[], + knownCancellations: Pick< + AccountCancellationSchemaType, + 'userId' | 'status' | 'requestedAt' + >[] = [] +) => { + const teamsWithOwners = teams.filter((team) => team.ownerId); + const ownerIds = Array.from(new Set(teamsWithOwners.map((team) => String(team.ownerId)))); + const knownByOwnerId = new Map( + knownCancellations.map((record) => [String(record.userId), record]) + ); + const ownerIdsToQuery = ownerIds.filter((ownerId) => !knownByOwnerId.has(ownerId)); + + const records = + ownerIdsToQuery.length > 0 + ? await MongoAccountCancellation.find({ + userId: { $in: ownerIdsToQuery }, + status: accountCancellationActiveStatusFilter + }).lean() + : []; + const recordsByOwnerId = new Map([ + ...knownByOwnerId, + ...records.map((record) => [String(record.userId), record] as const) + ]); + + return teamsWithOwners.flatMap((team) => { + const record = recordsByOwnerId.get(String(team.ownerId)); + return record ? [{ teamId: String(team._id), record }] : []; + }); +}; + +/** 批量读取团队 owner 的注销状态,供只持有团队 ID 的调用方使用。 */ +export const getActiveAccountCancellationsByTeamIds = async (teamIds: string[]) => { + const uniqueTeamIds = Array.from(new Set(teamIds.filter(Boolean))); + if (uniqueTeamIds.length === 0) return []; + + const teams = await MongoTeam.find( + { _id: { $in: uniqueTeamIds } }, + { _id: 1, ownerId: 1 } + ).lean(); + + return getActiveAccountCancellationsByTeams(teams); +}; + +export const isAccountCancellationActiveStatus = (status?: string) => + status === AccountCancellationStatus.pending || status === AccountCancellationStatus.finalizing; diff --git a/packages/service/support/user/account/cancellation/schema.ts b/packages/service/support/user/account/cancellation/schema.ts new file mode 100644 index 000000000000..710e383f62ef --- /dev/null +++ b/packages/service/support/user/account/cancellation/schema.ts @@ -0,0 +1,59 @@ +import { + AccountCancellationStatus as AccountCancellationStatusValues, + accountCancellationStatusMap +} from '@fastgpt/global/support/user/account/cancellation/constants'; +import type { AccountCancellationStatus as AccountCancellationStatusType } from '@fastgpt/global/support/user/account/cancellation/type'; +import { connectionMongo, defineIndex, getMongoModel } from '../../../../common/mongo'; +import type { Types } from 'mongoose'; +import { userCollectionName } from '../../schema'; + +const { Schema } = connectionMongo; + +export const accountCancellationCollectionName = 'account_cancellation'; + +export type AccountCancellationSchemaType = { + _id: Types.ObjectId; + userId: Types.ObjectId; + status: AccountCancellationStatusType; + requestedAt: Date; +}; + +const AccountCancellationSchema = new Schema( + { + userId: { + type: Schema.Types.ObjectId, + ref: userCollectionName, + required: true + }, + status: { + type: String, + enum: Object.keys(accountCancellationStatusMap), + required: true + }, + requestedAt: { + type: Date, + required: true + } + }, + { + collection: accountCancellationCollectionName, + timestamps: false, + versionKey: false + } +); + +defineIndex(AccountCancellationSchema, { + key: { userId: 1 }, + options: { unique: true } +}); + +defineIndex(AccountCancellationSchema, { + key: { status: 1, requestedAt: 1 } +}); + +export const MongoAccountCancellation = getMongoModel( + accountCancellationCollectionName, + AccountCancellationSchema +); + +export { AccountCancellationStatusValues as AccountCancellationStatus }; diff --git a/packages/service/support/user/account/cancellation/service.ts b/packages/service/support/user/account/cancellation/service.ts new file mode 100644 index 000000000000..215ef192a1a9 --- /dev/null +++ b/packages/service/support/user/account/cancellation/service.ts @@ -0,0 +1,89 @@ +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 { LeaseCache, RedisLeaseUnavailableError } from '@fastgpt/dal/redis/caches'; +import { getLogger, LogCategories } from '../../../../common/logger'; +import { getAccountCancellationAuthKey } from './formatter'; +import { getActiveAccountCancellationByUserId } from './read'; +import { MongoAccountCancellation } from './schema'; + +const accountCancellationLockTtlMs = 10 * 60 * 1000; +const accountCancellationTeamLockTtlMs = 10 * 60 * 1000; +const leaseCache = new LeaseCache({ logger: getLogger(LogCategories.INFRA.REDIS) }); + +/** + * 在注销用户维度串行化 submit、cancel、cron 和管理员删除,释放锁由 finally 保证。 + */ +export const withAccountCancellationUserLock = async (userId: string, fn: () => Promise) => { + try { + return await leaseCache.withLease({ + key: getAccountCancellationAuthKey(userId), + label: 'account-cancellation-user', + ttlMs: accountCancellationLockTtlMs, + fn: () => fn() + }); + } catch (error) { + if (error instanceof RedisLeaseUnavailableError) { + throw new Error('Account cancellation operation is busy'); + } + throw error; + } +}; + +/** + * 串行化团队删除、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() + }); + } catch (error) { + if (error instanceof RedisLeaseUnavailableError) { + throw new Error('Account cancellation team operation is busy'); + } + throw error; + } +}; + +export const assertAccountCancellationMethod = (method: string) => { + if (!isAccountCancellationMethod(method)) { + throw new Error('Password verification is not allowed for account cancellation'); + } +}; + +/** 条件删除 pending;finalizing/completed 永远不会被取消。 */ +export const cancelPendingAccountCancellation = async ({ + userId, + now = new Date() +}: { + userId: string; + now?: Date; +}) => + withAccountCancellationUserLock(userId, async () => { + const record = await getActiveAccountCancellationByUserId(userId); + if (!record) return { cancelled: false as const, record: null }; + if (record.status !== AccountCancellationStatus.pending) { + throw new Error('Account cancellation is already finalizing'); + } + + const scheduledCancelAt = deriveAccountCancellationSchedule( + record.requestedAt + ).scheduledCancelAt; + if (now >= scheduledCancelAt) + throw new Error('Account cancellation can no longer be cancelled'); + + const result = await MongoAccountCancellation.deleteOne({ + _id: record._id, + userId, + status: AccountCancellationStatus.pending + }); + return { + cancelled: result.deletedCount === 1, + record + } as const; + }); diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts index 2edcefcde035..90fe41ce3363 100644 --- a/packages/service/support/user/controller.ts +++ b/packages/service/support/user/controller.ts @@ -1,9 +1,10 @@ import { type UserType } from '@fastgpt/global/support/user/type'; import { MongoUser } from './schema'; -import { getTmbInfoByTmbId, getUserDefaultTeam } from './team/controller'; +import { getTmbInfoByTmbId } from './team/controller'; import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { TeamPermission } from '@fastgpt/global/support/permission/user/controller'; import type { ClientSession } from '../../common/mongo'; +import { getUserFallbackTeam } from './team/fallback'; export async function authUserExist({ userId, username }: { userId?: string; username?: string }) { if (userId) { @@ -15,26 +16,36 @@ export async function authUserExist({ userId, username }: { userId?: string; use return null; } +/** + * 加载用户及团队详情。登录恢复可显式允许注销中的团队作为 fallback,便于用户进入等待页取消注销。 + */ export async function getUserDetail({ tmbId, userId, isRoot = false, - session + session, + allowAccountCancellationTeamFallback = false }: { tmbId?: string; userId?: string; isRoot?: boolean; session?: ClientSession; + allowAccountCancellationTeamFallback?: boolean; }): Promise { const tmb = await (async () => { if (tmbId) { try { const result = await getTmbInfoByTmbId({ tmbId, session }); return result; - } catch (error) {} + } catch {} } if (userId) { - return getUserDefaultTeam({ userId, session }); + const fallback = await getUserFallbackTeam({ + userId, + session, + allowAccountCancellationTeam: allowAccountCancellationTeamFallback + }); + if (fallback) return getTmbInfoByTmbId({ tmbId: fallback.tmbId, session }); } return Promise.reject(ERROR_ENUM.unAuthorization); })(); diff --git a/packages/service/support/user/session.ts b/packages/service/support/user/session.ts index a7b9d93872af..7b486822c5ae 100644 --- a/packages/service/support/user/session.ts +++ b/packages/service/support/user/session.ts @@ -3,12 +3,70 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; import { getLogger, LogCategories } from '../../common/logger'; import { serviceEnv } from '../../env'; import { SessionCache, type SessionData } from '@fastgpt/dal/redis/caches'; +import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; +import { MongoTeamMember } from './team/teamMemberSchema'; +import { MongoTeam } from './team/teamSchema'; +import { getUserFallbackTeam } from './team/fallback'; const logger = getLogger(LogCategories.MODULE.USER.ACCOUNT); type SessionType = SessionData; const sessionCache = new SessionCache({ logger }); +export type UserSessionTeamFallback = { + teamId: string; + tmbId: string; +}; + +/** + * 校验 Session 当前 team/tmb 是否仍有效;失效时迁移到 fallback,否则注销当前 Session。 + */ +export const resolveUserSessionTeam = async ({ + userId, + teamId, + tmbId, + sessionId +}: { + userId: string; + teamId: string; + tmbId: string; + sessionId?: string; +}): Promise => { + const [member, team] = await Promise.all([ + MongoTeamMember.findOne( + { + _id: tmbId, + teamId, + userId, + status: TeamMemberStatusEnum.active + }, + { _id: 1 } + ).lean(), + MongoTeam.findOne( + { + _id: teamId, + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1 } + ).lean() + ]); + + if (member && team) return { teamId: String(teamId), tmbId: String(tmbId) }; + + const fallback = await getUserFallbackTeam({ userId, excludedTeamId: teamId }); + if (!fallback || !sessionId) { + if (sessionId) await sessionCache.delete(sessionId); + throw new Error(ERROR_ENUM.unAuthorization); + } + + await sessionCache.updateTeam({ + sessionId, + teamId: String(fallback.teamId), + tmbId: String(fallback.tmbId) + }); + return fallback; +}; + export const delUserAllSession = async (userId: string, whiteList?: (string | undefined)[]) => { const sessions = await sessionCache.listByUser(String(userId)); const whiteListSet = new Set(whiteList?.filter((item): item is string => Boolean(item))); @@ -19,6 +77,43 @@ export const delUserAllSession = async (userId: string, whiteList?: (string | un await sessionCache.deleteMany(sessionIds); }; +export const getUserSessionCount = async (userId: string) => { + const sessions = await sessionCache.listByUser(String(userId)); + return sessions.length; +}; + +/** 迁移指向已删除团队的 Session;没有 fallback 时只删除受影响的 Session。 */ +export const migrateUserSessionsFromTeam = async ({ + userId, + deletedTeamId, + fallback +}: { + userId: string; + deletedTeamId: string; + fallback?: UserSessionTeamFallback; +}) => { + const sessions = await sessionCache.listByUser(String(userId)); + const affectedSessions = sessions.filter( + ({ data }) => String(data.teamId) === String(deletedTeamId) + ); + + if (fallback) { + await Promise.all( + affectedSessions.map(({ sessionId }) => + sessionCache.updateTeam({ + sessionId, + teamId: String(fallback.teamId), + tmbId: String(fallback.tmbId) + }) + ) + ); + } else { + await sessionCache.deleteMany(affectedSessions.map(({ sessionId }) => sessionId)); + } + + return { affectedCount: affectedSessions.length }; +}; + // 会根据创建时间,删除超出客户端登录限制的 session const delRedundantSession = async (userId: string) => { // 至少为 1,默认为 10 diff --git a/packages/service/support/user/team/controller.ts b/packages/service/support/user/team/controller.ts index 24da88e65de4..15c319cd2539 100644 --- a/packages/service/support/user/team/controller.ts +++ b/packages/service/support/user/team/controller.ts @@ -19,6 +19,10 @@ 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 { + formatTeamAccountCancellationSummary, + getActiveAccountCancellationsByTeamIds +} from '../account/cancellation'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); @@ -29,10 +33,12 @@ async function getTeamMember( const query = MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team'); if (session) query.session(session); const tmb = await query.lean(); - if (!tmb) { + if (!tmb || !tmb.team || tmb.team.deleteTime) { return Promise.reject('member not exist'); } + const [cancellation] = await getActiveAccountCancellationsByTeamIds([String(tmb.teamId)]); + const role = (await getTmbPermission({ resourceType: PerResourceTypeEnum.team, @@ -59,7 +65,12 @@ async function getTeamMember( openaiAccount: tmb.team.openaiAccount, externalWorkflowVariables: tmb.team.externalWorkflowVariables, - isWecomTeam: !!tmb.team.meta?.wecom + isWecomTeam: !!tmb.team.meta?.wecom, + ...(cancellation + ? { + accountCancellation: formatTeamAccountCancellationSummary(cancellation.record) + } + : {}) }; } @@ -100,7 +111,13 @@ export async function getUserDefaultTeam({ if (!userId) { return Promise.reject('tmbId or userId is required'); } - return getTeamMember({ userId: new Types.ObjectId(userId) }, session); + return getTeamMember( + { + userId: new Types.ObjectId(userId), + status: TeamMemberStatusEnum.active + }, + session + ); } export async function createDefaultTeam({ diff --git a/packages/service/support/user/team/delete/processor.ts b/packages/service/support/user/team/delete/processor.ts index 1bf8b4ca2754..8efec040a7f8 100644 --- a/packages/service/support/user/team/delete/processor.ts +++ b/packages/service/support/user/team/delete/processor.ts @@ -1,13 +1,15 @@ import type { Processor } from '@fastgpt/dal/redis/bullmq'; import { type TeamDeleteJobData } from './index'; import { MongoImage } from '../../../../common/file/image/schema'; +import { MongoApp } from '../../../../core/app/schema'; +import { MongoDataset } from '../../../../core/dataset/schema'; import { MongoOpenApi } from '../../../openapi/schema'; import { MongoGroupMemberModel } from '../../../permission/memberGroup/groupMemberSchema'; import { MongoMemberGroupModel } from '../../../permission/memberGroup/memberGroupSchema'; import { MongoOrgMemberModel } from '../../../permission/org/orgMemberSchema'; import { MongoOrgModel } from '../../../permission/org/orgSchema'; import { MongoResourcePermission } from '../../../permission/schema'; -import { delUserAllSession } from '../../session'; +import { migrateUserSessionsFromTeam } from '../../session'; import { MongoTeamMember } from '../teamMemberSchema'; import { MongoTeam } from '../teamSchema'; import { MongoMcpKey } from '../../../mcp/schema'; @@ -17,126 +19,191 @@ import { MongoDiscountCoupon } from '../../../wallet/discountCoupon/schema'; import { MongoTeamAudit } from '../../audit/schema'; import { deleteTeamAllDatasets } from '../../../../core/dataset/delete/processor'; import { onDelAllApp } from './utils'; -import { MongoEvaluation } from '../../../../core/app/evaluation/evalSchema'; -import { MongoEvalItem } from '../../../../core/app/evaluation/evalItemSchema'; +import { deleteEvaluationsByTeamId } from '../../../../core/app/evaluation/delete'; import { MongoTeamSub } from '../../../../support/wallet/sub/schema'; import { getLogger, LogCategories } from '../../../../common/logger'; +import { getUserFallbackTeam } from '../fallback'; +import { MongoUser } from '../../schema'; +import { withAccountCancellationTeamLock } from '../../account/cancellation'; +import { MongoOutLink } from '../../../outLink/schema'; const logger = getLogger(LogCategories.MODULE.USER.TEAM); -export const teamDeleteProcessor: Processor = async (job) => { - const { teamId } = job.data; - const startTime = Date.now(); - - logger.info('Team delete started', { teamId }); +export const teamDeleteProcessor: Processor = async (job) => + withAccountCancellationTeamLock(job.data.teamId, async () => { + const { teamId } = job.data; + const startTime = Date.now(); + + // App/Dataset 使用独立队列删除,这类残留在 team-delete 重试耗尽前不应升级为 ERR。 + class TeamResourcesStillDeletingError extends Error { + constructor( + readonly remainingApps: number, + readonly remainingDatasets: number + ) { + super('Team resources are still being deleted'); + } + } - try { - // 1. 检查团队是否存在 - const team = await MongoTeam.findById(teamId); - if (!team) { - logger.warn('Team not found for deletion', { teamId }); - return; + if (job.attemptsMade === 0) { + logger.info('Team delete started', { teamId }); } - // 2. 先删除知识库和应用(它们内部有自己的队列) - await deleteTeamAllDatasets(teamId); - await onDelAllApp(teamId); - // 删除评估 - await MongoEvaluation.deleteMany({ - teamId - }); - // 删除评估项 - await MongoEvalItem.deleteMany({ - teamId - }); - - // 删除图片(旧的了) - await MongoImage.deleteMany({ - teamId: teamId - }); - - // 3. 删除门户 - await MongoChatSetting.deleteMany({ - teamId - }); - await MongoChatFavouriteApp.deleteMany({ - teamId - }); - - // 4. 删除独立资源 - // 删除 API key - await MongoOpenApi.deleteMany({ - teamId - }); - // 删除 MCP - await MongoMcpKey.deleteMany({ - teamId - }); - // 审计日志 - await MongoTeamAudit.deleteMany({ - teamId - }); - - // 5. 删除财务相关 - // 删除优惠券 - await MongoDiscountCoupon.deleteMany({ - teamId - }); - - await MongoTeamSub.deleteMany({ - teamId - }); - // 删除使用记录(不删除,等待自动过期) - // 充值记录不删除 - - // 6. 删除团队信息 - // 删除权限 - await MongoResourcePermission.deleteMany({ - teamId - }); - - // 删除群组 - const groups = await MongoMemberGroupModel.find({ teamId }); - await MongoGroupMemberModel.deleteMany({ - groupId: { $in: groups.map((item) => item._id) } - }); - await MongoMemberGroupModel.deleteMany({ - teamId - }); - - // 删除组织 - await MongoOrgModel.deleteMany({ - teamId - }); - await MongoOrgMemberModel.deleteMany({ - teamId - }); - - // 7. 删除成员 session 和成员信息 - const members = await MongoTeamMember.find({ - teamId - }); - - // 删除所有成员的 session - await Promise.all(members.map((member) => delUserAllSession(member.userId))); - - await MongoTeamMember.deleteMany({ - teamId - }); - - // 8. 清理团队敏感信息 - team.notificationAccount = ''; - team.openaiAccount = undefined; - team.externalWorkflowVariables = undefined; - team.meta = undefined; - await team.save(); - - logger.info('Team delete completed', { - teamId, - durationMs: Date.now() - startTime - }); - } catch (error: any) { - logger.error('Team delete failed', { teamId, error }); - throw error; - } -}; + try { + // 1. 检查团队是否存在 + const team = await MongoTeam.findById(teamId); + if (!team) { + logger.warn('Team not found for deletion', { teamId }); + return; + } + + // 2. 先删除知识库和应用(它们内部有自己的队列) + await deleteTeamAllDatasets(teamId); + await onDelAllApp(teamId); + await deleteEvaluationsByTeamId(teamId); + + // 删除图片(旧的了) + await MongoImage.deleteMany({ + teamId: teamId + }); + + // 3. 删除门户 + await MongoChatSetting.deleteMany({ + teamId + }); + await MongoChatFavouriteApp.deleteMany({ + teamId + }); + + // 4. 删除独立资源 + // 删除 API key + await MongoOpenApi.deleteMany({ + teamId + }); + // 分享链接直接绑定团队;不能只依赖 app delete 队列清理,避免队列延迟期间继续可访问。 + await MongoOutLink.deleteMany({ + teamId + }); + // 删除 MCP + await MongoMcpKey.deleteMany({ + teamId + }); + // 审计日志 + await MongoTeamAudit.deleteMany({ + teamId + }); + + // 5. 删除财务相关 + // 删除优惠券 + await MongoDiscountCoupon.deleteMany({ + teamId + }); + + await MongoTeamSub.deleteMany({ + teamId + }); + // 删除使用记录(不删除,等待自动过期) + // 充值记录不删除 + + const [remainingApps, remainingDatasets] = await Promise.all([ + MongoApp.countDocuments({ teamId }), + MongoDataset.countDocuments({ teamId }) + ]); + if (remainingApps > 0 || remainingDatasets > 0) { + // App/Dataset worker 必须先完成,否则删除团队后 finalizer 无法再按 teamId 观察残留。 + throw new TeamResourcesStillDeletingError(remainingApps, remainingDatasets); + } + + // 6. 删除团队信息 + // 删除权限 + await MongoResourcePermission.deleteMany({ + teamId + }); + + // 删除群组 + const groups = await MongoMemberGroupModel.find({ teamId }); + await MongoGroupMemberModel.deleteMany({ + groupId: { $in: groups.map((item) => item._id) } + }); + await MongoMemberGroupModel.deleteMany({ + teamId + }); + + // 删除组织 + await MongoOrgModel.deleteMany({ + teamId + }); + await MongoOrgMemberModel.deleteMany({ + teamId + }); + + // 7. 删除成员 session 和成员信息 + const members = await MongoTeamMember.find({ + teamId + }); + + // 仅迁移/删除指向本团队的会话,保留成员在其它团队的登录态。 + await Promise.all( + members.map(async (member) => { + try { + const fallback = await getUserFallbackTeam({ + userId: String(member.userId), + excludedTeamId: teamId + }); + await migrateUserSessionsFromTeam({ + userId: String(member.userId), + deletedTeamId: teamId, + fallback: fallback ?? undefined + }); + await MongoUser.updateOne( + { _id: member.userId, lastLoginTmbId: member._id }, + fallback + ? { $set: { lastLoginTmbId: fallback.tmbId } } + : { $unset: { lastLoginTmbId: 1 } } + ); + } catch (error) { + // Session 迁移失败不阻塞团队删除;旧会话由下次鉴权的 fallback 收口。 + logger.warn('Team delete session fallback failed', { + teamId, + userId: String(member.userId), + error + }); + } + }) + ); + + await MongoTeamMember.deleteMany({ + teamId + }); + + // 8. 清理团队敏感信息 + team.notificationAccount = ''; + team.openaiAccount = undefined; + team.externalWorkflowVariables = undefined; + team.meta = undefined; + await team.save(); + + await MongoTeam.deleteOne({ _id: teamId }); + + logger.info('Team delete completed', { + teamId, + durationMs: Date.now() - startTime + }); + } catch (error) { + const maxAttempts = job.opts.attempts ?? 1; + const isFinalAttempt = job.attemptsMade + 1 >= maxAttempts; + if (error instanceof TeamResourcesStillDeletingError) { + if (isFinalAttempt) { + logger.error('Team delete failed after retries', { + teamId, + attempts: maxAttempts, + remainingApps: error.remainingApps, + remainingDatasets: error.remainingDatasets + }); + } + throw error; + } + + logger.error('Team delete failed', { teamId, error }); + throw error; + } + }); diff --git a/packages/service/support/user/team/delete/utils.ts b/packages/service/support/user/team/delete/utils.ts index 62e9ccc81795..4be050b174c3 100644 --- a/packages/service/support/user/team/delete/utils.ts +++ b/packages/service/support/user/team/delete/utils.ts @@ -3,14 +3,15 @@ import { deleteAppsImmediate } from '../../../../core/app/controller'; import { addAppDeleteJob } from '../../../../core/app/delete'; export const onDelAllApp = async (teamId: string) => { - // 取根目录所有应用 + // 正常只投递根应用;如果历史数据留下孤立子应用,则把孤立应用作为自己的根补偿投递。 const apps = await MongoApp.find( { - teamId, - parentId: null + teamId }, - '_id' + '_id parentId' ); + const appIdSet = new Set(apps.map((app) => String(app._id))); + const deleteRootApps = apps.filter((app) => !app.parentId || !appIdSet.has(String(app.parentId))); const appIds = apps.map((app) => app._id); // Stop background tasks immediately @@ -32,10 +33,10 @@ export const onDelAllApp = async (teamId: string) => { ); // 添加到删除队列 - for (const appId of appIds) { + for (const app of deleteRootApps) { await addAppDeleteJob({ teamId, - appId + appId: String(app._id) }); } }; diff --git a/packages/service/support/user/team/fallback.ts b/packages/service/support/user/team/fallback.ts new file mode 100644 index 000000000000..9a848cd4955a --- /dev/null +++ b/packages/service/support/user/team/fallback.ts @@ -0,0 +1,98 @@ +import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; +import { + getActiveAccountCancellationByUserId, + getActiveAccountCancellationsByTeams +} from '../account/cancellation/read'; +import { MongoTeamMember } from './teamMemberSchema'; +import { MongoTeam } from './teamSchema'; +import type { ClientSession } from '../../../common/mongo'; + +/** + * 找到用户可继续使用的团队。默认排除已删除团队、无效成员关系和注销中的 owner 团队; + * 登录恢复场景可显式允许注销中的团队作为受限 Session 上下文,后续访问仍由注销 guard 控制。 + */ +export const getUserFallbackTeam = async ({ + userId, + excludedTeamId, + session, + allowAccountCancellationTeam = false +}: { + userId: string; + excludedTeamId?: string; + session?: ClientSession; + allowAccountCancellationTeam?: boolean; +}) => { + let ownerCancellation: Awaited> = null; + const ownerTeamQuery = MongoTeam.findOne( + { + ownerId: userId, + ...(excludedTeamId ? { _id: { $ne: excludedTeamId } } : {}), + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1 } + ).sort({ createTime: 1 }); + if (session) ownerTeamQuery.session(session); + const ownerTeam = await ownerTeamQuery.lean(); + + if (ownerTeam) { + const ownerMemberQuery = MongoTeamMember.findOne( + { + teamId: ownerTeam._id, + userId, + status: TeamMemberStatusEnum.active + }, + { _id: 1 } + ); + if (session) ownerMemberQuery.session(session); + + const [ownerMember, cancellation] = await Promise.all([ + ownerMemberQuery.lean(), + allowAccountCancellationTeam ? null : getActiveAccountCancellationByUserId(userId) + ]); + ownerCancellation = cancellation; + if (ownerMember && !ownerCancellation) { + return { teamId: String(ownerTeam._id), tmbId: String(ownerMember._id) }; + } + } + + const memberQuery = MongoTeamMember.find( + { + userId, + status: TeamMemberStatusEnum.active, + ...(excludedTeamId ? { teamId: { $ne: excludedTeamId } } : {}) + }, + { _id: 1, teamId: 1 } + ).sort({ createTime: 1 }); + if (session) memberQuery.session(session); + const members = await memberQuery.lean(); + + if (members.length === 0) return null; + + const teamQuery = MongoTeam.find( + { + _id: { $in: members.map((member) => member.teamId) }, + $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }] + }, + { _id: 1, ownerId: 1 } + ); + if (session) teamQuery.session(session); + const teams = await teamQuery.lean(); + if (teams.length === 0) return null; + + const cancellationTeams = await getActiveAccountCancellationsByTeams( + teams, + ownerCancellation ? [ownerCancellation] : [] + ); + const blockedTeamIds = new Set(cancellationTeams.map(({ teamId }) => teamId)); + const validTeams = new Map(teams.map((team) => [String(team._id), team])); + const candidates = members.flatMap((member) => { + const teamId = String(member.teamId); + return validTeams.has(teamId) ? [{ teamId, tmbId: String(member._id) }] : []; + }); + + return ( + candidates.find(({ teamId }) => !blockedTeamIds.has(teamId)) ?? + (allowAccountCancellationTeam ? candidates[0] : undefined) ?? + null + ); +}; diff --git a/packages/service/test/common/bullmq/index.test.ts b/packages/service/test/common/bullmq/index.test.ts new file mode 100644 index 000000000000..f9237faef1f9 --- /dev/null +++ b/packages/service/test/common/bullmq/index.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addOrRequeueFailedJob, type Job, type Queue } from '@fastgpt/dal/redis/bullmq'; +import { RedisLeaseUnavailableError } from '@fastgpt/dal/redis/caches'; + +type TestJobData = { id: string }; + +describe('addOrRequeueFailedJob', () => { + const getJob = vi.fn(); + const add = vi.fn(); + const queue = { name: 'test-queue', getJob, add } as unknown as Queue; + + beforeEach(() => { + getJob.mockReset(); + add.mockReset(); + }); + + it('adds a new job when the stable job ID does not exist', async () => { + const addedJob = { id: 'job-1' } as Job; + getJob.mockResolvedValue(null); + add.mockResolvedValue(addedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(addedJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'data-1' }, { jobId: 'job-1' }); + }); + + it('recreates a job removed between getJob and getState', async () => { + const staleJob = { + getState: vi.fn().mockResolvedValue('unknown') + } as unknown as Job; + const replacementJob = { id: 'job-1' } as Job; + getJob.mockResolvedValueOnce(staleJob).mockResolvedValueOnce(null); + add.mockResolvedValue(replacementJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(replacementJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'fresh-data' }, { jobId: 'job-1' }); + }); + + it('does not report a retained job with an unconfirmed unknown state as queued', async () => { + const unknownJob = { + getState: vi.fn().mockResolvedValue('unknown') + } as unknown as Job; + getJob.mockResolvedValue(unknownJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow('BullMQ job is in an unknown state: test-queue/job-1'); + + expect(add).not.toHaveBeenCalled(); + }); + + it('updates and retries a retained failed job without deleting its recovery point', async () => { + const updateData = vi.fn().mockResolvedValue(undefined); + const retry = vi.fn().mockResolvedValue(undefined); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData, + retry + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1', delay: 1000 } + }) + ).resolves.toBe(failedJob); + + expect(updateData).toHaveBeenCalledWith({ id: 'fresh-data' }); + expect(retry).toHaveBeenCalledWith('failed'); + expect(add).not.toHaveBeenCalled(); + }); + + it('keeps an unfinished job without adding a duplicate', async () => { + const waitingJob = { + getState: vi.fn().mockResolvedValue('waiting'), + updateData: vi.fn(), + retry: vi.fn() + } as unknown as Job; + getJob.mockResolvedValue(waitingJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(waitingJob); + + expect(waitingJob.updateData).not.toHaveBeenCalled(); + expect(waitingJob.retry).not.toHaveBeenCalled(); + expect(add).not.toHaveBeenCalled(); + }); + + it('does not hide an update error while the retained job is still failed', async () => { + const error = new Error('redis unavailable'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockRejectedValue(error), + retry: vi.fn() + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow(error); + + expect(failedJob.retry).not.toHaveBeenCalled(); + expect(add).not.toHaveBeenCalled(); + }); + + it('returns the job retried by another producer when retry races', async () => { + const retryError = new Error('job is not in the failed state'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockResolvedValue(undefined), + retry: vi.fn().mockRejectedValue(retryError) + } as unknown as Job; + const waitingJob = { + getState: vi.fn().mockResolvedValue('waiting') + } as unknown as Job; + getJob.mockResolvedValueOnce(failedJob).mockResolvedValueOnce(waitingJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(waitingJob); + + expect(add).not.toHaveBeenCalled(); + }); + + it('keeps and surfaces a failed job when retry itself fails', async () => { + const retryError = new Error('redis unavailable'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockResolvedValue(undefined), + retry: vi.fn().mockRejectedValue(retryError) + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'data-1' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toThrow(retryError); + + expect(add).not.toHaveBeenCalled(); + }); + + it('recreates the job if retention cleanup removes it during recovery', async () => { + const updateError = new Error('job no longer exists'); + const failedJob = { + getState: vi.fn().mockResolvedValue('failed'), + updateData: vi.fn().mockRejectedValue(updateError), + retry: vi.fn() + } as unknown as Job; + const replacementJob = { id: 'job-1' } as Job; + getJob.mockResolvedValueOnce(failedJob).mockResolvedValueOnce(null); + add.mockResolvedValue(replacementJob); + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'fresh-data' }, + opts: { jobId: 'job-1' } + }) + ).resolves.toBe(replacementJob); + + expect(add).toHaveBeenCalledWith('test', { id: 'fresh-data' }, { jobId: 'job-1' }); + expect(failedJob.retry).not.toHaveBeenCalled(); + }); + + it('does not let a concurrent producer overwrite data after another recovery starts', async () => { + let state = 'failed'; + let releaseUpdate!: () => void; + const updateStarted = new Promise((resolve) => { + releaseUpdate = resolve; + }); + let continueUpdate!: () => void; + const updateBlocked = new Promise((resolve) => { + continueUpdate = resolve; + }); + const updateData = vi.fn(async () => { + releaseUpdate(); + await updateBlocked; + }); + const retry = vi.fn(async () => { + state = 'waiting'; + }); + const failedJob = { + getState: vi.fn(async () => state), + updateData, + retry + } as unknown as Job; + getJob.mockResolvedValue(failedJob); + + const firstRecovery = addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'first-data' }, + opts: { jobId: 'job-1' } + }); + await updateStarted; + + await expect( + addOrRequeueFailedJob({ + queue, + name: 'test', + data: { id: 'second-data' }, + opts: { jobId: 'job-1' } + }) + ).rejects.toBeInstanceOf(RedisLeaseUnavailableError); + + expect(updateData).toHaveBeenCalledTimes(1); + expect(updateData).toHaveBeenCalledWith({ id: 'first-data' }); + continueUpdate(); + await expect(firstRecovery).resolves.toBe(failedJob); + expect(retry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/service/test/common/system/cron.test.ts b/packages/service/test/common/system/cron.test.ts new file mode 100644 index 000000000000..e24b22349219 --- /dev/null +++ b/packages/service/test/common/system/cron.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + schedule: vi.fn() +})); + +vi.mock('node-cron', () => ({ + default: { + schedule: mocks.schedule + } +})); + +import { setCron } from '@fastgpt/service/common/system/cron'; + +describe('setCron', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards scheduler options to node-cron', () => { + const callback = vi.fn(); + const task = { stop: vi.fn() }; + mocks.schedule.mockReturnValue(task); + + expect(setCron('0 10 * * *', callback, { timezone: 'Asia/Shanghai' })).toBe(task); + expect(mocks.schedule).toHaveBeenCalledWith('0 10 * * *', callback, { + timezone: 'Asia/Shanghai' + }); + }); +}); diff --git a/packages/service/test/core/app/evaluation/delete.test.ts b/packages/service/test/core/app/evaluation/delete.test.ts new file mode 100644 index 000000000000..18a2d4e6ce83 --- /dev/null +++ b/packages/service/test/core/app/evaluation/delete.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + findEvaluations: vi.fn(), + deleteEvaluations: vi.fn(), + deleteEvalItems: vi.fn() +})); + +vi.mock('@fastgpt/service/core/app/evaluation/evalSchema', () => ({ + MongoEvaluation: { + find: mocks.findEvaluations, + deleteMany: mocks.deleteEvaluations + } +})); + +vi.mock('@fastgpt/service/core/app/evaluation/evalItemSchema', () => ({ + MongoEvalItem: { + deleteMany: mocks.deleteEvalItems + } +})); + +import { deleteEvaluationsByTeamId } from '@fastgpt/service/core/app/evaluation/delete'; + +describe('deleteEvaluationsByTeamId', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.deleteEvaluations.mockResolvedValue(undefined); + mocks.deleteEvalItems.mockResolvedValue(undefined); + }); + + it('deletes evaluation items by evalId before deleting their parent evaluations', async () => { + const lean = vi.fn().mockResolvedValue([{ _id: 'eval-1' }, { _id: 'eval-2' }]); + mocks.findEvaluations.mockReturnValue({ lean }); + + await deleteEvaluationsByTeamId('team-1'); + + expect(mocks.findEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }, '_id'); + expect(mocks.deleteEvalItems).toHaveBeenCalledWith({ + evalId: { $in: ['eval-1', 'eval-2'] } + }); + expect(mocks.deleteEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }); + expect(mocks.deleteEvalItems.mock.invocationCallOrder[0]).toBeLessThan( + mocks.deleteEvaluations.mock.invocationCallOrder[0] + ); + }); + + it('skips the child deletion when the team has no evaluations', async () => { + mocks.findEvaluations.mockReturnValue({ lean: vi.fn().mockResolvedValue([]) }); + + await deleteEvaluationsByTeamId('team-1'); + + expect(mocks.deleteEvalItems).not.toHaveBeenCalled(); + expect(mocks.deleteEvaluations).toHaveBeenCalledWith({ teamId: 'team-1' }); + }); + + it('keeps parent evaluations available when deleting their items fails', async () => { + mocks.findEvaluations.mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ _id: 'eval-1' }]) + }); + mocks.deleteEvalItems.mockRejectedValue(new Error('delete items failed')); + + await expect(deleteEvaluationsByTeamId('team-1')).rejects.toThrow('delete items failed'); + + expect(mocks.deleteEvaluations).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/service/test/support/openapi/auth.test.ts b/packages/service/test/support/openapi/auth.test.ts index 2e7b2f81f56b..fe7873f4eb95 100644 --- a/packages/service/test/support/openapi/auth.test.ts +++ b/packages/service/test/support/openapi/auth.test.ts @@ -3,6 +3,9 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode'; import { MongoOpenApi } from '@fastgpt/service/support/openapi/schema'; import { Types } from 'mongoose'; import { AuthUserTypeEnum } from '@fastgpt/global/support/permission/constant'; +import { MongoUser } from '@fastgpt/service/support/user/schema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; import { authOpenApiKey, resolveOpenApiCredential } from '@fastgpt/service/support/openapi/auth'; @@ -96,7 +99,18 @@ describe('openapi auth', () => { }); it('Bearer apiKey-appId 用真实 key 查库并返回 parsedAppId', async () => { - await MongoOpenApi.create(teamApiKey); + const user = await MongoUser.create({ username: 'api-key-user', password: 'password' }); + const team = await MongoTeam.create({ name: 'API Key team', ownerId: user._id }); + const member = await MongoTeamMember.create({ + teamId: team._id, + userId: user._id, + status: 'active' + }); + await MongoOpenApi.create({ + ...teamApiKey, + teamId: String(team._id), + tmbId: String(member._id) + }); const result = await parseHeaderCert({ req: { @@ -108,8 +122,8 @@ describe('openapi auth', () => { }); expect(result).toMatchObject({ - teamId, - tmbId, + teamId: String(team._id), + tmbId: String(member._id), appId: '', legacyAppId: '', parsedAppId, @@ -209,6 +223,37 @@ describe('openapi auth', () => { expect(updateApiKeyUsedTimeSpy).toHaveBeenCalledTimes(1); }); + it('API Key 指向失效 member 时直接拒绝,不进入 Session fallback', async () => { + const user = await MongoUser.create({ + username: 'inactive-api-key-user', + password: 'password' + }); + const team = await MongoTeam.create({ name: 'Inactive API Key team', ownerId: user._id }); + const member = await MongoTeamMember.create({ + teamId: team._id, + userId: user._id, + status: 'leave' + }); + await MongoOpenApi.create({ + ...teamApiKey, + apiKey: 'fastgpt-inactive-member', + teamId: String(team._id), + tmbId: String(member._id) + }); + + await expect( + parseHeaderCert({ + req: { + headers: { + authorization: 'Bearer fastgpt-inactive-member' + } + } as any, + authApiKey: true + }) + ).rejects.toBe(ERROR_ENUM.unAuthorization); + expect(updateApiKeyUsedTimeSpy).toHaveBeenCalledTimes(1); + }); + it('返回 APIKey 是否开启 authProxy', async () => { await MongoOpenApi.create({ ...teamApiKey, diff --git a/packages/service/test/support/outLink/guard.test.ts b/packages/service/test/support/outLink/guard.test.ts new file mode 100644 index 000000000000..f950e8202a6d --- /dev/null +++ b/packages/service/test/support/outLink/guard.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + assertAccountUsable: vi.fn().mockResolvedValue(undefined) +})); + +vi.mock('@fastgpt/service/support/user/account/cancellation/guard', () => ({ + assertAccountUsable: mocks.assertAccountUsable +})); + +import { assertOutLinkTeamUsable } from '@fastgpt/service/support/outLink/guard'; + +describe('assertOutLinkTeamUsable', () => { + it('checks the team and member bound to the published link', async () => { + await assertOutLinkTeamUsable({ teamId: 'team-1', tmbId: 'member-1' }); + + expect(mocks.assertAccountUsable).toHaveBeenCalledWith({ + teamId: 'team-1', + tmbId: 'member-1' + }); + }); +}); diff --git a/packages/service/test/support/outLink/runtime/utils.test.ts b/packages/service/test/support/outLink/runtime/utils.test.ts index c31b23f49771..9953c3291bcd 100644 --- a/packages/service/test/support/outLink/runtime/utils.test.ts +++ b/packages/service/test/support/outLink/runtime/utils.test.ts @@ -89,6 +89,10 @@ vi.mock('@fastgpt/service/support/outLink/tools', () => ({ addOutLinkUsage: vi.fn() })); +vi.mock('@fastgpt/service/support/user/account/cancellation/guard', () => ({ + assertAccountUsable: vi.fn() +})); + vi.mock('@fastgpt/global/core/workflow/runtime/utils', async (importOriginal) => { const actual = await importOriginal(); diff --git a/packages/service/test/support/permission/auth/context.test.ts b/packages/service/test/support/permission/auth/context.test.ts new file mode 100644 index 000000000000..0236ec030734 --- /dev/null +++ b/packages/service/test/support/permission/auth/context.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + buildAuthContextPipeline, + resolveAuthContext +} from '@fastgpt/service/support/permission/auth/context'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { Types } from 'mongoose'; + +describe('auth context', () => { + beforeEach(async () => { + await Promise.all([MongoTeam.deleteMany({}), MongoTeamMember.deleteMany({})]); + }); + + it('resolves the actual user and current team owner from one aggregation', async () => { + const userId = new Types.ObjectId(); + const ownerId = new Types.ObjectId(); + const [team] = await MongoTeam.create([{ name: 'Auth context team', ownerId }]); + const [member] = await MongoTeamMember.create([ + { + teamId: team._id, + userId, + name: 'Member', + status: 'active' + } + ]); + + await expect( + resolveAuthContext({ + userId: String(userId), + teamId: String(team._id), + tmbId: String(member._id) + }) + ).resolves.toEqual({ + userId: String(userId), + teamId: String(team._id), + tmbId: String(member._id), + ownerId: String(ownerId) + }); + }); + + it('provides an explainable aggregation for benchmark checks', async () => { + const userId = new Types.ObjectId(); + const [team] = await MongoTeam.create([{ name: 'Explain team', ownerId: userId }]); + const [member] = await MongoTeamMember.create([ + { + teamId: team._id, + userId, + name: 'Owner', + status: 'active' + } + ]); + const pipeline = buildAuthContextPipeline({ + userId: String(userId), + teamId: String(team._id), + tmbId: String(member._id) + }); + + expect(pipeline).not.toBeNull(); + const explain = await MongoTeamMember.aggregate(pipeline!).explain(); + expect(explain).toBeDefined(); + }); +}); diff --git a/packages/service/test/support/user/account/cancellation/access.test.ts b/packages/service/test/support/user/account/cancellation/access.test.ts new file mode 100644 index 000000000000..548886ac37b4 --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/access.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAccountCancellationAccess } from '@fastgpt/service/support/user/account/cancellation/access'; + +describe('resolveAccountCancellationAccess', () => { + it.each([ + { + method: 'GET', + url: '/api/proApi/support/user/account/cancellation/status', + expected: true + }, + { + method: 'DELETE', + url: '/api/proApi/support/user/account/cancellation/cancel', + expected: true + }, + { + method: 'POST', + url: '/api/proApi/support/user/account/cancellation/verification/create', + expected: false + }, + { + method: 'POST', + url: '/api/proApi/support/user/account/cancellation/submit', + expected: false + } + ])('resolves current-session team access for $method $url', ({ method, url, expected }) => { + const result = resolveAccountCancellationAccess({ + req: { method, url }, + accountCancellationAccess: 'selfCancellation' + }); + + expect(result.allowCurrentSessionTeamAccountCancellationPending).toBe(expected); + }); + + it.each([ + '/api/support/user/account/tokenLogin', + '/api/support/user/account/tokenLogin?maxQuantity=1', + '/proApi/support/user/account/tokenLogin', + '/api/support/user/team/plan/getTeamPlanStatus' + ])('allows token initialization route %s', (url) => { + expect(() => + resolveAccountCancellationAccess({ + req: { method: 'GET', url }, + accountCancellationAccess: 'tokenLogin' + }) + ).not.toThrow(); + }); + + it.each([ + { method: 'GET', url: '/proApi/support/user/team/list', allowed: true }, + { method: 'POST', url: '/proApi/support/user/team/switch', allowed: true }, + { method: 'PUT', url: '/proApi/support/user/team/switch', allowed: true }, + { method: 'DELETE', url: '/proApi/support/user/team/member/leave', allowed: false } + ])('keeps teamEscape exact route scope for $method $url', ({ method, url, allowed }) => { + const run = () => + resolveAccountCancellationAccess({ + req: { method, url }, + accountCancellationAccess: 'teamEscape' + }); + + if (allowed) { + expect(run).not.toThrow(); + } else { + expect(run).toThrow(); + } + }); + + it('does not let tokenLogin bypass the user finalizing state', () => { + const result = resolveAccountCancellationAccess({ + req: { method: 'GET', url: '/api/support/user/account/tokenLogin' }, + accountCancellationAccess: 'tokenLogin' + }); + + expect(result).toMatchObject({ + allowUserAccountCancellationPending: true, + allowUserAccountCancellationFinalizing: false, + allowCurrentSessionTeamAccountCancellationFinalizing: true + }); + }); +}); diff --git a/packages/service/test/support/user/account/cancellation/formatter.test.ts b/packages/service/test/support/user/account/cancellation/formatter.test.ts new file mode 100644 index 000000000000..77f35f7ee7ee --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/formatter.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { + formatTeamAccountCancellationSummary, + maskAccount +} from '@fastgpt/service/support/user/account/cancellation/formatter'; + +describe('maskAccount', () => { + it.each([ + ['customer@example.com', 'cu***@example.com'], + ['13812345678', '138****5678'], + ['abcd', 'a***'], + ['abcde', 'ab***de'], + ['', ''] + ])('masks %s without exposing the full account', (account, expected) => { + expect(maskAccount(account)).toBe(expected); + }); +}); + +describe('formatTeamAccountCancellationSummary', () => { + it('keeps pending status and exposes the derived scheduled cleanup time', () => { + const summary = formatTeamAccountCancellationSummary({ + status: AccountCancellationStatus.pending, + requestedAt: new Date('2026-07-01T10:20:00.000Z') + }); + + expect(summary).toEqual({ + status: AccountCancellationStatus.pending, + scheduledCancelAt: new Date('2026-07-16T16:00:00.000Z') + }); + }); + + it('keeps finalizing status and hides the scheduled cleanup time', () => { + const summary = formatTeamAccountCancellationSummary({ + status: AccountCancellationStatus.finalizing, + requestedAt: new Date('2026-07-01T10:20:00.000Z') + }); + + expect(summary).toEqual({ + status: AccountCancellationStatus.finalizing + }); + }); +}); diff --git a/packages/service/test/support/user/account/cancellation/guard.test.ts b/packages/service/test/support/user/account/cancellation/guard.test.ts new file mode 100644 index 000000000000..ffd8034af45a --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/guard.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { resolveAccountCancellationAccess } from '@fastgpt/service/support/user/account/cancellation/access'; +import { Types } from '@fastgpt/service/common/mongo'; +import { assertAccountUsable } from '@fastgpt/service/support/user/account/cancellation/guard'; +import { MongoAccountCancellation } from '@fastgpt/service/support/user/account/cancellation/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; + +describe('assertAccountUsable', () => { + beforeEach(async () => { + await Promise.all([ + MongoAccountCancellation.deleteMany({}), + MongoTeamMember.deleteMany({}), + MongoTeam.deleteMany({}) + ]); + }); + + it('resolves the actual API Key member from tmbId when teamId is already present', async () => { + const userId = new Types.ObjectId(); + const [team] = await MongoTeam.create([ + { + name: 'API Key cancellation team', + ownerId: new Types.ObjectId() + } + ]); + const [member] = await MongoTeamMember.create([ + { + teamId: team._id, + userId, + name: 'Member', + status: 'active' + } + ]); + await MongoAccountCancellation.create([ + { + userId, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + } + ]); + + await expect( + assertAccountUsable({ + teamId: String(team._id), + tmbId: String(member._id) + }) + ).rejects.toThrow(UserErrEnum.accountCancellationPending); + }); + + it.each([AccountCancellationStatus.pending, AccountCancellationStatus.finalizing])( + 'blocks the current user %s cancellation under normal access', + async (status) => { + const userId = new Types.ObjectId(); + const ownerId = new Types.ObjectId(); + const context = { + userId: String(userId), + teamId: new Types.ObjectId().toString(), + tmbId: new Types.ObjectId().toString(), + ownerId: String(ownerId) + }; + + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId, status }] + }) + ).rejects.toThrow(UserErrEnum.accountCancellationPending); + } + ); + + it('allows pending recovery but never allows finalizing recovery through the pending flag', async () => { + const userId = new Types.ObjectId(); + const context = { + userId: String(userId), + teamId: new Types.ObjectId().toString(), + tmbId: new Types.ObjectId().toString() + }; + + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId, status: AccountCancellationStatus.pending }], + allowUserAccountCancellationPending: true + }) + ).resolves.toBeUndefined(); + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId, status: AccountCancellationStatus.finalizing }], + allowUserAccountCancellationPending: true + }) + ).rejects.toThrow(UserErrEnum.accountCancellationPending); + }); + + it('applies tokenLogin flags independently to user and owner-team cancellation states', async () => { + const userId = new Types.ObjectId(); + const ownerId = new Types.ObjectId(); + const context = { + userId: String(userId), + teamId: new Types.ObjectId().toString(), + tmbId: new Types.ObjectId().toString(), + ownerId: String(ownerId) + }; + const tokenLoginOptions = resolveAccountCancellationAccess({ + req: { method: 'GET', url: '/api/support/user/account/tokenLogin' }, + accountCancellationAccess: 'tokenLogin' + }); + + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId, status: AccountCancellationStatus.pending }], + ...tokenLoginOptions + }) + ).resolves.toBeUndefined(); + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId, status: AccountCancellationStatus.finalizing }], + ...tokenLoginOptions + }) + ).rejects.toThrow(UserErrEnum.accountCancellationPending); + for (const status of [ + AccountCancellationStatus.pending, + AccountCancellationStatus.finalizing + ]) { + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId: ownerId, status }], + ...tokenLoginOptions + }) + ).resolves.toBeUndefined(); + } + }); + + it.each([AccountCancellationStatus.pending, AccountCancellationStatus.finalizing])( + 'allows a member to inspect another owner cancellation through team escape: %s', + async (status) => { + const ownerId = new Types.ObjectId(); + const context = { + userId: new Types.ObjectId().toString(), + teamId: new Types.ObjectId().toString(), + tmbId: new Types.ObjectId().toString(), + ownerId: String(ownerId) + }; + + await expect( + assertAccountUsable({ + authContext: context, + cancellations: [{ userId: ownerId, status }], + allowCurrentSessionTeamAccountCancellationPending: status === 'pending', + allowCurrentSessionTeamAccountCancellationFinalizing: status === 'finalizing' + }) + ).resolves.toBeUndefined(); + } + ); + + it('uses one auth-context aggregation and one cancellation query on the normal path', async () => { + const userId = new Types.ObjectId(); + const [team] = await MongoTeam.create([ + { name: 'Query count team', ownerId: new Types.ObjectId() } + ]); + const [member] = await MongoTeamMember.create([ + { + teamId: team._id, + userId, + name: 'Member', + status: 'active' + } + ]); + const aggregateSpy = vi.spyOn(MongoTeamMember, 'aggregate'); + const cancellationFindSpy = vi.spyOn(MongoAccountCancellation, 'find'); + + await assertAccountUsable({ + userId: String(userId), + teamId: String(team._id), + tmbId: String(member._id) + }); + + expect(aggregateSpy).toHaveBeenCalledTimes(1); + expect(cancellationFindSpy).toHaveBeenCalledTimes(1); + const [query] = cancellationFindSpy.mock.calls[0]; + expect(query).toMatchObject({ status: { $in: ['pending', 'finalizing'] } }); + aggregateSpy.mockRestore(); + cancellationFindSpy.mockRestore(); + }); +}); diff --git a/packages/service/test/support/user/account/cancellation/service.test.ts b/packages/service/test/support/user/account/cancellation/service.test.ts new file mode 100644 index 000000000000..90449d3117c6 --- /dev/null +++ b/packages/service/test/support/user/account/cancellation/service.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LeaseCache, RedisLeaseUnavailableError } from '@fastgpt/dal/redis/caches'; + +import { + assertAccountCancellationMethod, + withAccountCancellationTeamLock, + withAccountCancellationUserLock +} from '@fastgpt/service/support/user/account/cancellation/service'; +import { accountCancellationAllowedMethods } from '@fastgpt/global/support/user/account/cancellation/constants'; + +describe('assertAccountCancellationMethod', () => { + it.each(accountCancellationAllowedMethods)('accepts %s', (method) => { + expect(() => assertAccountCancellationMethod(method)).not.toThrow(); + }); + + it.each(['oldPassword', 'oauth/unknown', '', 'CODE', 'oauth/Google', 'oauth', 'code '])( + 'rejects invalid method %s', + (method) => { + expect(() => assertAccountCancellationMethod(method)).toThrow( + 'Password verification is not allowed for account cancellation' + ); + } + ); +}); + +describe('account cancellation leases', () => { + let withLease: ReturnType; + + beforeEach(() => { + withLease = vi.spyOn(LeaseCache.prototype, 'withLease'); + withLease.mockImplementation(async ({ fn }) => fn()); + }); + + it('uses a user-scoped lease and returns the callback result', async () => { + const fn = vi.fn().mockResolvedValue('done'); + + await expect(withAccountCancellationUserLock('user-1', fn)).resolves.toBe('done'); + + expect(withLease).toHaveBeenCalledWith({ + key: 'accountCancellation:user-1', + label: 'account-cancellation-user', + ttlMs: 600000, + fn: expect.any(Function) + }); + expect(fn).toHaveBeenCalledOnce(); + }); + + 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' }) + ); + + 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', + ttlMs: 600000, + fn: expect.any(Function) + }); + }); + + it('preserves non-contention failures', async () => { + const error = new Error('redis unavailable'); + withLease.mockRejectedValue(error); + + await expect(withAccountCancellationUserLock('user-1', vi.fn())).rejects.toBe(error); + }); +}); diff --git a/packages/service/test/support/user/team/delete/index.test.ts b/packages/service/test/support/user/team/delete/index.test.ts new file mode 100644 index 000000000000..eb562bd25d23 --- /dev/null +++ b/packages/service/test/support/user/team/delete/index.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + addJob: vi.fn() +})); + +vi.mock('@fastgpt/dal/redis/bullmq', () => ({ + teamDeleteMQService: { + addJob: mocks.addJob, + getWorker: vi.fn() + } +})); + +vi.mock('@fastgpt/service/support/user/team/delete/processor', () => ({ + teamDeleteProcessor: vi.fn() +})); + +import { addTeamDeleteJob } from '@fastgpt/service/support/user/team/delete'; + +describe('addTeamDeleteJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('delegates retryable Team deletion to the DAL queue service', async () => { + await addTeamDeleteJob({ teamId: 'team-1' }); + + expect(mocks.addJob).toHaveBeenCalledWith({ teamId: 'team-1' }); + }); +}); diff --git a/packages/service/test/support/user/team/delete/processor.test.ts b/packages/service/test/support/user/team/delete/processor.test.ts new file mode 100644 index 000000000000..4de9475d860a --- /dev/null +++ b/packages/service/test/support/user/team/delete/processor.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + withTeamLock: vi.fn(), + findTeamById: vi.fn(), + deleteDatasets: vi.fn(), + deleteApps: vi.fn(), + deleteEvaluations: vi.fn(), + deleteMany: vi.fn(), + countApps: vi.fn(), + countDatasets: vi.fn(), + loggerInfo: vi.fn(), + loggerWarn: vi.fn(), + loggerError: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/account/cancellation', () => ({ + withAccountCancellationTeamLock: mocks.withTeamLock +})); + +vi.mock('@fastgpt/service/support/user/team/teamSchema', () => ({ + MongoTeam: { findById: mocks.findTeamById } +})); + +vi.mock('@fastgpt/service/core/dataset/delete/processor', () => ({ + deleteTeamAllDatasets: mocks.deleteDatasets +})); + +vi.mock('@fastgpt/service/support/user/team/delete/utils', () => ({ + onDelAllApp: mocks.deleteApps +})); + +vi.mock('@fastgpt/service/core/app/evaluation/delete', () => ({ + deleteEvaluationsByTeamId: mocks.deleteEvaluations +})); + +vi.mock('@fastgpt/service/common/file/image/schema', () => ({ + MongoImage: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/app/schema', () => ({ + MongoApp: { countDocuments: mocks.countApps } +})); + +vi.mock('@fastgpt/service/core/dataset/schema', () => ({ + MongoDataset: { countDocuments: mocks.countDatasets } +})); + +vi.mock('@fastgpt/service/support/openapi/schema', () => ({ + MongoOpenApi: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/chat/setting/schema', () => ({ + MongoChatSetting: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/core/chat/favouriteApp/schema', () => ({ + MongoChatFavouriteApp: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/wallet/discountCoupon/schema', () => ({ + MongoDiscountCoupon: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/user/audit/schema', () => ({ + MongoTeamAudit: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/wallet/sub/schema', () => ({ + MongoTeamSub: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/mcp/schema', () => ({ + MongoMcpKey: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/support/outLink/schema', () => ({ + MongoOutLink: { deleteMany: mocks.deleteMany } +})); + +vi.mock('@fastgpt/service/common/logger', () => ({ + getLogger: () => ({ + info: mocks.loggerInfo, + warn: mocks.loggerWarn, + error: mocks.loggerError + }), + LogCategories: { MODULE: { USER: { TEAM: 'team' } } } +})); + +import { teamDeleteProcessor } from '@fastgpt/service/support/user/team/delete/processor'; + +const createJob = (attemptsMade: number) => + ({ + data: { teamId: 'team-1' }, + attemptsMade, + opts: { attempts: 10 } + }) as Parameters[0]; + +describe('teamDeleteProcessor failure logging', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.withTeamLock.mockImplementation(async (_teamId, callback) => callback()); + mocks.findTeamById.mockResolvedValue({}); + mocks.deleteDatasets.mockResolvedValue(undefined); + mocks.deleteApps.mockResolvedValue(undefined); + mocks.deleteEvaluations.mockResolvedValue(undefined); + mocks.deleteMany.mockResolvedValue(undefined); + mocks.countApps.mockResolvedValue(1); + mocks.countDatasets.mockResolvedValue(0); + }); + + it('silently retries expected resource deletion lag before the final attempt', async () => { + const job = createJob(0); + + await expect(teamDeleteProcessor(job)).rejects.toThrow( + 'Team resources are still being deleted' + ); + + expect(mocks.loggerInfo).toHaveBeenCalledWith('Team delete started', { teamId: 'team-1' }); + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).not.toHaveBeenCalled(); + }); + + it('does not repeat the start log during retries', async () => { + const job = createJob(1); + + await expect(teamDeleteProcessor(job)).rejects.toThrow( + 'Team resources are still being deleted' + ); + + expect(mocks.loggerInfo).not.toHaveBeenCalled(); + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).not.toHaveBeenCalled(); + }); + + it('logs expected resource deletion lag as an error on the final attempt', async () => { + const job = createJob(9); + + mocks.countApps.mockResolvedValueOnce(5); + + await expect(teamDeleteProcessor(job)).rejects.toThrow( + 'Team resources are still being deleted' + ); + + expect(mocks.loggerInfo).not.toHaveBeenCalled(); + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).toHaveBeenCalledWith('Team delete failed after retries', { + teamId: 'team-1', + attempts: 10, + remainingApps: 5, + remainingDatasets: 0 + }); + }); + + it('logs infrastructure failures as errors without waiting for retries to exhaust', async () => { + const error = new Error('mongo unavailable'); + mocks.findTeamById.mockRejectedValueOnce(error); + const job = createJob(0); + + await expect(teamDeleteProcessor(job)).rejects.toThrow('mongo unavailable'); + + expect(mocks.loggerWarn).not.toHaveBeenCalled(); + expect(mocks.loggerError).toHaveBeenCalledWith('Team delete failed', { + teamId: 'team-1', + error + }); + }); +}); diff --git a/packages/service/test/support/user/team/fallback.test.ts b/packages/service/test/support/user/team/fallback.test.ts new file mode 100644 index 000000000000..b039d990544d --- /dev/null +++ b/packages/service/test/support/user/team/fallback.test.ts @@ -0,0 +1,280 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; +import { Types } from '@fastgpt/service/common/mongo'; +import { MongoAccountCancellation } from '@fastgpt/service/support/user/account/cancellation/schema'; +import { getUserFallbackTeam } from '@fastgpt/service/support/user/team/fallback'; +import { getActiveAccountCancellationsByTeams } from '@fastgpt/service/support/user/account/cancellation/read'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; + +describe('getUserFallbackTeam', () => { + beforeEach(async () => { + vi.restoreAllMocks(); + await Promise.all([ + MongoAccountCancellation.deleteMany({}), + MongoTeamMember.deleteMany({}), + MongoTeam.deleteMany({}) + ]); + }); + + it('returns the active owner team without scanning all memberships', async () => { + const userId = new Types.ObjectId(); + const [ownerTeam, joinedTeam] = await MongoTeam.create([ + { name: 'Owner team', ownerId: userId }, + { name: 'Joined team', ownerId: new Types.ObjectId() } + ]); + const [ownerMember] = await MongoTeamMember.create([ + { teamId: ownerTeam._id, userId, name: 'Owner', status: 'active' }, + { teamId: joinedTeam._id, userId, name: 'Member', status: 'active' } + ]); + const memberFindSpy = vi.spyOn(MongoTeamMember, 'find'); + const cancellationFindSpy = vi.spyOn(MongoAccountCancellation, 'findOne'); + + await expect(getUserFallbackTeam({ userId: String(userId) })).resolves.toEqual({ + teamId: String(ownerTeam._id), + tmbId: String(ownerMember._id) + }); + + expect(memberFindSpy).not.toHaveBeenCalled(); + expect(cancellationFindSpy).toHaveBeenCalledTimes(1); + }); + + it('skips the cancellation query when the caller explicitly allows the owner team', async () => { + const userId = new Types.ObjectId(); + const [ownerTeam] = await MongoTeam.create([{ name: 'Owner team', ownerId: userId }]); + const [ownerMember] = await MongoTeamMember.create([ + { teamId: ownerTeam._id, userId, name: 'Owner', status: 'active' } + ]); + await MongoAccountCancellation.create([ + { + userId, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + } + ]); + const cancellationFindSpy = vi.spyOn(MongoAccountCancellation, 'findOne'); + + await expect( + getUserFallbackTeam({ + userId: String(userId), + allowAccountCancellationTeam: true + }) + ).resolves.toEqual({ + teamId: String(ownerTeam._id), + tmbId: String(ownerMember._id) + }); + + expect(cancellationFindSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['deleted', { deleteTime: new Date() }], + ['excluded', {}] + ])( + 'falls back to another active team when the owner team is %s', + async (reason, ownerTeamData) => { + const userId = new Types.ObjectId(); + const [ownerTeam, joinedTeam] = await MongoTeam.create([ + { name: 'Owner team', ownerId: userId, ...ownerTeamData }, + { name: 'Joined team', ownerId: new Types.ObjectId() } + ]); + const [, joinedMember] = await MongoTeamMember.create([ + { teamId: ownerTeam._id, userId, name: 'Owner', status: 'active' }, + { teamId: joinedTeam._id, userId, name: 'Member', status: 'active' } + ]); + + await expect( + getUserFallbackTeam({ + userId: String(userId), + excludedTeamId: reason === 'excluded' ? String(ownerTeam._id) : undefined + }) + ).resolves.toEqual({ + teamId: String(joinedTeam._id), + tmbId: String(joinedMember._id) + }); + } + ); + + it('falls back to another active team when the owner team is cancelling', async () => { + const userId = new Types.ObjectId(); + const [ownerTeam, joinedTeam] = await MongoTeam.create([ + { name: 'Owner team', ownerId: userId }, + { name: 'Joined team', ownerId: new Types.ObjectId() } + ]); + const [, joinedMember] = await MongoTeamMember.create([ + { teamId: ownerTeam._id, userId, name: 'Owner', status: 'active' }, + { teamId: joinedTeam._id, userId, name: 'Member', status: 'active' } + ]); + await MongoAccountCancellation.create([ + { + userId, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + } + ]); + + await expect(getUserFallbackTeam({ userId: String(userId) })).resolves.toEqual({ + teamId: String(joinedTeam._id), + tmbId: String(joinedMember._id) + }); + }); + + it('falls back to another active team when the owner membership is inactive', async () => { + const userId = new Types.ObjectId(); + const [ownerTeam, joinedTeam] = await MongoTeam.create([ + { name: 'Owner team', ownerId: userId }, + { name: 'Joined team', ownerId: new Types.ObjectId() } + ]); + const [, joinedMember] = await MongoTeamMember.create([ + { teamId: ownerTeam._id, userId, name: 'Owner', status: 'leave' }, + { teamId: joinedTeam._id, userId, name: 'Member', status: 'active' } + ]); + + await expect(getUserFallbackTeam({ userId: String(userId) })).resolves.toEqual({ + teamId: String(joinedTeam._id), + tmbId: String(joinedMember._id) + }); + }); + + it('returns null when the user has no usable team', async () => { + await expect( + getUserFallbackTeam({ userId: new Types.ObjectId().toString() }) + ).resolves.toBeNull(); + }); +}); + +describe('getActiveAccountCancellationsByTeams', () => { + beforeEach(async () => { + vi.restoreAllMocks(); + await Promise.all([ + MongoAccountCancellation.deleteMany({}), + MongoTeamMember.deleteMany({}), + MongoTeam.deleteMany({}) + ]); + }); + + it('reuses known owner cancellations and maps records back to every matching team', async () => { + const knownOwnerId = new Types.ObjectId(); + const queriedOwnerId = new Types.ObjectId(); + const [knownTeam, queriedTeam] = await MongoTeam.create([ + { name: 'Known owner team', ownerId: knownOwnerId }, + { name: 'Queried owner team', ownerId: queriedOwnerId } + ]); + const queriedRecord = await MongoAccountCancellation.create({ + userId: queriedOwnerId, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + }); + const findSpy = vi.spyOn(MongoAccountCancellation, 'find'); + + const result = await getActiveAccountCancellationsByTeams( + [knownTeam, queriedTeam], + [ + { + userId: knownOwnerId, + status: AccountCancellationStatus.finalizing, + requestedAt: new Date() + } + ] + ); + + expect(findSpy).toHaveBeenCalledTimes(1); + expect(findSpy.mock.calls[0][0]).toMatchObject({ + userId: { $in: [String(queriedOwnerId)] }, + status: { $in: ['pending', 'finalizing'] } + }); + expect(result).toHaveLength(2); + expect(result.map(({ teamId }) => teamId)).toEqual([ + String(knownTeam._id), + String(queriedTeam._id) + ]); + expect(result[1].record).toMatchObject({ + status: AccountCancellationStatus.pending + }); + expect(String(result[1].record._id)).toBe(String(queriedRecord._id)); + expect(String(result[1].record.userId)).toBe(String(queriedOwnerId)); + }); + + it('returns without querying when teams are empty or have no owners', async () => { + const findSpy = vi.spyOn(MongoAccountCancellation, 'find'); + + await expect(getActiveAccountCancellationsByTeams([])).resolves.toEqual([]); + await expect( + getActiveAccountCancellationsByTeams([{ _id: 'team-1' }, { _id: 'team-2', ownerId: null }]) + ).resolves.toEqual([]); + + expect(findSpy).not.toHaveBeenCalled(); + }); + + it('queries each owner once when multiple teams share an owner', async () => { + const ownerId = new Types.ObjectId(); + const teams = [ + { _id: 'team-1', ownerId }, + { _id: 'team-2', ownerId: String(ownerId) } + ]; + const findSpy = vi.spyOn(MongoAccountCancellation, 'find').mockReturnValue({ + lean: vi + .fn() + .mockResolvedValue([ + { userId: ownerId, status: AccountCancellationStatus.pending, requestedAt: new Date() } + ]) + } as any); + + await expect(getActiveAccountCancellationsByTeams(teams)).resolves.toHaveLength(2); + + expect(findSpy).toHaveBeenCalledTimes(1); + expect(findSpy.mock.calls[0][0]).toMatchObject({ userId: { $in: [String(ownerId)] } }); + }); + + it('skips the query when known records cover all owners, including unrelated owners', async () => { + const ownerId = new Types.ObjectId(); + const findSpy = vi.spyOn(MongoAccountCancellation, 'find'); + + await expect( + getActiveAccountCancellationsByTeams( + [{ _id: 'team-1', ownerId }], + [ + { userId: ownerId, status: AccountCancellationStatus.pending, requestedAt: new Date() }, + { + userId: new Types.ObjectId(), + status: AccountCancellationStatus.pending, + requestedAt: new Date() + } + ] + ) + ).resolves.toHaveLength(1); + + expect(findSpy).not.toHaveBeenCalled(); + }); + + it.each([AccountCancellationStatus.pending, AccountCancellationStatus.finalizing])( + 'returns matching %s cancellation and ignores unrelated known owners', + async (status) => { + const ownerId = new Types.ObjectId(); + const unrelatedOwnerId = new Types.ObjectId(); + const findSpy = vi.spyOn(MongoAccountCancellation, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([{ userId: ownerId, status, requestedAt: new Date() }]) + } as any); + + const result = await getActiveAccountCancellationsByTeams( + [{ _id: 'team-1', ownerId }], + [{ userId: unrelatedOwnerId, status, requestedAt: new Date() }] + ); + + expect(findSpy).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ teamId: 'team-1', record: { userId: ownerId, status } }); + } + ); + + it('returns an empty array when queried owners have no active cancellation', async () => { + const findSpy = vi.spyOn(MongoAccountCancellation, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([]) + } as any); + + await expect( + getActiveAccountCancellationsByTeams([{ _id: 'team-1', ownerId: new Types.ObjectId() }]) + ).resolves.toEqual([]); + expect(findSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 399bd7cc406d..eb2b3f02b3e9 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -82,6 +82,10 @@ export const iconPaths = { 'common/model': () => import('./icons/common/model.svg'), 'common/openai': () => import('./icons/common/openai.svg'), 'common/overviewLight': () => import('./icons/common/overviewLight.svg'), + 'common/quickActionBook': () => import('./icons/common/quickActionBook.svg'), + 'common/quickActionFeedback': () => import('./icons/common/quickActionFeedback.svg'), + 'common/quickActionPhone': () => import('./icons/common/quickActionPhone.svg'), + 'common/quickActionUserX': () => import('./icons/common/quickActionUserX.svg'), 'common/refresh': () => import('./icons/common/refresh.svg'), 'common/refreshLight': () => import('./icons/common/refreshLight.svg'), 'common/retryLight': () => import('./icons/common/retryLight.svg'), diff --git a/packages/web/components/common/Icon/icons/common/quickActionBook.svg b/packages/web/components/common/Icon/icons/common/quickActionBook.svg new file mode 100644 index 000000000000..ee6d4fbab46b --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionBook.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg b/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg new file mode 100644 index 000000000000..1b1ab1e044f9 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionFeedback.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionPhone.svg b/packages/web/components/common/Icon/icons/common/quickActionPhone.svg new file mode 100644 index 000000000000..3c8f620a09a4 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionPhone.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/quickActionUserX.svg b/packages/web/components/common/Icon/icons/common/quickActionUserX.svg new file mode 100644 index 000000000000..f17316b131f5 --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/quickActionUserX.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index d0d21a34d3ce..ceae236d7b1c 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -1,5 +1,62 @@ { "account_knowledge_base_cleanup_warning": "When the free version team does not log in to the system for 30 consecutive days, the system will automatically clean up the account knowledge base.", + "account_cancellation": "Delete account", + "account_cancellation_account": "Account to delete", + "account_cancellation_cancel": "Cancel deletion", + "account_cancellation_cancel_error": "Could not cancel account deletion", + "account_cancellation_cancel_success": "Account deletion canceled", + "account_cancellation_code_countdown": "Resend ({{seconds}})", + "account_cancellation_code_resend": "Resend", + "account_cancellation_code_send_failed": "Could not send the verification code. Try again.", + "account_cancellation_code_sending": "Sending", + "account_cancellation_code_sent": "Verification code sent", + "account_cancellation_confirm": "Confirm deletion", + "account_cancellation_confirm_backup": "Backed up important data, configuration, and business materials", + "account_cancellation_confirm_before_continue": "Before continuing, confirm that you have completed the following:", + "account_cancellation_confirm_cancel_during_wait": "During the 15-day waiting period, you can sign in again and cancel account deletion.", + "account_cancellation_confirm_completion_intro": "After the waiting period, account deletion will be completed. At that point:", + "account_cancellation_confirm_intro": "Before deleting your account, confirm the following:", + "account_cancellation_confirm_leave_team_impact": "The account will automatically leave all other teams it has joined", + "account_cancellation_confirm_order_refund": "Resolved outstanding orders, refunds, and related matters", + "account_cancellation_confirm_owned_team_impact": "teams created by this account will be deleted", + "account_cancellation_confirm_owned_team_prefix": "All ", + "account_cancellation_confirm_personal_data_impact": "The account's personal information will be deleted or anonymized", + "account_cancellation_confirm_reregister": "If you register again with the same account after deletion, a new account will be created and none of the original data can be restored.", + "account_cancellation_confirm_service_impact": "channels that provide external services through this account will stop working", + "account_cancellation_confirm_service_stop": "Confirmed that stopping related services will not affect production workloads", + "account_cancellation_confirm_team_data_impact": "Apps, data, members, and configuration in those teams will be deleted, and team members will lose access", + "account_cancellation_confirm_team_transfer": "Transferred team ownership or handled team data", + "account_cancellation_confirm_title": "Account deletion notice", + "account_cancellation_confirm_verification_effect": "The deletion request takes effect after identity verification is complete.", + "account_cancellation_confirm_waiting_prefix": "After you submit the request, the account enters a 15-day waiting period. During this period, the account cannot be used normally and all ", + "account_cancellation_confirm_waiting_suffix": ", including API keys, shared links, and external APIs. System notifications will remain available.", + "account_cancellation_continue": "I understand, continue", + "account_cancellation_finalizing_desc": "Your account is being deleted and its related data is being cleaned up.", + "account_cancellation_finalizing_no_estimate": "Deletion can no longer be canceled, and an estimated completion time is not shown at this stage.", + "account_cancellation_in_progress_title": "Deleting account", + "account_cancellation_oauth_start": "Verify with {{provider}}", + "account_cancellation_pending_cancel_desc": "If you did not request this, or you want to keep using the account, cancel deletion before the scheduled deletion time. The account will return to normal after cancellation.", + "account_cancellation_pending_desc": "Your account deletion request has been submitted and is in the 15-day waiting period.", + "account_cancellation_pending_service_desc": "During the waiting period, the account cannot be used normally and all external service channels that depend on it are disabled.", + "account_cancellation_requested_at": "Requested: {{time}}", + "account_cancellation_scheduled_at": "Scheduled deletion: {{time}}", + "account_cancellation_send_code": "Get code", + "account_cancellation_submit_success": "Account deletion request submitted", + "account_cancellation_switch_team": "Switch team", + "account_cancellation_team_finalizing_desc": "This team is being deleted. Contact the team owner for an update.", + "account_cancellation_team_pending_desc": "The team owner requested deletion and the team is in its 15-day waiting period. Contact the owner to cancel deletion.", + "account_cancellation_team_scheduled_at": "Scheduled cleanup: {{time}}", + "account_cancellation_team_title": "Team deletion in progress", + "account_cancellation_title": "Delete account", + "account_cancellation_unavailable_desc": "This account has no supported non-password verification method.", + "account_cancellation_verification_failed": "Identity verification failed. Try again.", + "account_cancellation_verification_success": "Identity verified", + "account_cancellation_verifying": "Verifying", + "account_cancellation_wechat_expired": "The QR code expired. Get a new one.", + "account_cancellation_wechat_load_failed": "Could not load the QR code. Try again.", + "account_cancellation_wechat_qr": "WeChat QR code", + "account_cancellation_wechat_refresh": "Get a new QR code", + "account_cancellation_wechat_scan": "Scan with WeChat", "active": "Taking effect", "ai_points": "AI points", "ai_points_calculation_standard": "AI points", diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index 13b3d9477575..c63ac903e887 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "System announcement configuration", "admin_update_team": "Edit team information", "admin_update_user": "Edit User", + "admin_delete_user": "Delete User", + "account_cancellation_submit": "Submit Account Deletion", + "account_cancellation_cancel": "Cancel Account Deletion", + "account_cancellation_finalize": "Complete Account Deletion Cleanup", "assign_permission": "Permission change", "audit_log": "audit", "change_department_name": "Department Editor", @@ -123,6 +127,7 @@ "link_forbidden": "Forbidden", "log_admin_add_plan": "【{{name}}】A package will be added to a team with a team id [{{teamId}}]", "log_admin_add_user": "【{{name}}】Create a user named [{{userName}}]", + "log_admin_delete_user": "【{{name}}】Deleted user [{{userName}}]", "log_admin_create_app_template": "【{{name}}】Added a template named [{{templateName}}]", "log_admin_create_plugin": "【{{name}}】Added plugin named [{{pluginName}}]", "log_admin_create_plugin_group": "【{{name}}】Create a plug-in group called [{{groupName}}]", @@ -148,6 +153,9 @@ "log_change_department": "【{{name}}】Updated department【{{departmentName}}】", "log_change_member_name": "【{{name}}】Rename member [{{memberName}}] to 【{{newName}}】", "log_change_member_name_self": "【{{name}}】Change your member name to 【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】Submitted an account deletion request", + "log_account_cancellation_cancel": "【{{name}}】Canceled an account deletion request", + "log_account_cancellation_finalize": "【{{name}}】Completed account deletion cleanup", "log_change_notification_settings": "【{{name}}】A change notification receiving method operation was carried out", "log_change_password": "【{{name}}】The password change operation was performed", "log_copy_api_key": "【{{name}}】Copied the API key named [{{keyName}}]", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index c96aadf8cdc0..64e2d09ffc87 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -131,6 +131,7 @@ "code_editor": "Code Editor", "code_error.account_error": "Incorrect account name or password", "code_error.account_exist": "Account has been registered", + "code_error.account_cancellation_pending": "Account deletion is in progress. This account is temporarily unavailable.", "code_error.account_not_found": "User is not registered", "code_error.invalid_account": "Incorrect account", "code_error.app_error.can_not_edit_admin_permission": "Can not edit admin permission", @@ -193,6 +194,7 @@ "code_error.team_error.ai_points_not_enough": "Insufficient AI Points", "code_error.team_error.app_amount_not_enough": "Application Limit Reached", "code_error.team_error.app_folder_amount_not_enough": "Folder Limit Reached", + "code_error.team_error.account_cancellation_pending": "The team owner is deleting their account. This team is temporarily unavailable.", "code_error.team_error.cannot_delete_default_group": "Cannot delete default group", "code_error.team_error.cannot_delete_non_empty_org": "Cannot delete non-empty organization", "code_error.team_error.cannot_modify_root_org": "Cannot modify root organization", @@ -802,6 +804,7 @@ "error.registration_method_not_supported": "Unsupported username", "error.llm_track_expired": "Request details expired", "error.missingParams": "Insufficient parameters", + "error.operation_too_frequently": "Too many operations. Please try again later.", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json index 75362b425fd4..157aa3aa5828 100644 --- a/packages/web/i18n/zh-CN/account_info.json +++ b/packages/web/i18n/zh-CN/account_info.json @@ -1,5 +1,62 @@ { "account_knowledge_base_cleanup_warning": "免费版团队连续 30 天未登录系统时,系统会自动清理账号知识库。", + "account_cancellation": "账号注销", + "account_cancellation_account": "注销账号", + "account_cancellation_cancel": "取消注销", + "account_cancellation_cancel_error": "取消失败", + "account_cancellation_cancel_success": "已取消注销", + "account_cancellation_code_countdown": "重新获取({{seconds}})", + "account_cancellation_code_resend": "重新获取", + "account_cancellation_code_send_failed": "验证码发送失败,请重试", + "account_cancellation_code_sending": "发送中", + "account_cancellation_code_sent": "验证码已发送", + "account_cancellation_confirm": "确认注销", + "account_cancellation_confirm_backup": "已备份重要数据、配置和业务资料", + "account_cancellation_confirm_before_continue": "在继续前,请确认你已处理好以下事项:", + "account_cancellation_confirm_cancel_during_wait": "在 15 天等待期内,你可以重新登录账号并取消注销。", + "account_cancellation_confirm_completion_intro": "等待期结束后,账号注销将正式完成。届时:", + "account_cancellation_confirm_intro": "注销账号前,请确认以下事项:", + "account_cancellation_confirm_leave_team_impact": "该账号加入的其他团队将自动退出", + "account_cancellation_confirm_order_refund": "已处理未完成订单、退款等事项", + "account_cancellation_confirm_owned_team_impact": "创建的团队将被删除", + "account_cancellation_confirm_owned_team_prefix": "该账号下", + "account_cancellation_confirm_personal_data_impact": "该账号的个人信息将被删除或匿名化处理", + "account_cancellation_confirm_reregister": "账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。", + "account_cancellation_confirm_service_impact": "依赖该账号对外提供服务的渠道将停止生效", + "account_cancellation_confirm_service_stop": "已确认相关服务停用不会影响线上业务", + "account_cancellation_confirm_team_data_impact": "团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队", + "account_cancellation_confirm_team_transfer": "已完成团队归属转移或团队数据处理", + "account_cancellation_confirm_title": "注销提示", + "account_cancellation_confirm_verification_effect": "完成身份验证后,注销申请将正式生效。", + "account_cancellation_confirm_waiting_prefix": "提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有", + "account_cancellation_confirm_waiting_suffix": ",包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。", + "account_cancellation_continue": "已知晓,下一步", + "account_cancellation_finalizing_desc": "你的账号已进入注销处理阶段,系统正在清理账号及相关数据。", + "account_cancellation_finalizing_no_estimate": "该阶段无法取消注销,预计完成时间不再展示。", + "account_cancellation_in_progress_title": "注销中", + "account_cancellation_oauth_start": "前往 {{provider}} 验证", + "account_cancellation_pending_cancel_desc": "若这不是你本人操作,或你希望继续使用该账号,请在预计注销时间前取消注销。取消后,账号将恢复正常状态。", + "account_cancellation_pending_desc": "你的账号已提交注销申请,目前处于 15 天注销等待期。", + "account_cancellation_pending_service_desc": "等待期内,该账号将无法正常使用,所有依赖该账号对外提供服务的渠道已停止生效。", + "account_cancellation_requested_at": "申请时间:{{time}}", + "account_cancellation_scheduled_at": "预计注销时间:{{time}}", + "account_cancellation_send_code": "获取验证码", + "account_cancellation_submit_success": "注销提交成功", + "account_cancellation_switch_team": "切换团队", + "account_cancellation_team_finalizing_desc": "团队已进入注销清理阶段。您可联系团队所有者了解处理进度。", + "account_cancellation_team_pending_desc": "团队已由团队所有者提交注销申请,目前处于 15 天注销等待期。您可联系团队所有者取消注销。", + "account_cancellation_team_scheduled_at": "预计清理时间:{{time}}", + "account_cancellation_team_title": "团队注销中", + "account_cancellation_title": "注销账号", + "account_cancellation_unavailable_desc": "当前账号没有可用的非密码验证方式。", + "account_cancellation_verification_failed": "身份验证失败,请重试", + "account_cancellation_verification_success": "身份验证成功", + "account_cancellation_verifying": "验证中", + "account_cancellation_wechat_expired": "二维码已过期,请重新获取。", + "account_cancellation_wechat_load_failed": "二维码加载失败,请重试。", + "account_cancellation_wechat_qr": "微信二维码", + "account_cancellation_wechat_refresh": "重新获取二维码", + "account_cancellation_wechat_scan": "微信扫码登录", "active": "生效中", "ai_points": "AI 积分", "ai_points_calculation_standard": "AI 积分", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index 5208a4a3ee75..aa8b164d9ffe 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "系统公告配置", "admin_update_team": "编辑团队信息", "admin_update_user": "编辑用户信息", + "admin_delete_user": "删除用户", + "account_cancellation_submit": "提交账号注销", + "account_cancellation_cancel": "取消账号注销", + "account_cancellation_finalize": "完成账号注销清理", "assign_permission": "权限变更", "audit_log": "审计", "change_department_name": "部门编辑", @@ -121,6 +125,7 @@ "link_forbidden": "禁用", "log_admin_add_plan": "【{{name}}】将给团队id为【{{teamId}}】的团队添加了套餐", "log_admin_add_user": "【{{name}}】创建了一个名为【{{userName}}】的用户", + "log_admin_delete_user": "【{{name}}】删除了用户【{{userName}}】", "log_admin_create_app_template": "【{{name}}】添加了名为【{{templateName}}】的模板", "log_admin_create_plugin": "【{{name}}】添加了名为【{{pluginName}}】的插件", "log_admin_create_plugin_group": "【{{name}}】创建了名为【{{groupName}}】的插件分组", @@ -146,6 +151,9 @@ "log_change_department": "【{{name}}】更新了部门【{{departmentName}}】", "log_change_member_name": "【{{name}}】将成员【{{memberName}}】重命名为【{{newName}}】", "log_change_member_name_self": "【{{name}}】把自己的成员名从【{{oldName}}】变更为【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】提交了账号注销申请", + "log_account_cancellation_cancel": "【{{name}}】取消了账号注销申请", + "log_account_cancellation_finalize": "【{{name}}】完成了账号注销清理", "log_change_notification_settings": "【{{name}}】进行了变更通知接收途径操作", "log_change_password": "【{{name}}】进行了变更密码操作", "log_copy_api_key": "【{{name}}】复制了名为【{{keyName}}】的api密钥", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 896b3df83a34..2ebbb43b01b0 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -131,6 +131,7 @@ "code_editor": "代码编辑", "code_error.account_error": "账号名或密码错误", "code_error.account_exist": "账号已注册", + "code_error.account_cancellation_pending": "账号注销处理中,当前账号暂不可使用", "code_error.account_not_found": "用户未注册", "code_error.invalid_account": "账号错误", "code_error.app_error.can_not_edit_admin_permission": "不能编辑管理员权限", @@ -193,6 +194,7 @@ "code_error.team_error.ai_points_not_enough": "AI 积分不足", "code_error.team_error.app_amount_not_enough": "应用数量已达上限~", "code_error.team_error.app_folder_amount_not_enough": "文件夹数量已达上限~", + "code_error.team_error.account_cancellation_pending": "团队所有者正在注销,当前团队暂不可使用", "code_error.team_error.cannot_delete_default_group": "不能删除默认群组", "code_error.team_error.cannot_delete_non_empty_org": "不能删除非空部门", "code_error.team_error.cannot_modify_root_org": "不能修改根部门", @@ -802,6 +804,7 @@ "error.registration_method_not_supported": "不支持的用户名", "error.llm_track_expired": "请求详情已过期", "error.missingParams": "参数缺失", + "error.operation_too_frequently": "操作过于频繁,请稍后再试", "error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配", "error.send_auth_code_too_frequently": "请勿频繁获取验证码", "error.verify_code_too_frequently": "验证过于频繁,请稍后再试", diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json index 9516400fd719..587ec73183e7 100644 --- a/packages/web/i18n/zh-Hant/account_info.json +++ b/packages/web/i18n/zh-Hant/account_info.json @@ -1,5 +1,62 @@ { "account_knowledge_base_cleanup_warning": "免費版團隊連續 30 天未登入系統時,系統會自動清理帳號知識庫。", + "account_cancellation": "帳號註銷", + "account_cancellation_account": "註銷帳號", + "account_cancellation_cancel": "取消註銷", + "account_cancellation_cancel_error": "取消失敗", + "account_cancellation_cancel_success": "已取消註銷", + "account_cancellation_code_countdown": "重新取得({{seconds}})", + "account_cancellation_code_resend": "重新取得", + "account_cancellation_code_send_failed": "驗證碼傳送失敗,請重試", + "account_cancellation_code_sending": "傳送中", + "account_cancellation_code_sent": "驗證碼已傳送", + "account_cancellation_confirm": "確認註銷", + "account_cancellation_confirm_backup": "已備份重要資料、設定和業務資料", + "account_cancellation_confirm_before_continue": "繼續前,請確認你已處理好以下事項:", + "account_cancellation_confirm_cancel_during_wait": "在 15 天等待期內,你可以重新登入帳號並取消註銷。", + "account_cancellation_confirm_completion_intro": "等待期結束後,帳號註銷將正式完成。屆時:", + "account_cancellation_confirm_intro": "註銷帳號前,請確認以下事項:", + "account_cancellation_confirm_leave_team_impact": "該帳號加入的其他團隊將自動退出", + "account_cancellation_confirm_order_refund": "已處理未完成訂單、退款等事項", + "account_cancellation_confirm_owned_team_impact": "建立的團隊將被刪除", + "account_cancellation_confirm_owned_team_prefix": "該帳號下", + "account_cancellation_confirm_personal_data_impact": "該帳號的個人資訊將被刪除或匿名化處理", + "account_cancellation_confirm_reregister": "帳號註銷完成後,如果你再次使用該帳號註冊,將建立一個全新帳號,原帳號資料無法恢復。", + "account_cancellation_confirm_service_impact": "依賴該帳號對外提供服務的管道將停止生效", + "account_cancellation_confirm_service_stop": "已確認相關服務停用不會影響線上業務", + "account_cancellation_confirm_team_data_impact": "團隊內的應用、資料、成員、設定等資訊將被刪除,團隊成員無法進入團隊", + "account_cancellation_confirm_team_transfer": "已完成團隊歸屬轉移或團隊資料處理", + "account_cancellation_confirm_title": "註銷提示", + "account_cancellation_confirm_verification_effect": "完成身分驗證後,註銷申請將正式生效。", + "account_cancellation_confirm_waiting_prefix": "提交註銷申請後,帳號將進入 15 天等待期。等待期內,該帳號將無法正常使用,所有", + "account_cancellation_confirm_waiting_suffix": ",包括但不限於 API Key、分享連結和對外呼叫介面。系統通知資訊仍可正常接收。", + "account_cancellation_continue": "已知悉,下一步", + "account_cancellation_finalizing_desc": "你的帳號已進入註銷處理階段,系統正在清理帳號及相關資料。", + "account_cancellation_finalizing_no_estimate": "該階段無法取消註銷,預計完成時間不再顯示。", + "account_cancellation_in_progress_title": "註銷中", + "account_cancellation_oauth_start": "前往 {{provider}} 驗證", + "account_cancellation_pending_cancel_desc": "若這不是你本人操作,或你希望繼續使用該帳號,請在預計註銷時間前取消註銷。取消後,帳號將恢復正常狀態。", + "account_cancellation_pending_desc": "你的帳號已提交註銷申請,目前處於 15 天註銷等待期。", + "account_cancellation_pending_service_desc": "等待期內,該帳號將無法正常使用,所有依賴該帳號對外提供服務的管道已停止生效。", + "account_cancellation_requested_at": "申請時間:{{time}}", + "account_cancellation_scheduled_at": "預計註銷時間:{{time}}", + "account_cancellation_send_code": "取得驗證碼", + "account_cancellation_submit_success": "註銷提交成功", + "account_cancellation_switch_team": "切換團隊", + "account_cancellation_team_finalizing_desc": "團隊已進入註銷清理階段。你可聯絡團隊擁有者了解處理進度。", + "account_cancellation_team_pending_desc": "團隊已由團隊擁有者提交註銷申請,目前處於 15 天註銷等待期。你可聯絡團隊擁有者取消註銷。", + "account_cancellation_team_scheduled_at": "預計清理時間:{{time}}", + "account_cancellation_team_title": "團隊註銷中", + "account_cancellation_title": "註銷帳號", + "account_cancellation_unavailable_desc": "目前帳號沒有可用的非密碼驗證方式。", + "account_cancellation_verification_failed": "身分驗證失敗,請重試", + "account_cancellation_verification_success": "身分驗證成功", + "account_cancellation_verifying": "驗證中", + "account_cancellation_wechat_expired": "QR Code 已過期,請重新取得。", + "account_cancellation_wechat_load_failed": "QR Code 載入失敗,請重試。", + "account_cancellation_wechat_qr": "微信 QR Code", + "account_cancellation_wechat_refresh": "重新取得 QR Code", + "account_cancellation_wechat_scan": "微信掃碼登入", "active": "生效中", "ai_points": "AI 積分", "ai_points_calculation_standard": "AI 積分", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index cd9317d5e21c..0f0f57f4d82a 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -27,6 +27,10 @@ "admin_update_system_modal": "系統公告配置", "admin_update_team": "編輯團隊信息", "admin_update_user": "編輯用戶信息", + "admin_delete_user": "刪除用戶", + "account_cancellation_submit": "提交帳號註銷", + "account_cancellation_cancel": "取消帳號註銷", + "account_cancellation_finalize": "完成帳號註銷清理", "assign_permission": "權限變更", "audit_log": "審計", "change_department_name": "部門編輯", @@ -121,6 +125,7 @@ "link_forbidden": "禁用", "log_admin_add_plan": "【{{name}}】將給團隊id為【{{teamId}}】的團隊添加了套餐", "log_admin_add_user": "【{{name}}】創建了一個名為【{{userName}}】的用戶", + "log_admin_delete_user": "【{{name}}】刪除了用戶【{{userName}}】", "log_admin_create_app_template": "【{{name}}】添加了名為【{{templateName}}】的模板", "log_admin_create_plugin": "【{{name}}】添加了名為【{{pluginName}}】的插件", "log_admin_create_plugin_group": "【{{name}}】創建了名為【{{groupName}}】的插件分組", @@ -146,6 +151,9 @@ "log_change_department": "【{{name}}】更新了部門【{{departmentName}}】", "log_change_member_name": "【{{name}}】將成員【{{memberName}}】重命名為【{{newName}}】", "log_change_member_name_self": "【{{name}}】變更自己的成員名為【{{newName}}】", + "log_account_cancellation_submit": "【{{name}}】提交了帳號註銷申請", + "log_account_cancellation_cancel": "【{{name}}】取消了帳號註銷申請", + "log_account_cancellation_finalize": "【{{name}}】完成了帳號註銷清理", "log_change_notification_settings": "【{{name}}】進行了變更通知接收途徑操作", "log_change_password": "【{{name}}】進行了變更密碼操作", "log_copy_api_key": "【{{name}}】複製了名為【{{keyName}}】的api密鑰", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 265c15879d08..45fea784935e 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -130,6 +130,7 @@ "code_editor": "程式碼編輯器", "code_error.account_error": "帳號名稱或密碼錯誤", "code_error.account_exist": "賬號已註冊", + "code_error.account_cancellation_pending": "帳號註銷處理中,目前帳號暫不可使用", "code_error.account_not_found": "使用者未註冊", "code_error.invalid_account": "帳號錯誤", "code_error.app_error.can_not_edit_admin_permission": "不能編輯管理員權限", @@ -191,6 +192,7 @@ "code_error.team_error.ai_points_not_enough": "AI 點數不足", "code_error.team_error.app_amount_not_enough": "已達應用程式數量上限", "code_error.team_error.app_folder_amount_not_enough": "已達資料夾數量上限", + "code_error.team_error.account_cancellation_pending": "團隊擁有者正在註銷,目前團隊暫不可使用", "code_error.team_error.cannot_delete_default_group": "無法刪除預設群組", "code_error.team_error.cannot_delete_non_empty_org": "無法刪除非空組織", "code_error.team_error.cannot_modify_root_org": "無法修改根組織", @@ -796,6 +798,7 @@ "error.registration_method_not_supported": "不支援的用户名", "error.llm_track_expired": "請求詳情已過期", "error.missingParams": "參數不足", + "error.operation_too_frequently": "操作過於頻繁,請稍後再試", "error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配", "error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼", "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試", diff --git a/packages/web/support/user/audit/constants.ts b/packages/web/support/user/audit/constants.ts index ce6a2aaafaff..be7962648a55 100644 --- a/packages/web/support/user/audit/constants.ts +++ b/packages/web/support/user/audit/constants.ts @@ -19,6 +19,20 @@ export const adminAuditLogMap = { userName?: string; } }, + [AdminAuditEventEnum.ADMIN_DELETE_USER]: { + content: i18nT('account_team:log_admin_delete_user'), + typeLabel: i18nT('account_team:admin_delete_user'), + params: {} as { + userId?: string; + userName?: string; + operatorUserId?: string; + operatorType?: 'admin'; + requestSource?: 'admin'; + requestedAt?: Date; + scheduledCancelAt?: Date; + requestId?: string; + } + }, [AdminAuditEventEnum.ADMIN_UPDATE_TEAM]: { content: i18nT('account_team:log_admin_update_team'), typeLabel: i18nT('account_team:admin_update_team'), @@ -476,6 +490,50 @@ export const auditLogMap = { typeLabel: i18nT('account_team:change_member_name_self'), params: {} as { name?: string; oldName: string; newName: string } }, + [AuditEventEnum.ACCOUNT_CANCELLATION_SUBMIT]: { + content: i18nT('account_team:log_account_cancellation_submit'), + typeLabel: i18nT('account_team:account_cancellation_submit'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'self'; + requestSource: 'self'; + verificationMethod: string; + verificationProvider?: string; + affectedTeamIds: string[]; + requestedAt: Date; + scheduledCancelAt: Date; + requestId?: string; + } + }, + [AuditEventEnum.ACCOUNT_CANCELLATION_CANCEL]: { + content: i18nT('account_team:log_account_cancellation_cancel'), + typeLabel: i18nT('account_team:account_cancellation_cancel'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'self'; + requestSource: 'self'; + requestedAt: Date; + scheduledCancelAt: Date; + requestId?: string; + } + }, + [AuditEventEnum.ACCOUNT_CANCELLATION_FINALIZE]: { + content: i18nT('account_team:log_account_cancellation_finalize'), + typeLabel: i18nT('account_team:account_cancellation_finalize'), + params: {} as { + userId: string; + operatorUserId: string; + operatorType: 'system' | 'admin'; + requestSource: 'self' | 'admin'; + requestedAt: Date; + scheduledCancelAt: Date; + finalizedAt: Date; + requestId?: string; + cronExecutionId?: string; + } + }, [AuditEventEnum.PURCHASE_PLAN]: { content: i18nT('account_team:log_purchase_plan'), typeLabel: i18nT('account_team:purchase_plan'), diff --git a/pro b/pro index b77ab4229ba8..c917dab21533 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit b77ab4229ba80e47132a8fef6478609c5b783b61 +Subproject commit c917dab21533552a216ef5fc9a12decc6c335193 diff --git a/projects/app/src/components/Layout/SupportBot.tsx b/projects/app/src/components/Layout/SupportBot.tsx index 8a1ddd6443a0..9b132b97909e 100644 --- a/projects/app/src/components/Layout/SupportBot.tsx +++ b/projects/app/src/components/Layout/SupportBot.tsx @@ -78,7 +78,7 @@ const SupportBot = () => { {showChat && open && ( { {open && ( diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 070b1334a14d..1facb21b9b63 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -58,6 +58,7 @@ const pcUnShowLayoutRoute: Record = { '/login': true, '/login/provider': true, '/login/fastlogin': true, + '/account/cancel': true, '/chat/share': true, '/app/edit': true, '/chat': true, @@ -70,6 +71,7 @@ const phoneUnShowLayoutRoute: Record = { '/login': true, '/login/provider': true, '/login/fastlogin': true, + '/account/cancel': true, '/chat': true, '/chat/share': true, '/tools/price': true, @@ -164,6 +166,17 @@ const Layout = ({ children }: { children: JSX.Element }) => { setLastRoute(router.pathname); }, [router.pathname, setLastRoute]); + useEffect(() => { + if ( + userInfo?.team?.accountCancellation && + router.pathname !== '/account/cancel' && + router.pathname !== '/login' && + router.pathname !== '/login/provider' + ) { + router.replace('/account/cancel?view=team'); + } + }, [router, router.pathname, userInfo?.team?.accountCancellation]); + return ( <> diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useChatRecordActions.ts b/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useChatRecordActions.ts index 6f9f5042fc88..ce059d98c321 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useChatRecordActions.ts +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useChatRecordActions.ts @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useContextSelector } from 'use-context-selector'; import { useMemoizedFn } from 'ahooks'; +import { useTranslation } from 'react-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { getErrText } from '@fastgpt/global/common/error/utils'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; @@ -38,6 +39,7 @@ const uniqueDataIds = (dataIds: Array) => */ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps) => { const { toast } = useToast(); + const { t } = useTranslation(); const [isRecordActionLoading, setIsRecordActionLoading] = useState(false); const chatRecords = useContextSelector(ChatRecordContext, (v) => v.chatRecords); @@ -94,7 +96,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps) } catch (error) { toast({ status: 'warning', - title: getErrText(error, 'Retry failed') + title: t(getErrText(error, 'Retry failed')) }); } setIsRecordActionLoading(false); @@ -130,7 +132,7 @@ export const useChatRecordActions = ({ sendPrompt }: UseChatRecordActionsProps) } catch (error) { toast({ status: 'warning', - title: getErrText(error, 'Edit failed') + title: t(getErrText(error, 'Edit failed')) }); } finally { setIsRecordActionLoading(false); diff --git a/projects/app/src/pageComponents/account/AccountContainer.tsx b/projects/app/src/pageComponents/account/AccountContainer.tsx index 324b1391f445..04ed39dff7a3 100644 --- a/projects/app/src/pageComponents/account/AccountContainer.tsx +++ b/projects/app/src/pageComponents/account/AccountContainer.tsx @@ -153,7 +153,7 @@ const AccountContainer = ({ ); return ( - + {isPc ? ( void; + onConfirm: () => void; +}) => { + const { t } = useTranslation(); + const footerButtonStyles = { + h: 8, + minH: 8, + px: 3.5, + py: 2, + fontSize: 'mini', + lineHeight: '16px', + letterSpacing: 0.5 + }; + + return ( + + + + + } + > + + + {t('account_info:account_cancellation_confirm_intro', '注销账号前,请确认以下事项:')} + + +
+ + + {t( + 'account_info:account_cancellation_confirm_waiting_prefix', + '提交注销申请后,账号将进入 15 天等待期。等待期内,该账号将无法正常使用,所有' + )} + + {t( + 'account_info:account_cancellation_confirm_service_impact', + '依赖该账号对外提供服务的渠道将停止生效' + )} + + {t( + 'account_info:account_cancellation_confirm_waiting_suffix', + ',包括但不限于 API Key、分享链接和对外调用接口。系统通知信息仍可正常接收。' + )} + + +
+ + + + {t( + 'account_info:account_cancellation_confirm_completion_intro', + '等待期结束后,账号注销将正式完成。届时:' + )} + + + + {t('account_info:account_cancellation_confirm_owned_team_prefix', '该账号下')} + + {t( + 'account_info:account_cancellation_confirm_owned_team_impact', + '创建的团队将被删除' + )} + + + + {t( + 'account_info:account_cancellation_confirm_team_data_impact', + '团队内的应用、数据、成员、配置等信息将被删除,团队成员无法进入团队' + )} + + + {t( + 'account_info:account_cancellation_confirm_personal_data_impact', + '该账号的个人信息将被删除或匿名化处理' + )} + + + {t( + 'account_info:account_cancellation_confirm_leave_team_impact', + '该账号加入的其他团队将自动退出' + )} + + + + +
+ + + + {t( + 'account_info:account_cancellation_confirm_before_continue', + '在继续前,请确认你已处理好以下事项:' + )} + + + + {t( + 'account_info:account_cancellation_confirm_team_transfer', + '已完成团队归属转移或团队数据处理' + )} + + + {t( + 'account_info:account_cancellation_confirm_order_refund', + '已处理未完成订单、退款等事项' + )} + + + {t( + 'account_info:account_cancellation_confirm_backup', + '已备份重要数据、配置和业务资料' + )} + + + {t( + 'account_info:account_cancellation_confirm_service_stop', + '已确认相关服务停用不会影响线上业务' + )} + + + + +
+ + + + {t( + 'account_info:account_cancellation_confirm_verification_effect', + '完成身份验证后,注销申请将正式生效。' + )} + + + {t( + 'account_info:account_cancellation_confirm_cancel_during_wait', + '在 15 天等待期内,你可以重新登录账号并取消注销。' + )} + + + {t( + 'account_info:account_cancellation_confirm_reregister', + '账号注销完成后,如果你再次使用该账号注册,将会创建一个全新的账号,原账号数据无法恢复。' + )} + + +
+
+ ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx b/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx new file mode 100644 index 000000000000..97530975add1 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/AccountCancellationPageLayout.tsx @@ -0,0 +1,97 @@ +import { Box, Button, Flex, type FlexProps } from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'next-i18next'; + +/** 注销流程独立页骨架,复用登录页背景但不渲染账号导航和语言切换。 */ +export const AccountCancellationPageLayout = ({ + children, + showBack = false, + onBack, + cardProps +}: { + children: React.ReactNode; + showBack?: boolean; + onBack?: () => void; + cardProps?: FlexProps; +}) => { + const { t } = useTranslation(); + + return ( + + {showBack && ( + + )} + + + + + + {children} + + + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx new file mode 100644 index 000000000000..6844ce10288d --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/CancelAccountPage.tsx @@ -0,0 +1,120 @@ +import { Spinner } from '@chakra-ui/react'; +import { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; +import type { AccountCancellationStatusResponse } from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { + cancelAccountCancellation, + getAccountCancellationStatus +} from '@/web/support/user/account/cancellation/api'; +import { AccountCancellationPageLayout } from './AccountCancellationPageLayout'; +import { CancelPendingPanel } from './CancelPendingPanel'; +import { MemberPendingPanel } from './MemberPendingPanel'; +import { VerificationPanel } from './VerificationPanel'; + +const CancelAccountPage = () => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { userInfo, setUserInfo } = useUserStore(); + const [status, setStatus] = useState(); + const [loading, setLoading] = useState(true); + const [canceling, setCanceling] = useState(false); + + useEffect(() => { + void getAccountCancellationStatus() + .then(setStatus) + .catch(() => router.replace('/account/info')) + .finally(() => setLoading(false)); + }, [router]); + + const memberCancellation = userInfo?.team?.accountCancellation; + const isMemberView = status?.status === 'none' && !!memberCancellation; + const isVerificationView = + status?.status === 'none' && + status.canRequestCancellation && + router.query.confirmed === '1' && + !memberCancellation; + + useEffect(() => { + if (loading || !router.isReady || !status) return; + if (status.status === 'pending' || isMemberView || isVerificationView) return; + void router.replace('/account/info'); + }, [isMemberView, isVerificationView, loading, router, status]); + + const onSubmitted = useCallback(() => { + toast({ + status: 'success', + title: t('account_info:account_cancellation_submit_success', '注销提交成功') + }); + setUserInfo(null); + void router.replace('/login?lastRoute=/account/cancel'); + }, [router, setUserInfo, t, toast]); + + const onCancel = async () => { + setCanceling(true); + try { + await cancelAccountCancellation(); + toast({ + status: 'success', + title: t('account_info:account_cancellation_cancel_success', '已取消注销') + }); + await router.replace('/account/info'); + } catch { + toast({ + status: 'warning', + title: t('account_info:account_cancellation_cancel_error', '取消失败') + }); + } finally { + setCanceling(false); + } + }; + + const content = (() => { + if (loading || !status) { + return ; + } + if (isMemberView && memberCancellation) { + return ( + + ); + } + if (status.status === 'pending') { + return ( + void onCancel()} + loading={canceling} + /> + ); + } + if (isVerificationView) { + return ; + } + return ; + })(); + + return ( + void router.replace('/account/info')} + cardProps={ + loading || !status + ? { minH: '220px', alignItems: 'center', justifyContent: 'center' } + : undefined + } + > + {content} + + ); +}; + +export default CancelAccountPage; diff --git a/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx b/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx new file mode 100644 index 000000000000..7864466d4b7a --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/CancelPendingPanel.tsx @@ -0,0 +1,89 @@ +import { Button, Text, VStack } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; + +/** 展示本人注销等待期或 finalizing 状态,所有时间均直接使用 API 返回值。 */ +export const CancelPendingPanel = ({ + requestedAt, + scheduledCancelAt, + canCancel, + onCancel, + loading +}: { + requestedAt: string; + scheduledCancelAt?: string; + canCancel: boolean; + onCancel: () => void; + loading: boolean; +}) => { + const { t } = useTranslation(); + const formatDate = (value: string) => new Date(value).toLocaleString(); + + return ( + + + {t('account_info:account_cancellation_in_progress_title', '注销中')} + + + {canCancel ? ( + <> + + {t( + 'account_info:account_cancellation_pending_desc', + '你的账号已提交注销申请,目前处于 15 天注销等待期。' + )} + + + {t('account_info:account_cancellation_requested_at', '申请时间:{{time}}', { + time: formatDate(requestedAt) + })} + + {scheduledCancelAt && ( + + {t('account_info:account_cancellation_scheduled_at', '预计注销时间:{{time}}', { + time: formatDate(scheduledCancelAt) + })} + + )} + + {t( + 'account_info:account_cancellation_pending_service_desc', + '等待期内,该账号将无法正常使用,所有依赖该账号对外提供服务的渠道已停止生效。' + )} + + + {t( + 'account_info:account_cancellation_pending_cancel_desc', + '若这不是你本人操作,或你希望继续使用该账号,请在预计注销时间前取消注销。取消后,账号将恢复正常状态。' + )} + + + ) : ( + <> + + {t( + 'account_info:account_cancellation_finalizing_desc', + '你的账号已进入注销处理阶段,系统正在清理账号及相关数据。' + )} + + + {t('account_info:account_cancellation_requested_at', '申请时间:{{time}}', { + time: formatDate(requestedAt) + })} + + + {t( + 'account_info:account_cancellation_finalizing_no_estimate', + '该阶段无法取消注销,预计完成时间不再展示。' + )} + + + )} + + {canCancel && ( + + )} + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx b/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx new file mode 100644 index 000000000000..6c56e89cb184 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/MemberPendingPanel.tsx @@ -0,0 +1,57 @@ +import { Box, Text, VStack } from '@chakra-ui/react'; +import type { TeamAccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/type'; +import { useTranslation } from 'next-i18next'; +import TeamSelector from '@/pageComponents/account/TeamSelector'; + +/** 当前团队仍处于 owner 注销生命周期时,向成员提供说明和团队切换入口。 */ +export const MemberPendingPanel = ({ + teamName, + status, + scheduledCancelAt +}: { + teamName: string; + status: TeamAccountCancellationStatus; + scheduledCancelAt?: Date | string; +}) => { + const { t } = useTranslation(); + const isPending = status === 'pending'; + const scheduledTime = + isPending && scheduledCancelAt ? new Date(scheduledCancelAt).toLocaleString() : undefined; + + return ( + + + {t('account_info:account_cancellation_team_title', '团队注销中')} + + + + + {teamName}{' '} + + {isPending + ? t( + 'account_info:account_cancellation_team_pending_desc', + '团队已由团队所有者提交注销申请,目前处于 15 天注销等待期。您可联系团队所有者取消注销。' + ) + : t( + 'account_info:account_cancellation_team_finalizing_desc', + '团队已进入注销清理阶段。您可联系团队所有者了解处理进度。' + )} + + {scheduledTime && ( + + {t('account_info:account_cancellation_team_scheduled_at', '预计清理时间:{{time}}', { + time: scheduledTime + })} + + )} + + + + {t('account_info:account_cancellation_switch_team', '切换团队')} + + + + + ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx new file mode 100644 index 000000000000..90c08b672bdd --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx @@ -0,0 +1,439 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Center, + Image, + Input, + InputGroup, + InputRightElement, + Spinner, + Text, + VStack, + useDisclosure +} from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; +import type { OAuthEnum } from '@fastgpt/global/support/user/constant'; +import type { + AccountCancellationVerificationMethod, + AccountCancellationOAuthProvider +} from '@fastgpt/global/support/user/account/cancellation/type'; +import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation'; +import type { FastGPTFeConfigsType } from '@fastgpt/global/common/system/types'; +import type { + CreateAccountCancellationVerificationResponse, + SubmitAccountCancellationResponse +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import SendCodeAuthModal from '@/components/support/user/safe/SendCodeAuthModal'; +import { + createAccountCancellationVerification, + submitAccountCancellation +} from '@/web/support/user/account/cancellation/api'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { isAccountCancellationCodeError, isAccountCancellationRateLimitError } from './utils'; + +const getCapabilities = (feConfigs: FastGPTFeConfigsType) => ({ + ...(feConfigs.accountVerification?.accountCancellation ?? { + emailCode: feConfigs.login_method?.includes('email') ?? false, + phoneCode: feConfigs.login_method?.includes('phone') ?? false, + accountCancellation: feConfigs.accountCancellation?.enabled === true, + wechat: !!feConfigs.oauth?.wechat, + oauth: { + github: !!feConfigs.oauth?.github, + google: !!feConfigs.oauth?.google, + microsoft: !!feConfigs.oauth?.microsoft, + wecom: !!feConfigs.oauth?.wecom, + sso: !!feConfigs.sso?.url + } + }) +}); + +const isOAuthMethod = ( + method: AccountCancellationVerificationMethod +): method is Extract => + method.startsWith('oauth/'); + +/** 按统一 resolver 只渲染一种非密码验证方式,并承接各方式的加载与失败状态。 */ +export const VerificationPanel = ({ + onSubmitted +}: { + onSubmitted: (result: Extract) => void; +}) => { + const { t } = useTranslation(); + const router = useRouter(); + const { toast } = useToast(); + const { feConfigs } = useSystemStore(); + const { userInfo } = useUserStore(); + const { isOpen: isCaptchaOpen, onOpen: onOpenCaptcha, onClose: onCloseCaptcha } = useDisclosure(); + const [code, setCode] = useState(''); + const [codeCountDown, setCodeCountDown] = useState(0); + const [hasSentCode, setHasSentCode] = useState(false); + const [codeSending, setCodeSending] = useState(false); + const [codeSubmitting, setCodeSubmitting] = useState(false); + const [wechatQR, setWechatQR] = + useState>(); + const [wechatNow, setWechatNow] = useState(() => Date.now()); + const [wechatCreating, setWechatCreating] = useState(false); + const [wechatLoadFailed, setWechatLoadFailed] = useState(false); + const [oauthSubmitting, setOauthSubmitting] = useState(false); + const wechatCreateRequested = useRef(false); + const wechatPolling = useRef(false); + + const username = userInfo?.username; + const method = useMemo(() => { + if (!username) return; + const result = resolveAccountCancellationByUsername({ + username, + capabilities: getCapabilities(feConfigs) + }); + return result.status === 'supported' ? result.method : undefined; + }, [feConfigs, username]); + + const wechatExpired = + !!wechatQR?.expiredAt && new Date(wechatQR.expiredAt).getTime() <= wechatNow; + + useEffect(() => { + if (codeCountDown <= 0) return; + const timer = window.setTimeout(() => setCodeCountDown(codeCountDown - 1), 1000); + return () => window.clearTimeout(timer); + }, [codeCountDown]); + + useEffect(() => { + if (!wechatQR?.expiredAt) return; + const timer = window.setInterval(() => setWechatNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [wechatQR?.expiredAt]); + + const showVerificationFailure = useCallback( + (error?: unknown) => { + toast({ + status: 'error', + title: isAccountCancellationCodeError(error) + ? t('common:error.code_error') + : isAccountCancellationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + }); + }, + [t, toast] + ); + + const createWechatVerification = useCallback(async () => { + if (method !== 'wechat') return; + setWechatCreating(true); + setWechatLoadFailed(false); + try { + const result = await createAccountCancellationVerification({ method, payload: {} }); + if (result.method !== 'wechat') return; + setWechatQR(result); + setWechatNow(Date.now()); + } catch (error) { + setWechatLoadFailed(true); + showVerificationFailure(error); + } finally { + setWechatCreating(false); + } + }, [method, showVerificationFailure]); + + useEffect(() => { + if (method !== 'wechat' || wechatCreateRequested.current) return; + wechatCreateRequested.current = true; + void createWechatVerification(); + }, [createWechatVerification, method]); + + useEffect(() => { + if (!wechatQR || wechatExpired) return; + let disposed = false; + + const pollVerification = async () => { + if (wechatPolling.current) return; + wechatPolling.current = true; + try { + const result = await submitAccountCancellation({ + method: 'wechat', + payload: { code: wechatQR.code } + }); + if (!disposed && result.status === 'verificationExpired') { + setWechatQR(undefined); + await createWechatVerification(); + return; + } + if (!disposed && result.status === 'pending') { + onSubmitted(result); + } + } catch { + // 未扫码和 Provider 短暂异常都可能落入轮询失败,二维码有效期内继续等待。 + } finally { + wechatPolling.current = false; + } + }; + + void pollVerification(); + const timer = window.setInterval(() => void pollVerification(), 2000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [createWechatVerification, onSubmitted, wechatExpired, wechatQR]); + + const sendCode = async ({ captcha }: { username: string; captcha: string }) => { + if (method !== 'code') return; + setCodeSending(true); + try { + const result = await createAccountCancellationVerification({ + method, + payload: { captcha } + }); + if (result.method !== 'code') return; + setHasSentCode(true); + setCodeCountDown(60); + toast({ + status: 'success', + title: t('account_info:account_cancellation_code_sent', '验证码已发送') + }); + } catch (error) { + toast({ + status: 'error', + title: isAccountCancellationCodeError(error) + ? t('common:error.code_error') + : isAccountCancellationRateLimitError(error) + ? t('common:error.operation_too_frequently') + : t('account_info:account_cancellation_code_send_failed', '验证码发送失败,请重试') + }); + } finally { + setCodeSending(false); + } + }; + + const submitCode = async () => { + if (method !== 'code' || !code.trim()) return; + setCodeSubmitting(true); + try { + const result = await submitAccountCancellation({ method, payload: { code: code.trim() } }); + if (result.status !== 'pending') return; + onSubmitted(result); + } catch (error) { + showVerificationFailure(error); + } finally { + setCodeSubmitting(false); + } + }; + + const submitOAuth = async () => { + if (!method || !isOAuthMethod(method)) return; + setOauthSubmitting(true); + try { + const callbackUrl = `${window.location.origin}/login/provider`; + const result = await createAccountCancellationVerification({ + method, + payload: { callbackUrl } + }); + if (result.method !== method) return; + const provider = method.slice('oauth/'.length) as AccountCancellationOAuthProvider; + useSystemStore.getState().setLoginStore({ + provider: provider as OAuthEnum, + lastRoute: '/account/cancel?confirmed=1', + state: result.state, + callbackUrl, + flow: 'accountCancellation' + }); + await router.replace(result.url); + } catch { + setOauthSubmitting(false); + showVerificationFailure(); + } + }; + + if (!method || !username) { + return ( + + + {t('account_info:account_cancellation_title', '注销账号')} + + + {t( + 'account_info:account_cancellation_unavailable_desc', + '当前账号没有可用的非密码验证方式。' + )} + + + ); + } + + const oauthProvider = isOAuthMethod(method) + ? method.slice('oauth/'.length).toLowerCase() + : undefined; + const oauthProviderLabel = (() => { + if (oauthProvider === 'github') return 'GitHub'; + if (oauthProvider === 'google') return 'Google'; + if (oauthProvider === 'microsoft') return 'Microsoft'; + if (oauthProvider === 'wecom') return 'WeCom'; + if (oauthProvider === 'sso') return feConfigs.sso?.title ?? 'SSO'; + return ''; + })(); + + return ( + + + {t('account_info:account_cancellation_title', '注销账号')} + + + {method === 'code' && ( + + + + setCode(event.target.value)} + placeholder={t('user:password.verification_code', '验证码')} + aria-label={t('user:password.verification_code', '验证码')} + onKeyDown={(event) => { + if (event.key === 'Enter') void submitCode(); + }} + /> + + + + + + {isCaptchaOpen && ( + + )} + + )} + + {method === 'wechat' && ( + + + {t('account_info:account_cancellation_wechat_scan', '微信扫码登录')} + +
+ {wechatCreating ? ( + + ) : wechatQR && !wechatExpired ? ( + {t('account_info:account_cancellation_wechat_qr', + ) : ( + + + {wechatLoadFailed + ? t( + 'account_info:account_cancellation_wechat_load_failed', + '二维码加载失败,请重试。' + ) + : t( + 'account_info:account_cancellation_wechat_expired', + '二维码已过期,请重新获取。' + )} + + + + )} +
+
+ )} + + {isOAuthMethod(method) && ( + + + + + )} +
+ ); +}; diff --git a/projects/app/src/pageComponents/account/cancel/utils.ts b/projects/app/src/pageComponents/account/cancel/utils.ts new file mode 100644 index 000000000000..4e7cbc399808 --- /dev/null +++ b/projects/app/src/pageComponents/account/cancel/utils.ts @@ -0,0 +1,27 @@ +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils'; + +const accountCancellationRateLimitStatusTexts = new Set([ + UserErrEnum.sendVerificationCodeTooFrequently, + UserErrEnum.verifyCodeTooFrequently +]); +const legacyAccountCancellationRateLimitErrors = new Set([ + 'common:error.send_auth_code_too_frequently', + 'common:error.verify_code_too_frequently' +]); + +const verificationCodeError = 'common:error.code_error'; + +/** 统一识别注销验证码发送与校验阶段的频控错误。 */ +export const isAccountCancellationRateLimitError = (error: unknown) => { + const statusText = getErrResponse(error)?.statusText; + return ( + accountCancellationRateLimitStatusTexts.has(statusText) || + legacyAccountCancellationRateLimitErrors.has(getErrText(error)) + ); +}; + +/** 识别图片验证码错误,避免发送阶段统一降级为“验证码发送失败”。 */ +export const isAccountCancellationCodeError = (error: unknown) => + getErrResponse(error)?.statusText === UserErrEnum.invalidVerificationCode || + getErrText(error) === verificationCodeError; diff --git a/projects/app/src/pages/account/cancel/index.tsx b/projects/app/src/pages/account/cancel/index.tsx new file mode 100644 index 000000000000..74580acbb72f --- /dev/null +++ b/projects/app/src/pages/account/cancel/index.tsx @@ -0,0 +1,16 @@ +import dynamic from 'next/dynamic'; +import { serviceSideProps } from '@/web/common/i18n/utils'; + +const CancelAccountPage = dynamic( + () => import('@/pageComponents/account/cancel/CancelAccountPage') +); + +export async function getServerSideProps(context: any) { + return { + props: { + ...(await serviceSideProps(context, ['account', 'account_info', 'user'])) + } + }; +} + +export default CancelAccountPage; diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 248860d2a201..c624e19aee0d 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -48,6 +48,8 @@ import { getUploadAvatarPresignedUrl } from '@/web/common/file/api'; import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { getIsMemberSyncMode } from '@/web/common/system/utils'; +import { getAccountCancellationStatus } from '@/web/support/user/account/cancellation/api'; +import { AccountCancellationConfirmModal } from '@/pageComponents/account/cancel/AccountCancellationConfirmModal'; const RedeemCouponModal = dynamic(() => import('@/pageComponents/account/info/RedeemCouponModal'), { ssr: false @@ -783,22 +785,33 @@ const PlanUsage = () => { const ButtonStyles = { bg: 'white', - py: 3, px: 6, - border: 'sm', + h: '40px', borderWidth: '1.5px', + borderColor: 'borderColor.low', borderRadius: 'md', display: 'flex', alignItems: 'center', + gap: 2, cursor: 'pointer', userSelect: 'none' as any, fontSize: 'sm' }; const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { const { feConfigs, setNotSufficientModalType, subPlans } = useSystemStore(); - const { teamPlanStatus } = useUserStore(); + const { teamPlanStatus, userInfo } = useUserStore(); const { t } = useTranslation(); const { isPc } = useSystem(); + const router = useRouter(); + const { + isOpen: isCancellationConfirmOpen, + onOpen: onOpenCancellationConfirm, + onClose: onCloseCancellationConfirm + } = useDisclosure(); + const { data: accountCancellationStatus } = useRequest(getAccountCancellationStatus, { + manual: false, + refreshDeps: [userInfo?._id] + }); const { runAsync: onFeedback } = useRequest( async () => { @@ -826,7 +839,7 @@ const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { return ( - + {feConfigs?.docUrl && ( void }) => { textDecoration={'none !important'} {...ButtonStyles} > - - - {t('account_info:help_document')} - + + {t('account_info:help_document')} )} @@ -846,29 +863,71 @@ const Other = ({ onOpenContact }: { onOpenContact: () => void }) => { ?.filter((item) => item.isActive) .map((item) => ( window.open(item.url, '_blank')}> - - - {item.name} - + + {item.name} ))} {feConfigs?.concatMd && ( - - - {t('account_info:contact_us')} - + + {t('account_info:contact_us')} )} {feConfigs?.show_workorder && ( - - - {t('common:question_feedback')} - + + {t('common:question_feedback')} + + )} + {(accountCancellationStatus?.status === 'pending' || + (accountCancellationStatus?.status === 'none' && + accountCancellationStatus.canRequestCancellation)) && ( + { + if (accountCancellationStatus.status === 'pending') { + void router.push('/account/cancel'); + return; + } + onOpenCancellationConfirm(); + }} + > + + {t('account_info:account_cancellation', '账号注销')} )} + {accountCancellationStatus?.status === 'none' && + accountCancellationStatus.canRequestCancellation && ( + { + onCloseCancellationConfirm(); + void router.push('/account/cancel?confirmed=1'); + }} + /> + )} ); }; 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 b23007d13154..64fcb54caba5 100644 --- a/projects/app/src/pages/api/support/user/account/loginByPassword.ts +++ b/projects/app/src/pages/api/support/user/account/loginByPassword.ts @@ -20,6 +20,7 @@ import { reportCRMVisitorIdentity, resolveCRMVisitorId } from '@fastgpt/service/support/marketing/attribution'; +import { assertUserCanLogin } from '@fastgpt/service/support/user/account/cancellation/guard'; async function handler( req: ApiRequestProps, @@ -47,11 +48,14 @@ async function handler( return Promise.reject(new UserError('Wecom user can not login with password')); } + await assertUserCanLogin(String(user._id)); + const userDetail = await getUserDetail({ tmbId: user?.lastLoginTmbId, userId: user._id, isRoot: username === 'root', - session + session, + allowAccountCancellationTeamFallback: true }); user.lastLoginTmbId = userDetail.team.tmbId; diff --git a/projects/app/src/pages/api/support/user/account/tokenLogin.ts b/projects/app/src/pages/api/support/user/account/tokenLogin.ts index 993b6fe118bb..74039db56bf8 100644 --- a/projects/app/src/pages/api/support/user/account/tokenLogin.ts +++ b/projects/app/src/pages/api/support/user/account/tokenLogin.ts @@ -6,7 +6,11 @@ import { pushTrack } from '@fastgpt/service/common/middle/tracks/utils'; import type { UserType } from '@fastgpt/global/support/user/type'; async function handler(req: ApiRequestProps): Promise { - const { tmbId, userId, teamId, isRoot } = await authCert({ req, authToken: true }); + const { tmbId, userId, teamId, isRoot } = await authCert({ + req, + authToken: true, + accountCancellationAccess: 'tokenLogin' + }); const user = await getUserDetail({ tmbId, isRoot }); pushTrack.dailyUserActive({ diff --git a/projects/app/src/pages/api/support/user/team/plan/getTeamPlanStatus.ts b/projects/app/src/pages/api/support/user/team/plan/getTeamPlanStatus.ts index 3ca177e64143..bc42f960272f 100644 --- a/projects/app/src/pages/api/support/user/team/plan/getTeamPlanStatus.ts +++ b/projects/app/src/pages/api/support/user/team/plan/getTeamPlanStatus.ts @@ -28,7 +28,8 @@ async function handler( try { const { teamId } = await authCert({ req, - authToken: true + authToken: true, + accountCancellationAccess: 'tokenLogin' }); const [ diff --git a/projects/app/src/pages/login/provider.tsx b/projects/app/src/pages/login/provider.tsx index 9e8197175a06..3839c6ba550f 100644 --- a/projects/app/src/pages/login/provider.tsx +++ b/projects/app/src/pages/login/provider.tsx @@ -4,6 +4,7 @@ import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useUserStore } from '@/web/support/user/useUserStore'; import { clearToken } from '@/web/support/user/auth'; import { oauthLogin } from '@/web/support/user/api'; +import { submitAccountCancellation } from '@/web/support/user/account/cancellation/api'; import { useToast } from '@fastgpt/web/hooks/useToast'; import Loading from '@fastgpt/web/components/common/MyLoading'; import { serviceSideProps } from '@/web/common/i18n/utils'; @@ -39,7 +40,12 @@ const provider = () => { ? validateRedirectUrl(loginStore.lastRoute) : '/dashboard/agent'; const lastTmbId = loginStore?.lastTmbId || ''; - const errorRedirectPage = lastRoute.startsWith('/chat') ? lastRoute : '/login'; + const errorRedirectPage = + loginStore?.flow === 'accountCancellation' + ? '/account/cancel?confirmed=1' + : lastRoute.startsWith('/chat') + ? lastRoute + : '/login'; const loginSuccess = useCallback( async (res: LoginSuccessResponseType) => { @@ -82,6 +88,32 @@ const provider = () => { const authProps = useCallback( async (props: Record) => { try { + if (loginStore?.flow === 'accountCancellation') { + if (!props.code || !loginStore.callbackUrl) { + throw new Error('OAuth cancellation callback is incomplete'); + } + const result = await submitAccountCancellation({ + method: `oauth/${loginStore.provider}` as any, + payload: { + callbackUrl: loginStore.callbackUrl, + code: props.code, + ...(state ? { state } : {}), + props + } + }); + if (result.status !== 'pending') { + throw new Error('Account cancellation verification is still pending'); + } + toast({ + status: 'success', + title: t('account_info:account_cancellation_submit_success', '注销提交成功') + }); + setUserInfo(null); + setLoginStore(undefined); + await router.replace('/login?lastRoute=/account/cancel'); + return; + } + const res = await oauthLogin({ type: loginStore?.provider || OAuthEnum.sso, props, @@ -106,8 +138,11 @@ const provider = () => { await onFastGPTLoginSuccess(loginSuccess, res); } catch (error) { toast({ - status: 'warning', - title: getErrText(error, t('common:support.user.login.error')) + status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + title: + loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : getErrText(error, t('common:support.user.login.error')) }); setTimeout(() => { router.replace(errorRedirectPage); @@ -118,10 +153,12 @@ const provider = () => { [ errorRedirectPage, i18n.language, - loginStore?.provider, + loginStore, loginSuccess, router, setLoginStore, + setUserInfo, + state, t, toast ] @@ -130,8 +167,11 @@ const provider = () => { useEffect(() => { if (error) { toast({ - status: 'warning', - title: t('common:support.user.login.Provider error') + status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + title: + loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : t('common:support.user.login.Provider error') }); router.replace(errorRedirectPage); return; @@ -144,12 +184,17 @@ const provider = () => { isOauthLogging = true; (async () => { - await retryFn(async () => clearToken()); + if (loginStore?.flow !== 'accountCancellation') { + await retryFn(async () => clearToken()); + } router.prefetch('/dashboard/agent'); if (loginStore && loginStore.provider !== 'sso' && state !== loginStore.state) { toast({ - status: 'warning', - title: t('common:support.user.login.security_failed') + status: loginStore?.flow === 'accountCancellation' ? 'error' : 'warning', + title: + loginStore?.flow === 'accountCancellation' + ? t('account_info:account_cancellation_verification_failed', '身份验证失败,请重试') + : t('common:support.user.login.security_failed') }); setTimeout(() => { router.replace(errorRedirectPage); @@ -169,7 +214,7 @@ export default provider; export async function getServerSideProps(context: any) { return { props: { - ...(await serviceSideProps(context, ['login'])) + ...(await serviceSideProps(context, ['login', 'account_info'])) } }; } diff --git a/projects/app/src/service/support/mcp/utils.ts b/projects/app/src/service/support/mcp/utils.ts index 613714fae597..c1bcd6f8a462 100644 --- a/projects/app/src/service/support/mcp/utils.ts +++ b/projects/app/src/service/support/mcp/utils.ts @@ -42,6 +42,18 @@ import { preChatRound } from '@fastgpt/service/core/chat/utils/prepare'; import { UsageSourceEnum } from '@fastgpt/global/support/wallet/usage/constants'; import { removeDatasetCiteText } from '@fastgpt/global/core/ai/llm/utils'; import { getRuntimeNodeResponseSummary } from '@fastgpt/service/core/workflow/dispatch/utils'; +import { assertAccountUsable } from '@fastgpt/service/support/user/account/cancellation/guard'; +import { resolveAuthContext } from '@fastgpt/service/support/permission/auth/context'; + +const assertMcpTeamUsable = async (mcp: { teamId?: string; tmbId?: string }) => { + if (!mcp.teamId || !mcp.tmbId) return; + const authContext = await resolveAuthContext({ + teamId: mcp.teamId, + tmbId: mcp.tmbId + }); + if (!authContext) throw new Error('MCP team member is no longer active'); + await assertAccountUsable({ authContext }); +}; const stringifyMcpPluginOutput = (pluginOutput: unknown) => { if (pluginOutput === undefined || pluginOutput === null) { @@ -133,10 +145,11 @@ export const workflow2InputSchema = (chatConfig?: { * 不再因为创建人的应用权限后续变化而隐藏工具,避免已发布集成被普通权限调整意外中断。 */ export const getMcpServerTools = async (key: string): Promise => { - const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean(); + const mcp = await MongoMcpKey.findOne({ key }, { apps: 1, teamId: 1, tmbId: 1 }).lean(); if (!mcp) { return Promise.reject(CommonErrEnum.invalidResource); } + await assertMcpTeamUsable(mcp); // Get app list const appList = await MongoApp.find( @@ -350,11 +363,12 @@ export const callMcpServerTool = async ({ key, toolName, inputs }: toolCallProps } }; - const mcp = await MongoMcpKey.findOne({ key }, { apps: 1 }).lean(); + const mcp = await MongoMcpKey.findOne({ key }, { apps: 1, teamId: 1, tmbId: 1 }).lean(); if (!mcp) { return Promise.reject(CommonErrEnum.invalidResource); } + await assertMcpTeamUsable(mcp); // Get app list const appList = await MongoApp.find({ diff --git a/projects/app/src/web/common/system/useSystemStore.ts b/projects/app/src/web/common/system/useSystemStore.ts index c6b12156c507..6376f7b017a4 100644 --- a/projects/app/src/web/common/system/useSystemStore.ts +++ b/projects/app/src/web/common/system/useSystemStore.ts @@ -22,7 +22,14 @@ import { } from '@fastgpt/global/core/ai/provider'; import { getMyModels, getOperationalAd } from './api'; -type LoginStoreType = { provider: OAuthEnum; lastRoute: string; state: string; lastTmbId?: string }; +type LoginStoreType = { + provider: OAuthEnum; + lastRoute: string; + state: string; + lastTmbId?: string; + callbackUrl?: string; + flow?: 'login' | 'accountCancellation'; +}; export type NotSufficientModalType = | TeamErrEnum.datasetSizeNotEnough diff --git a/projects/app/src/web/styles/default.scss b/projects/app/src/web/styles/default.scss index dc6e10ffb557..fab912ef7629 100644 --- a/projects/app/src/web/styles/default.scss +++ b/projects/app/src/web/styles/default.scss @@ -21,9 +21,6 @@ overflow: hidden; text-overflow: ellipsis; } -.grecaptcha-badge { - display: none !important; -} .textlg { background: linear-gradient(to bottom right, #1237b3 0%, #3370ff 40%, #4e83fd 80%, #85b1ff 100%); -webkit-background-clip: text; diff --git a/projects/app/src/web/support/user/account/cancellation/api.ts b/projects/app/src/web/support/user/account/cancellation/api.ts new file mode 100644 index 000000000000..554b811a320f --- /dev/null +++ b/projects/app/src/web/support/user/account/cancellation/api.ts @@ -0,0 +1,29 @@ +import { DELETE, GET, POST } from '@/web/common/api/request'; +import type { + AccountCancellationStatusResponse, + CreateAccountCancellationVerificationBody, + CreateAccountCancellationVerificationResponse, + SubmitAccountCancellationBody, + SubmitAccountCancellationResponse +} from '@fastgpt/global/openapi/support/user/account/cancellation/api'; + +export const getAccountCancellationStatus = () => + GET( + '/proApi/support/user/account/cancellation/status', + {}, + { maxQuantity: 1 } + ); + +export const createAccountCancellationVerification = ( + body: CreateAccountCancellationVerificationBody +) => + POST( + '/proApi/support/user/account/cancellation/verification/create', + body + ); + +export const submitAccountCancellation = (body: SubmitAccountCancellationBody) => + POST('/proApi/support/user/account/cancellation/submit', body); + +export const cancelAccountCancellation = () => + DELETE('/proApi/support/user/account/cancellation/cancel'); diff --git a/projects/app/src/web/support/user/hooks/useSendCode.tsx b/projects/app/src/web/support/user/hooks/useSendCode.tsx index 2480b7ef85a7..c11e07758e08 100644 --- a/projects/app/src/web/support/user/hooks/useSendCode.tsx +++ b/projects/app/src/web/support/user/hooks/useSendCode.tsx @@ -1,9 +1,6 @@ import { useState, useMemo } from 'react'; import { sendAuthCode } from '@/web/support/user/api'; -import type { - VerificationCodePurposeForType, - VerificationCodeType -} from '@fastgpt/global/support/user/account/verification/type'; +import type { SendAuthCodeBodyType } from '@fastgpt/global/openapi/support/user/inform/api'; import { useTranslation } from 'next-i18next'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { Box, type BoxProps, useDisclosure } from '@chakra-ui/react'; @@ -13,13 +10,13 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; import type { LangEnum } from '@fastgpt/global/common/i18n/type'; let timer: NodeJS.Timeout; -type UseSendCodeParams = { - [T in VerificationCodeType]: { - type: T; - purpose: VerificationCodePurposeForType; - validateBeforeSend?: (username: string) => true | string; - }; -}[VerificationCodeType]; +type UseSendCodeParams = SendAuthCodeBodyType extends infer Body + ? Body extends { type: unknown; purpose: unknown } + ? Pick & { + validateBeforeSend?: (username: string) => true | string; + } + : never + : never; export const useSendCode = (params: UseSendCodeParams) => { const { t, i18n } = useTranslation(); 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 dfe76f8883bd..00bb645d587a 100644 --- a/projects/app/test/api/support/user/account/loginByPassword.test.ts +++ b/projects/app/test/api/support/user/account/loginByPassword.test.ts @@ -14,6 +14,8 @@ import type { LoginByPasswordBodyType } from '@fastgpt/global/openapi/support/us import { ApiRequestInputParseError } from '@fastgpt/service/common/zod/requestParseError'; import { Call } from '@test/utils/request'; import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; +import { MongoAccountCancellation } from '@fastgpt/service/support/user/account/cancellation/schema'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; const saveLoginCode = (username: string, code = '123456') => MongoTmpData.updateOne( @@ -175,6 +177,54 @@ describe('loginByPassword API', () => { expect(res.error).toBe('Invalid account!'); }); + it('should allow a pending cancellation user to recover a login session', async () => { + await MongoUser.findByIdAndUpdate(testUser._id, { $unset: { lastLoginTmbId: 1 } }); + await MongoAccountCancellation.create({ + userId: testUser._id, + status: AccountCancellationStatus.pending, + requestedAt: new Date() + }); + + const res = await Call, any>(loginApi.default, { + body: { + username: 'testuser', + password: 'testpassword', + code: '123456', + language: 'zh-CN' + } + }); + + expect(res.code).toBe(200); + expect(res.data.user.team.tmbId).toBe(String(testTmb._id)); + expect(res.data.token).toEqual(expect.any(String)); + }); + + it('should reject a finalizing user before profile updates and session creation', async () => { + await MongoAccountCancellation.create({ + userId: testUser._id, + status: AccountCancellationStatus.finalizing, + requestedAt: new Date() + }); + + const res = await Call, any>(loginApi.default, { + body: { + username: 'testuser', + password: 'testpassword', + code: '123456', + language: 'en' + } + }); + + expect(res.code).toBe(500); + expect(res.error).toEqual( + expect.objectContaining({ message: UserErrEnum.accountCancellationPending }) + ); + await expect(MongoUser.findById(testUser._id).lean()).resolves.not.toMatchObject({ + language: 'en' + }); + expect(setCookie).not.toHaveBeenCalled(); + }); + it('should reject login when password is incorrect', async () => { const res = await Call, any>(loginApi.default, { body: { diff --git a/projects/app/test/pageComponents/account/cancel/utils.test.ts b/projects/app/test/pageComponents/account/cancel/utils.test.ts new file mode 100644 index 000000000000..ab0406133b4f --- /dev/null +++ b/projects/app/test/pageComponents/account/cancel/utils.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { UserErrEnum } from '@fastgpt/global/common/error/code/user'; +import { + isAccountCancellationCodeError, + isAccountCancellationRateLimitError +} from '@/pageComponents/account/cancel/utils'; + +describe('isAccountCancellationRateLimitError', () => { + it.each([ + 'common:error.send_auth_code_too_frequently', + 'common:error.verify_code_too_frequently' + ])('recognizes %s', (message) => { + expect(isAccountCancellationRateLimitError(new Error(message))).toBe(true); + }); + + it('recognizes an error returned by the request client', () => { + expect( + isAccountCancellationRateLimitError({ + response: { data: { message: 'common:error.send_auth_code_too_frequently' } } + }) + ).toBe(true); + }); + + it.each([UserErrEnum.sendVerificationCodeTooFrequently, UserErrEnum.verifyCodeTooFrequently])( + 'recognizes stable statusText %s', + (statusText) => { + expect(isAccountCancellationRateLimitError({ statusText })).toBe(true); + } + ); + + it('does not classify unrelated verification failures as rate limits', () => { + expect(isAccountCancellationRateLimitError(new Error('common:error.code_error'))).toBe(false); + }); +}); + +describe('isAccountCancellationCodeError', () => { + it('recognizes a verification code error', () => { + expect(isAccountCancellationCodeError(new Error('common:error.code_error'))).toBe(true); + }); + + it('recognizes an error returned by the request client', () => { + expect( + isAccountCancellationCodeError({ + response: { data: { message: 'common:error.code_error' } } + }) + ).toBe(true); + }); + + it('recognizes the stable invalid verification code statusText', () => { + expect( + isAccountCancellationCodeError({ + statusText: UserErrEnum.invalidVerificationCode, + message: 'localized message may change' + }) + ).toBe(true); + }); + + it('does not classify other send failures as verification code errors', () => { + expect(isAccountCancellationCodeError(new Error('common:error.send_failed'))).toBe(false); + }); +}); diff --git a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts index 3528b2c6156f..d6eae1e5bbf4 100644 --- a/projects/app/test/pages/api/support/mcp/server/toolList.test.ts +++ b/projects/app/test/pages/api/support/mcp/server/toolList.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { callMcpServerTool, pluginNodes2InputSchema, @@ -17,6 +17,9 @@ import { dispatchWorkFlow } from '@fastgpt/service/core/workflow/dispatch'; import { failChatRound, finalizeChatRound } from '@fastgpt/service/core/chat/saveChat'; import { preChatRound } from '@fastgpt/service/core/chat/utils/prepare'; import { getRunningUserInfoByTmbId } from '@fastgpt/service/support/user/team/utils'; +import { resolveAuthContext } from '@fastgpt/service/support/permission/auth/context'; +import { assertAccountUsable } from '@fastgpt/service/support/user/account/cancellation/guard'; +import { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/constants'; vi.mock('@fastgpt/service/support/mcp/schema', () => ({ MongoMcpKey: { @@ -59,6 +62,14 @@ vi.mock('@fastgpt/service/support/user/team/utils', () => ({ getRunningUserInfoByTmbId: vi.fn() })); +vi.mock('@fastgpt/service/support/permission/auth/context', () => ({ + resolveAuthContext: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/account/cancellation/guard', () => ({ + assertAccountUsable: vi.fn() +})); + vi.mock('@fastgpt/service/core/workflow/dispatch', () => ({ dispatchWorkFlow: vi.fn() })); @@ -76,6 +87,17 @@ vi.mock('@fastgpt/service/core/chat/utils/prepare', () => ({ preChatRound: vi.fn() })); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveAuthContext).mockResolvedValue({ + userId: 'user-id', + teamId: 'team-id', + tmbId: 'tmb-id', + ownerId: 'owner-id' + }); + vi.mocked(assertAccountUsable).mockResolvedValue(undefined); +}); + describe('toolList', () => { describe('pluginNodes2InputSchema', () => { it('should generate input schema for plugin nodes', () => { @@ -178,6 +200,82 @@ describe('toolList', () => { }); describe('callMcpServerTool', () => { + it('rejects an MCP key whose member or team is no longer valid', async () => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ + teamId: 'team-id', + tmbId: 'tmb-id', + apps: [] + }) + } as any); + vi.mocked(resolveAuthContext).mockResolvedValue(null); + + await expect( + callMcpServerTool({ key: 'mcp-key', toolName: 'missing-tool', inputs: {} }) + ).rejects.toThrow('MCP team member is no longer active'); + expect(assertAccountUsable).not.toHaveBeenCalled(); + }); + + it('checks account usability with the resolved MCP auth context', async () => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ + teamId: 'team-id', + tmbId: 'tmb-id', + apps: [] + }) + } as any); + + await expect( + callMcpServerTool({ key: 'mcp-key', toolName: 'missing-tool', inputs: {} }) + ).rejects.toMatchObject({ message: expect.any(String) }); + expect(resolveAuthContext).toHaveBeenCalledWith({ + teamId: 'team-id', + tmbId: 'tmb-id' + }); + expect(assertAccountUsable).toHaveBeenCalledWith({ + authContext: expect.objectContaining({ userId: 'user-id', teamId: 'team-id' }) + }); + }); + + it.each([AccountCancellationStatus.pending, AccountCancellationStatus.finalizing])( + 'propagates %s account cancellation errors from usability checks', + async (status) => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ teamId: 'team-id', tmbId: 'tmb-id', apps: [] }) + } as any); + const error = new Error(`account cancellation ${status}`); + vi.mocked(assertAccountUsable).mockRejectedValue(error); + + await expect( + callMcpServerTool({ key: 'mcp-key', toolName: 'missing-tool', inputs: {} }) + ).rejects.toBe(error); + } + ); + + it('skips account usability checks when an MCP key has no team binding', async () => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ apps: [] }) + } as any); + + await expect( + callMcpServerTool({ key: 'mcp-key', toolName: 'missing-tool', inputs: {} }) + ).rejects.toMatchObject({ message: expect.any(String) }); + expect(resolveAuthContext).not.toHaveBeenCalled(); + expect(assertAccountUsable).not.toHaveBeenCalled(); + }); + + it('propagates resolveAuthContext failures unchanged', async () => { + vi.mocked(MongoMcpKey.findOne).mockReturnValue({ + lean: () => ({ teamId: 'team-id', tmbId: 'tmb-id', apps: [] }) + } as any); + const error = new Error('auth context failed'); + vi.mocked(resolveAuthContext).mockRejectedValue(error); + + await expect( + callMcpServerTool({ key: 'mcp-key', toolName: 'missing-tool', inputs: {} }) + ).rejects.toBe(error); + }); + it('returns workflowTool pluginOutput using the same value source as main', async () => { vi.mocked(MongoMcpKey.findOne).mockReturnValue({ lean: () => ({ diff --git a/projects/app/test/scripts/migration/authCodeToTmpData.test.ts b/projects/app/test/scripts/migration/authCodeToTmpData.test.ts index 381805855dbb..313637af80dd 100644 --- a/projects/app/test/scripts/migration/authCodeToTmpData.test.ts +++ b/projects/app/test/scripts/migration/authCodeToTmpData.test.ts @@ -81,7 +81,7 @@ describe('mapLegacyAuthCode', () => { ) ).toEqual({ kind: 'mapped', - records: ['register', 'forgetPassword', 'bindNotification'].map((scene) => ({ + records: ['register', 'forgetPassword', 'unsubscribe', 'bindNotification'].map((scene) => ({ dataId: `verification:v1:${scene}:captcha:account@example.com`, data: { code: 'abc123' }, expireAt