diff --git a/.agents/design/account-verification/account- verification.md b/.agents/design/account-verification/account- verification.md
new file mode 100644
index 000000000000..80ddf9a0027c
--- /dev/null
+++ b/.agents/design/account-verification/account- verification.md
@@ -0,0 +1,1697 @@
+# 账号身份验证组件技术设计
+
+状态:设计稿(统一身份验证接入并保持旧 SSO 兼容)
+日期:2026-07-17
+Mermaid 兼容基线:8.8.3
+关联需求:[requirements.md](./requirements.md)
+
+## 1. 结论
+
+本方案不建立新的认证协议,而是把现有的短期验证材料收拢为统一组件:
+
+```text
+create:创建验证材料
+consume:校验并消费验证材料,返回可信身份
+```
+
+验证组件到“可信身份”为止。创建 FastGPT 用户、加载团队、创建 Session、写 Cookie、埋点、审计以及注册/改密/绑定/注销等业务仍在组件之外。
+
+本设计采用以下默认决策:
+
+1. OAuth state 改由服务端生成并传给所有 Provider,仍复用 `auth_codes` 集合;直连 Provider 和返回 state 的 SSO 完整校验并一次性消费,只有旧 SSO 回调完全不返回 state 时允许 code-only 兼容。
+2. 材料消费必须显式检查过期时间并使用原子删除;TTL 只负责异步清理。
+3. `{ key, type }` 最终升级为唯一索引,保证同一验证场景只保留最新材料。
+4. 微信扫码、OAuth Provider 交换和验证码发送逻辑从 API 路由下沉到验证服务。
+5. `usernameLogin` 保留业务能力,但迁入专用登录应用服务,并只接收可信外部身份。
+6. 快速登录不实现验证类,按独立废弃计划移除。
+7. 前端展示与后端 create 分派共同使用 `resolveAccountVerificationByUsername`;后端在 create 时以持久化 username 和真实配置为最终依据,consume 沿用材料绑定。
+8. 本期范围是接入统一身份验证并保持旧 SSO 兼容,不修改 `pro/sso`,不引入兼容开关,也不解决旧 SSO 缺少 state 的登录 CSRF 和协议降级风险。
+9. 短信/邮件验证码的每次 `consume` 都在材料查询前按 Redis `account + scene` 累加固定窗口频控;同一键 1 分钟最多提交 10 次,第 11 次起返回“验证过于频繁,请稍后再试”。
+
+## 2. 设计原则与边界
+
+### 2.1 依赖方向
+
+```mermaid
+flowchart TB
+ subgraph API["API 边界"]
+ PasswordAPI["密码登录 API"]
+ CodeAPI["注册 / 改密 / 绑定 API"]
+ WechatAPI["微信 QR / callback / result API"]
+ OAuthAPI["OAuth create / consume API"]
+ end
+
+ subgraph Application["应用编排层"]
+ LocalLogin["loginLocalAccount"]
+ ExternalLogin["loginExternalAccount"]
+ AccountBusiness["注册 / 改密 / 绑定 / 注销"]
+ end
+
+ subgraph Verification["身份验证组件"]
+ Password["PasswordAccountVerification"]
+ Code["CodeAccountVerification"]
+ Wechat["WechatAccountVerification"]
+ OAuth["OAuthAccountVerification"]
+ end
+
+ Material["VerificationMaterial entity"]
+ Mongo[("MongoDB auth_codes")]
+ User[("MongoDB users / teams")]
+ Session[("Redis session")]
+
+ PasswordAPI --> Password --> LocalLogin
+ CodeAPI --> Code --> AccountBusiness
+ WechatAPI --> Wechat --> ExternalLogin
+ OAuthAPI --> OAuth --> ExternalLogin
+ Password --> Material
+ Code --> Material
+ Wechat --> Material
+ OAuth --> Material
+ Material --> Mongo
+ LocalLogin --> User
+ ExternalLogin --> User
+ LocalLogin --> Session
+ ExternalLogin --> Session
+ AccountBusiness --> User
+```
+
+依赖必须单向:
+
+- API 可以依赖验证组件和应用服务。
+- 应用服务可以依赖用户、团队、Session 服务,但不能反向被验证组件依赖。
+- 验证组件可以依赖短期材料实体和 Provider SDK/HTTP,不依赖 Cookie、埋点或具体账号业务。
+- `packages/global` 中的 username resolver 只依赖共享 schema、常量和纯函数,前端与服务端都可安全导入。
+- `packages/service` 不得导入 `pro/admin`;商业实现只能向下依赖共享抽象。
+
+### 2.2 与相邻概念的区别
+
+| 概念 | 本方案中的位置 |
+| --- | --- |
+| Account verification | 校验密码、验证码、微信扫码或 OAuth code,产出可信身份 |
+| User provisioning | 根据可信外部身份查找或创建 FastGPT 用户,由登录应用服务负责 |
+| Session authentication | 使用 Redis Session 校验后续请求,继续由 `authUserSession` / `parseHeaderCert` 负责 |
+| Authorization | 团队、资源、API key 权限,不属于本组件 |
+| Token login | 读取已有 Session,不是新的验证方式 |
+| Fast login | 外部自定义快速登录协议,明确不纳入组件 |
+
+## 3. 核心契约
+
+### 3.1 通用抽象
+
+```ts
+/**
+ * 统一账号验证方式的材料创建与消费模型。
+ * 泛型允许不同方式保留自己的协议,不要求返回相同材料或身份结构。
+ */
+export abstract class AccountVerification<
+ TCreateParams,
+ TCreateResult,
+ TConsumeParams,
+ TConsumeResult
+> {
+ abstract create(params: TCreateParams): Promise;
+ abstract consume(params: TConsumeParams): Promise;
+}
+```
+
+不增加统一 `verify()`、`login()` 或 Provider 大枚举分支。`create` / `consume` 是唯一公共模型,Provider 特有的回调写入可以作为实现类的附加方法。
+
+### 3.2 可信身份
+
+验证结果使用带判别字段的服务端内部类型,不直接返回 Mongoose Document:
+
+```ts
+type LocalAccountIdentity = {
+ kind: 'local';
+ userId: string;
+ username: string;
+ lastLoginTmbId?: string;
+ isRoot: boolean;
+};
+
+type VerifiedContactIdentity = {
+ kind: 'contact';
+ account: string;
+ scene: 'register' | 'findPassword' | 'bindNotification';
+};
+
+type ExternalAccountIdentity = {
+ kind: 'external';
+ provider: 'github' | 'google' | 'microsoft' | 'wecom' | 'sso' | 'wechat';
+ subject: string;
+ username: string;
+ avatar?: string;
+ notificationAccount?: string;
+ phonePrefix?: number;
+ teamName?: string;
+ memberName?: string;
+ organizationId?: string;
+};
+```
+
+约束:
+
+- `subject` 是 Provider 返回的稳定账号 ID;SSO 没有独立 subject 时退化为已验证 username。
+- `username` 映射必须保持当前兼容值:`git-*`、`google-*`、`microsoft-*`、`wecom-*`、`wechat-*`;SSO 继续使用其返回值。
+- 受“不新增身份绑定表”约束,实际用户查找仍使用兼容 username;`subject` 用于响应校验和后续演进,不能宣称已解决 Provider 账号改名问题。
+- `organizationId` 只描述已验证的外部组织,不在验证组件内查询 FastGPT 团队或创建用户。
+- 类型只在服务端使用,不放入前端 API schema。
+
+### 3.3 实例与工厂
+
+```mermaid
+classDiagram
+ class AccountVerification {
+ <>
+ +create(params)
+ +consume(params)
+ }
+ class PasswordAccountVerification
+ class CodeAccountVerification
+ class WechatAccountVerification {
+ +recordCallback(params)
+ }
+ class OAuthAccountVerification {
+ <>
+ #buildAuthorizationUrl(params)
+ #exchangeCode(params)
+ }
+ class GithubAccountVerification
+ class GoogleAccountVerification
+ class MicrosoftAccountVerification
+ class WecomAccountVerification
+ class SsoAccountVerification
+
+ AccountVerification <|-- PasswordAccountVerification
+ AccountVerification <|-- CodeAccountVerification
+ AccountVerification <|-- WechatAccountVerification
+ AccountVerification <|-- OAuthAccountVerification
+ OAuthAccountVerification <|-- GithubAccountVerification
+ OAuthAccountVerification <|-- GoogleAccountVerification
+ OAuthAccountVerification <|-- MicrosoftAccountVerification
+ OAuthAccountVerification <|-- WecomAccountVerification
+ OAuthAccountVerification <|-- SsoAccountVerification
+```
+
+OAuth 路由通过 `getOAuthAccountVerification(provider)` 取得实例。工厂必须穷举支持的五个 Provider;`wechat` 虽仍存在于当前 `OAuthEnum`,但只能走微信扫码实现,不能进入 OAuth 工厂。
+
+### 3.4 根据用户名推导验证方式
+
+#### 3.4.1 设计目标
+
+新增前后端共享纯函数:
+
+```ts
+resolveAccountVerificationByUsername(params): AccountVerificationResolution
+```
+
+它只负责把 username 分类,并结合部署能力自动选中唯一验证方式。只有没有可展示的验证码或 Provider 方式时才返回旧密码。它不读取数据库、全局配置或浏览器状态,也不创建验证材料。这样前端和后端使用的是同一套分支逻辑,但后端仍以自己的输入为准。
+
+共享数据结构使用 Zod schema 推导类型。验证方式采用图中约定的稳定字符串,避免对象比较、前后端序列化差异以及额外的 `isSameMethod`:
+
+```ts
+export const AccountVerificationMethodSchema = z.enum([
+ 'code',
+ 'oldPassword',
+ 'wechat',
+ 'oauth/github',
+ 'oauth/google',
+ 'oauth/microsoft',
+ 'oauth/wecom',
+ 'oauth/sso'
+]);
+export type AccountVerificationMethod = z.infer;
+
+export const AccountEmailUsernameSchema = z.email().max(254);
+export const AccountPhoneUsernameSchema = z.string().regex(/^1[3456789]\d{9}$/);
+
+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<
+ typeof AccountVerificationCapabilitiesSchema
+>;
+
+export const RecognizedAccountKindSchema = z.enum([
+ 'email',
+ 'phone',
+ 'local',
+ 'wechat',
+ 'github',
+ 'google',
+ 'microsoft',
+ 'wecom',
+ 'sso'
+]);
+export const AccountKindSchema = z.union([RecognizedAccountKindSchema, z.literal('invalid')]);
+
+export const AccountVerificationUnsupportedReasonSchema = z.literal('empty_username');
+
+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: AccountVerificationUnsupportedReasonSchema
+ })
+]);
+export type AccountVerificationResolution = z.infer<
+ typeof AccountVerificationResolutionSchema
+>;
+```
+
+`capabilities` 只描述部署实际具备哪些验证码或 Provider 能力,不能藏在 resolver 内读取全局变量。旧密码不是可关闭的业务策略:`users.password` 在当前模型中必填,因此没有其它可展示方式的非空 username 最终都能得到 `oldPassword`。
+
+返回值由 resolver 保证三条契约:非空 username 一定为 `status=supported`;支持结果只有一个 `method`;`oldPassword` 不会与验证码或 Provider 方式一起返回。只有空 username 返回 `unsupported/empty_username`。
+
+#### 3.4.2 推导函数
+
+```ts
+/**
+ * 根据 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'
+ };
+ }
+
+ /** 前缀必须完整匹配 `${prefix}-`,并且分隔符后至少有一个字符。 */
+ const hasPrefix = (prefix: string) =>
+ normalizedUsername.startsWith(`${prefix}-`) &&
+ normalizedUsername.length > prefix.length + 1;
+
+ // 客户 SSO 前缀不做枚举:第一个分隔符前后均非空即可。
+ const firstSeparatorIndex = normalizedUsername.indexOf('-');
+ const hasSsoPrefix =
+ firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1;
+
+ // 邮箱/手机号优先,避免 user-name@example.com 被通用连字符规则识别为 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 z.infer;
+
+ type ConfiguredAccountVerificationMethod = Exclude<
+ AccountVerificationMethod,
+ 'oldPassword'
+ >;
+
+ // 候选顺序只供 resolver 内部自动降级,不能作为可选列表返回给客户端。
+ const candidateMethods: readonly ConfiguredAccountVerificationMethod[] = (() => {
+ switch (accountKind) {
+ case 'email':
+ case 'phone':
+ return ['code'] as const;
+ case 'local':
+ return [];
+ case 'wechat':
+ return ['wechat'] as const;
+ case 'github':
+ return ['oauth/github'] as const;
+ case 'google':
+ return ['oauth/google'] as const;
+ case 'microsoft':
+ return ['oauth/microsoft'] as const;
+ case 'sso':
+ return ['oauth/sso'] as const;
+ case 'wecom':
+ return ['oauth/sso', 'oauth/wecom'] as const;
+ default: {
+ const exhaustiveAccountKind: never = accountKind;
+ return exhaustiveAccountKind;
+ }
+ }
+ })();
+
+ const isMethodAvailable = (method: ConfiguredAccountVerificationMethod) => {
+ 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;
+ }
+ }
+ };
+
+ const method = candidateMethods.find(isMethodAvailable) ?? 'oldPassword';
+ return {
+ status: 'supported',
+ accountKind,
+ method
+ };
+};
+```
+
+`AccountEmailUsernameSchema` 与 `AccountPhoneUsernameSchema` 同样放在 `type.ts`。邮箱统一使用本地 Zod 4 的 `z.email()` 并限制总长度,不再沿用服务端 `includes('@')` 或两个前端表单中重复的窄正则。邮箱 local-part 和域名标签都允许出现在合法位置的 `-`,例如 `user-name@example-domain.com`,因此邮箱判定必须先于通用 SSO 连字符判定。确切语法只由共享 schema 和 fixture 决定,resolver 不再解释邮箱字符规则。
+
+手机号沿用注册/找回密码表单当前的 `^1[3456789]\d{9}$` 规则;实现时同样把散落正则迁到共享 schema。resolver 只在去除首尾空白的副本上分类,不改写大小写,也不修改用于数据库查询、发送验证码或身份归属比较的原始 username。
+
+明确的直连 Provider 前缀必须按大小写精确匹配 `${prefix}-` 且后缀非空。其余账号只判断第一个 `-` 前后是否均非空,不枚举 SSO 客户前缀:
+
+| Username | `accountKind` | resolver 唯一返回结果 |
+| --- | --- | --- |
+| 邮箱 | `email` | 邮件能力可用时为 `code`,否则为 `oldPassword` |
+| 手机号 | `phone` | 短信能力可用时为 `code`,否则为 `oldPassword` |
+| 普通账号,或 SSO 未配置时的未知连字符账号 | `local` | `oldPassword` |
+| `wechat-*` | `wechat` | 微信能力可用时为 `wechat`,否则为 `oldPassword` |
+| `git-*` | `github` | GitHub 能力可用时为 `oauth/github`,否则为 `oldPassword` |
+| `google-*` | `google` | Google 能力可用时为 `oauth/google`,否则为 `oldPassword` |
+| `microsoft-*` | `microsoft` | Microsoft 能力可用时为 `oauth/microsoft`,否则为 `oldPassword` |
+| `wecom-*` | `wecom` | SSO 可用时为 `oauth/sso`,否则内部 Wecom 可用时为 `oauth/wecom`,再否则为 `oldPassword` |
+| 其它 `prefix-suffix` 且 SSO 已配置 | `sso` | `oauth/sso` |
+
+`accountKind=sso` 只表示“交给统一 SSO 中转”,不表示 Feishu、DingTalk 或任何具体 Provider。`RecognizedAccountKindSchema` 枚举的是 resolver 的有限输出类型,不是客户 SSO prefix 枚举。
+
+除 Wecom 外,明确的直连 Provider 绝不降级为 SSO。例如 `git-*` 在 GitHub OAuth 未配置时唯一得到 `method=oldPassword`,不能改走 SSO。Wecom 是唯一跨两条企业身份链路的特例,由 resolver 固定按 SSO、内部 Wecom、旧密码顺序自动选中第一个可用方式。只有“不属于明确直连 Provider”的连字符账号受 SSO capability 控制:SSO 可用时唯一返回 `oauth/sso`,不可用时按本地账号返回 `oldPassword`。
+
+启用 SSO 会占用所有未知 `prefix-suffix` 命名空间。上线前必须扫描已有本地连字符账号并迁移冲突;不通过 Admin 前缀白名单制造另一套长期规则。
+
+#### 3.4.3 能力输入
+
+resolver 不直接理解 `feConfigs` 或 `global.systemConfig`。前后端分别用适配函数生成同一个 `AccountVerificationCapabilities`,避免把环境读取混入纯函数:
+
+| 能力 | 前端公开配置来源 | 后端权威判断 |
+| --- | --- | --- |
+| `emailCode` / `phoneCode` | 当前业务 scene 对应的公开 method 列表 | 邮件/SMS 发送配置、scene 模板和业务开关均可用 |
+| `wechat` | `feConfigs.oauth.wechat` | 微信 `appID`、`appSecret` 等必填配置完整 |
+| `oauth.github` / `google` / `microsoft` | 对应 `feConfigs.oauth` 客户端配置存在 | 对应 client id 与 secret 等服务端配置完整 |
+| `oauth.wecom` | 服务端下发的可用布尔值,不由浏览器只看按钮配置推断 | 企业微信内部应用/套件配置完整;与 SSO 同时启用时还需确认 `wecom-*` 身份命名空间兼容 |
+| `oauth.sso` | `feConfigs.sso.url` | SSO URL、License 与服务端访问配置均有效 |
+
+账号注销等新增 scene 不能误用 `bind_notification_method` 猜测验证码能力,应在服务端配置归一化时增加该 scene 的公开 capability。SSO 只需要已有的可用性 capability,不新增 prefix 列表或 Admin 配置;client id、secret、短信密钥等敏感值绝不进入 resolver 输入。
+
+本地代码中 `pro/sso/src/provider/wecom.ts` 使用 `wecom-{userid}`,内部套件 `pro/admin/src/service/support/wecom/auth.ts` 使用 `wecom-{open_userid}`。两者并非天然相等。因此 `oauth.wecom=true` 在双入口场景必须表示“配置可用且部署已通过迁移或映射确认两条链路产出同一持久化 username”,不能只表示按钮已配置。未完成对齐时只开放账号原始来源对应的入口;即使配置误报为可用,consume 后的精确 username 校验仍必须拒绝不一致身份。
+
+```mermaid
+flowchart TD
+ U["username + capabilities"] --> E{"username 为空?"}
+ E -- "是" --> X0["unsupported / invalid / empty_username"]
+ E -- "否" --> F{"邮箱 -> 手机号 -> 明确直连 Provider"}
+ F -- "邮箱 / 手机号" --> P["生成首选候选 code"]
+ F -- "wechat-*" --> P2["生成首选候选 wechat"]
+ F -- "git/google/microsoft-*" --> P3["生成对应 OAuth 首选候选"]
+ F -- "wecom-*" --> P4["生成 SSO -> Wecom 首选候选"]
+ F -- "其它" --> SC{"SSO 已配置且第一个 - 前后非空?"}
+ SC -- "是" --> P5["生成首选候选 oauth/sso"]
+ SC -- "否" --> P6["无额外首选候选"]
+
+ P --> C["按 capabilities 查找第一个可用候选"]
+ P2 --> C
+ P3 --> C
+ P4 --> C
+ P5 --> C
+ P6 --> C
+ C --> D{"找到候选?"}
+ D -- "是" --> M["返回该唯一 method"]
+ D -- "否" --> O["唯一 method = oldPassword"]
+ M --> R["AccountVerificationResolution"]
+ O --> R
+
+ R --> FE["前端只渲染唯一入口"]
+ R --> BE["后端按唯一 method 分派 verification"]
+```
+
+#### 3.4.4 前端使用
+
+前端从当前用户信息取得 username,将公开的 `feConfigs` 转成 `AccountVerificationCapabilities`:
+
+```ts
+const resolution = resolveAccountVerificationByUsername({
+ username: userInfo.username,
+ capabilities: getAccountVerificationCapabilitiesFromFeConfig(feConfigs)
+});
+
+if (resolution.status === 'unsupported') {
+ return renderUnsupported(resolution.unsupportedReason);
+}
+
+switch (resolution.method) {
+ case 'code':
+ return renderCodeForm({ channel: resolution.accountKind });
+ case 'oldPassword':
+ return renderOldPasswordForm();
+ case 'wechat':
+ return renderWechatPanel();
+ default:
+ return renderOAuthButton(resolution.method);
+}
+```
+
+页面只渲染 `resolution.method` 对应的一个入口,不提供“使用旧密码”或其它验证方式切换入口。仅当 resolver 判断当前账号没有可展示的验证码或 Provider 方式时,页面才会得到并展示 `oldPassword`。前端结果只控制 UI,不作为后端授权依据。
+
+#### 3.4.5 后端使用
+
+后端只在敏感验证流程的 create 入口做一次权威推导。请求中的 method 是 Zod 判别字段,不是用户可选策略;服务端先通过 Session 得到 userId,再读取数据库中的 username 和真实 Provider 配置并精确比对:
+
+```ts
+const { body } = parseApiInput({
+ req,
+ bodySchema: SensitiveAccountVerificationCreateBodySchema
+});
+const { userId } = await parseHeaderCert({ req, authToken: true });
+const user = await MongoUser.findById(userId, { username: 1 }).lean();
+if (!user) {
+ throw new UserError('User not found');
+}
+
+const resolution = resolveAccountVerificationByUsername({
+ username: user.username,
+ capabilities: getAccountVerificationCapabilitiesFromServerConfig()
+});
+
+if (resolution.status === 'unsupported' || body.method !== resolution.method) {
+ throw new UserError('Verification method is not allowed for this account');
+}
+
+const result = await createAccountVerificationForUser({
+ method: resolution.method,
+ payload: body.payload,
+ user,
+ scene: 'accountCancellation'
+});
+```
+
+create 必须把 `method + userId + scene` 绑定到现有验证材料的 key/type/provider 命名空间。后续 action/consume 请求仍以 method 作为 payload 判别字段,但不再读取 capabilities 或调用 resolver,只校验请求 method 与材料绑定一致:
+
+```ts
+const { body } = parseApiInput({
+ req,
+ bodySchema: SensitiveAccountVerificationBodySchema
+});
+const { userId } = await parseHeaderCert({ req, authToken: true });
+const user = await MongoUser.findById(userId, { username: 1 }).lean();
+if (!user) {
+ throw new UserError('User not found');
+}
+const identity = await consumeAccountVerificationForUser({
+ verification: body,
+ user,
+ scene: 'accountCancellation'
+});
+await assertVerifiedIdentityMatchesUser({ user, identity });
+```
+
+敏感业务请求 schema 根本不包含 username,而不是“接收后忽略”。method 虽由客户端随当前唯一入口回传,但 create 会与服务端 resolver 结果精确比较,consume 会与服务端材料绑定精确比较,因此客户端不能借此选择其它方式。`consumeAccountVerificationForUser` 位于服务端应用层,按绑定后的 method 调用:
+
+| method | 后端调用 | 归属校验 |
+| --- | --- | --- |
+| `code` | `codeVerification.consume({ account: user.username, scene, code })` | `identity.account === user.username` |
+| `oldPassword` | `passwordVerification.consume({ username: user.username, password, code: preLoginCode })` | `identity.userId === user._id` |
+| `wechat` | `wechatVerification.consume({ code })` | `identity.username === user.username` |
+| `oauth/*` | `getOAuthAccountVerification(provider).consume(...)` | `identity.username === user.username` |
+
+所有比较都使用持久化原值,不使用 resolver 的小写副本。resolver 只确定 create 时的唯一方式,不替代材料绑定或身份归属校验。验证码只能发往当前账号,OAuth state 绑定当前 userId 与业务 scene。流程建立后即使 capability 配置变化也不切换 method;对应 Provider 在 create 或 consume 时不可用,直接按该实现的正常错误处理。
+
+## 4. 验证材料模型
+
+### 4.1 存储兼容
+
+继续使用集合 `auth_codes`,不新增表和 verification token。目标模型改名为 `MongoAccountVerificationMaterial`,但 collection name 保持不变。
+
+| 字段 | 用途 | 调整 |
+| --- | --- | --- |
+| `key` | 按场景构造的材料键 | 保留 |
+| `code` | 预登录、图片或短信/邮件验证码 | 保留六位限制 |
+| `openid` | 微信回调写入的 openid | 保留,仅微信使用 |
+| `type` | 验证场景 | 保留现有值,增加 `oauthLogin` |
+| `createTime` | 创建时间 | 每次 upsert 必须刷新 |
+| `expiredTime` | 业务过期时间 | 每次创建必须显式传入 |
+
+材料 key 规则:
+
+| 场景 | `type` | `key` | 典型有效期 |
+| --- | --- | --- | --- |
+| 密码预登录 | `login` | 原始 username | 30 秒 |
+| 图片验证码 | `captcha` | 原始 account | 5 分钟 |
+| 注册验证码 | `register` | 原始 account | 5 分钟 |
+| 找回密码 | `findPassword` | 原始 account | 5 分钟 |
+| 绑定联系方式 | `bindNotification` | 原始 account | 5 分钟 |
+| 微信扫码 | `wxLogin` | scene code | 与二维码 1 小时有效期一致 |
+| OAuth state | `oauthLogin` | `oauth:{purpose}:{subjectHash}:{provider}:{callbackHash}:{state}` | 10 分钟 |
+
+`callbackHash` 使用规范化 callback URL 的 SHA-256 摘要。登录场景的 `purpose=login`、`subjectHash=anonymous`;敏感业务使用固定 scene 作为 purpose,并用当前 Session userId 的 SHA-256 摘要作为 subjectHash。这样可在不增加字段的前提下把 state 绑定到用途、当前用户、Provider 和回调地址。
+
+敏感业务的 code、oldPassword 和 wechat 材料同样在现有 key 命名空间中绑定 `purpose + subjectHash + method`;OAuth 则由 key 中已有的 provider 段绑定具体 method。consume 使用请求 method 只查询同一 userId、scene 和 method 下的有效材料。该绑定不新增字段或 verification token,但可以让 consume 沿用 create 已确认的方式,而不必再次读取 capabilities。
+
+### 4.2 索引与过期
+
+目标索引:
+
+```ts
+schema.index({ key: 1, type: 1 }, { unique: true });
+schema.index({ expiredTime: 1 }, { expireAfterSeconds: 0 });
+```
+
+TTL 删除不是实时保证。所有读取和消费都必须包含:
+
+```ts
+expiredTime: { $gt: now }
+```
+
+升级唯一索引前必须先运行重复数据 dry-run 和清理,不能直接依赖启动时 `syncIndexes()`,否则历史重复记录会导致建索引失败。
+
+### 4.3 材料实体 API
+
+`entity.ts` 只负责数据库原子操作,并按代码规范接受可选 `session`:
+
+```ts
+createVerificationMaterial(data, session?)
+upsertVerificationMaterial(data, session?)
+findValidVerificationMaterial(query)
+consumeVerificationMaterial(query, session?)
+updateWechatMaterialIdentity(data, session?)
+deleteVerificationMaterialIfMatch(query, session?)
+```
+
+关键语义:
+
+- `upsert` 使用 `$set` 刷新材料和过期时间,重发即使旧材料失效。
+- OAuth state 和微信 scene 等随机键使用 `create`;发生极低概率碰撞时失败并重新生成,不能覆盖已有流程。
+- 普通消费使用单次 `findOneAndDelete`,不使用“先 find 再 delete”的事务。
+- 验证码大小写兼容必须对输入做转义后再进行锚定匹配,不能把用户输入直接拼成正则。
+- 微信回调只更新 `create()` 已建立、尚未过期的 scene 占位记录,不允许 callback 自行 upsert 任意 scene。
+- OAuth 在 Provider 交换成功后原子删除 state;删除不到记录时丢弃已取得的身份,不进入登录业务。
+- 清理发送失败的验证码时必须同时匹配本次生成的 code,避免删除稍后创建的新材料。
+
+### 4.4 生命周期
+
+```mermaid
+stateDiagram-v2
+ [*] --> Created: create / upsert
+ Created --> Ready: code、password、oauth state
+ Created --> Pending: 微信等待扫码
+ Pending --> Ready: callback 写入 openid
+ Created --> Expired: expiredTime 到期
+ Pending --> Expired: expiredTime 到期
+ Ready --> Expired: expiredTime 到期
+ Ready --> Consumed: 原子 findOneAndDelete
+ Consumed --> [*]
+ Expired --> Deleted: TTL 异步清理
+ Deleted --> [*]
+```
+
+数据库不新增 `status` 字段。`Pending`、`Ready` 等状态由记录是否存在、是否过期及微信 `openid` 是否存在推导。
+
+## 5. 各验证方式
+
+### 5.1 密码验证
+
+职责:
+
+- `create({ username })`:生成六位预登录 code,以 `login + username` upsert,30 秒后过期并返回 code。
+- `consume({ username, password, code })`:先消费预登录 code,再按 `username + password` 查询用户并检查禁用状态,返回 `LocalAccountIdentity`;不因账号来自第三方而拒绝正确密码。
+- 不加载团队,不修改语言或 `lastLoginTmbId`,不创建 Session。
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant PreAPI as preLogin API
+ participant Verify as PasswordVerification
+ participant Material as Material Entity
+ participant LoginAPI as loginByPassword API
+ participant Login as Local Login Service
+ participant Redis as Redis Session
+
+ Browser->>PreAPI: GET username
+ PreAPI->>Verify: create(username)
+ Verify->>Material: upsert(login, username, code, 30s)
+ Material-->>Verify: stored
+ Verify-->>Browser: code
+ Browser->>LoginAPI: username + password hash + code
+ LoginAPI->>Verify: consume(...)
+ Verify->>Material: findOneAndDelete(valid code)
+ Material-->>Verify: consumed
+ Verify->>Verify: 查询用户、密码、状态、账号类型
+ Verify-->>LoginAPI: LocalAccountIdentity
+ LoginAPI->>Login: loginLocalAccount(identity, language, ip)
+ Login->>Login: getUserDetail + 更新登录信息
+ Login->>Redis: createUserSession
+ Login-->>LoginAPI: user + token
+ LoginAPI-->>Browser: Set-Cookie + user + token
+```
+
+密码错误继续返回统一账号/密码错误,避免区分“用户不存在”和“密码错误”。当前 `loginByPassword.ts` 的 Wecom 密码登录禁令移到密码登录应用服务:登录 purpose 仍拒绝 Wecom,账号注销等敏感业务只调用密码验证组件,因此可以使用同一账号的旧密码兜底。IP 频率限制保留在 API 边界;`preLogin` 另加轻量 IP/username 限流,避免无限写入。
+
+### 5.2 图片验证码与短信/邮件验证码
+
+图片验证码是发送验证码前的人机挑战,不产出账号身份,因此由 `CaptchaChallengeService` 管理,不强行实现 `AccountVerification`。
+
+`CodeAccountVerification`:
+
+- `create` 仅允许 `register`、`findPassword`、`bindNotification` 三种 scene。
+- 依次消费图片验证码、校验配置存在时的 reCAPTCHA、获取一分钟发送锁、生成六位数字码、upsert 材料、调用现有 `sendMessage`。
+- 发送失败时删除本次 code 并释放发送锁,允许用户立即重试。
+- `consume` 先按 `account + scene` 执行 Redis 固定窗口频控,60 秒内每次提交都累加,前 10 次允许,第 11 次起返回“验证过于频繁,请稍后再试”;通过频控后再按 `account + scene + code + expiredTime` 原子删除并返回 `VerifiedContactIdentity`。
+- 不查询用户是否存在,不执行注册、改密或绑定。
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant CaptchaAPI as Captcha API
+ participant Captcha as CaptchaChallengeService
+ participant SendAPI as sendAuthCode API
+ participant Code as CodeVerification
+ participant Guard as reCAPTCHA + TimerLock
+ participant ConsumeLimit as Redis Consume Limit
+ participant Material as Material Entity
+ participant Message as Email / SMS
+ participant Business as Register / Reset / Bind API
+
+ Browser->>CaptchaAPI: account
+ CaptchaAPI->>Captcha: create(account)
+ Captcha->>Material: upsert(captcha, answer, 5m)
+ Captcha-->>Browser: image
+ Browser->>SendAPI: account + scene + captcha + googleToken
+ SendAPI->>Code: create(...)
+ Code->>Captcha: consume(account, captcha)
+ Code->>Guard: verify human + acquire send lock
+ Code->>Material: upsert(scene, account, code, 5m)
+ Code->>Message: send(code)
+ Message-->>Browser: success
+ Browser->>Business: account + scene-specific code + business data
+ Business->>Code: consume(account, scene, code)
+ Code->>ConsumeLimit: INCR(account + scene), EXPIRE NX 60s
+ ConsumeLimit-->>Code: allow when count <= 10
+ Code->>Material: findOneAndDelete(valid code)
+ Code-->>Business: VerifiedContactIdentity
+ Business->>Business: 注册 / 改密 / 绑定
+```
+
+外部消息发送不能与 Mongo 事务形成真正原子操作,因此不再把网络发送包进 Mongo transaction。采用“先持久化、再发送、失败时条件清理”的补偿策略。
+
+### 5.3 微信扫码验证
+
+`WechatAccountVerification` 提供:
+
+- `create()`:生成 scene 并创建占位材料,再取得微信 access token 和临时二维码,返回 `{ code, codeUrl, expiredAt? }`;上游失败时条件删除占位材料。
+- `recordCallback({ code, openid })`:微信签名和 XML 解析仍在 callback API;验证通过后只更新有效占位材料。
+- `consume({ code })`:无记录返回 `expired`,没有 `openid` 返回 `pending`;有 `openid` 时原子删除记录,调用微信用户信息 API 并返回外部身份。
+- callback 消息校验 token 继续使用既有源码常量 `WX_AUTH_TOKEN`,不新增后台配置入口;AppID 和 AppSecret 仍由现有后台配置提供。
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant QRAPI as wx/getQR
+ participant Verify as WechatVerification
+ participant Wechat as WeChat API
+ participant Material as Material Entity
+ participant Callback as wx/callback
+ participant Result as wx/getResult
+ participant Login as External Login Service
+
+ Browser->>QRAPI: create QR
+ QRAPI->>Verify: create()
+ Verify->>Material: create(scene placeholder, 1h)
+ Verify->>Wechat: create temporary QR
+ Wechat-->>Verify: ticket + expires
+ Verify-->>Browser: code + codeUrl
+ Wechat->>Callback: signed SCAN / subscribe event
+ Callback->>Callback: verify signature + parse XML
+ Callback->>Verify: recordCallback(code, openid)
+ Verify->>Material: update valid placeholder
+ loop every 3 seconds
+ Browser->>Result: code
+ Result->>Verify: consume(code)
+ alt not scanned
+ Verify-->>Result: pending
+ Result-->>Browser: current empty result
+ else scanned
+ Verify->>Material: findOneAndDelete(openid exists)
+ Verify->>Wechat: fetch verified user profile
+ Verify-->>Result: ExternalAccountIdentity
+ Result->>Login: loginExternalAccount(identity)
+ Login-->>Browser: Set-Cookie + user + token
+ end
+ end
+```
+
+只有取得并删除记录的一个轮询请求能继续登录,解决当前同一 scene 重复创建 Session 的问题。Provider 查询失败时记录已消费,用户重新扫码;这是避免已确认扫码材料被无限重试的安全取舍。
+
+### 5.4 OAuth 验证
+
+公共流程位于 `OAuthAccountVerification`:
+
+```ts
+abstract class OAuthAccountVerification extends AccountVerification<
+ CreateOAuthParams,
+ OAuthRedirectResult,
+ ConsumeOAuthParams,
+ ExternalAccountIdentity
+> {
+ async create(params) {
+ // 校验 provider 配置和 callback URL
+ // 生成高熵 state,以 purpose + subjectHash + provider + callback 绑定后 create
+ // 调用子类构造授权 URL
+ }
+
+ async consume(params) {
+ // 旧 SSO 回调完全没有 state 时,仅按旧协议使用 code 换取身份
+ if (provider === 'sso' && params.state === undefined) {
+ return exchangeCode(params);
+ }
+ // 只读校验 purpose + subjectHash + provider + callback + state + expiredTime
+ // 子类使用 code 换取并校验 Provider 身份
+ // 原子删除 state;删除失败则拒绝身份
+ }
+
+ protected abstract buildAuthorizationUrl(params): Promise;
+ protected abstract exchangeCode(params): Promise;
+}
+```
+
+```mermaid
+sequenceDiagram
+ participant Browser
+ participant CreateAPI as oauth/create API
+ participant Verify as Provider Verification
+ participant Material as Material Entity
+ participant IdP as OAuth / SSO Provider
+ participant Callback as /login/provider
+ participant ConsumeAPI as oauth API
+ participant Login as External Login Service
+
+ Browser->>CreateAPI: provider + callbackUrl + terminal info
+ CreateAPI->>Verify: create(...)
+ Verify->>Verify: validate config and callbackUrl
+ Verify->>Material: create(purpose + subjectHash + provider + callbackHash + state, 10m)
+ Verify->>IdP: build authorization URL
+ Verify-->>Browser: state + url
+ Browser->>IdP: redirect
+ IdP-->>Callback: code + state(旧 SSO 可能不返回 state)
+ Callback->>ConsumeAPI: provider + code + optional state + callbackUrl
+ ConsumeAPI->>Verify: consume(...)
+ alt provider 为 SSO 且 callback 完全没有 state
+ Verify->>IdP: exchange code only and fetch identity
+ IdP-->>Verify: validated provider identity
+ Verify-->>ConsumeAPI: ExternalAccountIdentity
+ ConsumeAPI->>Login: loginExternalAccount(identity, context)
+ Login-->>Browser: Set-Cookie + user + token
+ else state 存在,或 Provider 不是 SSO
+ Verify->>Material: assert valid state
+ Verify->>IdP: exchange code and fetch identity
+ IdP-->>Verify: validated provider identity
+ Verify->>Material: findOneAndDelete state
+ alt state invalid, expired or consumed
+ Verify-->>ConsumeAPI: reject
+ else consumed successfully
+ Verify-->>ConsumeAPI: ExternalAccountIdentity
+ ConsumeAPI->>Login: loginExternalAccount(identity, context)
+ Login-->>Browser: Set-Cookie + user + token
+ end
+ end
+```
+
+state 存在时先交换 code、再原子消费 state,沿用基准逻辑并允许 Provider 临时失败时重试。并发请求即使都完成交换,也只有一个能删除 state 并进入登录业务。旧 SSO code-only 路径不读取或消费本地 state,create 时生成的记录由短期过期机制清理;该路径只依赖 SSO 一次性 code,并保留第 10.1 节记录的残余风险。
+
+callback URL 规则:
+
+- 使用 `adminEnv.FE_DOMAIN` 生成或校验允许的 origin;
+- path 必须精确为 `/login/provider`,不得含用户名、密码或 hash;
+- 开发环境可以显式允许 localhost,生产环境只允许 HTTPS;
+- 不直接信任客户端传入的任意 `callbackUrl`;
+- state key 同时绑定 callback URL 摘要,避免跨回调地址使用。
+
+### 5.5 Provider 适配
+
+| Provider | create | consume 与身份映射 | 必要校验 |
+| --- | --- | --- | --- |
+| GitHub | 服务端构造 authorize URL | code 换 token,读取 `/user`,映射 `git-{login}` | token 使用 form body,响应 Zod 校验,不在 URL/日志暴露 secret |
+| Google | 服务端构造 authorize URL | code 换 token,验证 id token,映射 `google-{sub}` | 校验签名、`aud`、`iss`、`exp`,需要直接依赖官方验证库 |
+| Microsoft | 服务端构造 tenant authorize URL | code 换 token,读取 Graph `/me`,映射 `microsoft-{id}` | tenant/client 配置、token/user 响应 Zod 校验 |
+| Wecom | 服务端构造企业微信 URL | code 换 `open_userid + corpid`,映射 `wecom-{open_userid}` | 验证企业微信 errcode;不在验证类创建 FastGPT 用户 |
+| SSO | 调用配置的 SSO 服务获取授权 URL,并始终传入 state | 固定 SSO base URL 下用 code 获取身份 | callback 带 state 时完整校验;完全无 state 时 code-only;固定同源 URL;限制 props 数量和长度 |
+
+Wecom 当前 `authWecom()` 会预先创建 FastGPT 用户。迁移后该副作用移动到登录应用服务:验证结果只返回 `organizationId=corpid`,登录服务再查找关联团队并按现有规则设置 `defaultTeamIdList`、`forbidCreateDefaultTeam` 和 Wecom tag。
+
+本期范围是“接入统一身份验证并保持旧 SSO 兼容”。`pro/admin` 获取 SSO 授权地址时始终传入服务端生成的 state,但不要求现有 SSO 必须返回;回调带 state 时执行完整的错误、过期和一次性消费校验,回调完全没有 state 时仅对 `provider=sso` 使用旧 code-only 协议。GitHub、Google、Microsoft 和 Wecom 等非 SSO Provider 缺少 state 时直接拒绝,不允许 fallback。
+
+本期不修改 `pro/sso` 的协议实现、进程级回调缓存或多实例行为,不新增 SSO 响应 capability 或其它兼容开关。Pro Admin 不再声明、读取或依赖历史 capability 字段,旧 SSO 即使继续返回该额外字段也会被忽略。统一组件只限制 SSO base URL、callback props 和返回身份结构;敏感业务即使走 code-only,也必须把 SSO 返回的持久化 username 与当前 Session 用户精确比较。
+
+## 6. 登录与业务编排
+
+### 6.1 总调用关系
+
+```mermaid
+flowchart LR
+ Create["verification.create"] --> Material["短期材料"]
+ Material --> Consume["verification.consume"]
+ Consume --> Identity{"可信身份类型"}
+ Identity -- local --> LocalLogin["loginLocalAccount"]
+ Identity -- external --> ExternalLogin["loginExternalAccount / usernameLogin"]
+ Identity -- contact --> Business["注册 / 改密 / 绑定 / 注销"]
+ LocalLogin --> Session["user + Session"]
+ ExternalLogin --> Provision["查找或创建用户 / 团队"]
+ Provision --> Session
+ Business --> DomainResult["具体业务结果"]
+```
+
+### 6.2 本地账号登录服务
+
+`projects/app/src/service/support/user/login/service.ts` 提供 `loginLocalAccount`:
+
+1. 接收 `LocalAccountIdentity`、语言和客户端 IP;
+2. 通过 userId 加载用户详情及默认/最后团队;
+3. 更新 `lastLoginTmbId` 和语言;
+4. 创建现有 Redis Session;
+5. 返回 `{ user, token }`。
+
+Cookie、`pushTrack.login` 和 LOGIN 审计由 API 成功分支显式执行,验证失败不能触发。
+
+### 6.3 外部账号登录服务
+
+`pro/admin/src/service/support/user/login/service.ts`:
+
+- `loginExternalAccount` 只接收 `ExternalAccountIdentity` 和注册上下文;
+- 内部保留并迁移 `usernameLogin` 的查找、自动注册、团队创建、联系方式同步、语言更新和 Session 创建规则;
+- 已存在但状态为 `forbidden` 的用户必须拒绝登录,补齐当前外部登录绕过禁用状态的问题;
+- Wecom 的组织到团队映射在本层处理;
+- 新用户并发登录依赖 `users.username` 唯一索引收敛;捕获重复键后重新读取用户,不能把可恢复竞争直接返回为登录失败;
+- 第三方身份字段不能由 API body 直接构造,只能来自 Provider `consume()` 返回值。
+
+登录成功后 API 再执行 Cookie、登录埋点和广告转化追踪。微信和 OAuth 共用该应用服务,但保留各自 track type。
+
+### 6.4 验证码业务消费者
+
+| 业务 | 调用顺序 | 验证组件之外的动作 |
+| --- | --- | --- |
+| 注册 | `code.consume(register)` | 重名/License 检查、创建用户团队、Session、Cookie、推广与转化追踪 |
+| 找回密码 | `code.consume(findPassword)` | 更新密码和语言、创建新 Session、撤销其他 Session |
+| 用户联系方式 | 先校验当前 Session,再 `code.consume(bindNotification)` | 更新用户 contact,按现有规则补团队通知账号 |
+| 团队通知账号 | 先校验 owner 权限,再 `code.consume(bindNotification)` | 更新团队账号、按现有规则补 owner contact、写审计 |
+| 账号注销 | 由存在该功能的分支增加独立 scene 后复用 | 注销资格、等待期和资源清理由注销业务负责 |
+
+验证码实现只证明对目标邮箱/手机号的控制权,不证明 FastGPT 用户存在。是否允许该身份执行具体业务由调用方判断。
+
+迁移完成前,仍调用旧 `authCode` 的用户联系方式和团队通知账号入口必须复用同一个 Redis 提交频控断言,不能因新旧消费路径并存而留下无限尝试入口。注册场景使用请求中的 `username` 作为 account;其它场景使用实际接收验证码的邮箱或手机号。
+
+### 6.5 敏感业务的验证方式分派
+
+账号注销等敏感业务按“resolver 唯一确定方式、后端校验并创建材料、业务 API 直接消费并执行动作”的顺序使用共享 resolver。前端不能提供方式选择器,每次只展示 resolver 返回的一种入口。create 请求携带 method 作为协议判别字段,服务端使用持久化 username 和真实 capabilities 推导一次并精确比对,随后把 method 绑定到验证材料。consume 不再重新推导,只验证请求 method、当前 userId、scene 与材料绑定。不能先在通用接口消费身份再返回布尔值,否则没有 verification token 就无法把验证结果安全传递给后续请求;也不能新增本方案明确排除的 verification token。
+
+| 服务端确认的 method | create 请求(均不含 username) | create 结果 | action 请求中的 consume payload |
+| --- | --- | --- | --- |
+| `code` | `{ method: 'code', payload: captcha/reCAPTCHA }` | 验证码发送结果 | `{ method: 'code', payload: { code } }` |
+| `oldPassword` | `{ method: 'oldPassword', payload: {} }`;服务端用 Session username 调用 password create | `{ preLoginCode }` | `{ method: 'oldPassword', payload: { password, preLoginCode } }` |
+| `wechat` | `{ method: 'wechat', payload: {} }` | `{ code, codeUrl, expiredAt? }` | `{ method: 'wechat', payload: { code } }`;未扫码时 action 返回 pending |
+| `oauth/*` | `{ method, payload: { callbackUrl, isWecomWorkTerminal? } }` | `{ state, url }` | `{ method, payload: { state, code, callbackUrl } }`;SSO 可额外带受限 props |
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant UI as Frontend
+ participant Resolver as Shared Username Resolver
+ participant CreateAPI as Verification Create API
+ participant Session as Session Auth
+ participant DB as MongoUser
+ participant API as Sensitive Business API
+ participant Dispatcher as Verification Dispatcher
+ participant Verification as AccountVerification
+ participant Business as Account Deletion / Sensitive Action
+
+ UI->>Resolver: username + public capabilities
+ Resolver-->>UI: status + unique method
+ alt supported
+ UI-->>User: 只展示唯一验证入口
+ User->>UI: 开始当前验证流程
+ UI->>CreateAPI: unique method + create payload
+ CreateAPI->>Session: 校验 Session
+ Session-->>CreateAPI: userId
+ CreateAPI->>DB: 按 userId 查询 username
+ DB-->>CreateAPI: persisted username
+ CreateAPI->>Resolver: username + server capabilities
+ Resolver-->>CreateAPI: authoritative unique method
+ CreateAPI->>CreateAPI: 精确比对请求 method
+ CreateAPI->>Verification: create and bind method + userId + scene
+ Verification-->>UI: code sent / preLogin code / QR / OAuth URL
+ User->>API: bound method + consume payload
+ API->>Session: 校验 Session
+ Session-->>API: userId
+ API->>DB: 按 userId 查询 username
+ DB-->>API: persisted username
+ API->>Dispatcher: method + typed payload + persisted user
+ Dispatcher->>Dispatcher: 校验 method + userId + scene 材料绑定
+ Dispatcher->>Verification: consume(payload)
+ Verification-->>Dispatcher: verified identity / pending / expired
+ Dispatcher-->>API: verified identity / pending / expired
+ alt verified and identity belongs to user
+ API->>Business: 执行注销或敏感操作
+ else pending / expired / identity mismatch
+ API-->>UI: 等待或拒绝,不执行业务
+ end
+ else unsupported
+ UI-->>User: 展示稳定不可用原因
+ end
+```
+
+唯一 method 只在 create 接收前端请求时由后端推导和校验一次。consume 不重新读取 capabilities,也不因为流程中途配置变化改走其它方式;它只接受与服务端材料绑定相同的 method,并继续完成材料校验和身份归属校验。如果对应渠道在 create 或 consume 时实际不可用,当前流程按正常上游失败返回,不隐式切换到旧密码或另一 Provider。
+
+## 7. API 契约与兼容
+
+### 7.1 路由
+
+| 路由 | 处理方式 |
+| --- | --- |
+| `GET /support/user/account/preLogin` | 路径和响应不变,改调 `passwordVerification.create` |
+| `POST /support/user/account/loginByPassword` | 路径和请求不变,改调 password consume + local login |
+| `GET /proApi/.../captcha/getImgCaptcha` | 路径不变,改调 captcha service |
+| `POST /proApi/.../inform/sendAuthCode` | 路径不变,改调 code create |
+| 注册、改密、绑定路由 | 路径和响应不变,改调 code consume |
+| `GET /proApi/.../login/wx/getQR` | 路径不变,改调 wechat create |
+| `POST /proApi/.../login/wx/getResult` | 路径不变,改调 wechat consume + external login |
+| `POST /proApi/.../login/oauth/create` | 新增统一 OAuth create,返回 `{ state, url }` |
+| `POST /proApi/.../login/oauth` | 路径不变;直连 Provider 的 `state` 必填,SSO 可省略;state 存在时执行完整 consume,无 state 时仅 SSO code-only |
+| 旧 `getAuthURL` / `wecom/getRedirectUrl` | 新前端切换后删除,不保留转发文件 |
+| 敏感业务的 verification create 路由 | 校验 Session,用持久化 username 推导一次并精确比对请求 method,创建绑定 method、userId 与 scene 的材料;不接收 username |
+| 敏感业务 action 路由 | 不重算 method 或 capabilities;校验请求 method 与材料绑定后,在同一请求内 consume、校验身份归属并执行业务;不接收 username,不新增通用“验证成功”接口或 verification token |
+| `fastLogin` | 不接入组件,按单独阶段废弃 |
+
+主应用和 Pro Admin 需要协调发布统一 create/consume schema,但不增加前端门控、SSO 响应声明或兼容开关。OAuth/SSO 配置存在时前端直接使用统一入口;只有 `provider=sso && state===undefined` 命中旧协议,其它请求都进入 required-state 流程。
+
+本地 `pro` 是独立 Git submodule。实现时需要先形成可独立校验的 Pro commit,再由主仓提交共享包、主应用改动和 submodule 指针;两边的 schema/capability 必须在同一发布单元中配套,不能只移动其中一侧。
+
+### 7.2 OpenAPI schema
+
+新增或调整 schema:
+
+```ts
+const OAuthVerificationProviderSchema = z.enum([
+ OAuthEnum.github,
+ OAuthEnum.google,
+ OAuthEnum.microsoft,
+ OAuthEnum.wecom,
+ OAuthEnum.sso
+]);
+
+const CreateOAuthVerificationBodySchema = z.object({
+ type: OAuthVerificationProviderSchema,
+ callbackUrl: UrlSchema,
+ isWecomWorkTerminal: z.boolean().optional()
+});
+
+const CreateOAuthVerificationResponseSchema = z.object({
+ state: z.string(),
+ url: UrlSchema
+});
+
+const OauthLoginCommonBodySchema = TrackRegisterParamsSchema.extend({
+ callbackUrl: UrlSchema,
+ code: z.string().min(1).max(4096),
+ props: z.record(z.string(), z.string()),
+ language: LanguageSchema.optional()
+});
+
+const OauthLoginBodySchema = z.discriminatedUnion('provider', [
+ OauthLoginCommonBodySchema.extend({
+ provider: z.literal(OAuthEnum.sso),
+ state: z.string().min(32).max(128).optional()
+ }),
+ OauthLoginCommonBodySchema.extend({
+ provider: OAuthVerificationProviderSchema.exclude([OAuthEnum.sso]),
+ state: z.string().min(32).max(128)
+ })
+]);
+
+const CodeVerificationPayloadSchema = z.object({
+ code: z.string().length(6)
+});
+
+const OldPasswordVerificationPayloadSchema = z.object({
+ password: z.string().min(1).max(512),
+ preLoginCode: z.string().length(6)
+});
+
+const WechatVerificationPayloadSchema = z.object({
+ code: z.string().min(16).max(128)
+});
+
+const OAuthVerificationPayloadSchema = z.object({
+ state: z.string().min(32).max(256),
+ code: z.string().min(1).max(4096),
+ callbackUrl: UrlSchema
+});
+
+const reservedSsoCallbackProps = new Set([
+ 'method',
+ 'username',
+ 'state',
+ 'code',
+ 'callbackUrl'
+]);
+const SsoCallbackPropsSchema = z
+ .record(z.string().regex(/^[A-Za-z0-9_.-]+$/).max(64), z.string().max(4096))
+ .refine(
+ (props) =>
+ Object.keys(props).length <= 20 &&
+ Object.keys(props).every((key) => !reservedSsoCallbackProps.has(key)),
+ 'Invalid SSO callback properties'
+ );
+
+const SsoVerificationPayloadSchema = OAuthVerificationPayloadSchema.omit({ state: true }).extend({
+ state: z.string().min(32).max(256).optional(),
+ props: SsoCallbackPropsSchema.default({})
+});
+
+const SensitiveAccountVerificationBodySchema = z.discriminatedUnion('method', [
+ z.object({
+ method: z.literal('code'),
+ payload: CodeVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oldPassword'),
+ payload: OldPasswordVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('wechat'),
+ payload: WechatVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oauth/github'),
+ payload: OAuthVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oauth/google'),
+ payload: OAuthVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oauth/microsoft'),
+ payload: OAuthVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oauth/wecom'),
+ payload: OAuthVerificationPayloadSchema
+ }),
+ z.object({
+ method: z.literal('oauth/sso'),
+ payload: SsoVerificationPayloadSchema
+ })
+]);
+export type SensitiveAccountVerificationBody = z.infer<
+ typeof SensitiveAccountVerificationBodySchema
+>;
+```
+
+敏感业务请求不包含 username;method 是严格 union 的协议判别字段,不是用户可选策略。create schema 使用相同的 `method + payload` 结构:后端在该入口用 resolver 校验 method 一次,校验成功后将其绑定到材料。consume 直接解析上面的判别 union,不再检查当前 capabilities。除旧 SSO code-only 例外外,请求必须命中同一 userId 和 scene 下的材料绑定;旧 SSO 无 state 时无法用 state 绑定流程,因此必须依赖当前 Session、一次性 code 和消费后的持久化 username 精确归属校验。`code` 的邮件/手机通道沿用 create 时绑定的 `accountKind`。SSO adapter 只把受限 props 作为附加字段,并始终以顶层 `state`、`code`、`callbackUrl` 为准,禁止 props 覆盖安全字段。所有对象在生产 schema 中使用 `.strict()` 并补齐 OpenAPI `description`、`example`;callback URL 还要做服务端 allowlist 校验。所有改动路由统一使用 `parseApiInput`;第三方 Provider 响应使用普通 `Schema.parse`,失败应作为内部/上游异常记录。
+
+### 7.3 微信轮询兼容
+
+组件内部返回:
+
+```ts
+type WechatConsumeResult =
+ | { status: 'pending' }
+ | { status: 'expired' }
+ | { status: 'verified'; identity: ExternalAccountIdentity };
+```
+
+第一阶段 API 将 `pending` 映射为现有空结果。QR create 响应可向后兼容增加 `expiredAt`,前端到期后重新获取二维码。若暂不扩展响应,`expired` 也映射为空结果以维持旧轮询协议,但服务端不得继续接受过期材料。
+
+## 8. 目标目录
+
+```text
+packages/global/support/user/
+└── account/
+ └── verification/
+ ├── constants.ts # 验证场景、方式常量和稳定原因码
+ ├── type.ts # username/method/payload/capabilities/resolution Zod schema
+ └── utils.ts # resolveAccountVerificationByUsername 纯函数
+
+packages/global/common/system/types/
+└── index.ts # 公开 accountVerification capability
+
+packages/global/test/support/user/account/verification/
+└── utils.test.ts # 前后端共享 username 推导 fixture
+
+packages/global/openapi/support/user/account/verification/
+├── api.ts # create/consume 请求响应 schema
+└── index.ts # OpenAPI 路由声明
+
+packages/service/support/user/
+├── account/
+│ └── verification/
+│ ├── index.ts # 统一导出真实实现
+│ ├── schema.ts # auth_codes Mongoose schema/model
+│ ├── entity.ts # 原子 upsert/find/consume/update
+│ ├── service.ts # AccountVerification 抽象与内部身份类型
+│ ├── utils.ts # key、callback hash、code 匹配纯函数
+│ └── password/
+│ └── service.ts # PasswordAccountVerification
+└── session.ts # 保持现有位置,不属于验证组件
+
+projects/app/src/service/support/user/login/
+└── service.ts # loginLocalAccount
+
+projects/app/src/web/support/user/account/verification/
+├── api.ts # OAuth create/consume、微信和验证码 API 封装
+├── utils.ts # feConfigs -> capabilities 纯适配
+├── useAccountVerificationMethod.ts # 调用共享 resolver 并映射 UI 入口
+└── useOAuthVerification.ts # 创建授权、保存回跳上下文、发起跳转
+
+pro/admin/src/service/support/user/
+├── account/
+│ └── verification/
+│ ├── service.ts # 敏感业务 method 分派与身份归属校验
+│ ├── utils.ts # server config -> capabilities 纯适配
+│ ├── captcha/
+│ │ └── service.ts # 图片验证码 challenge
+│ ├── code/
+│ │ └── service.ts # CodeAccountVerification
+│ ├── wechat/
+│ │ └── service.ts # QR、callback material、openid exchange
+│ └── oauth/
+│ ├── service.ts # OAuth 基类、工厂和公共 state 流程
+│ ├── github.ts # GitHub Provider
+│ ├── google.ts # Google Provider
+│ ├── microsoft.ts # Microsoft Provider
+│ ├── wecom.ts # 企业微信 Provider,仅返回身份
+│ ├── sso.ts # 通用 SSO Provider
+│ └── utils.ts # callback 校验与 Provider 公共纯函数
+└── login/
+ └── service.ts # usernameLogin / loginExternalAccount
+```
+
+OAuth Provider 文件是同一 `oauth` 子功能下的策略实现,不再为每个 Provider 增加一层目录,避免超过仓库约定的子功能嵌套深度。
+
+### 8.1 旧文件迁移映射
+
+| 当前文件 | 目标 |
+| --- | --- |
+| `packages/global/support/user/auth/constants.ts` | `.../account/verification/constants.ts` |
+| `packages/global/support/user/auth/type.ts` | DB 类型移入新 `schema.ts`,不再放 global |
+| 散落的 username 前缀/配置判断 | `account/verification/type.ts` + `utils.ts` 统一推导 |
+| `FastGPTFeConfigsType` 与 Pro auth 配置 | 增加规范化 capability 输入,前端不暴露 secret,也不增加 SSO prefix 配置 |
+| `packages/service/support/user/auth/schema.ts` | `.../account/verification/schema.ts` |
+| `packages/service/support/user/auth/controller.ts` | 拆到 `entity.ts`、`service.ts`、`utils.ts` |
+| `projects/app/.../preLogin.ts` 中的业务 | `password/service.ts#create` |
+| `projects/app/.../loginByPassword.ts` 中的验证 | `password/service.ts#consume` |
+| 密码登录后的用户/Session 编排 | `projects/app/.../login/service.ts` |
+| `projects/app/src/web/support/user/api.ts` 中的验证 API | `web/support/user/account/verification/api.ts` |
+| 前端各验证面板中的 username 分支 | `useAccountVerificationMethod.ts` 调用共享 resolver |
+| 前后端直接读取原始配置判断 Provider | 各自 `account/verification/utils.ts` 适配成共享 capabilities |
+| `FormLayout.tsx` 中的 OAuth URL/state 构造 | `useOAuthVerification.ts` |
+| `pages/login/provider.tsx` 中的回调验证 | OAuth callback 调用新 consume API,页面只负责结果跳转 |
+| `pro/admin/.../sendAuthCode.ts` 中的业务 | `code/service.ts#create` |
+| `pro/admin/.../captcha/getImgCaptcha.ts` 中的业务 | `captcha/service.ts` |
+| `pro/admin/.../login/wx/*` 中的 Provider 业务 | `wechat/service.ts` |
+| `pro/admin/.../login/oauth.ts` 中的 Provider 函数 | `oauth/*.ts` |
+| `pro/admin/.../login/getAuthURL.ts` | 统一 OAuth create 后删除 |
+| `pro/admin/.../login/wecom/getRedirectUrl.ts` | 统一 OAuth create 后删除 |
+| `pro/admin/src/service/support/wecom/auth.ts` | 身份交换到 `oauth/wecom.ts`,建用户逻辑到 login service |
+| `pro/admin/src/service/support/user/login/wx.ts` | 配置/请求能力合并到 `wechat/service.ts` |
+| `pro/admin/src/service/support/user/controller.ts#usernameLogin` | `login/service.ts` |
+
+迁移完成后直接修改全部 import 并删除旧文件,不创建旧路径 re-export 转发文件。
+
+## 9. 安全与异常语义
+
+### 9.1 必须修正
+
+1. 每次 consume 显式判断 `expiredTime`。
+2. 验证材料使用原子 `findOneAndDelete`,配合唯一索引实现一次性语义。
+3. 用户输入 code 正则必须转义;优先逐步归一化后精确匹配。
+4. OAuth state 至少 32 个高熵字符,并绑定 purpose、当前用户(登录场景为 anonymous)、Provider 与 callback URL;仅旧 SSO 回调完全无 state 时走明确的 code-only 兼容分支。
+5. OAuth create/consume 做 IP 频率限制;Provider code/state 不写日志。
+6. Provider access token、refresh token、id token 和 client secret 不落库、不返回前端、不进入 URL 或结构化日志。
+7. Google token 完整校验;所有 Provider 响应使用 Zod 收窄。
+8. 外部登录同样拒绝 `forbidden` 用户。
+9. SSO 请求只能访问配置的固定 base origin,callback props 设置数量和长度上限。
+10. 微信 callback 必须先使用既有源码常量 `WX_AUTH_TOKEN` 验证签名,再允许写入已存在 scene;为兼容现有部署,不新增后台 token 配置入口。
+11. Pro 始终向 SSO 传 state;SSO 回调带 state 时必须完整校验,完全无 state 时才可 code-only,且该例外不得扩展到非 SSO Provider。
+12. username resolver 的前端结果用于展示和填写协议 method;后端只在 create 入口用 Session userId 对应的持久化 username 和服务端能力推导一次并精确比对。
+13. create 将确认后的 method 绑定到材料;consume 校验请求 method 与 userId、scene、材料绑定一致,并在成功后校验外部身份 username 与当前用户一致。
+14. 通用 SSO 只使用“第一个 `-` 前后非空”的格式规则,并以 SSO capability 为开关;不维护客户前缀枚举或 Admin 白名单。
+15. 敏感业务的 create 与 action 请求都不接受 username;method 仅作为严格判别字段。OAuth state 绑定 userId 和 scene,action 必须在同一请求中消费身份并执行业务。
+16. `oldPassword` 只在没有可展示的验证码或 Provider 方式时成为唯一 method;密码验证组件接受第三方账号的正确密码,Wecom 禁止密码登录的规则只留在登录应用服务。
+17. 短信/邮件验证码 consume 在读取材料前按 Redis `account + scene` 累加 60 秒固定窗口计数;错误和成功提交都计数,前 10 次允许,第 11 次起拒绝,新旧消费入口必须使用同一策略。
+
+### 9.2 失败处理
+
+| 失败 | 组件行为 |
+| --- | --- |
+| 材料不存在、过期或已消费 | 统一验证失败,不透露具体原因;微信轮询内部可返回 expired |
+| 同一 `account + scene` 在 60 秒内提交验证码超过 10 次 | 在查询材料前拒绝,返回“验证过于频繁,请稍后再试”;窗口从首次提交起固定 60 秒,超限请求继续累加但不延长窗口 |
+| 密码错误/用户不存在 | 统一账号密码错误 |
+| Provider capability 缺失 | resolver 在 create 前唯一返回 `oldPassword`,不调用 Provider,也不同时向用户展示两种入口 |
+| create 请求 method 与服务端推导不一致 | 拒绝创建材料,前端刷新配置后重新渲染唯一入口 |
+| 流程建立后 Provider 暂时不可用 | 当前 create/consume 正常返回上游失败;不重算 capabilities,也不隐式切换 method |
+| 授权 URL 构建失败 | 条件删除本次 state |
+| Provider code 交换失败 | state 在有效期内可重试,并受频率限制 |
+| Provider 交换成功但 state 删除失败 | 丢弃身份,不创建用户或 Session |
+| SSO callback 完全无 state | 仅 SSO 按旧协议用一次性 code 换取身份;敏感业务继续精确校验当前用户,非 SSO 直接拒绝 |
+| 短信/邮件发送失败 | 条件删除本次 code、释放锁并返回失败 |
+| 微信 profile 获取失败 | scene 已消费,要求重新扫码 |
+| 用户/团队/Session 业务失败 | 不回滚已消费材料,沿用当前“重新验证后重试”语义 |
+
+## 10. 可观测性
+
+验证组件记录结构化但不含敏感值的事件:
+
+- `verificationType`、`scene`、`provider`;
+- `operation=create|consume|callback`;
+- `outcome=success|pending|expired|invalid|rate_limited|upstream_error`;
+- 耗时和上游 HTTP 状态;
+- 可选的材料 key 哈希前缀,用于关联但不能反推账号/state。
+
+不得记录 username、手机号、邮箱、code、state、openid、OAuth token、Provider 原始响应或 client secret。登录埋点仍在应用层,不能把验证成功等同于登录成功。
+
+### 10.1 残余风险
+
+| 风险 | 本轮处理 |
+| --- | --- |
+| GitHub login 可改名,而兼容 username 使用 `git-{login}` | 保持既有账号映射;彻底解决需要独立 Provider subject 绑定表,超出“不新增存储”边界 |
+| SSO 可返回与本地账号相同的 username | SSO 返回的用户名必须有前缀,否则失败 |
+| 部分定制 SSO Provider 无 state 能力 | 本期显式保留 SSO code-only 兼容;不把该 fallback 扩展到其它 Provider |
+| OAuth 未采用 PKCE | 本轮使用机密客户端、服务端 code 交换和一次性 state;PKCE 属于后续协议增强 |
+| 消息发送与数据库无法分布式原子提交 | 使用条件补偿并覆盖故障测试,仍可能出现“消息已发但客户端收到失败”的可接受窗口 |
+| 企业微信 SSO 使用 `userid`、内部套件使用 `open_userid` | 双入口 capability 以前置身份映射/迁移为条件;未对齐时只开放单入口,最终 username 仍精确校验 |
+| 账号级验证码提交频控可被外部请求触发短时锁定 | 固定窗口 60 秒后自动恢复,并按 scene 隔离;发送侧人机校验和 API IP 频控作为补充,但不能完全消除针对特定账号的短时拒绝服务 |
+
+为兼容现有不支持 state 的 SSO,本期允许 SSO 回调在缺少 state 时按旧协议仅使用一次性 code 完成身份验证。该兼容路径不解决登录 CSRF 和协议降级风险;SSO state 强制校验、PKCE 或等价的流程绑定能力留待后续专项改造。
+
+## 11. 测试设计
+
+### 11.1 测试分层与代码落点
+
+测试按依赖边界放入对应 workspace,不把 Pro 实现的测试放进开源包,也不通过前端测试替代服务端授权测试。
+
+| 层级 | 建议目录 | 主要职责 | 外部依赖策略 |
+| --- | --- | --- | --- |
+| Global 纯函数与 schema | `packages/global/test/support/user/account/verification/` | username resolver、method/capabilities/resolution schema、OpenAPI 合约 | 无网络、无数据库;共享固定 fixture |
+| Service 材料与密码 | `packages/service/test/support/user/account/verification/` | `auth_codes` entity、过期与原子消费、密码验证 | `mongodb-memory-server`、fake timers、固定随机数 |
+| App API 与 Web | `projects/app/test/api/support/user/account/`、`projects/app/test/web/support/user/account/verification/` | 密码 API、Session/Cookie、前端唯一入口与回调上下文 | mock Pro API 和浏览器跳转 |
+| Admin 验证与应用服务 | `pro/admin/test/service/support/user/account/verification/`、`pro/admin/test/api/support/user/account/verification/` | captcha/code/wechat/OAuth/SSO 兼容、method 分派、外部登录服务 | mock Provider HTTP、消息发送、Redis Session |
+| 数据迁移 | 迁移脚本同目录的 `*.test.ts` | dry-run 统计、重复清理、幂等、唯一索引前置检查 | 独立测试库和可重复 fixture |
+
+`pro/sso` 不在本期修改和测试范围内。SSO 兼容测试在 Pro Admin 边界模拟“正确 state、错误 state、完全无 state”三类现有服务响应。
+
+### 11.2 必测矩阵
+
+#### 11.2.1 Username resolver 与 capability adapter
+
+同一组 canonical fixture 由 global resolver 测试直接消费;前端和后端 adapter 只测试“配置 -> capabilities”映射,不复制 username 分支。
+
+| 场景 | 输入重点 | 期望结果 |
+| --- | --- | --- |
+| 非法账号 | 空字符串、全空白 | `unsupported/invalid/empty_username` |
+| 邮箱优先 | 标准邮箱、local-part/domain 含合法 `-` | 先识别 `email`;邮件可用为 `code`,否则为 `oldPassword` |
+| 手机号 | 合法手机号、相邻非法长度或号段 | 合法值识别 `phone`;非法值继续进入后续规则 |
+| 普通本地账号 | 无前缀账号、首尾为 `-` 的账号 | 唯一 method 为 `oldPassword` |
+| 明确直连 Provider | `wechat-*`、`git-*`、`google-*`、`microsoft-*` | capability 可用时返回对应方式;不可用时只返回 `oldPassword`,即使 SSO 可用也不能转 SSO |
+| Wecom | SSO/内部 Wecom 的开关组合 | 按 SSO、内部 Wecom、`oldPassword` 顺序自动返回一个 method |
+| 通用 SSO | 未命中直连 Provider 的 `prefix-suffix` | SSO 可用时为 `oauth/sso`,否则按 `local/oldPassword` |
+| 匹配边界 | 空后缀、大小写变体、多个 `-` | 前缀精确且后缀非空;未命中的合法连字符账号再进入通用 SSO |
+| 结果不变量 | 所有非空 username | `status=supported`、只有一个 method,不返回候选列表 |
+
+Adapter 测试必须覆盖缺 client id、缺 secret、scene 未启用、SSO URL 缺失、License 不可用,以及 Wecom 身份命名空间未对齐时关闭对应 capability。前端 adapter 不得读取或暴露 secret;后端 adapter 以完整服务端配置为准。
+
+#### 11.2.2 Verification material entity
+
+| 能力 | 必测场景 | 断言 |
+| --- | --- | --- |
+| 创建与覆盖 | 同一 `{ key, type }` 连续 upsert | 只保留最新 code、`createTime` 和 `expiredTime` |
+| 过期判断 | 有效、刚好到期、已过期但 TTL 尚未删除 | 只有 `expiredTime > now` 可读取或消费 |
+| 单次消费 | 串行重复消费、`Promise.all` 并发消费 | 最多一个调用得到材料,其余统一失败 |
+| 随机键创建 | OAuth state、微信 scene 碰撞 | 不覆盖已有流程,重新生成或明确失败 |
+| 条件更新 | 微信 callback 更新不存在、过期、已带身份的 scene | 只允许更新仍有效的 create 占位记录 |
+| 条件清理 | 消息/授权 URL 创建失败后发生同 key 重试 | 只删除本次材料,不误删后来创建的记录 |
+| 敏感业务绑定 | method、userId、scene、provider/callback 任一不一致 | 查询和消费均失败,不跨业务复用材料 |
+| 兼容数据 | 迁移前已存在验证码和微信记录 | 在原有效期内仍可按旧 key/type 规则消费 |
+| 唯一索引 | 清理前存在重复、清理后建索引、重复执行脚本 | dry-run 能阻止建索引;清理幂等;最终索引可创建 |
+| 验证码提交频控 | 同账号同场景连续提交、不同账号、不同 scene、新旧消费入口 | 前 10 次允许,第 11 次起返回频控错误;账号和 scene 独立计数;旧 `authCode` 与统一 `consume` 使用同一策略 |
+
+#### 11.2.3 验证实现与应用编排
+
+| 模块 | 成功路径 | 失败、安全与兼容路径 |
+| --- | --- | --- |
+| Password | create 30 秒材料;正确密码返回 `LocalAccountIdentity` | code 错误/过期/重复、用户不存在/禁用、密码错误;第三方账号可做敏感验证,但 Wecom 密码登录仍被应用服务拒绝 |
+| Captcha/Code | 图片码 -> reCAPTCHA -> 锁 -> upsert -> 发送;consume 先做 Redis 提交频控,再返回 contact identity | 图片码大小写、reCAPTCHA 失败、锁冲突、发送失败条件补偿、scene 串用、重发旧码失效、同账号场景第 11 次提交被拒绝、旧 `authCode` 不绕过频控 |
+| WeChat | create 占位、callback 写入、pending 轮询、扫码后返回 external identity | 签名失败、伪造/过期 scene、并发轮询仅一个成功、profile 上游失败、现有 pending 响应兼容 |
+| OAuth base | create state 和 URL;required-state consume 交换身份并原子删除 state | state 熵、过期、provider/user/purpose/callback 不匹配、build 失败清理、交换失败保留、并发 consume 仅一个成功;非 SSO 缺 state 拒绝 |
+| GitHub | authorize、token、user 映射 `git-*` | secret 不进 URL/日志;token/user 响应 Zod 错误 |
+| Google | authorize、官方库验证 id token、映射 `google-*` | 签名、`aud`、`iss`、`exp` 任一失败即拒绝 |
+| Microsoft | tenant authorize、token、Graph user 映射 | tenant/client 配置缺失;token/user 响应校验失败 |
+| Wecom | 返回 `wecom-*` 外部身份,不创建 FastGPT 用户 | errcode、corpid、username 归属不一致;`userid/open_userid` 未对齐时不开放双 capability |
+| SSO | 固定 base URL、始终发送 state、身份映射 | 正确 state 成功、错误/过期/已消费 state 拒绝、完全无 state code-only 成功、props 越界;敏感业务身份不匹配拒绝 |
+| Login services | 本地/外部已有用户与新用户、团队映射、Session 创建 | forbidden 用户、并发首次登录重复键恢复、验证失败不写 Cookie/埋点/审计 |
+
+#### 11.2.4 API、前端与敏感业务
+
+| 边界 | 必测场景 |
+| --- | --- |
+| API schema | 所有新增/修改路由使用 OpenAPI Zod schema 和 `parseApiInput`;额外字段、method/payload 错配、非法 callback/props 返回请求错误 |
+| Create 授权 | 服务端从 Session 加载持久化 username;请求 method 与唯一 resolver 结果不一致时不创建材料 |
+| Consume 授权 | 不重新读取 capabilities;请求 method 必须命中同一 userId、scene 和 method 的材料,并在成功后校验身份归属 |
+| 敏感业务原子边界 | consume 与注销/改密等具体动作在同一业务请求内编排;不存在通用“验证成功”布尔接口或 verification token |
+| 前端入口 | 每个 resolver 结果只渲染一个入口;不存在方式选择器或“切换旧密码”;Provider 缺失时直接渲染 `oldPassword` |
+| OAuth 前端 | create API 返回后才跳转;要求 loginStore、非空 code 和相同 callback URL;仅 SSO 可缺 state,state 存在时必须与 loginStore 精确相等 |
+| 微信前端 | pending 继续轮询,过期重新取二维码,动态内容不改变布局 |
+| 兼容行为 | 既有路由、成功响应、Cookie、Session、username 映射、团队创建和成功后埋点保持不变 |
+
+### 11.3 并发、故障与敏感信息检查
+
+- 时间相关测试统一使用 fake timers 或注入 `now`,明确覆盖边界时刻,不使用真实 sleep。
+- code、state、scene 和随机碰撞通过可注入生成器固定,不让测试依赖概率。
+- Provider、消息发送、Redis 和 Mongo 故障分别注入在“调用前、上游成功后、材料消费前后”,验证补偿和不可回滚边界。
+- 原子消费至少使用两组并发测试:同进程 `Promise.all` 和两个独立 service 实例共享同一测试库。
+- Provider 单元测试禁止访问真实外网;请求 URL、headers、form body 和响应解析都由本地 mock 断言。
+- 日志测试捕获结构化事件,确认 username、邮箱、手机号、code、state、openid、token、Provider 原始响应和 secret 均未出现。
+
+### 11.4 执行命令与门禁
+
+开发中只运行当前阶段涉及的 workspace 和文件;阶段完成时运行该 workspace 全量测试,全部迁移完成后再运行仓库全量测试。
+
+```bash
+# 定向测试
+pnpm --filter @fastgpt/global test -- test/support/user/account/verification
+pnpm --filter @fastgpt/service test -- test/support/user/account/verification
+pnpm --filter @fastgpt/app test -- test/api/support/user/account test/web/support/user/account/verification
+pnpm --filter @fastgpt/admin test -- test/service/support/user/account/verification test/api/support/user/account/verification
+
+# 应用类型检查
+pnpm --filter @fastgpt/app typecheck
+pnpm --filter @fastgpt/admin typecheck
+
+# 最终门禁
+pnpm lint
+pnpm test
+git diff --check
+```
+
+不设置脱离风险面的统一覆盖率数字。resolver 全分支、材料并发与过期、method 绑定、Provider 响应校验、登录副作用顺序必须有直接断言;只通过行覆盖率不能视为完成。
+
+## 12. 分阶段迁移 TODO
+
+迁移按依赖顺序推进。每一阶段都必须保持仓库可构建、可局部测试和可回滚;不得先删除旧实现,再等待后续阶段补齐调用方。
+
+```mermaid
+flowchart TD
+ P0["阶段 0 基线与发布约束"] --> P1["阶段 1 共享契约与 resolver"]
+ P1 --> P2["阶段 2 材料实体与数据安全"]
+ P2 --> P3["阶段 3 密码验证与本地登录"]
+ P3 --> P4["阶段 4 Captcha 与消息验证码"]
+ P4 --> P5["阶段 5 微信扫码与外部登录"]
+ P5 --> P6["阶段 6 直连 OAuth"]
+ P6 --> P7["阶段 7 SSO 兼容与 Wecom"]
+ P7 --> P8["阶段 8 敏感业务与前端单入口"]
+ P8 --> P9["阶段 9 灰度、清理与下线"]
+```
+
+### 阶段 0:基线、数据盘点与发布约束
+
+- [ ] 固化密码、验证码、微信、OAuth 的现有路由、请求、响应、Cookie、Session、username 映射、团队创建和埋点回归用例。
+- [ ] 枚举主仓和 Pro 中所有 `auth_codes` 读写点,标注 key/type、过期时间、写入方式和消费方式,确认没有遗漏调用方。
+- [ ] 编写重复材料 dry-run,按 type 输出重复组数、记录数、保留记录和脱敏样本;本阶段只读,不直接清理。
+- [ ] 扫描启用 SSO 的部署中已有 `prefix-suffix` 本地账号,形成冲突迁移与回滚清单。
+- [ ] 核对 Wecom SSO 的 `userid` 与内部 Wecom 的 `open_userid`,明确哪些部署可以同时声明两项 capability。
+- [ ] 明确主应用、`pro/admin` 和 Pro submodule 指针的提交/发布顺序,并验证新旧 SSO 回调组合;`pro/sso` 保持不变。
+
+完成门槛:现有行为回归测试通过;数据和账号冲突报告可重复生成;每个发布单元都有明确回滚点。
+
+### 阶段 1:共享契约、resolver 与 API schema
+
+- [ ] 在 `packages/global/support/user/account/verification/` 增加 method、account kind、capabilities、resolution 和稳定原因码 schema。
+- [ ] 实现 `resolveAccountVerificationByUsername`,建立 canonical fixture,覆盖邮箱/手机号优先、直连 Provider、Wecom 自动顺序、通用 SSO 和旧密码兜底。
+- [ ] 在前端和服务端分别实现配置 adapter;resolver 内不读取 `window`、数据库、`global.systemConfig` 或服务端 SDK。
+- [ ] 在 `packages/global/openapi/support/user/account/verification/` 定义 create/consume 判别 union、响应和路由文档,补齐 `description`、`example` 与 `.strict()`。
+- [ ] 统一导出前后端共享的 method、capabilities 和 resolution 类型,不在 global 放服务端身份实现。
+- [ ] 增加敏感业务公开 capability,但不增加 OAuth/SSO 兼容开关。
+
+完成门槛:global fixture 与 adapter 测试通过;前后端对同一输入得到同一唯一 method;现有生产行为没有变化。
+
+### 阶段 2:材料实体、过期与唯一性
+
+- [ ] 在 `packages/service/support/user/account/verification/` 建立 `schema.ts`、`entity.ts`、`service.ts`、`utils.ts` 和统一导出。
+- [ ] 在 `service.ts` 定义 `AccountVerification` 抽象和三类可信身份类型;接口不出现 Session、Cookie 或具体业务返回值。
+- [ ] 保持 collection 为 `auth_codes`,保留现有 type 值;新增 OAuth state scene 和敏感业务 key 命名空间。
+- [ ] 实现 create、upsert、有效读取、原子 consume、微信条件更新和条件删除;全部查询显式包含 `expiredTime > now`。
+- [ ] 按场景把普通验证码写入改为 upsert,把 OAuth state/微信 scene 改为随机键 create;碰撞不能覆盖已有流程。
+- [ ] 在 key/type/provider 命名空间绑定敏感业务的 method、userId、scene 和 callback,不新增字段或 verification token。
+- [ ] 保证迁移前已存在的验证码和微信记录在原有效期内仍可消费。
+- [ ] 将全部旧读写方切到新 entity 后运行重复材料清理;先预览、再执行、再复核,脚本必须幂等。
+- [ ] 仅在重复为零且所有写入方已兼容后创建 `{ key, type }` 唯一索引;失败时不修改现有索引。
+
+完成门槛:材料 entity 的过期、并发、绑定、补偿和兼容测试通过;清理复核无重复;唯一索引可重复验证。
+
+### 阶段 3:密码验证与本地登录应用服务
+
+- [ ] 实现 `PasswordAccountVerification.create/consume`,迁移 `preLogin` 和 `loginByPassword`,保持客户端 hash 协议与响应不变。
+- [ ] consume 先原子消费预登录 code,再校验用户、密码和禁用状态,只返回 `LocalAccountIdentity`。
+- [ ] 拆出 `loginLocalAccount`,迁移用户详情、语言、最后团队和 Redis Session 编排;Cookie、埋点和审计留在 API 成功分支。
+- [ ] 密码验证组件允许第三方来源账号验证正确密码;密码登录应用服务继续拒绝 Wecom 创建 Session。
+- [ ] 更新全部 import 并删除已无引用的旧密码验证代码,不保留旧路径转发文件。
+
+完成门槛:现有 `preLogin`、密码登录和 Session 回归测试全部通过;敏感密码验证与 Wecom 密码登录边界均有测试。
+
+### 阶段 4:Captcha、消息验证码与业务消费者
+
+- [ ] 拆出 `CaptchaChallengeService` 与 `CodeAccountVerification`,限定 register/findPassword/bindNotification 及新增敏感 scene。
+- [ ] 把图片验证码、reCAPTCHA、发送锁、材料 upsert 和消息发送按第 5.2 节顺序编排。
+- [ ] 在统一 `CodeAccountVerification.consume` 和迁移期旧 `authCode` 中复用 Redis `account + scene` 固定窗口频控,覆盖 60 秒 10 次边界、账号与 scene 隔离及超限错误文案。
+- [ ] 将消息网络请求移出 Mongo transaction;发送失败时按 code 条件清理材料并释放锁。
+- [ ] 迁移注册、找回密码、用户联系方式和团队通知账号消费者,保持业务校验、Session、Cookie 和审计行为。
+- [ ] 所有相关 API 改用 global OpenAPI schema 与 `parseApiInput`,删除直接解析 `req.body/query` 的写法。
+- [ ] 删除旧验证码 controller 前确认重发旧码失效、scene 隔离和迁移前记录兼容。
+
+完成门槛:captcha/code、四类消费者和补偿故障测试通过;不存在继续向旧 controller 写入的调用方。
+
+### 阶段 5:微信扫码与外部登录应用服务
+
+- [ ] `WechatAccountVerification.create` 先创建有效期一致的 scene 占位,再请求临时二维码;上游失败按 scene 条件清理。
+- [ ] callback 校验签名和请求尺寸后,只调用 `recordCallback` 更新有效占位,禁止任意 upsert。
+- [ ] 保留微信 callback token 的既有源码常量,不新增后台配置入口;callback 仍必须先验签。
+- [ ] `consume` 区分 pending/expired/verified,扫码成功时原子删除;保持现有 pending 对外响应。
+- [ ] 微信 profile 请求和响应进入 service 并用 Zod 校验;上游失败按既定语义要求重新扫码。
+- [ ] 建立 `loginExternalAccount`,迁入 `usernameLogin` 的已有用户、新用户、团队、联系方式、Session 和 forbidden 检查。
+- [ ] 微信 API 成功后再写 Cookie 和埋点;并发轮询只能创建一个 Session。
+
+完成门槛:微信签名、占位、轮询并发和登录回归测试通过;验证 service 中不存在用户创建或 Session 调用。
+
+### 阶段 6:OAuth 基类与直连 Provider
+
+- [ ] 实现 OAuth create API、服务端高熵 state、callback allowlist,以及 purpose/user/provider/callback 绑定。
+- [ ] 实现 OAuth 基类的“只读校验 state -> Provider 交换 -> 原子删除 state”流程和并发保护。
+- [ ] 迁移 GitHub、Google、Microsoft Provider,分别补齐 form body、官方 token 验证和所有上游响应 Zod schema。
+- [ ] 前端改为调用 create API 获取 URL,不再本地拼接授权地址或生成安全 state;直连 Provider 回调必须提交 state,SSO 仅在 callback 完全无 state 时允许兼容。
+- [ ] OAuth 登录统一进入 `loginExternalAccount`,保持 username 映射、Cookie、Session 和 track type。
+- [ ] Provider code/state/token/secret 不进入 URL、响应或结构化日志。
+
+完成门槛:三个直连 Provider 的 URL、交换、身份映射、state 并发与失败测试通过;非 SSO 缺 state 的请求全部被拒绝。
+
+### 阶段 7:SSO 兼容、Wecom 与跨服务协调
+
+- [ ] Pro Admin 获取 SSO 授权地址时始终传 state,但不要求旧 SSO 必须返回,也不修改 `pro/sso`。
+- [ ] 实现唯一兼容分支:`provider=sso && state===undefined` 时 code-only;state 存在时统一进入 required-state consume。
+- [ ] SSO 仅访问固定 base origin,限制 callback props 的键、数量、长度和保留字段。
+- [ ] 实现 SSO 与 Wecom OAuth adapter;验证类只返回身份,不创建 FastGPT 用户。
+- [ ] 把 Wecom `corpid` 到团队的映射和用户 provisioning 留在 `loginExternalAccount`。
+- [ ] 前端仅允许 SSO 缺 state;state 存在时必须与 loginStore.state 相同,非 SSO 缺 state 必须拒绝。
+- [ ] 敏感业务的 SSO code-only 结果必须与当前 Session 用户的持久化 username 精确一致。
+- [ ] 对齐或迁移 `userid/open_userid` 命名空间;未对齐部署只开放来源明确的一项 capability。
+
+完成门槛:SSO 正确 state、错误 state、无 state code-only、直连 Provider 无 state、敏感业务身份不匹配和 Wecom 团队映射测试通过。
+
+### 阶段 8:敏感业务分派与前端唯一入口
+
+- [ ] 实现前端 `useAccountVerificationMethod` 和 capabilities adapter,每个账号只渲染 resolver 返回的一种入口。
+- [ ] 删除方式选择器和“切换旧密码”;只有无可展示验证码或 Provider 时才渲染 oldPassword。
+- [ ] 实现敏感业务 create dispatcher:从 Session 加载持久化 username,推导一次并精确校验请求 method,再创建绑定材料。
+- [ ] 实现 consume dispatcher:不重算 capabilities,只校验 method/userId/scene 材料绑定并调用对应验证实现。
+- [ ] consume 后必须校验 contact/userId/username 与当前用户一致,再在同一请求内执行具体敏感业务。
+- [ ] 请求 schema 不接受 username,method/payload 使用严格判别 union;篡改 method、跨 user/scene/state 和额外字段均被拒绝。
+- [ ] 在实际存在的敏感业务中接入 dispatcher;账号注销仅由包含该功能的分支接入,不在当前分支虚构业务实现。
+
+完成门槛:前端单入口、create 单次推导、consume 材料绑定和身份归属测试通过;不存在通用验证成功接口或 verification token。
+
+### 阶段 9:灰度发布、旧代码清理与快速登录下线
+
+- [ ] 按主应用、Admin 和 Pro submodule 计划完成灰度,观察 required-state/code-only consume outcome、上游错误和登录成功率;日志不得含敏感值。
+- [ ] 演练应用版本回退和回滚唯一索引之外代码的流程;数据脚本保留 dry-run 和复核能力。
+- [ ] 扫描并移除重复 username 分支、旧授权 URL/state 构造、旧 Provider route 函数和无引用 `support/user/auth` 文件。
+- [ ] 确认目标实现均位于 `account/verification`,不存在 `accountVerification` 目录或旧路径 re-export。
+- [ ] 统计 fastLogin 配置和路由使用,完成弃用窗口后删除 schema、OpenAPI、web API、页面、Pro handler 和管理配置;保留可信身份使用的 `usernameLogin` 业务能力。
+- [ ] 运行各 workspace 测试、App/Admin typecheck、lint、仓库全量测试、`git diff --check` 和数据迁移复核。
+- [ ] 使用 Mermaid 8.8.3 解析全部设计图,并同步更新 OpenAPI、运维说明和必要的用户文案。
+
+完成门槛:灰度与回滚演练完成;旧路径和快速登录入口清零;第 13 章所有验收项均有可审计证据。
+
+## 13. 验收标准
+
+验收以“可观察行为 + 自动化测试或迁移输出”为准,不能只以文件已移动或代码已编译作为完成证据。
+
+### 13.1 架构与契约
+
+| ID | 验收条件 | 证据 |
+| --- | --- | --- |
+| A-01 | 通用契约位于 `packages/global/support/user/account/verification/`,服务实现位于各自 `account/verification/`;不存在 `accountVerification` 目录 | 目录扫描、import 扫描 |
+| A-02 | `packages/service` 不导入 `pro/admin`;前端不导入 service-only 实现 | 依赖扫描、构建 |
+| A-03 | 验证组件只创建/消费材料并返回可信身份,不创建用户、Session、Cookie,不写登录埋点或审计 | 单元测试、调用扫描 |
+| A-04 | Local/contact/external identity 均为服务端判别类型;第三方身份字段只能来自 Provider consume | 类型检查、API 负例 |
+| A-05 | 所有新增或修改 API 有 global OpenAPI schema,并通过 `parseApiInput` 校验边界输入 | OpenAPI 测试、路由扫描 |
+| A-06 | collection 仍为 `auth_codes`,没有 verification token、OAuth token 或身份绑定新表 | schema diff、数据库检查 |
+
+### 13.2 Resolver、前端与敏感业务
+
+| ID | 验收条件 | 证据 |
+| --- | --- | --- |
+| R-01 | 前后端共用同一 resolver 和 canonical fixture;resolver 不读取运行环境 | Global/adapter 测试、依赖扫描 |
+| R-02 | 邮箱和手机号优先于连字符规则;含合法 `-` 的邮箱不会进入 SSO | Resolver fixture |
+| R-03 | WeChat/GitHub/Google/Microsoft capability 缺失时只降级 oldPassword,不转 SSO | Resolver fixture |
+| R-04 | Wecom 按 SSO、内部 Wecom、oldPassword 自动单选;通用连字符账号仅受 SSO capability 控制 | Resolver fixture |
+| R-05 | 每个非空 username 只得到一个 method;前端只展示一个入口,不存在方式选择器或旧密码切换 | Resolver 与组件测试、页面扫描 |
+| R-06 | create 使用持久化 username 和服务端 capabilities 推导一次并校验 method;consume 不重算 capabilities | API/service 测试 |
+| R-07 | consume 只接受同一 method、userId、scene 的材料,验证身份必须归属当前用户 | API 安全测试、并发测试 |
+| R-08 | 敏感业务请求不接受 username,不签发通用验证成功结果或 verification token | OpenAPI schema、API 负例、路由扫描 |
+
+### 13.3 材料、安全与并发
+
+| ID | 验收条件 | 证据 |
+| --- | --- | --- |
+| M-01 | 每次读取/消费显式要求 `expiredTime > now`,TTL 延迟不会让过期材料成功 | 边界时间测试 |
+| M-02 | 消费使用原子删除;并发请求最多一个得到可信身份或进入登录/敏感业务 | Mongo 并发测试、API 并发测试 |
+| M-03 | 同一 `{ key, type }` 只保留最新材料;重复清理完成后唯一索引存在 | 迁移报告、索引检查 |
+| M-04 | 迁移前已有验证码和微信记录在原有效期内仍可消费 | 兼容 fixture |
+| M-05 | OAuth state 高熵、短期、一次性,并绑定 purpose、subject/provider、callback;仅旧 SSO 完全无 state 时走显式 code-only 兼容 | OAuth/SSO 测试 |
+| M-06 | 微信 callback 只能更新有效占位;扫码成功只能被一个轮询消费 | 微信 service/API 测试 |
+| M-07 | 消息或授权 URL 上游失败只条件清理本次材料,不误删并发重试产生的新材料 | 故障注入测试 |
+| M-08 | 日志和响应不含 username、联系方式、code、state、openid、token、Provider 原始响应或 secret | 日志捕获测试、静态扫描 |
+| M-09 | 验证码提交按 Redis `account + scene` 在 60 秒固定窗口内累计,前 10 次允许、第 11 次起统一返回频控错误,且新旧消费入口策略一致 | Service/Admin 频控边界与兼容测试 |
+
+### 13.4 验证方式与登录兼容
+
+| ID | 验收条件 | 证据 |
+| --- | --- | --- |
+| V-01 | 密码预登录协议、密码摘要、成功响应和 Session/Cookie 保持兼容 | App API 回归测试 |
+| V-02 | 第三方来源账号可用正确旧密码完成敏感验证;Wecom 仍不能通过密码登录创建 Session | Password/service 测试 |
+| V-03 | Captcha/code 的人机校验、发送锁、重发覆盖、scene 隔离、提交频控和失败补偿符合第 5.2 节 | Admin service/API 测试 |
+| V-04 | 微信 pending 对外行为兼容,扫码后单次登录,签名和上游失败按第 5.3 节处理 | 微信回归与并发测试 |
+| V-05 | GitHub、Google、Microsoft、Wecom、SSO 均由服务端 create/consume,响应经过 Zod 或官方库验证 | Provider 测试 |
+| V-06 | 外部登录拒绝 forbidden 用户,并保持既有自动注册、默认团队、Wecom 团队映射、联系方式同步和 track type | 登录应用服务回归测试 |
+| V-07 | 既有公开路由、成功响应、username 映射、Redis Session 规则和成功后埋点无非预期变化 | API contract diff、回归测试 |
+
+### 13.5 迁移与交付
+
+| ID | 验收条件 | 证据 |
+| --- | --- | --- |
+| D-01 | 重复材料、SSO 前缀冲突和 Wecom 命名空间均完成 dry-run、处理和复核,脚本重复执行结果稳定 | 脱敏迁移报告 |
+| D-02 | 主应用、Admin 和 Pro submodule 的兼容矩阵通过;旧 SSO 无 state 可 code-only,新 SSO 带 state 必须完整校验,`pro/sso` 无本期改动 | 版本组合测试、发布记录 |
+| D-03 | 旧 auth/controller、前端 URL/state 构造、route 内 Provider 逻辑和旧路径 re-export 已清除 | `rg`/依赖扫描、diff |
+| D-04 | fastLogin 完成弃用窗口后从 schema、API、页面、handler 和管理配置移除,但 `usernameLogin` 可信身份能力保留 | 使用统计、路由与配置扫描 |
+| D-05 | 定向测试、各 workspace 测试、App/Admin typecheck、lint、`pnpm test` 和 `git diff --check` 全部通过 | CI/本地命令输出 |
+| D-06 | 全部 Mermaid 图由 8.8.3 解析通过,OpenAPI 和运维说明与最终实现一致 | Mermaid 校验输出、文档 diff |
+| D-07 | 灰度指标无异常,应用回滚演练成功,未产生不可恢复的短期材料或 Session 行为 | 监控截图、演练记录 |
+
+## 14. 账号注销审查问题修复
+
+状态:已确认实施范围
+日期:2026-07-20
+
+### 14.1 问题与约束
+
+本轮只修复账号验证与注销实现中的审查问题,不改变注销等待期、提醒窗口、验证方式和对外 API:
+
+1. 团队审计与管理员审计继续共用 `operationLogs` 集合,但前端/API 列表类型和请求校验必须按事件域拆分;团队接口只能接收、查询 `AuditEventEnum`,管理员接口只能接收、查询 `AdminAuditEventEnum`。两个列表接口都必须完整支持公共分页 schema 声明的 pageNum/offset 语义。
+2. `auth_codes` 只能由 `account/verification/schema.ts` 注册一次。旧 `auth/controller` 继续作为兼容业务入口,但必须使用统一 model,不能保留第二份 schema。
+3. API Key 鉴权没有 Session `uid` 时,注销 guard 必须通过 `tmbId` 解析真实用户,不能把团队 owner 当成当前用户。最终注销还必须删除该用户全部成员身份创建的 API Key,并把残留 API Key 纳入完成条件。
+4. BullMQ 稳定 `jobId` 的幂等语义只覆盖未结束任务。发现同 ID 的 `failed` job 时,生产者必须在按 queue/jobId 隔离的 Redis 租约内刷新数据并重试;等待、延迟或执行中的 job 仍直接复用。
+5. `createPendingAccountCancellation`、`claimAccountCancellationForFinalizing`、`getActiveAccountCancellationStatuses` 及 Pro 中两个无引用 alias 没有明确调用方,直接移除,缩小导出面。
+6. 提醒消息已有 `sendInform2OneUser` 的 24 小时投递锁,本轮不增加逐用户状态。只为提醒扫描和最终清理扫描分别增加 Cron 任务级锁,且不得复用套餐提醒的 `TimerIdEnum.notification`。
+7. 注销记录与团队审计日志使用不同 MongoClient。主库状态提交后,日志库故障不能反向改变 submit、cancel 或 finalize 的业务结果;三类成功审计均按 best-effort 记录 warning,submit 的后续通知和 Session 清理继续执行。
+
+### 14.2 开发设计
+
+#### 审计类型
+
+共享类型提供 `TeamAuditEvent`、`AdminAuditEvent`、通用 `AuditSchemaType`,以及分别收窄事件域的 `TeamAuditListItemType`、`AdminAuditListItemType`。Mongo model 使用通用联合事件类型;团队和管理员 API 分别使用 OpenAPI Zod schema 与 `parseApiInput` 拒绝异域事件,并始终通过本域枚举 `$in` 白名单查询,再映射为对应列表类型。两个处理器都通过 `parsePaginationRequest` 消费已校验 body,避免 schema 接受 offset 后仍按第一页查询。
+
+#### `auth_codes` 单一 model
+
+删除旧 `support/user/auth/schema.ts`,把兼容 controller 和测试改为导入 `MongoAccountVerificationMaterial`。兼容写入口显式刷新 `createTime` 和 `expiredTime`,避免依赖旧 schema 默认值。统一 schema 的 type enum 覆盖全部旧值和 `accountCancellation`,并保留 `purpose`、`userIdHash`、`provider` 等绑定字段。
+
+#### API Key 注销边界
+
+`assertAccountUsable` 在缺少 `userId` 或 `teamId` 任一信息时按 `tmbId` 读取成员记录,从而在 API Key 路径用真实成员 `userId` 查询注销状态。最终清理先收集用户全部成员 ID 并删除对应 `MongoOpenApi` 记录,再清理活跃非 owner 关系;残留检查按用户仍存在的成员 ID 统计 API Key,非零时不得把注销标记为 completed。
+
+#### BullMQ 重投
+
+共享 BullMQ 模块增加一个小型 helper:
+
+- 不存在同 ID job:正常 `queue.add`。
+- 同 ID job 为 `failed`:取得按 queue/jobId 隔离的 Redis 租约,重新确认状态后执行 `updateData` 与 `retry`。
+- 其它状态:返回现有 job,不制造并发重复任务。
+
+账号 finalizer 和 team-delete 两个生产者统一调用该 helper,并保持现有 attempts/backoff/retention 配置。原 failed job 在刷新和重试期间始终保留,进程中断或 Redis 错误不会形成 remove/add 丢任务窗口。租约竞争显式向调用方报错并由现有重试/下一轮扫描补偿,不能让后来的生产者覆盖已经进入 waiting/active 的任务数据,也不能把仍为 failed 的任务误报为已入队。由于 `getJob` 与 `getState` 不是同一 Redis 操作,`unknown` 状态必须重新读取:确认 retention cleanup 已删除 job 后才重新 `add`;若 job 仍存在但状态无法确认则向上报错,不能返回失效 Job 假装已入队。
+
+#### 注销生命周期审计
+
+注销生命周期记录先在主库提交,随后向独立日志库写团队审计。submit 审计失败时记录不含验证材料的 warning,并继续团队通知与 Session 清理;cancel 已删除 pending、finalize 已标记 completed 后,审计失败也只记录 warning。三条路径都不能把已经持久化的成功状态伪装成整体失败,也不依赖客户端或队列重试补偿无法再生成的审计。
+
+#### Cron 锁
+
+增加 `TimerIdEnum.accountCancellationReminder` 和 `TimerIdEnum.accountCancellationFinalize`。Pro Cron 在调用业务扫描前分别申请锁;未取得锁时静默跳过,业务异常继续使用现有结构化错误日志。锁时长覆盖单次日任务执行窗口,但不跨越下一次计划执行。最终清理扫描保持串行投递,但每条记录单独捕获 enqueue 错误并继续后续记录,避免单个 Redis/租约错误把无关账号统一延迟到下一轮 Cron。
+
+### 14.3 回归用例
+
+1. App typecheck 能证明团队审计事件可安全索引 `auditLogMap`;Admin typecheck 能证明管理员列表使用独立类型;请求 schema 负例证明 Team/Admin 事件不能跨域传入,Admin API 的 offset 用例证明公共分页合约被实际执行。
+2. 先加载兼容 auth controller 后,统一 model 仍能保存和读取注销材料的扩展字段,且 `accountCancellation` 不触发 enum 错误。
+3. API Key 同时提供 `teamId/tmbId` 且 `uid` 为空时,guard 使用成员 `userId` 并阻断 pending/finalizing 用户。
+4. 普通成员最终注销会删除其所有 `tmbId` 对应 API Key;仍有 API Key 时 residual check 返回未完成。
+5. finalizer 和 team-delete 遇到 failed job 会在同一 queue/jobId 的租约内调用 `updateData` 与 `retry`;并发生产者不能覆盖已恢复任务的数据,等待/执行中的同 ID job 不重投;`getState` 返回 unknown 且复查已删除时会重建任务,仍无法确认时不会误报成功。
+6. 两个注销 Cron 分别使用独立 timer ID,未取得锁时不扫描;套餐通知锁不受影响;最终清理首条投递失败时后续到期记录仍继续入队。
+7. 主库已提交后日志库写入失败,submit 仍继续发送团队通知、清理 Session 并返回 pending;cancel 返回成功,finalize 返回 completed 且队列任务不失败。
+
+### 14.4 TODO
+
+- [x] 拆分 Team/Admin 审计事件和列表类型,更新 App/Admin 调用方。
+- [x] 删除重复 `auth_codes` schema,让兼容 controller 使用统一 model 并补回归测试。
+- [x] 修复 API Key 用户解析,清理普通成员 API Key,并扩展 residual check。
+- [x] 增加 BullMQ failed stable-job 重投 helper,接入 finalizer 与 team-delete 并补测试。
+- [x] 增加两个独立 `TimerIdEnum`,为提醒和最终清理 Cron 加任务锁。
+- [x] 删除五个无引用函数/alias,扫描确认没有调用方。
+- [x] 运行定向测试、App/Admin typecheck、全量测试和 `git diff --check`。
+- [x] 使用干净上下文 subagent 执行 `local-pr-review`,忽略微信扫码地址问题并处理其余有效发现。
diff --git a/deploy/helm/fastgpt/templates/secret-env.yaml b/deploy/helm/fastgpt/templates/secret-env.yaml
index ddb7e439b2f7..3ee5c14b32c5 100644
--- a/deploy/helm/fastgpt/templates/secret-env.yaml
+++ b/deploy/helm/fastgpt/templates/secret-env.yaml
@@ -9,6 +9,7 @@ stringData:
FILE_TOKEN_KEY: "filetoken"
AES256_SECRET_KEY: "fastgptsecret"
INVOKE_TOKEN_SECRET: "fastgpt_invoke_token_secret_32_chars_min"
+ JWT_SECRET: "replace_with_a_random_secret_at_least_32_chars"
MONGODB_URI: "mongodb://{{ .Values.mongodb.auth.rootUser }}:{{ .Values.mongodb.auth.rootPassword }}@{{ include "fastgpt.fullname" . }}-mongodb-headless:27017/fastgpt?authSource=admin"
PG_URL: "postgresql://postgres:{{ .Values.postgresql.auth.rootPassword }}@{{ include "fastgpt.fullname" . }}-postgresql:5432/{{ .Values.postgresql.global.postgresql.auth.database }}"
kind: Secret
diff --git a/deploy/version/main/docker-compose.template.yml b/deploy/version/main/docker-compose.template.yml
index 246b0b722136..b59221f52533 100644
--- a/deploy/version/main/docker-compose.template.yml
+++ b/deploy/version/main/docker-compose.template.yml
@@ -179,6 +179,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/deploy/version/v4.14/docker-compose.template.yml b/deploy/version/v4.14/docker-compose.template.yml
index 2e3b11e4d370..8f0fbcb6a4db 100644
--- a/deploy/version/v4.14/docker-compose.template.yml
+++ b/deploy/version/v4.14/docker-compose.template.yml
@@ -182,6 +182,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/deploy/version/v4.15/docker-compose.template.yml b/deploy/version/v4.15/docker-compose.template.yml
index 83179c2bddca..25e6e55ec957 100644
--- a/deploy/version/v4.15/docker-compose.template.yml
+++ b/deploy/version/v4.15/docker-compose.template.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/content/self-host/config/env.en.mdx b/document/content/self-host/config/env.en.mdx
index 407ee5042c3e..2d5bcf92da64 100644
--- a/document/content/self-host/config/env.en.mdx
+++ b/document/content/self-host/config/env.en.mdx
@@ -12,7 +12,7 @@ This page describes the environment variables commonly used in a self-hosted Fas
- `projects/code-sandbox`: the code execution sandbox service. It exposes the `/sandbox` endpoint and is called by App through `CODE_SANDBOX_URL`.
- `packages/service/env.ts` exports `serviceEnv`; `projects/app/src/env.ts` exports `appEnv`.
- Shared App/Admin boolean variables use `true`, `1`, `yes`, or `y` to enable a feature. Other values are treated as disabled.
-- `FILE_TOKEN_KEY`, `AES256_SECRET_KEY`, and `INVOKE_TOKEN_SECRET` are required at runtime. Use strong random secrets and do not use the example values in production.
+- `FILE_TOKEN_KEY`, `AES256_SECRET_KEY`, `INVOKE_TOKEN_SECRET`, and `JWT_SECRET` are required at runtime. Use strong random secrets and do not use the example values in production.
## Shared App/Admin Variables
@@ -27,6 +27,7 @@ These variables are mainly validated by `packages/service/env.ts` and apply to `
| `FILE_TOKEN_KEY` | None, **required** | Secret for file read and file authorization flows. Must be at least 6 characters. |
| `AES256_SECRET_KEY` | None, **required** | Secret used by AES encryption and decryption. Must be at least 6 characters. |
| `INVOKE_TOKEN_SECRET` | None, **required** | JWT secret for Invoke reverse calls. Must be at least 32 characters. |
+| `JWT_SECRET` | None, **required** | General-purpose JWT signing secret. Must be at least 32 characters and use the same value in FastGPT and Pro. |
| `ROOT_KEY` | `fastgpt_root_key` | Admin API key for the current system. It can call `/api/admin/**` APIs and must be at least 6 characters. |
| `PRO_TOKEN` | Empty | Token for FastGPT app server calls to pro/admin internal APIs. It must match the pro/admin configuration and is required when App configures `PRO_URL`. |
| `PRO_URL` | Empty | Commercial service URL. When set, App can call Pro APIs, and the domain is allowed by file URL validation. |
diff --git a/document/content/self-host/config/env.mdx b/document/content/self-host/config/env.mdx
index 25a6c3b7b884..ad700073263f 100644
--- a/document/content/self-host/config/env.mdx
+++ b/document/content/self-host/config/env.mdx
@@ -12,7 +12,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
- `projects/code-sandbox`:代码沙箱服务,对外暴露 `/sandbox` 执行接口,供 App 通过 `CODE_SANDBOX_URL` 调用。
- 代码中 `packages/service/env.ts` 导出名为 `serviceEnv`,`projects/app/src/env.ts` 导出名为 `appEnv`。
- App/Admin 共享布尔变量使用 `true`、`1`、`yes` 或 `y` 表示开启;其他值视为关闭。
-- `FILE_TOKEN_KEY`、`AES256_SECRET_KEY` 与 `INVOKE_TOKEN_SECRET` 为运行期必填,建议使用随机强密钥,不要使用示例值。
+- `FILE_TOKEN_KEY`、`AES256_SECRET_KEY`、`INVOKE_TOKEN_SECRET` 与 `JWT_SECRET` 为运行期必填,建议使用随机强密钥,不要使用示例值。
## App/Admin 共享变量
@@ -27,6 +27,7 @@ description: projects/app、projects/code-sandbox 与 pro/admin 环境变量说
| `FILE_TOKEN_KEY` | 无,**必填** | 文件读取、文件鉴权相关密钥,长度至少 6 位。 |
| `AES256_SECRET_KEY` | 无,**必填** | AES 加解密密钥,长度至少 6 位。 |
| `INVOKE_TOKEN_SECRET` | 无,**必填** | Invoke 反向调用 JWT 密钥,长度至少 32 位。 |
+| `JWT_SECRET` | 无,**必填** | 通用 JWT 签名密钥,长度至少 32 位;FastGPT 与 Pro 必须配置为相同值。 |
| `ROOT_KEY` | `fastgpt_root_key` | 当前系统管理员 API 密钥,可用于调用 `/api/admin/**` 接口,长度至少 6 位。 |
| `PRO_TOKEN` | 空 | FastGPT app 服务端调用 pro/admin 内部接口的凭证,需与 pro/admin 配置一致;App 配置 `PRO_URL` 时必填。 |
| `PRO_URL` | 空 | 商业版服务地址,配置后 App 可调用 Pro API,也会作为文件 URL 安全校验允许域名。 |
diff --git a/document/content/self-host/upgrading/4-15/4154.en.mdx b/document/content/self-host/upgrading/4-15/4154.en.mdx
index 50ebd862ecaf..9548c006f907 100644
--- a/document/content/self-host/upgrading/4-15/4154.en.mdx
+++ b/document/content/self-host/upgrading/4-15/4154.en.mdx
@@ -41,6 +41,22 @@ V4.15.3 removes every index that is not declared in its schemas, which may inclu
Setting `MONGO_DEPRECATE_INDEX=false` skips deprecated-index cleanup that may be introduced in future releases, but does not skip creation of missing indexes.
+### Add the JWT Signing Secret
+
+`JWT_SECRET` is now required in production. Before updating the images for an existing deployment, generate a random secret with at least 32 characters:
+
+```bash
+openssl rand -hex 32
+```
+
+Set the generated value on the `fastgpt` service:
+
+```dotenv
+JWT_SECRET=
+```
+
+For commercial edition deployments, set the same value on the `fastgpt-pro` service. Redeploy or restart the affected services after updating the configuration. Do not use the example value from the deployment template in production.
+
## 🚀 New Features
## ⚙️ Improvements
diff --git a/document/content/self-host/upgrading/4-15/4154.mdx b/document/content/self-host/upgrading/4-15/4154.mdx
index 8c9f4c949d52..50c71ee45371 100644
--- a/document/content/self-host/upgrading/4-15/4154.mdx
+++ b/document/content/self-host/upgrading/4-15/4154.mdx
@@ -39,6 +39,22 @@ V4.15.3 的索引同步会删除所有未在当时 Schema 中声明的索引,
`MONGO_DEPRECATE_INDEX=false` 会跳过未来版本可能声明的废弃索引清理,但不会跳过缺失索引的创建。
+### 新增 JWT 签名密钥
+
+`JWT_SECRET` 已改为生产环境必填项。已有部署在更新镜像前,需要生成一个至少 32 位的随机密钥:
+
+```bash
+openssl rand -hex 32
+```
+
+将生成的值配置到 `fastgpt` 服务:
+
+```dotenv
+JWT_SECRET=<上一步生成的随机密钥>
+```
+
+商业版部署还需将同一个值配置到 `fastgpt-pro` 服务。配置完成后,重新部署或重启对应服务。请勿在生产环境中使用部署模板里的示例值。
+
## 🚀 新增内容
## ⚙️ 优化
diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json
index 597869f19a79..2d2d6e256801 100644
--- a/document/data/doc-last-modified.json
+++ b/document/data/doc-last-modified.json
@@ -471,4 +471,4 @@
"content/self-host/upgrading/upgrade-intruction.mdx": "2026-04-26T21:08:47+08:00",
"content/toc.en.mdx": "2026-07-26T18:37:23+08:00",
"content/toc.mdx": "2026-07-26T18:37:23+08:00"
-}
\ No newline at end of file
+}
diff --git a/document/public/deploy/docker/main/cn/docker-compose.milvus.yml b/document/public/deploy/docker/main/cn/docker-compose.milvus.yml
index 96b72f7d1e96..8ccfbe2f7a97 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.milvus.yml
@@ -237,6 +237,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml
index 9a294eafdf44..299e124284c7 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.oceanbase.yml
@@ -215,6 +215,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml
index 1b433adc3dc1..5d32a6243255 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.opengauss.yml
@@ -199,6 +199,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/cn/docker-compose.pg.yml b/document/public/deploy/docker/main/cn/docker-compose.pg.yml
index d31f0d11790d..5f9d86d707b7 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.pg.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.pg.yml
@@ -197,6 +197,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml
index b3dc5bf47c1b..ea94f198fc1d 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.seekdb.yml
@@ -202,6 +202,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml
index 1f2a23a457b1..f0940cd99050 100644
--- a/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/main/cn/docker-compose.zilliz.yml
@@ -178,6 +178,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.milvus.yml b/document/public/deploy/docker/main/global/docker-compose.milvus.yml
index 14210b6a2d46..765da2c3eb89 100644
--- a/document/public/deploy/docker/main/global/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.milvus.yml
@@ -237,6 +237,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml
index 9b7e222a7cb1..54851ab5953b 100644
--- a/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.oceanbase.yml
@@ -215,6 +215,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.opengauss.yml b/document/public/deploy/docker/main/global/docker-compose.opengauss.yml
index a7c1e40c9ab9..5b2d298ebcc1 100644
--- a/document/public/deploy/docker/main/global/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.opengauss.yml
@@ -199,6 +199,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.pg.yml b/document/public/deploy/docker/main/global/docker-compose.pg.yml
index 490ac5162e2f..5cdc46529b89 100644
--- a/document/public/deploy/docker/main/global/docker-compose.pg.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.pg.yml
@@ -197,6 +197,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.seekdb.yml b/document/public/deploy/docker/main/global/docker-compose.seekdb.yml
index 8e04037b8fc4..ecef5a91484c 100644
--- a/document/public/deploy/docker/main/global/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.seekdb.yml
@@ -202,6 +202,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/main/global/docker-compose.zilliz.yml b/document/public/deploy/docker/main/global/docker-compose.zilliz.yml
index 01b3d456177e..afcb49a1040f 100644
--- a/document/public/deploy/docker/main/global/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/main/global/docker-compose.zilliz.yml
@@ -178,6 +178,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml
index 7efe7441a958..8c9fa2e4e930 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.milvus.yml
@@ -239,6 +239,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml
index 662c2ce5ca8b..032fbcee8112 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.oceanbase.yml
@@ -217,6 +217,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml
index b4fb8dfee503..394b1d0db74f 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.opengauss.yml
@@ -201,6 +201,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml
index 2b4c035c4b35..1c0d1979b3ec 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.pg.yml
@@ -199,6 +199,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml
index e26c8389edd9..47b4ab2f10e3 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.seekdb.yml
@@ -204,6 +204,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml
index 7676d31e3500..4a96e1e1e11e 100644
--- a/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/v4.14/cn/docker-compose.zilliz.yml
@@ -182,6 +182,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml b/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml
index 279ddc88c26d..cc940abc75f1 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.milvus.yml
@@ -239,6 +239,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml
index 56ddec286d43..e15339e17d5c 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.oceanbase.yml
@@ -217,6 +217,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml
index e96c2f0207c9..354d5231a0c0 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.opengauss.yml
@@ -201,6 +201,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml b/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml
index d0ddb4c1b019..a6600509a5af 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.pg.yml
@@ -199,6 +199,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml
index 68a86bc4570b..d4aa2e32b68d 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.seekdb.yml
@@ -204,6 +204,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml
index 38d59363a6d6..bf2964105bbf 100644
--- a/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/v4.14/global/docker-compose.zilliz.yml
@@ -182,6 +182,8 @@ services:
AES256_SECRET_KEY: fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min
+ # 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,手动部署必须配置
+ JWT_SECRET: ''
# 强制将图片转成 base64 传递给模型
MULTIPLE_DATA_TO_BASE64: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml
index fdb540811f91..4fd3a31ee1bc 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.milvus.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml
index 4e70f2d0ad8d..976ea8b83085 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.oceanbase.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml
index 9c126dea6af6..a3fce00a3973 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.opengauss.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml
index 0eec8752d687..c0a5205fe57b 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.pg.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml
index c9ec84fb7a7a..b76656004f7b 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.seekdb.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml
index 2fd50bb8d573..9a87e4648cf0 100644
--- a/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/v4.15/cn/docker-compose.zilliz.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml b/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml
index 185b07c51501..aa2d50782f4c 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.milvus.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml b/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml
index d3d946204308..1b8578ed33bd 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.oceanbase.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml b/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml
index 87bee894944c..ff390be3041b 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.opengauss.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml b/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml
index 4ce2ac230021..4841331fa3c5 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.pg.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml b/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml
index 6482b76a08f8..26dacb1552be 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.seekdb.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml b/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml
index 842415db115c..cdaf84f85717 100644
--- a/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml
+++ b/document/public/deploy/docker/v4.15/global/docker-compose.zilliz.yml
@@ -14,6 +14,8 @@ x-file-token-key: &x-file-token-key 'filetokenkey'
x-aes256-secret-key: &x-aes256-secret-key 'fastgptsecret'
# Invoke 反向调用 JWT 密钥,至少 32 位
x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'
+# 通用 JWT 签名密钥,至少 32 位;安装脚本会自动生成,FastGPT 与 Pro 必须保持一致
+x-jwt-secret: &x-jwt-secret ''
# plugin auth token,v4.15 plugin 服务要求至少 32 位
x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'
# code sandbox token
@@ -120,6 +122,7 @@ x-service-env-config: &x-service-env-config
FILE_TOKEN_KEY: *x-file-token-key
AES256_SECRET_KEY: *x-aes256-secret-key
INVOKE_TOKEN_SECRET: *x-invoke-token-secret
+ JWT_SECRET: *x-jwt-secret
MULTIPLE_DATA_TO_BASE64: false
USE_IP_LIMIT: false
CHECK_INTERNAL_IP: false
diff --git a/document/public/deploy/install.sh b/document/public/deploy/install.sh
index f354eb552798..b743c1cdbede 100644
--- a/document/public/deploy/install.sh
+++ b/document/public/deploy/install.sh
@@ -220,7 +220,7 @@ normalize_bool_env() {
ROOT_LOGIN_PASSWORD="1234"
randomize_compose_credentials() {
- local system_key file_token_key aes256_secret_key invoke_token_secret
+ local system_key file_token_key aes256_secret_key invoke_token_secret jwt_secret
local plugin_token code_sandbox_token volume_manager_token agent_proxy_secret aiproxy_token
local root_password mongo_password redis_password minio_password
local pg_password aiproxy_pg_password oceanbase_sys_password oceanbase_tenant_password seekdb_password opengauss_password
@@ -229,6 +229,7 @@ randomize_compose_credentials() {
file_token_key="$(random_hex 32)"
aes256_secret_key="$(random_hex 32)"
invoke_token_secret="$(random_hex 32)"
+ jwt_secret="$(random_hex 32)"
plugin_token="$(random_hex 32)"
code_sandbox_token="$(random_hex 32)"
volume_manager_token="$(random_hex 32)"
@@ -263,6 +264,8 @@ randomize_compose_credentials() {
replace_text 'x-aes256-secret-key: &x-aes256-secret-key "fastgptsecret"' "x-aes256-secret-key: &x-aes256-secret-key \"$aes256_secret_key\""
replace_text "x-invoke-token-secret: &x-invoke-token-secret 'fastgpt_invoke_token_secret_32_chars_min'" "x-invoke-token-secret: &x-invoke-token-secret '$invoke_token_secret'"
replace_text 'x-invoke-token-secret: &x-invoke-token-secret "fastgpt_invoke_token_secret_32_chars_min"' "x-invoke-token-secret: &x-invoke-token-secret \"$invoke_token_secret\""
+ replace_text "x-jwt-secret: &x-jwt-secret ''" "x-jwt-secret: &x-jwt-secret '$jwt_secret'"
+ replace_text 'x-jwt-secret: &x-jwt-secret ""' "x-jwt-secret: &x-jwt-secret \"$jwt_secret\""
replace_text "x-plugin-auth-token: &x-plugin-auth-token 'token'" "x-plugin-auth-token: &x-plugin-auth-token '$plugin_token'"
replace_text 'x-plugin-auth-token: &x-plugin-auth-token "token"' "x-plugin-auth-token: &x-plugin-auth-token \"$plugin_token\""
replace_text "x-plugin-auth-token: &x-plugin-auth-token 'fastgpt-plugin-token-please-change'" "x-plugin-auth-token: &x-plugin-auth-token '$plugin_token'"
@@ -280,6 +283,8 @@ randomize_compose_credentials() {
replace_text "FILE_TOKEN_KEY: filetokenkey" "FILE_TOKEN_KEY: $file_token_key"
replace_text "AES256_SECRET_KEY: fastgptsecret" "AES256_SECRET_KEY: $aes256_secret_key"
replace_text "INVOKE_TOKEN_SECRET: fastgpt_invoke_token_secret_32_chars_min" "INVOKE_TOKEN_SECRET: $invoke_token_secret"
+ replace_text "JWT_SECRET: ''" "JWT_SECRET: $jwt_secret"
+ replace_text 'JWT_SECRET: ""' "JWT_SECRET: $jwt_secret"
# MongoDB 主库与 plugin 独立库使用同一个 Mongo root 密码。
replace_text "mongodb://myusername:mypassword@fastgpt-mongo:27017/fastgpt?authSource=admin" "mongodb://myusername:$mongo_password@fastgpt-mongo:27017/fastgpt?authSource=admin"
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 00b0408d2f0b..cfe03504c701 100644
--- a/packages/global/common/error/code/user.ts
+++ b/packages/global/common/error/code/user.ts
@@ -6,7 +6,15 @@ export enum UserErrEnum {
userExist = 'userExist',
unAuthRole = 'unAuthRole',
account_psw_error = 'account_psw_error',
- unAuthSso = 'unAuthSso'
+ unAuthSso = 'unAuthSso',
+ accountCancellationPending = 'accountCancellationPending',
+ invalidVerificationCode = 'invalidVerificationCode',
+ sendVerificationCodeTooFrequently = 'sendVerificationCodeTooFrequently',
+ verifyCodeTooFrequently = 'verifyCodeTooFrequently',
+ passwordChangeAuthorizationInvalid = 'passwordChangeAuthorizationInvalid',
+ newPasswordSameAsOld = 'newPasswordSameAsOld',
+ ssoPasswordUnavailable = 'ssoPasswordUnavailable',
+ verificationChannelUnavailable = 'verificationChannelUnavailable'
}
const errList = [
{
@@ -24,6 +32,45 @@ const errList = [
{
statusText: UserErrEnum.unAuthSso,
message: i18nT('user:sso_auth_failed')
+ },
+ {
+ statusText: UserErrEnum.accountCancellationPending,
+ message: i18nT('common:code_error.account_cancellation_pending')
+ },
+ {
+ statusText: UserErrEnum.invalidVerificationCode,
+ message: i18nT('common:error.code_error'),
+ httpStatus: 400
+ },
+ {
+ statusText: UserErrEnum.sendVerificationCodeTooFrequently,
+ message: i18nT('common:error.send_auth_code_too_frequently'),
+ httpStatus: 429
+ },
+ {
+ statusText: UserErrEnum.verifyCodeTooFrequently,
+ message: i18nT('common:error.verify_code_too_frequently'),
+ httpStatus: 429
+ },
+ {
+ statusText: UserErrEnum.passwordChangeAuthorizationInvalid,
+ message: 'Password change authorization is invalid',
+ httpStatus: 403
+ },
+ {
+ statusText: UserErrEnum.newPasswordSameAsOld,
+ message: i18nT('common:user.Password has no change'),
+ httpStatus: 400
+ },
+ {
+ statusText: UserErrEnum.ssoPasswordUnavailable,
+ message: i18nT('common:error.sso_password_unavailable'),
+ httpStatus: 403
+ },
+ {
+ statusText: UserErrEnum.verificationChannelUnavailable,
+ message: i18nT('common:error.verification_channel_unavailable'),
+ httpStatus: 403
}
];
export default errList.reduce((acc, cur, index) => {
@@ -33,7 +80,8 @@ export default errList.reduce((acc, cur, index) => {
code: 503000 + index,
statusText: cur.statusText,
message: cur.message,
- data: null
+ data: null,
+ ...(cur.httpStatus !== undefined ? { httpStatus: cur.httpStatus } : {})
}
};
}, {} as ErrType<`${UserErrEnum}`>);
diff --git a/packages/global/common/middle/tracks/constants.ts b/packages/global/common/middle/tracks/constants.ts
index cf2394f2bc82..6717f1486d09 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 71b4a3ba4812..accc4dd9ca1d 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 { AccountVerificationCapabilities } from '../../../support/user/account/verification/type';
import type {
LLMModelItemType,
EmbeddingModelItemType,
@@ -76,6 +77,13 @@ export type FastGPTFeConfigsType = {
show_enterprise_auth?: boolean;
showWecomConfig?: boolean;
wecomLoginAutoRedirect?: boolean;
+ accountCancellation?: {
+ enabled?: boolean;
+ };
+ /** 仅暴露注销验证的布尔能力,不包含任何 Provider 密钥。 */
+ accountVerification?: {
+ accountCancellation?: AccountVerificationCapabilities;
+ };
show_dataset_feishu?: boolean;
show_dataset_yuque?: boolean;
@@ -109,6 +117,7 @@ export type FastGPTFeConfigsType = {
title?: string;
url?: string;
autoLogin?: boolean;
+ disablePasswordForSsoUsers?: boolean;
};
oauth?: {
github?: string;
diff --git a/packages/global/openapi/admin/routes/settings/api.ts b/packages/global/openapi/admin/routes/settings/api.ts
index d8726537ad86..6ddee4c0bdf2 100644
--- a/packages/global/openapi/admin/routes/settings/api.ts
+++ b/packages/global/openapi/admin/routes/settings/api.ts
@@ -1,5 +1,124 @@
import z from 'zod';
+const AuthProviderSchema = z
+ .object({
+ enabled: z.boolean().optional(),
+ clientId: z.string(),
+ secret: z.string()
+ })
+ .passthrough();
+
+const UpdateAuthConfigSchema = z
+ .object({
+ googleServiceVerKey: z.string().optional(),
+ email: z
+ .object({
+ enabled: z.boolean().optional(),
+ register: z.boolean(),
+ notification: z.boolean().optional(),
+ smtp: z.string(),
+ user: z.string(),
+ pass: z.string(),
+ port: z.number().int().positive().max(65535).optional(),
+ secure: z.boolean().optional()
+ })
+ .passthrough()
+ .optional(),
+ phone: z
+ .object({
+ enabled: z.boolean().optional(),
+ register: z.boolean().optional(),
+ notification: z.boolean().optional(),
+ SNED_PHONE_ACCESSKEYID: z.string(),
+ SNED_PHONE_ACCESSSECRET: z.string(),
+ SNED_PHONE_SIGNNAME: z.string()
+ })
+ .passthrough()
+ .optional(),
+ sms: z.record(z.string(), z.string()).optional(),
+ thirdPartyLogin: z
+ .object({
+ enabled: z.boolean().optional()
+ })
+ .passthrough()
+ .optional(),
+ wechat: z
+ .object({
+ enabled: z.boolean().optional(),
+ appID: z.string(),
+ appSecret: z.string()
+ })
+ .passthrough()
+ .optional(),
+ github: AuthProviderSchema.optional(),
+ google: AuthProviderSchema.optional(),
+ microsoft: AuthProviderSchema.extend({
+ tenantId: z.string(),
+ customButton: z.string().optional()
+ }).optional(),
+ dingtalk: z
+ .object({
+ clientId: z.string(),
+ secret: z.string()
+ })
+ .passthrough()
+ .optional(),
+ wecom: z
+ .object({
+ suiteId: z.string(),
+ secret: z.string(),
+ token: z.string(),
+ encodingAESKey: z.string(),
+ cropId: z.string(),
+ providerSecret: z.string(),
+ buyerUserId: z.string(),
+ basicVersionId: z.string(),
+ advancedVersionId: z.string(),
+ paySecret: z.string()
+ })
+ .passthrough()
+ .optional()
+ })
+ .passthrough()
+ .optional();
+
+const UpdateFastGPTConfigSchema = z
+ .object({
+ feConfigs: z
+ .object({
+ sso: z
+ .object({
+ icon: z.string().optional(),
+ title: z.string().optional(),
+ url: z.string().optional(),
+ autoLogin: z.boolean().optional(),
+ disablePasswordForSsoUsers: z.boolean().optional()
+ })
+ .passthrough()
+ .optional()
+ })
+ .passthrough(),
+ systemEnv: z.object({}).passthrough()
+ })
+ .passthrough();
+
+const UpdateFastGPTProConfigSchema = z
+ .object({
+ auth: UpdateAuthConfigSchema,
+ teamMode: z.enum(['multi', 'single', 'sync']).optional(),
+ accountCancellation: z
+ .object({
+ enabled: z.boolean().optional()
+ })
+ .passthrough()
+ .optional(),
+ censor: z.object({}).passthrough().optional(),
+ pay: z.object({}).passthrough().optional(),
+ fileUrlWhitelist: z.array(z.string()).optional(),
+ license: z.never().optional()
+ })
+ .passthrough();
+
export const GetConfigResponseSchema = z.object({
fastgpt: z.any().optional().meta({ description: '系统 FastGPT 配置' }),
fastgptPro: z
@@ -8,7 +127,13 @@ export const GetConfigResponseSchema = z.object({
.meta({ description: '系统 FastGPT Pro 商业版配置(不含 license)' })
});
-export const UpdateConfigBodySchema = z.object({
- fastgpt: z.any().optional().meta({ description: 'FastGPT 系统配置对象' }),
- fastgptPro: z.any().optional().meta({ description: 'FastGPT Pro 商业版配置对象' })
-});
+export const UpdateConfigBodySchema = z
+ .object({
+ fastgpt: UpdateFastGPTConfigSchema.meta({ description: 'FastGPT 系统配置对象' }),
+ fastgptPro: UpdateFastGPTProConfigSchema.meta({
+ description: 'FastGPT Pro 商业版配置对象(不允许提交 license)'
+ })
+ })
+ .strict();
+
+export const UpdateConfigResponseSchema = z.undefined().meta({ description: '更新成功' });
diff --git a/packages/global/openapi/admin/routes/settings/index.ts b/packages/global/openapi/admin/routes/settings/index.ts
index fdd29887e20a..7657a13dcc68 100644
--- a/packages/global/openapi/admin/routes/settings/index.ts
+++ b/packages/global/openapi/admin/routes/settings/index.ts
@@ -1,6 +1,6 @@
import type { OpenAPIPath } from '../../../type';
import { DevApiTagsMap } from '../../../tag';
-import { GetConfigResponseSchema, UpdateConfigBodySchema } from './api';
+import { GetConfigResponseSchema, UpdateConfigBodySchema, UpdateConfigResponseSchema } from './api';
export const AdminSettingsPath: OpenAPIPath = {
'/admin/routes/settings/getConfig': {
@@ -37,7 +37,7 @@ export const AdminSettingsPath: OpenAPIPath = {
description: '更新成功',
content: {
'application/json': {
- schema: {}
+ schema: UpdateConfigResponseSchema
}
}
}
diff --git a/packages/global/openapi/admin/routes/users/api.ts b/packages/global/openapi/admin/routes/users/api.ts
index b6492415b732..b170a5525758 100644
--- a/packages/global/openapi/admin/routes/users/api.ts
+++ b/packages/global/openapi/admin/routes/users/api.ts
@@ -5,35 +5,45 @@ import { UserStatusEnum } from '../../../../support/user/constant';
export const UserItemSchema = z.object({
_id: z.string().meta({ description: '用户ID' }),
username: z.string().meta({ description: '用户名' }),
- avatar: z.string().optional().meta({ description: '用户头像' }),
+ contact: z.string().optional().meta({ description: '用户联系方式' }),
status: z.enum(UserStatusEnum).meta({ description: '用户状态' }),
- createTime: z.date().meta({ description: '创建时间' })
+ createTime: z.number().meta({ description: '创建时间戳' }),
+ isSsoUser: z.boolean().meta({ description: '当前运行时是否将该账号识别为 SSO 用户' })
});
+export type UserItemType = z.infer;
// getUsers
export const GetUsersBodySchema = PaginationSchema.extend({
- username: z.string().meta({ description: '搜索用户名(支持模糊匹配)' })
+ username: z.string().optional().default('').meta({ description: '搜索用户名(支持模糊匹配)' })
});
export type GetUsersBodyType = z.infer;
export const GetUsersResponseSchema = PaginationResponseSchema(UserItemSchema);
+export type GetUsersResponseType = z.infer;
// addUser
-export const AddUserBodySchema = z.object({
- username: z.string().min(1).meta({ description: '用户名' }),
- password: z.string().min(1).meta({ description: '密码' })
-});
+export const AddUserBodySchema = z
+ .object({
+ username: z.string().trim().min(1).meta({ description: '用户名' }),
+ password: z.string().min(1).meta({ description: '密码' })
+ })
+ .strict();
+export type AddUserBodyType = z.infer;
export const AddUserResponseSchema = z.object({
userId: z.string().meta({ description: '新创建的用户ID' }),
teamId: z.string().meta({ description: '用户的团队ID' })
});
+export type AddUserResponseType = z.infer;
// updateUser
-export const UpdateUserBodySchema = z.object({
- _id: z.string().min(1).meta({ description: '用户ID' }),
- username: z.string().min(1).optional().meta({ description: '新用户名' }),
- password: z.string().min(1).optional().meta({ description: '新密码' }),
- status: z.enum(UserStatusEnum).optional().meta({ description: '用户状态' })
-});
+export const UpdateUserBodySchema = z
+ .object({
+ _id: z.string().min(1).meta({ description: '用户ID' }),
+ username: z.string().min(1).optional().meta({ description: '新用户名' }),
+ password: z.string().min(1).optional().meta({ description: '新密码' }),
+ status: z.enum(UserStatusEnum).optional().meta({ description: '用户状态' })
+ })
+ .strict();
+export type UpdateUserBodyType = z.infer;
// delete
export const DeleteUserBodySchema = z.object({
diff --git a/packages/global/openapi/admin/support/user/audit/api.ts b/packages/global/openapi/admin/support/user/audit/api.ts
new file mode 100644
index 000000000000..3e4108308089
--- /dev/null
+++ b/packages/global/openapi/admin/support/user/audit/api.ts
@@ -0,0 +1,31 @@
+import { PaginationResponseSchema, PaginationSchema } from '../../../../api';
+import { AdminAuditEventEnum } from '../../../../../support/user/audit/constants';
+import { SourceMemberSchema } from '../../../../../support/user/type';
+import { z } from 'zod';
+
+/* ============================================================================
+ * API: 获取管理员审计日志
+ * Route: POST /api/support/user/audit/adminList
+ * Method: POST
+ * Description: 分页获取当前团队的管理员事件审计日志
+ * Tags: ['系统日志']
+ * ============================================================================ */
+
+export const AdminAuditListBodySchema = PaginationSchema.extend({
+ tmbIds: z.array(z.string()).optional().meta({ description: '成员 ID 筛选' }),
+ events: z
+ .array(z.enum(AdminAuditEventEnum))
+ .optional()
+ .meta({ description: '管理员审计事件筛选' })
+}).strict();
+export type AdminAuditListBody = z.infer;
+
+export const AdminAuditListItemSchema = z.object({
+ _id: z.string().meta({ description: '审计日志 ID' }),
+ sourceMember: SourceMemberSchema.meta({ description: '操作成员' }),
+ event: z.enum(AdminAuditEventEnum).meta({ description: '管理员审计事件' }),
+ timestamp: z.date().meta({ description: '操作时间' }),
+ metadata: z.record(z.string(), z.any()).meta({ description: '事件元数据' })
+});
+
+export const AdminAuditListResponseSchema = PaginationResponseSchema(AdminAuditListItemSchema);
diff --git a/packages/global/openapi/admin/support/user/audit/index.ts b/packages/global/openapi/admin/support/user/audit/index.ts
new file mode 100644
index 000000000000..1dc09591a014
--- /dev/null
+++ b/packages/global/openapi/admin/support/user/audit/index.ts
@@ -0,0 +1,22 @@
+import type { OpenAPIPath } from '../../../../type';
+import { DevApiTagsMap } from '../../../../tag';
+import { AdminAuditListBodySchema, AdminAuditListResponseSchema } from './api';
+
+export const AdminAuditPath: OpenAPIPath = {
+ '/support/user/audit/adminList': {
+ post: {
+ summary: '获取管理员审计日志',
+ description: '分页获取当前团队的管理员事件审计日志',
+ tags: [DevApiTagsMap.adminLogs],
+ requestBody: {
+ content: { 'application/json': { schema: AdminAuditListBodySchema } }
+ },
+ responses: {
+ 200: {
+ description: '管理员审计日志列表',
+ content: { 'application/json': { schema: AdminAuditListResponseSchema } }
+ }
+ }
+ }
+ }
+};
diff --git a/packages/global/openapi/admin/support/user/index.ts b/packages/global/openapi/admin/support/user/index.ts
index f5ff6c6223cb..f815cb7bd202 100644
--- a/packages/global/openapi/admin/support/user/index.ts
+++ b/packages/global/openapi/admin/support/user/index.ts
@@ -2,9 +2,11 @@ import { AdminLoginPath } from './login';
import { AdminInformPath } from './inform';
import { AdminAuthPath } from './auth';
import type { OpenAPIPath } from '../../../type';
+import { AdminAuditPath } from './audit';
export const AdminUserPath: OpenAPIPath = {
...AdminInformPath,
...AdminLoginPath,
- ...AdminAuthPath
+ ...AdminAuthPath,
+ ...AdminAuditPath
};
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..4a3c5a8a4542
--- /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: 'verification_unavailable'
+ })
+ })
+ .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' }),
+ googleToken: z.string().max(4096).optional().meta({ description: 'reCAPTCHA token' })
+ })
+ .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('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 b812405f16a5..508048e19ed7 100644
--- a/packages/global/openapi/support/user/account/index.ts
+++ b/packages/global/openapi/support/user/account/index.ts
@@ -2,9 +2,13 @@ import type { OpenAPIPath } from '../../../type';
import { LoginPath } from './login';
import { RegisterPath } from './register';
import { PasswordPath } from './password';
+import { AccountVerificationPath } from './verification';
+import { AccountCancellationPath } from './cancellation';
export const UserAccountPath: OpenAPIPath = {
...LoginPath,
...RegisterPath,
- ...PasswordPath
+ ...PasswordPath,
+ ...AccountVerificationPath,
+ ...AccountCancellationPath
};
diff --git a/packages/global/openapi/support/user/account/login/api.ts b/packages/global/openapi/support/user/account/login/api.ts
index 36570afb02e4..a0ff60c3d51f 100644
--- a/packages/global/openapi/support/user/account/login/api.ts
+++ b/packages/global/openapi/support/user/account/login/api.ts
@@ -1,9 +1,9 @@
import { z } from 'zod';
-import { OAuthEnum } from '../../../../../support/user/constant';
import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type';
import { LanguageSchema } from '../../../../../common/i18n/type';
import { UserSchema } from '../../../../../support/user/type';
import { TeamTmbItemSchema } from '../../../../../support/user/team/type';
+import { OAuthAccountVerificationProviderSchema } from '../../../../../support/user/account/verification/type';
const OpenAPITeamTmbItemSchema = TeamTmbItemSchema.omit({
permission: true
@@ -35,12 +35,14 @@ export const LoginSuccessResponseSchema = z.object({
export type LoginSuccessResponseType = z.infer;
// ===== Pre login - get login verification code =====
-export const PreLoginQuerySchema = z.object({
- username: z.string().meta({
- example: 'admin',
- description: '用户名'
+export const PreLoginQuerySchema = z
+ .object({
+ username: z.string().meta({
+ example: 'admin',
+ description: '用户名'
+ })
})
-});
+ .strict();
export type PreLoginQueryType = z.infer;
export const PreLoginResponseSchema = z
@@ -54,7 +56,8 @@ export const PreLoginResponseSchema = z
example: {
code: 'a1b2c3'
}
- });
+ })
+ .strict();
export type PreLoginResponseType = z.infer;
// ===== Login by password =====
@@ -75,51 +78,116 @@ export const LoginByPasswordBodySchema = TrackRegisterParamsSchema.extend({
example: 'zh-CN',
description: '用户语言偏好'
})
-}).meta({
- example: {
- username: 'admin',
- password: 'hashed_password',
- code: '123456',
- language: 'zh-CN'
- }
-});
+})
+ .meta({
+ example: {
+ username: 'admin',
+ password: 'hashed_password',
+ code: '123456',
+ language: 'zh-CN'
+ }
+ })
+ .strict();
export type LoginByPasswordBodyType = z.infer;
-/* ===== Wecom Login ===== */
-export const WecomGetRedirectURLBodySchema = z.object({
- redirectUri: z.string(),
- state: z.string(),
- isWecomWorkTerminal: z.boolean()
-});
-export const WecomGetRedirectURLResponseSchema = z.string();
-export type WecomGetRedirectURLBodyType = z.infer;
-export type WecomGetRedirectURLResponseType = z.infer;
+/* ============================================================================
+ * API: 创建 OAuth 登录
+ * Route: POST /proApi/support/user/account/login/oauth/create
+ * Method: POST
+ * Description: 创建 OAuth/SSO 登录 state 并返回 Provider 授权地址
+ * Tags: ['Account Verification', 'User', 'Write']
+ * ============================================================================ */
+export const CreateOauthLoginBodySchema = z
+ .object({
+ provider: OAuthAccountVerificationProviderSchema.meta({ description: 'OAuth Provider' }),
+ callbackUrl: z.url().max(2048).meta({ description: '登录回调 URL' }),
+ isWecomWorkTerminal: z.boolean().optional().default(false).meta({
+ description: '是否在企业微信工作台内发起登录'
+ })
+ })
+ .strict();
+export type CreateOauthLoginBodyType = z.infer;
-// ===== OAuth Login =====
-export const OauthLoginBodySchema = TrackRegisterParamsSchema.extend({
- type: z.enum(OAuthEnum).meta({ description: 'OAuth 登录类型' }),
- callbackUrl: z.string().meta({ description: '回调 URL' }),
- props: z.record(z.string(), z.string()).meta({ description: '附加属性' }),
- language: LanguageSchema.optional().meta({ description: '语言' })
+export const CreateOauthLoginResponseSchema = z
+ .object({
+ state: z.string().min(32).max(128).meta({ description: '服务端生成的一次性 OAuth state' }),
+ url: z.url().meta({ description: 'Provider 授权地址' })
+ })
+ .strict();
+export type CreateOauthLoginResponseType = z.infer;
+
+const reservedOAuthCallbackProps = new Set(['method', 'username', 'state', 'code', 'callbackUrl']);
+
+const OAuthStateSchema = z.string().min(32).max(128).meta({
+ description: '服务端生成的一次性 OAuth state;仅旧 SSO 回调可以省略'
});
-export type OauthLoginBodyType = z.infer;
-// ===== Fast Login =====
-export const FastLoginBodySchema = TrackRegisterParamsSchema.extend({
- token: z.string().meta({ description: 'Token' }),
- code: z.string().meta({ description: 'Code' }),
+export const OAuthCallbackPropsSchema = z
+ .record(
+ z
+ .string()
+ .regex(/^[A-Za-z0-9_.-]+$/)
+ .max(64),
+ z.string().max(4096)
+ )
+ .superRefine((value, context) => {
+ const keys = Object.keys(value);
+ if (keys.length > 20) {
+ context.addIssue({
+ code: 'custom',
+ message: 'OAuth callback props cannot contain more than 20 fields'
+ });
+ }
+ for (const key of keys) {
+ if (reservedOAuthCallbackProps.has(key)) {
+ context.addIssue({
+ code: 'custom',
+ path: [key],
+ message: 'OAuth callback props contain a reserved field'
+ });
+ }
+ }
+ });
+
+const OauthLoginCommonBodySchema = TrackRegisterParamsSchema.extend({
+ callbackUrl: z.url().max(2048).meta({ description: '登录回调 URL' }),
+ code: z.string().min(1).max(4096).meta({ description: 'Provider 返回的授权 Code' }),
+ props: OAuthCallbackPropsSchema.optional().meta({ description: 'SSO 回调附加属性' }),
language: LanguageSchema.optional().meta({ description: '语言' })
});
-export type FastLoginBodyType = z.infer;
+
+/* ============================================================================
+ * API: 消费 OAuth 登录回调
+ * Route: POST /proApi/support/user/account/login/oauth
+ * Method: POST
+ * Description: 校验 OAuth state,或兼容旧 SSO 的无 state code-only 回调,并完成登录
+ * Tags: ['Account Verification', 'User', 'Write']
+ * ============================================================================ */
+export const OauthLoginBodySchema = z.discriminatedUnion('provider', [
+ OauthLoginCommonBodySchema.extend({
+ provider: z.literal('sso').meta({ description: '旧 SSO 兼容 Provider' }),
+ state: OAuthStateSchema.optional()
+ }).strict(),
+ OauthLoginCommonBodySchema.extend({
+ provider: OAuthAccountVerificationProviderSchema.exclude(['sso']).meta({
+ description: '必须校验 state 的 OAuth Provider'
+ }),
+ state: OAuthStateSchema
+ }).strict()
+]);
+export type OauthLoginBodyType = z.infer;
// ===== WeChat Login Result =====
export const WxLoginBodySchema = TrackRegisterParamsSchema.extend({
- code: z.string().meta({ description: '微信登录 Code' }),
+ code: z.string().min(16).max(128).meta({ description: '微信登录 Code' }),
language: LanguageSchema.optional().meta({ description: '语言' })
-});
+}).strict();
export type WxLoginBodyType = z.infer;
-export const GetWXLoginQRResponseSchema = z.object({
- code: z.string().meta({ description: '微信登录 Code' }),
- codeUrl: z.string().meta({ description: '微信登录二维码 URL' })
-});
+export const GetWXLoginQRResponseSchema = z
+ .object({
+ code: z.string().min(16).max(128).meta({ description: '微信登录 Code' }),
+ codeUrl: z.url().meta({ description: '微信登录二维码 URL' }),
+ expiredAt: z.iso.datetime().optional().meta({ description: '二维码业务过期时间' })
+ })
+ .strict();
export type GetWXLoginQRResponseType = z.infer;
diff --git a/packages/global/openapi/support/user/account/login/index.ts b/packages/global/openapi/support/user/account/login/index.ts
index f39e7fec7559..eea78a449a49 100644
--- a/packages/global/openapi/support/user/account/login/index.ts
+++ b/packages/global/openapi/support/user/account/login/index.ts
@@ -1,3 +1,4 @@
+import z from 'zod';
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import {
@@ -5,7 +6,8 @@ import {
PreLoginQuerySchema,
PreLoginResponseSchema,
OauthLoginBodySchema,
- FastLoginBodySchema,
+ CreateOauthLoginBodySchema,
+ CreateOauthLoginResponseSchema,
WxLoginBodySchema,
GetWXLoginQRResponseSchema,
LoginSuccessResponseSchema,
@@ -70,6 +72,14 @@ export const LoginPath: OpenAPIPath = {
schema: LoginSuccessResponseSchema
}
}
+ },
+ 400: {
+ description: '请求参数或预登录验证码错误',
+ content: {
+ 'application/json': {
+ schema: z.null()
+ }
+ }
}
}
}
@@ -98,24 +108,24 @@ export const LoginPath: OpenAPIPath = {
}
}
},
- '/proApi/support/user/account/login/fastLogin': {
+ '/proApi/support/user/account/login/oauth/create': {
post: {
- summary: '快捷登录',
- description: '使用 Token 和 Code 进行快捷登录',
+ summary: '创建 OAuth 登录流程',
+ description: '由服务端创建一次性 state 并返回 Provider 授权地址',
tags: [DevApiTagsMap.userLogin],
requestBody: {
content: {
'application/json': {
- schema: FastLoginBodySchema
+ schema: CreateOauthLoginBodySchema
}
}
},
responses: {
200: {
- description: '登录成功',
+ description: 'OAuth 登录流程创建成功',
content: {
'application/json': {
- schema: LoginSuccessResponseSchema
+ schema: CreateOauthLoginResponseSchema
}
}
}
diff --git a/packages/global/openapi/support/user/account/password/api.ts b/packages/global/openapi/support/user/account/password/api.ts
index 8d885788047a..c49a44d1ea3f 100644
--- a/packages/global/openapi/support/user/account/password/api.ts
+++ b/packages/global/openapi/support/user/account/password/api.ts
@@ -1,64 +1,236 @@
import { z } from 'zod';
import { LanguageSchema } from '../../../../../common/i18n/type';
+import {
+ AccountContactUsernameSchema,
+ AccountVerificationMethodSchema
+} from '../../../../../support/user/account/verification/type';
-// ===== Update password by old password =====
-export const UpdatePasswordByOldBodySchema = z
+const DateTimeSchema = z.iso.datetime({ offset: true });
+const OAuthVerificationMethods = [
+ 'oauth/github',
+ 'oauth/google',
+ 'oauth/microsoft',
+ 'oauth/wecom',
+ 'oauth/sso'
+] as const;
+type OAuthVerificationMethod = (typeof OAuthVerificationMethods)[number];
+
+/** 将固定的 OAuth 验证方式展开为静态 tuple,避免动态数组擦除 union 的 schema 类型。 */
+const createOAuthVerificationSchemaTuple = (
+ createSchema: (method: OAuthVerificationMethod) => Schema
+) =>
+ [
+ createSchema(OAuthVerificationMethods[0]),
+ createSchema(OAuthVerificationMethods[1]),
+ createSchema(OAuthVerificationMethods[2]),
+ createSchema(OAuthVerificationMethods[3]),
+ createSchema(OAuthVerificationMethods[4])
+ ] as const;
+
+const OAuthCreatePayloadSchema = z
.object({
- oldPsw: z.string().trim().min(1).meta({
- example: 'hashed_old_password',
- description: '旧密码(已加密)'
- }),
- newPsw: z.string().trim().min(1).meta({
- example: 'hashed_new_password',
- description: '新密码(已加密)'
- })
+ callbackUrl: z.url().max(2048),
+ isWecomWorkTerminal: z.boolean().optional()
})
- .meta({
- example: {
- oldPsw: 'hashed_old_password',
- newPsw: 'hashed_new_password'
- }
+ .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'
});
-export type UpdatePasswordByOldBodyType = z.infer;
-export const UpdatePasswordByOldResponseSchema = z.any().meta({
- description: '用户信息'
-});
-export type UpdatePasswordByOldResponseType = z.infer;
-// ===== Check password expired =====
+const OAuthConsumePayloadSchema = z
+ .object({
+ callbackUrl: z.url().max(2048),
+ code: z.string().min(1).max(4096),
+ state: z.string().min(16).max(256).optional(),
+ props: OAuthPropsSchema.optional()
+ })
+ .strict();
+
+const CodeVerificationCreateSchema = z
+ .object({
+ method: z.literal('code'),
+ payload: z
+ .object({
+ captcha: z.string().min(1).max(64),
+ googleToken: z.string().max(4096).optional()
+ })
+ .strict()
+ })
+ .strict();
+
+const OldPasswordVerificationCreateSchema = z
+ .object({
+ method: z.literal('oldPassword'),
+ payload: z.object({}).strict()
+ })
+ .strict();
+
+const WechatVerificationCreateSchema = z
+ .object({
+ method: z.literal('wechat'),
+ payload: z.object({}).strict()
+ })
+ .strict();
+
+const OAuthVerificationCreateSchemas = createOAuthVerificationSchemaTuple((method) =>
+ z
+ .object({
+ method: z.literal(method),
+ payload: OAuthCreatePayloadSchema
+ })
+ .strict()
+);
+
+export const CreatePasswordVerificationBodySchema = z.discriminatedUnion('method', [
+ CodeVerificationCreateSchema,
+ OldPasswordVerificationCreateSchema,
+ WechatVerificationCreateSchema,
+ ...OAuthVerificationCreateSchemas
+]);
+export type CreatePasswordVerificationBody = z.infer;
+
+const OAuthVerificationResponseSchemas = createOAuthVerificationSchemaTuple((method) =>
+ z
+ .object({
+ method: z.literal(method),
+ state: z.string().min(16),
+ url: z.url()
+ })
+ .strict()
+);
+
+export const CreatePasswordVerificationResponseSchema = z.discriminatedUnion('method', [
+ z.object({ method: z.literal('code'), sent: z.literal(true), maskedTarget: z.string() }).strict(),
+ z.object({ method: z.literal('oldPassword'), preLoginCode: z.string().min(1) }).strict(),
+ z
+ .object({
+ method: z.literal('wechat'),
+ code: z.string().min(16),
+ codeUrl: z.url(),
+ expiredAt: DateTimeSchema.optional()
+ })
+ .strict(),
+ ...OAuthVerificationResponseSchemas
+]);
+export type CreatePasswordVerificationResponse = z.infer<
+ typeof CreatePasswordVerificationResponseSchema
+>;
+
+const CodeVerificationConsumeSchema = z
+ .object({
+ method: z.literal('code'),
+ payload: z.object({ code: z.string().min(1).max(32) }).strict()
+ })
+ .strict();
+
+const OldPasswordVerificationConsumeSchema = z
+ .object({
+ method: z.literal('oldPassword'),
+ payload: z
+ .object({
+ password: z.string().length(64),
+ preLoginCode: z.string().min(1).max(128)
+ })
+ .strict()
+ })
+ .strict();
+
+const WechatVerificationConsumeSchema = z
+ .object({
+ method: z.literal('wechat'),
+ payload: z.object({ code: z.string().min(1).max(128) }).strict()
+ })
+ .strict();
+
+const OAuthVerificationConsumeSchemas = createOAuthVerificationSchemaTuple((method) =>
+ z
+ .object({
+ method: z.literal(method),
+ payload: OAuthConsumePayloadSchema
+ })
+ .strict()
+);
+
+export const SensitiveAccountVerificationBodySchema = z.discriminatedUnion('method', [
+ CodeVerificationConsumeSchema,
+ OldPasswordVerificationConsumeSchema,
+ WechatVerificationConsumeSchema,
+ ...OAuthVerificationConsumeSchemas
+]);
+export type SensitiveAccountVerificationBody = z.infer<
+ typeof SensitiveAccountVerificationBodySchema
+>;
+
+export const PasswordAuthorizationBodySchema = z.discriminatedUnion('source', [
+ z.object({ source: z.literal('verificationMethod') }).strict(),
+ z
+ .object({
+ source: z.literal('accountVerification'),
+ verification: SensitiveAccountVerificationBodySchema
+ })
+ .strict()
+]);
+export type PasswordAuthorizationBody = z.infer;
+
+export const PasswordAuthorizationResponseSchema = z.discriminatedUnion('status', [
+ z
+ .object({
+ status: z.literal('authorized'),
+ token: z.string().min(1).max(4096),
+ expiredAt: DateTimeSchema
+ })
+ .strict(),
+ z
+ .object({
+ status: z.literal('verificationRequired'),
+ method: AccountVerificationMethodSchema
+ })
+ .strict(),
+ z.object({ status: z.literal('verificationPending') }).strict(),
+ z
+ .object({
+ status: z.literal('verificationUnavailable'),
+ reason: z.literal('no_available_verification_method')
+ })
+ .strict()
+]);
+export type PasswordAuthorizationResponse = z.infer;
+
+export const UpdatePasswordBodySchema = z
+ .object({
+ newPsw: z.string().length(64).meta({
+ description: '沿用现有客户端 SHA-256 协议的新密码摘要'
+ }),
+ passwordChangeToken: z.string().min(1).max(4096)
+ })
+ .strict();
+export type UpdatePasswordBody = z.infer;
+
+export const UpdatePasswordResponseSchema = z.undefined().meta({ description: '密码设置成功' });
+export type UpdatePasswordResponse = z.infer;
+
export const CheckPswExpiredResponseSchema = z.boolean().meta({
example: false,
description: '密码是否已过期'
});
export type CheckPswExpiredResponseType = z.infer;
-// ===== Reset expired password =====
-export const ResetExpiredPswBodySchema = z
+export const UpdatePasswordByCodeBodySchema = z
.object({
- newPsw: z.string().trim().min(1).meta({
- example: 'hashed_new_password',
- description: '新密码(已加密)'
- })
+ username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }),
+ code: z.string().length(6).meta({ description: '验证码' }),
+ password: z.string().trim().min(1).max(512).meta({ description: '新密码' }),
+ tmbId: z.string().optional().meta({ description: '团队成员 ID(可选)' }),
+ language: LanguageSchema.optional().meta({ description: '语言' })
})
- .meta({
- example: {
- newPsw: 'hashed_new_password'
- }
- });
-export type ResetExpiredPswBodyType = z.infer;
-
-export const ResetExpiredPswResponseSchema = z.undefined().meta({
- description: '重置成功'
-});
-export type ResetExpiredPswResponseType = z.infer;
-
-// ===== Find Password (update by code) =====
-export const UpdatePasswordByCodeBodySchema = z.object({
- username: z.string().trim().min(1).meta({ description: '用户名' }),
- code: z.string().meta({ description: '验证码' }),
- password: z.string().trim().min(1).meta({ description: '新密码' }),
- tmbId: z.string().optional().meta({ description: '团队成员 ID(可选)' }),
- language: LanguageSchema.optional().meta({ description: '语言' })
-});
-
+ .strict();
export type UpdatePasswordByCodeBodyType = z.infer;
diff --git a/packages/global/openapi/support/user/account/password/index.ts b/packages/global/openapi/support/user/account/password/index.ts
index c2bea2d344ba..b6683b67ed9f 100644
--- a/packages/global/openapi/support/user/account/password/index.ts
+++ b/packages/global/openapi/support/user/account/password/index.ts
@@ -1,76 +1,83 @@
+import z from 'zod';
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import {
- UpdatePasswordByOldBodySchema,
- UpdatePasswordByOldResponseSchema,
CheckPswExpiredResponseSchema,
- ResetExpiredPswBodySchema,
- ResetExpiredPswResponseSchema,
- UpdatePasswordByCodeBodySchema
+ CreatePasswordVerificationBodySchema,
+ CreatePasswordVerificationResponseSchema,
+ PasswordAuthorizationBodySchema,
+ PasswordAuthorizationResponseSchema,
+ UpdatePasswordBodySchema,
+ UpdatePasswordByCodeBodySchema,
+ UpdatePasswordResponseSchema
} from './api';
export const PasswordPath: OpenAPIPath = {
- '/support/user/account/updatePasswordByOld': {
+ '/proApi/support/user/account/password/authorization': {
post: {
- summary: '通过旧密码修改密码',
- description: '使用旧密码验证后修改为新密码,修改成功后其他会话将被注销',
- tags: [DevApiTagsMap.userLogin],
+ summary: '获取修改密码授权',
+ description: '通过当前账号的唯一身份验证方式签发短期改密授权',
+ tags: [DevApiTagsMap.userLogin, 'Account Verification'],
requestBody: {
- content: {
- 'application/json': {
- schema: UpdatePasswordByOldBodySchema
- }
- }
+ content: { 'application/json': { schema: PasswordAuthorizationBodySchema } }
},
responses: {
200: {
- description: '密码修改成功',
- content: {
- 'application/json': {
- schema: UpdatePasswordByOldResponseSchema
- }
- }
+ description: '授权结果',
+ content: { 'application/json': { schema: PasswordAuthorizationResponseSchema } }
}
}
}
},
- '/support/user/account/checkPswExpired': {
- get: {
- summary: '检查密码是否过期',
- description: '检查当前用户的密码是否已过期,需要强制修改',
- tags: [DevApiTagsMap.userLogin],
+ '/proApi/support/user/account/password/verification/create': {
+ post: {
+ summary: '创建修改密码验证材料',
+ description: '创建绑定当前用户和 passwordChange 场景的验证材料',
+ tags: [DevApiTagsMap.userLogin, 'Account Verification'],
+ requestBody: {
+ content: { 'application/json': { schema: CreatePasswordVerificationBodySchema } }
+ },
responses: {
200: {
- description: '返回密码是否过期',
- content: {
- 'application/json': {
- schema: CheckPswExpiredResponseSchema
- }
- }
+ description: '验证材料已创建',
+ content: { 'application/json': { schema: CreatePasswordVerificationResponseSchema } }
+ },
+ 400: {
+ description: '请求参数或验证码错误',
+ content: { 'application/json': { schema: z.null() } }
}
}
}
},
- '/support/user/account/resetExpiredPsw': {
+ '/support/user/account/password/update': {
post: {
- summary: '重置过期密码',
- description: '当密码过期时,使用此接口重置密码,重置后其他会话将被注销',
+ summary: '设置或修改密码',
+ description: '使用当前 Session 和短期改密授权设置或修改密码,并注销其他 Session',
tags: [DevApiTagsMap.userLogin],
requestBody: {
- content: {
- 'application/json': {
- schema: ResetExpiredPswBodySchema
- }
- }
+ content: { 'application/json': { schema: UpdatePasswordBodySchema } }
},
responses: {
200: {
- description: '密码重置成功',
- content: {
- 'application/json': {
- schema: ResetExpiredPswResponseSchema
- }
- }
+ description: '密码设置成功',
+ content: { 'application/json': { schema: UpdatePasswordResponseSchema } }
+ },
+ 400: {
+ description: '新密码与当前密码相同',
+ content: { 'application/json': { schema: z.null() } }
+ }
+ }
+ }
+ },
+ '/support/user/account/checkPswExpired': {
+ get: {
+ summary: '检查密码是否过期',
+ description: '无密码账号直接返回 false;有密码账号沿用原密码更新时间规则',
+ tags: [DevApiTagsMap.userLogin],
+ responses: {
+ 200: {
+ description: '返回密码是否过期',
+ content: { 'application/json': { schema: CheckPswExpiredResponseSchema } }
}
}
}
@@ -81,20 +88,20 @@ export const PasswordPath: OpenAPIPath = {
description: '通过邮箱/手机验证码找回或修改密码',
tags: [DevApiTagsMap.userLogin],
requestBody: {
- content: {
- 'application/json': {
- schema: UpdatePasswordByCodeBodySchema
- }
- }
+ content: { 'application/json': { schema: UpdatePasswordByCodeBodySchema } }
},
responses: {
200: {
description: '修改成功',
- content: {
- 'application/json': {
- schema: {}
- }
- }
+ content: { 'application/json': { schema: {} } }
+ },
+ 400: {
+ description: '请求参数、验证码错误或新密码与当前密码相同',
+ content: { 'application/json': { schema: z.null() } }
+ },
+ 429: {
+ description: '验证码校验过于频繁',
+ content: { 'application/json': { schema: z.null() } }
}
}
}
diff --git a/packages/global/openapi/support/user/account/register/api.ts b/packages/global/openapi/support/user/account/register/api.ts
index 60b567839319..b16453bab6c3 100644
--- a/packages/global/openapi/support/user/account/register/api.ts
+++ b/packages/global/openapi/support/user/account/register/api.ts
@@ -1,13 +1,14 @@
import { z } from 'zod';
import { TrackRegisterParamsSchema } from '../../../../../support/marketing/type';
import { LanguageSchema } from '../../../../../common/i18n/type';
+import { AccountContactUsernameSchema } from '../../../../../support/user/account/verification/type';
// ===== Register by email or phone =====
export const AccountRegisterBodySchema = TrackRegisterParamsSchema.extend({
- username: z.string().meta({ description: '用户名(邮箱或手机号)' }),
- code: z.string().meta({ description: '验证码' }),
- password: z.string().meta({ description: '密码(已加密)' }),
+ username: AccountContactUsernameSchema.meta({ description: '用户名(邮箱或手机号)' }),
+ code: z.string().length(6).meta({ description: '验证码' }),
+ password: z.string().min(1).max(512).meta({ description: '密码(已加密)' }),
language: LanguageSchema.optional().meta({ description: '语言' })
-});
+}).strict();
export type AccountRegisterBodyType = z.infer;
diff --git a/packages/global/openapi/support/user/account/register/index.ts b/packages/global/openapi/support/user/account/register/index.ts
index ec27f38aa8e4..44d5683bc5ce 100644
--- a/packages/global/openapi/support/user/account/register/index.ts
+++ b/packages/global/openapi/support/user/account/register/index.ts
@@ -1,3 +1,4 @@
+import z from 'zod';
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import { AccountRegisterBodySchema } from './api';
@@ -23,6 +24,22 @@ export const RegisterPath: OpenAPIPath = {
schema: {}
}
}
+ },
+ 400: {
+ description: '请求参数或验证码错误',
+ content: {
+ 'application/json': {
+ schema: z.null()
+ }
+ }
+ },
+ 429: {
+ description: '验证码校验过于频繁',
+ content: {
+ 'application/json': {
+ schema: z.null()
+ }
+ }
}
}
}
diff --git a/packages/global/openapi/support/user/account/verification/api.ts b/packages/global/openapi/support/user/account/verification/api.ts
new file mode 100644
index 000000000000..ee58d2ca8888
--- /dev/null
+++ b/packages/global/openapi/support/user/account/verification/api.ts
@@ -0,0 +1,59 @@
+import { z } from 'zod';
+import { LanguageSchema } from '../../../../../common/i18n/type';
+import {
+ AccountContactUsernameSchema,
+ CodeAccountVerificationSceneSchema
+} from '../../../../../support/user/account/verification/type';
+
+export const GetAccountCaptchaQuerySchema = z
+ .object({
+ username: AccountContactUsernameSchema.meta({
+ description: '待验证的邮箱或手机号',
+ example: 'user@example.com'
+ })
+ })
+ .strict();
+export type GetAccountCaptchaQuery = z.infer;
+
+export const GetAccountCaptchaResponseSchema = z
+ .object({
+ captchaImage: z.string().startsWith('data:image/').meta({
+ description: 'Data URL 格式的图片验证码'
+ })
+ })
+ .strict();
+export type GetAccountCaptchaResponse = z.infer;
+
+export const SendAccountVerificationCodeBodySchema = z
+ .object({
+ username: AccountContactUsernameSchema.meta({
+ description: '接收验证码的邮箱或手机号',
+ example: 'user@example.com'
+ }),
+ type: CodeAccountVerificationSceneSchema.meta({
+ description: '验证码业务场景',
+ example: 'register'
+ }),
+ googleToken: z.string().max(4096).default('').meta({
+ description: '部署启用 reCAPTCHA 时由客户端取得的校验 token'
+ }),
+ captcha: z.string().min(1).max(64).meta({
+ description: '图片验证码答案',
+ example: 'A1B2C3'
+ }),
+ lang: LanguageSchema.meta({
+ description: '验证码消息语言',
+ example: 'zh-CN'
+ })
+ })
+ .strict();
+export type SendAccountVerificationCodeBody = z.infer;
+
+export const SendAccountVerificationCodeResponseSchema = z
+ .object({
+ message: z.string().meta({ description: '发送结果说明' })
+ })
+ .strict();
+export type SendAccountVerificationCodeResponse = z.infer<
+ typeof SendAccountVerificationCodeResponseSchema
+>;
diff --git a/packages/global/openapi/support/user/account/verification/index.ts b/packages/global/openapi/support/user/account/verification/index.ts
new file mode 100644
index 000000000000..04b9e359f5ea
--- /dev/null
+++ b/packages/global/openapi/support/user/account/verification/index.ts
@@ -0,0 +1,72 @@
+import z from 'zod';
+import type { OpenAPIPath } from '../../../../type';
+import { DevApiTagsMap } from '../../../../tag';
+import {
+ GetAccountCaptchaQuerySchema,
+ GetAccountCaptchaResponseSchema,
+ SendAccountVerificationCodeBodySchema,
+ SendAccountVerificationCodeResponseSchema
+} from './api';
+
+export const AccountVerificationPath: OpenAPIPath = {
+ '/proApi/support/user/account/captcha/getImgCaptcha': {
+ get: {
+ summary: '获取账号图片验证码',
+ description: '获取发送邮箱或短信验证码前的人机校验图片',
+ tags: [DevApiTagsMap.userLogin],
+ requestParams: {
+ query: GetAccountCaptchaQuerySchema
+ },
+ responses: {
+ 200: {
+ description: '成功创建图片验证码',
+ content: {
+ 'application/json': {
+ schema: GetAccountCaptchaResponseSchema
+ }
+ }
+ }
+ }
+ }
+ },
+ '/proApi/support/user/inform/sendAuthCode': {
+ post: {
+ summary: '发送账号验证码',
+ description: '消费图片验证码后,按注册、找回密码或绑定场景发送邮箱/短信验证码',
+ tags: [DevApiTagsMap.userLogin],
+ requestBody: {
+ content: {
+ 'application/json': {
+ schema: SendAccountVerificationCodeBodySchema
+ }
+ }
+ },
+ responses: {
+ 200: {
+ description: '验证码发送成功',
+ content: {
+ 'application/json': {
+ schema: SendAccountVerificationCodeResponseSchema
+ }
+ }
+ },
+ 400: {
+ description: '请求参数或图片验证码错误',
+ content: {
+ 'application/json': {
+ schema: z.null()
+ }
+ }
+ },
+ 429: {
+ description: '验证码发送过于频繁',
+ content: {
+ 'application/json': {
+ schema: z.null()
+ }
+ }
+ }
+ }
+ }
+ }
+};
diff --git a/packages/global/openapi/support/user/audit/api.ts b/packages/global/openapi/support/user/audit/api.ts
new file mode 100644
index 000000000000..0d9b1e697f6e
--- /dev/null
+++ b/packages/global/openapi/support/user/audit/api.ts
@@ -0,0 +1,28 @@
+import { PaginationResponseSchema, PaginationSchema } from '../../../api';
+import { AuditEventEnum } from '../../../../support/user/audit/constants';
+import { SourceMemberSchema } from '../../../../support/user/type';
+import { z } from 'zod';
+
+/* ============================================================================
+ * API: 获取团队审计日志
+ * Route: POST /proApi/support/user/audit/list
+ * Method: POST
+ * Description: 分页获取当前团队的团队事件审计日志
+ * Tags: ['团队管理']
+ * ============================================================================ */
+
+export const TeamAuditListBodySchema = PaginationSchema.extend({
+ tmbIds: z.array(z.string()).optional().meta({ description: '成员 ID 筛选' }),
+ events: z.array(z.enum(AuditEventEnum)).optional().meta({ description: '团队审计事件筛选' })
+}).strict();
+export type TeamAuditListBody = z.infer;
+
+export const TeamAuditListItemSchema = z.object({
+ _id: z.string().meta({ description: '审计日志 ID' }),
+ sourceMember: SourceMemberSchema.meta({ description: '操作成员' }),
+ event: z.enum(AuditEventEnum).meta({ description: '团队审计事件' }),
+ timestamp: z.date().meta({ description: '操作时间' }),
+ metadata: z.record(z.string(), z.any()).meta({ description: '事件元数据' })
+});
+
+export const TeamAuditListResponseSchema = PaginationResponseSchema(TeamAuditListItemSchema);
diff --git a/packages/global/openapi/support/user/audit/index.ts b/packages/global/openapi/support/user/audit/index.ts
new file mode 100644
index 000000000000..54d854c8aff4
--- /dev/null
+++ b/packages/global/openapi/support/user/audit/index.ts
@@ -0,0 +1,22 @@
+import type { OpenAPIPath } from '../../../type';
+import { DevApiTagsMap } from '../../../tag';
+import { TeamAuditListBodySchema, TeamAuditListResponseSchema } from './api';
+
+export const UserAuditPath: OpenAPIPath = {
+ '/proApi/support/user/audit/list': {
+ post: {
+ summary: '获取团队审计日志',
+ description: '分页获取当前团队的团队事件审计日志',
+ tags: [DevApiTagsMap.teamManage],
+ requestBody: {
+ content: { 'application/json': { schema: TeamAuditListBodySchema } }
+ },
+ responses: {
+ 200: {
+ description: '团队审计日志列表',
+ content: { 'application/json': { schema: TeamAuditListResponseSchema } }
+ }
+ }
+ }
+ }
+};
diff --git a/packages/global/openapi/support/user/index.ts b/packages/global/openapi/support/user/index.ts
index 8d13f89286e8..a3ca5a8e06c0 100644
--- a/packages/global/openapi/support/user/index.ts
+++ b/packages/global/openapi/support/user/index.ts
@@ -2,9 +2,11 @@ import { UserInformPath } from './inform';
import type { OpenAPIPath } from '../../type';
import { UserAccountPath } from './account';
import { TeamPath } from './team';
+import { UserAuditPath } from './audit';
export const UserPath: OpenAPIPath = {
...UserInformPath,
...UserAccountPath,
- ...TeamPath
+ ...TeamPath,
+ ...UserAuditPath
};
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..72a0d06355b4
--- /dev/null
+++ b/packages/global/support/user/account/cancellation/constants.ts
@@ -0,0 +1,44 @@
+export const accountCancellationWaitDays = 15;
+export const accountCancellationTimezone = 'Asia/Shanghai';
+
+export enum AccountCancellationStatusEnum {
+ pending = 'pending',
+ finalizing = 'finalizing',
+ completed = 'completed'
+}
+
+export const accountCancellationActiveStatuses = [
+ AccountCancellationStatusEnum.pending,
+ AccountCancellationStatusEnum.finalizing
+] as const;
+
+export const accountCancellationAllowedMethods = [
+ 'code',
+ 'wechat',
+ 'oauth/github',
+ 'oauth/google',
+ 'oauth/microsoft',
+ 'oauth/wecom',
+ 'oauth/sso'
+] as const;
+
+export enum AccountCancellationReminderEnum {
+ sevenDays = '7d',
+ oneDay = '1d',
+ today = 'today'
+}
+
+export enum AccountCancellationUnavailableReasonEnum {
+ featureDisabled = 'feature_disabled',
+ unsupportedTeamMode = 'unsupported_team_mode',
+ rootAccount = 'root_account',
+ accountForbidden = 'account_forbidden',
+ emptyUsername = 'empty_username',
+ verificationUnavailable = 'verification_unavailable'
+}
+
+export const accountCancellationStatusMap = {
+ [AccountCancellationStatusEnum.pending]: { label: 'Pending' },
+ [AccountCancellationStatusEnum.finalizing]: { label: 'Finalizing' },
+ [AccountCancellationStatusEnum.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..828c52e9b735
--- /dev/null
+++ b/packages/global/support/user/account/cancellation/index.ts
@@ -0,0 +1,4 @@
+export * 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..947458137fda
--- /dev/null
+++ b/packages/global/support/user/account/cancellation/resolver.ts
@@ -0,0 +1,36 @@
+import { resolveAccountVerificationByUsername } from '../verification/utils';
+import type { AccountCancellationResolveResult, AccountCancellationResolverInput } from './type';
+
+/** 将统一 resolver 的结果收窄为注销允许的非密码验证方式。 */
+export const resolveAccountCancellationByUsername = ({
+ username,
+ capabilities
+}: AccountCancellationResolverInput): AccountCancellationResolveResult => {
+ const account = username ?? '';
+ if (!account.trim()) {
+ return {
+ status: 'unsupported',
+ accountKind: 'invalid',
+ unsupportedReason: 'empty_username'
+ };
+ }
+
+ const result = resolveAccountVerificationByUsername({
+ username: account,
+ capabilities,
+ allowPasswordFallback: false
+ });
+ if (result.status === 'unsupported' || result.method === 'oldPassword') {
+ return {
+ status: 'unsupported',
+ accountKind: result.accountKind,
+ unsupportedReason: 'verification_unavailable'
+ };
+ }
+
+ 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..5ed5f9bea182
--- /dev/null
+++ b/packages/global/support/user/account/cancellation/type.ts
@@ -0,0 +1,107 @@
+import { z } from 'zod';
+import type {
+ AccountVerificationCapabilities,
+ AccountVerificationMethod
+} from '../verification/type';
+import {
+ AccountCancellationStatusEnum,
+ AccountCancellationUnavailableReasonEnum,
+ accountCancellationActiveStatuses,
+ accountCancellationAllowedMethods,
+ accountCancellationTimezone
+} from './constants';
+
+export const AccountCancellationStatusSchema = z.enum([
+ AccountCancellationStatusEnum.pending,
+ AccountCancellationStatusEnum.finalizing,
+ AccountCancellationStatusEnum.completed
+]);
+export type AccountCancellationStatus = z.infer;
+
+export const AccountCancellationPublicStatusSchema = z.enum(['none', 'pending']);
+export type AccountCancellationPublicStatus = z.infer;
+
+export const TeamAccountCancellationStatusSchema = AccountCancellationStatusSchema.exclude([
+ AccountCancellationStatusEnum.completed
+]);
+export type TeamAccountCancellationStatus = z.infer;
+
+export const AccountCancellationAllowedMethodSchema = z.enum(accountCancellationAllowedMethods);
+export type AccountCancellationAllowedMethod = z.infer<
+ typeof AccountCancellationAllowedMethodSchema
+>;
+
+export const AccountCancellationUnavailableReasonSchema = z.enum([
+ AccountCancellationUnavailableReasonEnum.featureDisabled,
+ AccountCancellationUnavailableReasonEnum.unsupportedTeamMode,
+ AccountCancellationUnavailableReasonEnum.rootAccount,
+ AccountCancellationUnavailableReasonEnum.accountForbidden,
+ AccountCancellationUnavailableReasonEnum.emptyUsername,
+ AccountCancellationUnavailableReasonEnum.verificationUnavailable
+]);
+export type AccountCancellationUnavailableReason = z.infer<
+ typeof AccountCancellationUnavailableReasonSchema
+>;
+
+export type AccountCancellationRecordType = {
+ _id: string;
+ userId: string;
+ status: AccountCancellationStatus;
+ requestedAt: Date;
+};
+
+export type AccountCancellationSchedule = {
+ requestedAt: Date;
+ waitEndsAt: Date;
+ cleanupLocalDate: string;
+ sevenDayReminderAt: Date;
+ oneDayReminderAt: Date;
+ finalNoticeAt: Date;
+ scheduledCancelAt: Date;
+ timezone: string;
+};
+
+export type AccountCancellationUserState = {
+ status: 'pending';
+ requestedAt: Date;
+ scheduledCancelAt?: Date;
+ canCancelCancellation: boolean;
+};
+
+export type TeamAccountCancellationSummary = {
+ status: TeamAccountCancellationStatus;
+ scheduledCancelAt?: Date | string;
+};
+
+export type AccountCancellationResolverInput = {
+ username?: string | null;
+ capabilities: AccountVerificationCapabilities;
+};
+
+export type AccountCancellationResolveResult =
+ | {
+ status: 'supported';
+ method: AccountCancellationAllowedMethod;
+ accountKind: string;
+ unsupportedReason?: undefined;
+ }
+ | {
+ status: 'unsupported';
+ method?: undefined;
+ accountKind: 'invalid' | string;
+ unsupportedReason: 'empty_username' | 'verification_unavailable';
+ };
+
+export type AccountCancellationAccessPreset =
+ | 'normal'
+ | 'selfCancellation'
+ | 'teamEscape'
+ | 'tokenLogin';
+
+export type AccountCancellationVerificationMethod = Exclude<
+ AccountVerificationMethod,
+ 'oldPassword'
+>;
+
+export const accountCancellationDefaultTimezone = accountCancellationTimezone;
+export const accountCancellationActiveStatusValues = accountCancellationActiveStatuses;
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..8ad548f40fbc
--- /dev/null
+++ b/packages/global/support/user/account/cancellation/utils.ts
@@ -0,0 +1,253 @@
+import {
+ accountCancellationTimezone,
+ accountCancellationWaitDays,
+ AccountCancellationReminderEnum
+} from './constants';
+import type { 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: AccountCancellationReminderEnum;
+ timeZone?: string;
+}) => {
+ const schedule = deriveAccountCancellationSchedule(requestedAt, timeZone);
+ if (reminder === AccountCancellationReminderEnum.sevenDays) return schedule.sevenDayReminderAt;
+ if (reminder === AccountCancellationReminderEnum.oneDay) return schedule.oneDayReminderAt;
+ return schedule.finalNoticeAt;
+};
+
+/**
+ * 反推出指定自然日应发送某类提醒的 requestedAt 半开区间,供数据库范围查询使用。
+ * 区间按配置时区的自然日计算,避免受服务进程时区影响。
+ */
+export const getAccountCancellationReminderRequestedAtWindow = ({
+ now,
+ reminder,
+ timeZone = accountCancellationTimezone
+}: {
+ now: Date;
+ reminder: AccountCancellationReminderEnum;
+ timeZone?: string;
+}) => {
+ const reminderDaysBeforeCleanup = (() => {
+ if (reminder === AccountCancellationReminderEnum.sevenDays) return 7;
+ if (reminder === AccountCancellationReminderEnum.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) =>
+ method === 'code' || method === 'wechat' || method.startsWith('oauth/');
+
+/**
+ * 判断用户名是否由账号注销流程生成,同时兼容已落库的历史匿名用户名格式。
+ */
+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
new file mode 100644
index 000000000000..5d23a1c09339
--- /dev/null
+++ b/packages/global/support/user/account/verification/constants.ts
@@ -0,0 +1,53 @@
+export enum AccountVerificationMaterialTypeEnum {
+ register = 'register',
+ findPassword = 'findPassword',
+ wxLogin = 'wxLogin',
+ bindNotification = 'bindNotification',
+ captcha = 'captcha',
+ login = 'login',
+ oauthLogin = 'oauthLogin',
+ accountCancellation = 'accountCancellation',
+ passwordChange = 'passwordChange'
+}
+
+export enum AccountVerificationMethodEnum {
+ code = 'code',
+ oldPassword = 'oldPassword',
+ wechat = 'wechat',
+ oauthGithub = 'oauth/github',
+ oauthGoogle = 'oauth/google',
+ oauthMicrosoft = 'oauth/microsoft',
+ oauthWecom = 'oauth/wecom',
+ oauthSso = 'oauth/sso'
+}
+
+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;
+
+export const oauthAccountVerificationProviders = [
+ '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
new file mode 100644
index 000000000000..ada7865ca1ef
--- /dev/null
+++ b/packages/global/support/user/account/verification/type.ts
@@ -0,0 +1,89 @@
+import { z } from 'zod';
+import {
+ accountVerificationMethods,
+ oauthAccountVerificationProviders,
+ recognizedAccountKinds
+} from './constants';
+
+export const AccountVerificationMethodSchema = z.enum(accountVerificationMethods);
+export type AccountVerificationMethod = z.infer;
+
+export const AccountEmailUsernameSchema = z.email().max(254);
+export const AccountPhoneUsernameSchema = z.string().regex(/^1[3456789]\d{9}$/);
+export const AccountContactUsernameSchema = z.union([
+ AccountEmailUsernameSchema,
+ AccountPhoneUsernameSchema
+]);
+
+export const AccountVerificationCapabilitiesSchema = z.object({
+ emailCode: z.boolean(),
+ phoneCode: z.boolean(),
+ accountCancellation: z.boolean().optional(),
+ 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 AccountKindSchema = z.union([RecognizedAccountKindSchema, z.literal('invalid')]);
+export type AccountKind = z.infer;
+
+export const AccountVerificationUnsupportedReasonSchema = z.enum([
+ 'empty_username',
+ 'no_available_verification_method'
+]);
+
+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: AccountKindSchema,
+ method: z.undefined().optional(),
+ unsupportedReason: AccountVerificationUnsupportedReasonSchema
+ })
+]);
+export type AccountVerificationResolution = z.infer;
+
+export type AccountVerificationPasswordPolicy =
+ | {
+ allowPasswordFallback: false;
+ oldPasswordAvailable?: never;
+ }
+ | {
+ allowPasswordFallback: true;
+ oldPasswordAvailable: boolean;
+ };
+
+export const CodeAccountVerificationSceneSchema = z.enum([
+ 'register',
+ 'findPassword',
+ 'bindNotification',
+ 'accountCancellation',
+ 'passwordChange'
+]);
+export type CodeAccountVerificationScene = z.infer;
+
+export const AccountVerificationPurposeSchema = z.enum([
+ 'login',
+ 'accountCancellation',
+ 'passwordChange'
+]);
+export type AccountVerificationPurpose = z.infer;
+
+export const OAuthAccountVerificationProviderSchema = z.enum(oauthAccountVerificationProviders);
+export type OAuthAccountVerificationProvider = z.infer<
+ typeof OAuthAccountVerificationProviderSchema
+>;
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..5a1fecb84506
--- /dev/null
+++ b/packages/global/support/user/account/verification/utils.ts
@@ -0,0 +1,140 @@
+import {
+ AccountEmailUsernameSchema,
+ AccountPhoneUsernameSchema,
+ type AccountKind,
+ type AccountVerificationCapabilities,
+ type AccountVerificationMethod,
+ type AccountVerificationPasswordPolicy,
+ type AccountVerificationResolution
+} from './type';
+
+/**
+ * 根据持久化 username 和 SSO 配置状态识别账号类型。
+ * 邮箱、手机号和已知第三方前缀优先于通用 SSO 连字符规则。
+ */
+export const resolveAccountKindByUsername = ({
+ username,
+ ssoConfigured
+}: {
+ username: string;
+ ssoConfigured: boolean;
+}): AccountKind => {
+ const normalizedUsername = username.trim();
+ if (!normalizedUsername) return 'invalid';
+
+ const firstSeparatorIndex = normalizedUsername.indexOf('-');
+ const prefix =
+ firstSeparatorIndex > 0 && firstSeparatorIndex < normalizedUsername.length - 1
+ ? normalizedUsername.slice(0, firstSeparatorIndex)
+ : undefined;
+
+ if (AccountEmailUsernameSchema.safeParse(normalizedUsername).success) return 'email';
+ if (AccountPhoneUsernameSchema.safeParse(normalizedUsername).success) return 'phone';
+ if (prefix === 'wechat') return 'wechat';
+ if (prefix === 'git') return 'github';
+ if (prefix === 'google') return 'google';
+ if (prefix === 'microsoft') return 'microsoft';
+ if (prefix === 'wecom') return 'wecom';
+ if (ssoConfigured && prefix) return 'sso';
+ return 'local';
+};
+
+/**
+ * 根据持久化 username 和部署能力推导唯一验证方式。
+ * 该纯函数只做分类和降级,不读取运行环境,也不改写传入的 username。
+ */
+export const resolveAccountVerificationByUsername = ({
+ username,
+ capabilities,
+ allowPasswordFallback,
+ oldPasswordAvailable
+}: {
+ username: string;
+ capabilities: AccountVerificationCapabilities;
+} & AccountVerificationPasswordPolicy): AccountVerificationResolution => {
+ const accountKind = resolveAccountKindByUsername({
+ username,
+ ssoConfigured: capabilities.oauth.sso
+ });
+ if (accountKind === 'invalid') {
+ return {
+ status: 'unsupported',
+ accountKind: 'invalid',
+ unsupportedReason: 'empty_username'
+ };
+ }
+
+ type ConfiguredAccountVerificationMethod = Exclude;
+
+ const candidateMethods: readonly ConfiguredAccountVerificationMethod[] = (() => {
+ switch (accountKind) {
+ case 'email':
+ case 'phone':
+ return ['code'] as const;
+ case 'local':
+ return [];
+ case 'wechat':
+ return ['wechat'] as const;
+ case 'github':
+ return ['oauth/github'] as const;
+ case 'google':
+ return ['oauth/google'] as const;
+ case 'microsoft':
+ return ['oauth/microsoft'] as const;
+ case 'sso':
+ return ['oauth/sso'] as const;
+ case 'wecom':
+ return ['oauth/sso', 'oauth/wecom'] as const;
+ default: {
+ const exhaustiveAccountKind: never = accountKind;
+ return exhaustiveAccountKind;
+ }
+ }
+ })();
+
+ const isMethodAvailable = (method: ConfiguredAccountVerificationMethod) => {
+ 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;
+ }
+ }
+ };
+
+ const method = candidateMethods.find(isMethodAvailable);
+ if (method) {
+ return {
+ status: 'supported',
+ accountKind,
+ method
+ };
+ }
+
+ if (allowPasswordFallback && oldPasswordAvailable) {
+ return {
+ status: 'supported',
+ accountKind,
+ method: 'oldPassword'
+ };
+ }
+
+ return {
+ status: 'unsupported',
+ accountKind,
+ unsupportedReason: 'no_available_verification_method'
+ };
+};
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..cc4f4124e75c 100644
--- a/packages/global/support/user/audit/type.ts
+++ b/packages/global/support/user/audit/type.ts
@@ -1,19 +1,26 @@
import type { SourceMemberType } from '../type';
-import type { AuditEventEnum } from './constants';
+import type { AdminAuditEventEnum, AuditEventEnum } from './constants';
-export type TeamAuditSchemaType = {
+export type TeamAuditEvent = `${AuditEventEnum}`;
+export type AdminAuditEvent = `${AdminAuditEventEnum}`;
+export type AuditEvent = TeamAuditEvent | AdminAuditEvent;
+
+export type AuditSchemaType = {
_id: string;
tmbId: string;
teamId: string;
timestamp: Date;
- event: `${AuditEventEnum}`;
- metadata?: Record;
+ event: TEvent;
+ metadata?: Record;
};
-export type TeamAuditListItemType = {
+export type AuditListItemType = {
_id: string;
sourceMember: SourceMemberType;
- event: `${AuditEventEnum}`;
+ event: TEvent;
timestamp: Date;
- metadata: Record;
+ metadata: Record;
};
+
+export type TeamAuditListItemType = AuditListItemType;
+export type AdminAuditListItemType = AuditListItemType;
diff --git a/packages/global/support/user/auth/constants.ts b/packages/global/support/user/auth/constants.ts
index 7d909b71613e..f59f1bd212d7 100644
--- a/packages/global/support/user/auth/constants.ts
+++ b/packages/global/support/user/auth/constants.ts
@@ -4,7 +4,8 @@ export enum UserAuthTypeEnum {
wxLogin = 'wxLogin',
bindNotification = 'bindNotification',
captcha = 'captcha',
- login = 'login'
+ login = 'login',
+ oauthLogin = 'oauthLogin'
}
export const userAuthTypeMap = {
@@ -13,5 +14,6 @@ export const userAuthTypeMap = {
[UserAuthTypeEnum.wxLogin]: 'wxLogin',
[UserAuthTypeEnum.bindNotification]: 'bindNotification',
[UserAuthTypeEnum.captcha]: 'captcha',
- [UserAuthTypeEnum.login]: 'login'
+ [UserAuthTypeEnum.login]: 'login',
+ [UserAuthTypeEnum.oauthLogin]: 'oauthLogin'
};
diff --git a/packages/global/support/user/auth/type.ts b/packages/global/support/user/auth/type.ts
deleted file mode 100644
index 829acf106294..000000000000
--- a/packages/global/support/user/auth/type.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { UserAuthTypeEnum } from './constants';
-
-export type UserAuthSchemaType = {
- key: string;
- type: `${UserAuthTypeEnum}`;
- code?: string;
- openid?: string;
- createTime: Date;
- expiredTime: Date;
-};
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/support/user/type.ts b/packages/global/support/user/type.ts
index e79421db69c1..876640fc8d14 100644
--- a/packages/global/support/user/type.ts
+++ b/packages/global/support/user/type.ts
@@ -17,7 +17,7 @@ export type UserMetaType = {
export type UserModelSchema = {
_id: string;
username: string;
- password: string;
+ password?: string;
promotionRate: number;
inviterId?: string;
openaiKey: string;
@@ -43,7 +43,9 @@ export const UserSchema = z.object({
team: TeamTmbItemSchema,
permission: z.instanceof(TeamPermission),
contact: z.string().optional(),
- tags: z.array(UserTagsSchema).optional()
+ tags: z.array(UserTagsSchema).optional(),
+ hasPassword: z.boolean(),
+ passwordAvailable: z.boolean().optional()
});
export type UserType = z.infer;
diff --git a/packages/global/support/user/utils.ts b/packages/global/support/user/utils.ts
index b0db78a92d95..54d688f21c46 100644
--- a/packages/global/support/user/utils.ts
+++ b/packages/global/support/user/utils.ts
@@ -14,3 +14,7 @@ export const getRandomUserAvatar = () => {
return defaultAvatars[Math.floor(Math.random() * defaultAvatars.length)];
};
+
+/** 统一判断持久化密码是否存在;历史缺失、null 和空字符串都按未设置处理。 */
+export const hasStoredPassword = (password: unknown): password is string =>
+ typeof password === 'string' && password.length > 0;
diff --git a/packages/global/test/common/error/utils.test.ts b/packages/global/test/common/error/utils.test.ts
index 9eec71792f37..d531ff3a600d 100644
--- a/packages/global/test/common/error/utils.test.ts
+++ b/packages/global/test/common/error/utils.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { getErrText, ToastHandledError, UserError } from '@fastgpt/global/common/error/utils';
import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
describe('getErrText', () => {
it('should return mapped message for error enum', () => {
@@ -100,6 +101,20 @@ describe('UserError', () => {
});
});
+describe('verification error responses', () => {
+ it.each([
+ [UserErrEnum.invalidVerificationCode, 400],
+ [UserErrEnum.sendVerificationCodeTooFrequently, 429],
+ [UserErrEnum.verifyCodeTooFrequently, 429],
+ [UserErrEnum.newPasswordSameAsOld, 400]
+ ] as const)('maps %s to HTTP %s', (error, httpStatus) => {
+ expect(ERROR_RESPONSE[error]).toMatchObject({
+ statusText: error,
+ httpStatus
+ });
+ });
+});
+
describe('ToastHandledError', () => {
it('should set name to ToastHandledError', () => {
const err = new ToastHandledError('handled');
diff --git a/packages/global/test/openapi/admin/routes/settings/api.test.ts b/packages/global/test/openapi/admin/routes/settings/api.test.ts
new file mode 100644
index 000000000000..fbcd68e8117f
--- /dev/null
+++ b/packages/global/test/openapi/admin/routes/settings/api.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { UpdateConfigBodySchema } from '../../../../../openapi/admin/routes/settings/api';
+
+const createConfig = () => ({
+ fastgpt: {
+ feConfigs: {
+ uploadFileMaxAmount: 10,
+ uploadFileMaxSize: 100,
+ sso: {
+ url: 'https://sso.example.com',
+ disablePasswordForSsoUsers: true
+ }
+ },
+ systemEnv: {}
+ },
+ fastgptPro: {
+ teamMode: 'multi',
+ auth: {
+ email: {
+ enabled: true,
+ register: false,
+ notification: true,
+ smtp: 'smtp.example.com',
+ user: 'mailer',
+ pass: 'secret'
+ }
+ }
+ }
+});
+
+describe('UpdateConfigBodySchema', () => {
+ it('accepts the account configuration contract', () => {
+ expect(UpdateConfigBodySchema.safeParse(createConfig()).success).toBe(true);
+ });
+
+ it('rejects malformed channel switches and attempts to submit a license', () => {
+ const malformedSwitch = createConfig();
+ malformedSwitch.fastgptPro.auth.email.enabled = 'true' as unknown as boolean;
+ expect(UpdateConfigBodySchema.safeParse(malformedSwitch).success).toBe(false);
+
+ const forgedLicense = createConfig() as ReturnType & {
+ fastgptPro: ReturnType['fastgptPro'] & { license: string };
+ };
+ forgedLicense.fastgptPro.license = 'forged-license';
+ expect(UpdateConfigBodySchema.safeParse(forgedLicense).success).toBe(false);
+ });
+});
diff --git a/packages/global/test/openapi/admin/support/user/audit/api.test.ts b/packages/global/test/openapi/admin/support/user/audit/api.test.ts
new file mode 100644
index 000000000000..48a9ee39024d
--- /dev/null
+++ b/packages/global/test/openapi/admin/support/user/audit/api.test.ts
@@ -0,0 +1,31 @@
+import {
+ AuditEventEnum,
+ AdminAuditEventEnum
+} from '../../../../../../support/user/audit/constants';
+import { AdminAuditListBodySchema } from '../../../../../../openapi/admin/support/user/audit/api';
+import { describe, expect, it } from 'vitest';
+
+describe('AdminAuditListBodySchema', () => {
+ it('accepts admin events', () => {
+ expect(
+ AdminAuditListBodySchema.safeParse({
+ pageNum: 1,
+ pageSize: 20,
+ events: [AdminAuditEventEnum.ADMIN_LOGIN]
+ }).success
+ ).toBe(true);
+ });
+
+ it('rejects team events and extra fields', () => {
+ expect(
+ AdminAuditListBodySchema.safeParse({
+ pageNum: 1,
+ pageSize: 20,
+ events: [AuditEventEnum.LOGIN]
+ }).success
+ ).toBe(false);
+ expect(
+ AdminAuditListBodySchema.safeParse({ pageNum: 1, pageSize: 20, unexpected: true }).success
+ ).toBe(false);
+ });
+});
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..1a7a202789ea
--- /dev/null
+++ b/packages/global/test/openapi/support/user/account/cancellation/api.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import {
+ CreateAccountCancellationVerificationBodySchema,
+ SubmitAccountCancellationBodySchema
+} 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();
+ });
+});
diff --git a/packages/global/test/openapi/support/user/account/password/api.test.ts b/packages/global/test/openapi/support/user/account/password/api.test.ts
new file mode 100644
index 000000000000..6a705e5f2cf6
--- /dev/null
+++ b/packages/global/test/openapi/support/user/account/password/api.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, expectTypeOf, it } from 'vitest';
+import {
+ CreatePasswordVerificationBodySchema,
+ PasswordAuthorizationBodySchema,
+ SensitiveAccountVerificationBodySchema,
+ UpdatePasswordBodySchema
+} from '@fastgpt/global/openapi/support/user/account/password/api';
+import type {
+ CreatePasswordVerificationBody,
+ CreatePasswordVerificationResponse,
+ PasswordAuthorizationBody,
+ SensitiveAccountVerificationBody
+} from '@fastgpt/global/openapi/support/user/account/password/api';
+
+describe('password API contracts', () => {
+ it('preserves the inferred verification contracts', () => {
+ expectTypeOf().not.toBeAny();
+ expectTypeOf().not.toBeAny();
+ expectTypeOf().not.toBeAny();
+
+ type AccountVerificationAuthorization = Extract<
+ PasswordAuthorizationBody,
+ { source: 'accountVerification' }
+ >;
+ expectTypeOf().toEqualTypeOf<{
+ source: 'accountVerification';
+ verification: SensitiveAccountVerificationBody;
+ }>();
+ });
+
+ it('accepts an empty old-password create payload without client identity fields', () => {
+ expect(
+ CreatePasswordVerificationBodySchema.parse({ method: 'oldPassword', payload: {} })
+ ).toEqual({ method: 'oldPassword', payload: {} });
+ expect(() =>
+ CreatePasswordVerificationBodySchema.parse({
+ method: 'oldPassword',
+ payload: {},
+ username: 'other-user'
+ })
+ ).toThrow();
+ });
+
+ it('requires a bounded password digest and pre-login code for old-password consume', () => {
+ const password = 'a'.repeat(64);
+ expect(
+ SensitiveAccountVerificationBodySchema.parse({
+ method: 'oldPassword',
+ payload: { password, preLoginCode: 'pre-login-code' }
+ })
+ ).toMatchObject({ method: 'oldPassword' });
+ expect(() =>
+ SensitiveAccountVerificationBodySchema.parse({
+ method: 'oldPassword',
+ payload: { password: 'plain-text', preLoginCode: 'pre-login-code' }
+ })
+ ).toThrow();
+ });
+
+ it('keeps the verification-flow initializer strict', () => {
+ expect(PasswordAuthorizationBodySchema.parse({ source: 'verificationMethod' })).toEqual({
+ source: 'verificationMethod'
+ });
+ expect(() =>
+ PasswordAuthorizationBodySchema.parse({
+ source: 'verificationMethod',
+ userId: 'other-user'
+ })
+ ).toThrow();
+ expect(() => PasswordAuthorizationBodySchema.parse({ source: 'recentLogin' })).toThrow();
+ });
+
+ it('requires a SHA-256 digest and a bounded authorization token for updates', () => {
+ expect(
+ UpdatePasswordBodySchema.parse({
+ newPsw: 'b'.repeat(64),
+ passwordChangeToken: 'token'
+ })
+ ).toBeDefined();
+ expect(() =>
+ UpdatePasswordBodySchema.parse({ newPsw: 'short', passwordChangeToken: 'token' })
+ ).toThrow();
+ expect(() =>
+ UpdatePasswordBodySchema.parse({
+ newPsw: 'b'.repeat(64),
+ passwordChangeToken: 'token',
+ confirmPsw: 'b'.repeat(64)
+ })
+ ).toThrow();
+ });
+});
diff --git a/packages/global/test/openapi/support/user/audit/api.test.ts b/packages/global/test/openapi/support/user/audit/api.test.ts
new file mode 100644
index 000000000000..5f2ac39a8c87
--- /dev/null
+++ b/packages/global/test/openapi/support/user/audit/api.test.ts
@@ -0,0 +1,28 @@
+import { AuditEventEnum, AdminAuditEventEnum } from '../../../../../support/user/audit/constants';
+import { TeamAuditListBodySchema } from '../../../../../openapi/support/user/audit/api';
+import { describe, expect, it } from 'vitest';
+
+describe('TeamAuditListBodySchema', () => {
+ it('accepts team events', () => {
+ expect(
+ TeamAuditListBodySchema.safeParse({
+ pageNum: 1,
+ pageSize: 20,
+ events: [AuditEventEnum.LOGIN]
+ }).success
+ ).toBe(true);
+ });
+
+ it('rejects admin events and extra fields', () => {
+ expect(
+ TeamAuditListBodySchema.safeParse({
+ pageNum: 1,
+ pageSize: 20,
+ events: [AdminAuditEventEnum.ADMIN_LOGIN]
+ }).success
+ ).toBe(false);
+ expect(
+ TeamAuditListBodySchema.safeParse({ pageNum: 1, pageSize: 20, unexpected: true }).success
+ ).toBe(false);
+ });
+});
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..a669e46a2e4a
--- /dev/null
+++ b/packages/global/test/support/user/account/cancellation/resolver.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type';
+import { resolveAccountCancellationByUsername } from '@fastgpt/global/support/user/account/cancellation/resolver';
+
+const capabilities: AccountVerificationCapabilities = {
+ 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('disables password fallback for a local account', () => {
+ expect(
+ resolveAccountCancellationByUsername({
+ username: 'local',
+ capabilities
+ })
+ ).toEqual({
+ status: 'unsupported',
+ accountKind: 'local',
+ unsupportedReason: 'verification_unavailable'
+ });
+ });
+
+ 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: 'verification_unavailable'
+ });
+ });
+});
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..c16294928b65
--- /dev/null
+++ b/packages/global/test/support/user/account/cancellation/utils.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest';
+import { AccountCancellationReminderEnum } 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: AccountCancellationReminderEnum.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: AccountCancellationReminderEnum.sevenDays
+ })
+ ).toEqual({
+ start: new Date('2026-06-30T16:00:00.000Z'),
+ end: new Date('2026-07-01T16:00:00.000Z')
+ });
+ expect(
+ getAccountCancellationReminderRequestedAtWindow({
+ now,
+ reminder: AccountCancellationReminderEnum.oneDay
+ })
+ ).toEqual({
+ start: new Date('2026-06-24T16:00:00.000Z'),
+ end: new Date('2026-06-25T16:00:00.000Z')
+ });
+ expect(
+ getAccountCancellationReminderRequestedAtWindow({
+ now,
+ reminder: AccountCancellationReminderEnum.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: AccountCancellationReminderEnum.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/oauthApi.test.ts b/packages/global/test/support/user/account/verification/oauthApi.test.ts
new file mode 100644
index 000000000000..f9d2d82571c2
--- /dev/null
+++ b/packages/global/test/support/user/account/verification/oauthApi.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from 'vitest';
+import {
+ CreateOauthLoginBodySchema,
+ OauthLoginBodySchema
+} from '@fastgpt/global/openapi/support/user/account/login/api';
+
+describe('OAuth login API contracts', () => {
+ it('accepts only supported OAuth providers and a callback URL', () => {
+ expect(
+ CreateOauthLoginBodySchema.parse({
+ provider: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider'
+ })
+ ).toEqual({
+ provider: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ isWecomWorkTerminal: false
+ });
+ expect(
+ CreateOauthLoginBodySchema.safeParse({
+ provider: 'wechat',
+ callbackUrl: 'https://fastgpt.example.com/login/provider'
+ }).success
+ ).toBe(false);
+ });
+
+ it('requires state for direct OAuth providers and allows legacy SSO without it', () => {
+ const state = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG';
+ expect(
+ OauthLoginBodySchema.safeParse({
+ provider: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ code: 'provider-code',
+ state
+ }).success
+ ).toBe(true);
+ expect(
+ OauthLoginBodySchema.safeParse({
+ provider: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ code: 'provider-code'
+ }).success
+ ).toBe(false);
+ expect(
+ OauthLoginBodySchema.safeParse({
+ provider: 'sso',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ code: 'provider-code'
+ }).success
+ ).toBe(true);
+ expect(
+ OauthLoginBodySchema.safeParse({
+ type: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ props: { code: 'legacy-code' }
+ }).success
+ ).toBe(false);
+ });
+
+ it('limits SSO callback fields and rejects reserved or malformed keys', () => {
+ const base = {
+ provider: 'sso',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ code: 'provider-code',
+ state: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'
+ };
+ expect(
+ OauthLoginBodySchema.safeParse({
+ ...base,
+ props: Object.fromEntries(
+ Array.from({ length: 21 }, (_, index) => [`key${index}`, 'value'])
+ )
+ }).success
+ ).toBe(false);
+ expect(
+ OauthLoginBodySchema.safeParse({
+ ...base,
+ props: { value: 'x'.repeat(4097) }
+ }).success
+ ).toBe(false);
+ expect(
+ OauthLoginBodySchema.safeParse({ ...base, props: { code: 'cannot-override' } }).success
+ ).toBe(false);
+ expect(
+ OauthLoginBodySchema.safeParse({ ...base, props: { 'invalid key': 'value' } }).success
+ ).toBe(false);
+ });
+
+ it('rejects unknown top-level OAuth fields', () => {
+ expect(
+ OauthLoginBodySchema.safeParse({
+ provider: 'github',
+ callbackUrl: 'https://fastgpt.example.com/login/provider',
+ code: 'provider-code',
+ state: 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
+ username: 'forged-user'
+ }).success
+ ).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..957c9b859c08
--- /dev/null
+++ b/packages/global/test/support/user/account/verification/utils.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from 'vitest';
+import type { AccountVerificationCapabilities } from '@fastgpt/global/support/user/account/verification/type';
+import {
+ resolveAccountKindByUsername,
+ resolveAccountVerificationByUsername
+} from '@fastgpt/global/support/user/account/verification/utils';
+
+const capabilities = {
+ emailCode: true,
+ phoneCode: true,
+ wechat: true,
+ oauth: {
+ github: true,
+ google: true,
+ microsoft: true,
+ wecom: true,
+ sso: true
+ }
+} satisfies AccountVerificationCapabilities;
+
+type CapabilityOverrides = Partial> & {
+ oauth?: Partial;
+};
+
+const resolve = (username: string, overrides: CapabilityOverrides = {}) =>
+ resolveAccountVerificationByUsername({
+ username,
+ capabilities: {
+ ...capabilities,
+ ...overrides,
+ oauth: {
+ ...capabilities.oauth,
+ ...overrides.oauth
+ }
+ },
+ allowPasswordFallback: true,
+ oldPasswordAvailable: true
+ });
+
+describe('resolveAccountKindByUsername', () => {
+ it.each([
+ ['', true, 'invalid'],
+ [' ', true, 'invalid'],
+ ['user@example.com', true, 'email'],
+ ['user-name@example-domain.com', true, 'email'],
+ ['13800138000', true, 'phone'],
+ ['wechat-openid', false, 'wechat'],
+ ['git-octocat', false, 'github'],
+ ['google-sub', false, 'google'],
+ ['microsoft-id', false, 'microsoft'],
+ ['wecom-id', false, 'wecom'],
+ ['customer-user', true, 'sso'],
+ ['customer-user-extra', true, 'sso'],
+ ['customer-user', false, 'local'],
+ ['Git-user', true, 'sso'],
+ ['local', true, 'local'],
+ ['-leading', true, 'local'],
+ ['trailing-', true, 'local'],
+ ['git-', true, 'local'],
+ [' customer-user ', true, 'sso']
+ ])('classifies %j with ssoConfigured=%s as %s', (username, ssoConfigured, accountKind) => {
+ expect(resolveAccountKindByUsername({ username, ssoConfigured })).toBe(accountKind);
+ });
+});
+
+describe('resolveAccountVerificationByUsername', () => {
+ it.each(['', ' '])('rejects an empty username: %j', (username) => {
+ expect(resolve(username)).toEqual({
+ status: 'unsupported',
+ accountKind: 'invalid',
+ unsupportedReason: 'empty_username'
+ });
+ });
+
+ it.each([
+ ['user@example.com', 'email', 'code'],
+ ['user-name@example-domain.com', 'email', 'code'],
+ ['13800138000', 'phone', 'code'],
+ ['local', 'local', 'oldPassword'],
+ ['-leading', 'local', 'oldPassword'],
+ ['trailing-', 'local', 'oldPassword'],
+ ['wechat-openid', 'wechat', 'wechat'],
+ ['git-octocat', 'github', 'oauth/github'],
+ ['google-sub', 'google', 'oauth/google'],
+ ['microsoft-id', 'microsoft', 'oauth/microsoft'],
+ ['wecom-id', 'wecom', 'oauth/sso'],
+ ['customer-user', 'sso', 'oauth/sso']
+ ])('resolves %s to one method', (username, accountKind, method) => {
+ expect(resolve(username)).toEqual({ status: 'supported', accountKind, method });
+ });
+
+ it('falls back to old password when a contact channel is unavailable', () => {
+ expect(resolve('user@example.com', { emailCode: false })).toMatchObject({
+ accountKind: 'email',
+ method: 'oldPassword'
+ });
+ expect(resolve('13800138000', { phoneCode: false })).toMatchObject({
+ accountKind: 'phone',
+ method: 'oldPassword'
+ });
+ });
+
+ it('requires both password fallback policy and a stored password', () => {
+ expect(
+ resolveAccountVerificationByUsername({
+ username: 'local',
+ capabilities,
+ allowPasswordFallback: true,
+ oldPasswordAvailable: false
+ })
+ ).toEqual({
+ status: 'unsupported',
+ accountKind: 'local',
+ unsupportedReason: 'no_available_verification_method'
+ });
+
+ expect(
+ resolveAccountVerificationByUsername({
+ username: 'local',
+ capabilities,
+ allowPasswordFallback: false
+ })
+ ).toEqual({
+ status: 'unsupported',
+ accountKind: 'local',
+ unsupportedReason: 'no_available_verification_method'
+ });
+ });
+
+ it('keeps a configured non-password method ahead of password fallback', () => {
+ expect(
+ resolveAccountVerificationByUsername({
+ username: 'user@example.com',
+ capabilities,
+ allowPasswordFallback: false
+ })
+ ).toEqual({ status: 'supported', accountKind: 'email', method: 'code' });
+ });
+
+ it.each([
+ ['wechat-openid', { wechat: false }, 'wechat'],
+ ['git-octocat', { oauth: { github: false } }, 'github'],
+ ['google-sub', { oauth: { google: false } }, 'google'],
+ ['microsoft-id', { oauth: { microsoft: false } }, 'microsoft']
+ ] as const)(
+ 'does not route a disabled direct provider through SSO: %s',
+ (username, overrides, accountKind) => {
+ expect(resolve(username, overrides)).toMatchObject({
+ accountKind,
+ method: 'oldPassword'
+ });
+ }
+ );
+
+ it('uses Wecom SSO, direct Wecom and old password in order', () => {
+ expect(resolve('wecom-id')).toMatchObject({ method: 'oauth/sso' });
+ expect(resolve('wecom-id', { oauth: { sso: false } })).toMatchObject({
+ method: 'oauth/wecom'
+ });
+ expect(
+ resolve('wecom-id', {
+ oauth: { sso: false, wecom: false }
+ })
+ ).toMatchObject({ method: 'oldPassword' });
+ });
+
+ it('treats unknown hyphenated names as local when SSO is unavailable', () => {
+ expect(resolve('customer-user', { oauth: { sso: false } })).toEqual({
+ status: 'supported',
+ accountKind: 'local',
+ method: 'oldPassword'
+ });
+ });
+
+ it.each(['Git-user', 'git-', 'wechat-', '1380013800', '138001380000'])(
+ 'applies strict provider and phone boundaries: %s',
+ (username) => {
+ const result = resolve(username, { oauth: { sso: false } });
+ expect(result).toMatchObject({
+ status: 'supported',
+ accountKind: 'local',
+ method: 'oldPassword'
+ });
+ }
+ );
+});
diff --git a/packages/global/test/support/user/utils.test.ts b/packages/global/test/support/user/utils.test.ts
index 0cd94c277718..b734c70b2067 100644
--- a/packages/global/test/support/user/utils.test.ts
+++ b/packages/global/test/support/user/utils.test.ts
@@ -1,46 +1,39 @@
-import { describe, it, expect } from 'vitest';
-import { getRandomUserAvatar } from '@fastgpt/global/support/user/utils';
+import { describe, expect, it } from 'vitest';
+import { getRandomUserAvatar, hasStoredPassword } from '@fastgpt/global/support/user/utils';
-describe('user/utils', () => {
- describe('getRandomUserAvatar', () => {
- const defaultAvatars = [
- '/imgs/avatar/RoyalBlueAvatar.svg',
- '/imgs/avatar/PurpleAvatar.svg',
- '/imgs/avatar/AdoraAvatar.svg',
- '/imgs/avatar/OrangeAvatar.svg',
- '/imgs/avatar/RedAvatar.svg',
- '/imgs/avatar/GrayModernAvatar.svg',
- '/imgs/avatar/TealAvatar.svg',
- '/imgs/avatar/GreenAvatar.svg',
- '/imgs/avatar/BrightBlueAvatar.svg',
- '/imgs/avatar/BlueAvatar.svg'
- ];
+describe('getRandomUserAvatar', () => {
+ const defaultAvatars = [
+ '/imgs/avatar/RoyalBlueAvatar.svg',
+ '/imgs/avatar/PurpleAvatar.svg',
+ '/imgs/avatar/AdoraAvatar.svg',
+ '/imgs/avatar/OrangeAvatar.svg',
+ '/imgs/avatar/RedAvatar.svg',
+ '/imgs/avatar/GrayModernAvatar.svg',
+ '/imgs/avatar/TealAvatar.svg',
+ '/imgs/avatar/GreenAvatar.svg',
+ '/imgs/avatar/BrightBlueAvatar.svg',
+ '/imgs/avatar/BlueAvatar.svg'
+ ];
- it('should return one of the default avatars', () => {
- const avatar = getRandomUserAvatar();
- expect(defaultAvatars).toContain(avatar);
- });
+ it('returns one of the default avatars', () => {
+ expect(defaultAvatars).toContain(getRandomUserAvatar());
+ });
+
+ it('returns a string', () => {
+ expect(typeof getRandomUserAvatar()).toBe('string');
+ });
- it('should return a string', () => {
- const avatar = getRandomUserAvatar();
- expect(typeof avatar).toBe('string');
- });
+ it('returns a valid avatar path', () => {
+ expect(getRandomUserAvatar()).toMatch(/^\/imgs\/avatar\/\w+Avatar\.svg$/);
+ });
+});
- it('should return different avatars on multiple calls (probabilistic)', () => {
- const results = new Set();
- // Call 50 times to get different avatars
- for (let i = 0; i < 50; i++) {
- results.add(getRandomUserAvatar());
- }
- // With 10 avatars and 50 calls, we should get at least 5 different ones
- expect(results.size).toBeGreaterThanOrEqual(5);
- });
+describe('hasStoredPassword', () => {
+ it.each([undefined, null, '', 0, false])('treats %j as no stored password', (password) => {
+ expect(hasStoredPassword(password)).toBe(false);
+ });
- it('should always return valid avatar path', () => {
- for (let i = 0; i < 20; i++) {
- const avatar = getRandomUserAvatar();
- expect(avatar).toMatch(/^\/imgs\/avatar\/\w+Avatar\.svg$/);
- }
- });
+ it.each(['digest', ' '])('treats a non-empty string as a stored password', (password) => {
+ expect(hasStoredPassword(password)).toBe(true);
});
});
diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts
index b7bd34936e23..5faca25095f8 100644
--- a/packages/service/common/bullmq/index.ts
+++ b/packages/service/common/bullmq/index.ts
@@ -1,5 +1,6 @@
import {
type ConnectionOptions,
+ DelayedError,
type Processor,
Queue,
type QueueOptions,
@@ -9,9 +10,11 @@ import {
} from 'bullmq';
import { getLogger, LogCategories } from '../logger';
import { newQueueRedisConnection, newWorkerRedisConnection } from '../redis';
+import { withRedisLease } from '../redis/lock';
import { delay } from '@fastgpt/global/common/system/utils';
const logger = getLogger(LogCategories.INFRA.QUEUE);
+const FAILED_JOB_RECOVERY_LEASE_TTL_MS = 30 * 1000;
const defaultWorkerOpts: Omit = {
removeOnComplete: {
@@ -34,6 +37,7 @@ export enum QueueNames {
appDelete = 'appDelete',
agentSkillDelete = 'agentSkillDelete',
teamDelete = 'teamDelete',
+ accountCancellation = 'accountCancellation',
// Publish
wechatPoll = 'wechatPoll',
@@ -81,6 +85,86 @@ export function getQueue(
return newQueue;
}
+/**
+ * 添加稳定 ID 任务;若同 ID 历史任务已失败,则在分布式租约内刷新数据并手动重试。
+ * 其它状态继续复用现有任务,避免并发生产者制造重复工作;租约竞争会向上抛错,
+ * 由调用方或下一轮扫描重试,不能把仍处于 failed 的任务误报为已入队。
+ */
+export async function addOrRequeueFailedJob({
+ queue,
+ name,
+ data,
+ opts
+}: {
+ queue: Queue;
+ name: Parameters['add']>[0];
+ data: Parameters['add']>[1];
+ opts: NonNullable['add']>[2]> & { jobId: string };
+}) {
+ /**
+ * getJob 与 getState 之间任务可能被 retention cleanup 删除。unknown 不能视为可复用状态;
+ * 重新读取后确认任务已不存在才允许使用相同稳定 ID 新建,仍存在的异常状态向上报错。
+ */
+ 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;
+
+ return withRedisLease({
+ key: `bullmq:failed-job-recovery:${queue.name}:${opts.jobId}`,
+ label: 'bullmq-failed-job-recovery',
+ ttlMs: FAILED_JOB_RECOVERY_LEASE_TTL_MS,
+ fn: async () => {
+ // 取得租约后重新读取,避免等待期间使用过期的 failed 状态或 Job 实例。
+ const current = await getJobWithConfirmedState();
+ if (!current) return queue.add(name, data, opts);
+ if (current.state !== 'failed') return current.job;
+ const currentJob = current.job;
+
+ // 保留 failed job,避免删除与重建之间进程退出造成任务永久丢失。
+ try {
+ // Queue.add 会提取 Job 联合定义的数据类型,Job.updateData 的公开泛型没有同步提取。
+ 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);
+}
+
export function getWorker(
name: QueueNames,
processor: Processor,
@@ -151,5 +235,5 @@ export function getWorker(
return newWorker;
}
-export { Queue, UnrecoverableError, Worker, delay };
+export { DelayedError, Queue, UnrecoverableError, Worker, delay };
export type { ConnectionOptions, Job, Processor, QueueOptions, WorkerOptions } from 'bullmq';
diff --git a/packages/service/common/http/entry.ts b/packages/service/common/http/entry.ts
index 3d4fc769e3b4..4c0ebc68b84a 100644
--- a/packages/service/common/http/entry.ts
+++ b/packages/service/common/http/entry.ts
@@ -167,14 +167,17 @@ export const createApiEntry = <
});
}
- span.setAttribute('http.response.status_code', 500);
- setSpanError(span, error);
-
- return jsonRes(res, {
+ const response = jsonRes(res, {
code: 500,
error,
url: req.url
});
+ span.setAttribute('http.response.status_code', res.statusCode);
+ if (res.statusCode >= 500) {
+ setSpanError(span, error);
+ }
+
+ return response;
}
}
)
diff --git a/packages/service/common/middle/tracks/utils.ts b/packages/service/common/middle/tracks/utils.ts
index 507e2e3c0270..014b776f8662 100644
--- a/packages/service/common/middle/tracks/utils.ts
+++ b/packages/service/common/middle/tracks/utils.ts
@@ -17,6 +17,24 @@ import type { StandardSubLevelEnum } from '@fastgpt/global/support/wallet/sub/co
const logger = getLogger(LogCategories.EVENT.TRACK);
+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', {
@@ -195,6 +213,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/response/index.ts b/packages/service/common/response/index.ts
index e20bc6a32401..5f3232bbacb9 100644
--- a/packages/service/common/response/index.ts
+++ b/packages/service/common/response/index.ts
@@ -43,12 +43,21 @@ function resolveHttpStatusForApiError(
return bc;
}
+ const raw = typeof error === 'string' ? error : error?.message;
+ const configuredHttpStatus = ERROR_RESPONSE[raw]?.httpStatus;
+ if (
+ typeof configuredHttpStatus === 'number' &&
+ configuredHttpStatus >= 400 &&
+ configuredHttpStatus <= 599
+ ) {
+ return configuredHttpStatus;
+ }
+
// packages/global/common/error/code/s3.ts:510000 段为上传校验类客户端错误
if (typeof bc === 'number' && bc >= 510000 && bc < 511000) {
return 400;
}
- const raw = typeof error === 'string' ? error : error?.message;
if (raw === 'EntityTooLarge') {
return 413;
}
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 c07287e4f905..9d41ddc8a7fe 100644
--- a/packages/service/common/system/timerLock/constants.ts
+++ b/packages/service/common/system/timerLock/constants.ts
@@ -17,6 +17,8 @@ export enum TimerIdEnum {
archiveInactiveSandboxes = 'archiveInactiveSandboxes',
clearStaleArchivingSandboxes = 'clearStaleArchivingSandboxes',
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 ef39910739f8..5ecefd126468 100644
--- a/packages/service/core/dataset/delete/processor.ts
+++ b/packages/service/core/dataset/delete/processor.ts
@@ -44,6 +44,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(
{
@@ -59,9 +64,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/env.ts b/packages/service/env.ts
index e4cce5daed6e..e520f85087f1 100644
--- a/packages/service/env.ts
+++ b/packages/service/env.ts
@@ -40,6 +40,8 @@ export const serviceEnv = createEnv({
// Invoke 反向调用相关。该密钥用于签发/校验插件反向调用 JWT,必须显式配置,避免未配置时落到公开默认值。
INVOKE_TOKEN_SECRET: z.string().min(32, 'INVOKE_TOKEN_SECRET must be at least 32 characters'),
+ // 新增 JWT 类型共用的签名密钥;每种 Token 仍需在业务入口校验独立 purpose。
+ JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
// ==================== 服务地址与集成 ====================
// 插件
diff --git a/packages/service/env.util.ts b/packages/service/env.util.ts
index d1fa0c403431..6b7129e297cc 100644
--- a/packages/service/env.util.ts
+++ b/packages/service/env.util.ts
@@ -5,6 +5,7 @@ import type { StorageDownloadUrlMode } from './common/s3/contracts/type';
import type { StorageVendorSchema } from './env.const';
const TEST_INVOKE_TOKEN_SECRET = 'fastgpt_test_invoke_token_secret_32';
+const TEST_JWT_SECRET = 'fastgpt_test_jwt_signing_secret_32_chars';
const TEST_PRO_TOKEN = 'fastgpt_test_pro_token_32_chars_min';
/**
* 测试套件会在多个 workspace(包含 pro/admin 子模块)里直接导入 serviceEnv。
@@ -18,6 +19,11 @@ export const getRuntimeEnv = (): NodeJS.ProcessEnv => ({
(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
? TEST_INVOKE_TOKEN_SECRET
: undefined),
+ JWT_SECRET:
+ process.env.JWT_SECRET ??
+ (process.env.VITEST === 'true' || process.env.NODE_ENV === 'test'
+ ? TEST_JWT_SECRET
+ : undefined),
PRO_TOKEN:
process.env.PRO_TOKEN ??
(process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' ? TEST_PRO_TOKEN : undefined)
diff --git a/packages/service/support/outLink/runtime/utils.ts b/packages/service/support/outLink/runtime/utils.ts
index 11c0a945856c..97ae76734b3f 100644
--- a/packages/service/support/outLink/runtime/utils.ts
+++ b/packages/service/support/outLink/runtime/utils.ts
@@ -41,6 +41,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 { assertAccountUsable } from '../../user/account/cancellation/guard';
const logger = getLogger(LogCategories.MODULE.OUTLINK);
@@ -113,6 +114,17 @@ const DEFAULT_REPLY = 'This is default reply';
export const STREAM_END_FLAG = '[DONE]';
export const STREAM_CACHE_KEY_PREFIX = 'streamResponse:';
+/** 分享链接没有用户 Session,使用发布链接绑定的 tmb/team 作为业务执行身份。 */
+export const assertOutLinkTeamUsable = async ({
+ teamId,
+ tmbId
+}: {
+ teamId: string;
+ tmbId: string;
+}) => {
+ await assertAccountUsable({ teamId, tmbId });
+};
+
export async function outlinkInvokeChat({
outLinkConfig,
chatId,
@@ -123,6 +135,10 @@ export async function outlinkInvokeChat({
onStreamChunk,
streamId
}: outLinkInvokeChatProps) {
+ await assertOutLinkTeamUsable({
+ teamId: String(outLinkConfig.teamId),
+ tmbId: String(outLinkConfig.tmbId)
+ });
const streamResKey = `${STREAM_CACHE_KEY_PREFIX}${streamId}`;
const roundState = {
preparedRound: undefined as PreChatRoundResult | undefined,
diff --git a/packages/service/support/permission/auth/common.ts b/packages/service/support/permission/auth/common.ts
index 83dd1dee9201..5655980e6c46 100644
--- a/packages/service/support/permission/auth/common.ts
+++ b/packages/service/support/permission/auth/common.ts
@@ -4,10 +4,12 @@ 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 { assertAccountUsable } from '../../user/account/cancellation/guard';
export const authCert = async (props: AuthModeType) => {
const result = await parseHeaderCert(props);
@@ -30,7 +32,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 +155,38 @@ export async function parseHeaderCert({
return Promise.reject(ERROR_ENUM.unAuthorization);
})();
- if (!authRoot && (!teamId || !tmbId)) {
+ let resolvedTeamId = teamId;
+ let resolvedTmbId = tmbId;
+ if (uid && sessionId && teamId && tmbId) {
+ const sessionTeam = await resolveUserSessionTeam({
+ userId: String(uid),
+ teamId: String(teamId),
+ tmbId: String(tmbId),
+ sessionId
+ });
+ resolvedTeamId = sessionTeam.teamId;
+ resolvedTmbId = sessionTeam.tmbId;
+ }
+
+ if (!authRoot && (!resolvedTeamId || !resolvedTmbId)) {
return Promise.reject(ERROR_ENUM.unAuthorization);
}
+ const accountCancellationGuard = resolveAccountCancellationAccess({
+ req,
+ accountCancellationAccess
+ });
+ await assertAccountUsable({
+ userId: uid ? String(uid) : undefined,
+ teamId: resolvedTeamId ? String(resolvedTeamId) : undefined,
+ tmbId: resolvedTmbId ? String(resolvedTmbId) : undefined,
+ ...accountCancellationGuard
+ });
+
return {
userId: String(uid),
- teamId: String(teamId),
- tmbId: String(tmbId),
+ teamId: String(resolvedTeamId),
+ tmbId: String(resolvedTmbId),
appId,
authType,
sourceName,
diff --git a/packages/service/support/permission/publish/authLink.ts b/packages/service/support/permission/publish/authLink.ts
index 0e0688990049..bca09639b0e0 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/runtime/utils';
/* 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..906d8d99362b
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/access.ts
@@ -0,0 +1,121 @@
+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'
+ | 'allowCurrentUserOwnedTeamAccountCancellationPending'
+ | 'allowCurrentSessionTeamAccountCancellationPending'
+ >
+ >;
+};
+
+export const accountCancellationAccessPresets: Record<
+ AccountCancellationAccessPreset,
+ AccessPreset
+> = {
+ normal: {
+ apis: [],
+ options: {
+ allowUserAccountCancellationPending: false,
+ allowCurrentUserOwnedTeamAccountCancellationPending: false,
+ allowCurrentSessionTeamAccountCancellationPending: 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,
+ allowCurrentUserOwnedTeamAccountCancellationPending: true,
+ allowCurrentSessionTeamAccountCancellationPending: true
+ }
+ },
+ teamEscape: {
+ apis: [
+ 'GET /proApi/support/user/team/list',
+ 'POST /proApi/support/user/team/switch',
+ 'PUT /proApi/support/user/team/switch'
+ ],
+ options: {
+ allowUserAccountCancellationPending: false,
+ allowCurrentUserOwnedTeamAccountCancellationPending: false,
+ allowCurrentSessionTeamAccountCancellationPending: 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,
+ allowCurrentUserOwnedTeamAccountCancellationPending: true,
+ allowCurrentSessionTeamAccountCancellationPending: 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
+ };
+ }
+ 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..0d6c1f9246d0
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/formatter.ts
@@ -0,0 +1,63 @@
+import { AccountCancellationStatusEnum } 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 ||
+ ![AccountCancellationStatusEnum.pending, AccountCancellationStatusEnum.finalizing].includes(
+ record.status
+ )
+ ) {
+ throw new Error('Invalid account cancellation active record');
+ }
+
+ const schedule = deriveAccountCancellationSchedule(record.requestedAt);
+ const isPending = record.status === AccountCancellationStatusEnum.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 === AccountCancellationStatusEnum.pending) {
+ return {
+ status: AccountCancellationStatusEnum.pending,
+ scheduledCancelAt: deriveAccountCancellationSchedule(record.requestedAt).scheduledCancelAt
+ };
+ }
+
+ if (record.status === AccountCancellationStatusEnum.finalizing) {
+ return {
+ status: AccountCancellationStatusEnum.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..1aa99e187fa7
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/guard.ts
@@ -0,0 +1,85 @@
+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 { MongoTeamMember } from '../../team/teamMemberSchema';
+import { MongoTeam } from '../../team/teamSchema';
+import { getActiveAccountCancellationByTeamId, getActiveAccountCancellationByUserId } from './read';
+
+export type AssertAccountUsableProps = {
+ userId?: string;
+ teamId?: string;
+ tmbId?: string;
+ allowUserAccountCancellationPending?: boolean;
+ allowCurrentUserOwnedTeamAccountCancellationPending?: boolean;
+ allowCurrentSessionTeamAccountCancellationPending?: boolean;
+};
+
+/**
+ * 统一阻断用户本人和当前团队 owner 的 pending/finalizing 业务访问。
+ * owner 关联始终实时读取团队 ownerId,不依赖注销记录中的团队快照。
+ */
+export const assertAccountUsable = async ({
+ userId,
+ teamId,
+ tmbId,
+ allowUserAccountCancellationPending = false,
+ allowCurrentUserOwnedTeamAccountCancellationPending = false,
+ allowCurrentSessionTeamAccountCancellationPending = false
+}: AssertAccountUsableProps) => {
+ // API Key 没有 Session userId;即使已有 teamId,也必须从 tmbId 还原实际成员。
+ const tmb =
+ tmbId && (!userId || !teamId)
+ ? await MongoTeamMember.findById(tmbId, { userId: 1, teamId: 1 }).lean()
+ : null;
+ const currentUserId = userId ?? (tmb?.userId ? String(tmb.userId) : undefined);
+ const currentTeamId = teamId ?? (tmb?.teamId ? String(tmb.teamId) : undefined);
+ const [userCancellation, teamCancellation] = await Promise.all([
+ allowUserAccountCancellationPending
+ ? null
+ : getActiveAccountCancellationByUserId(currentUserId),
+ getActiveAccountCancellationByTeamId(currentTeamId)
+ ]);
+
+ if (!userCancellation && !teamCancellation) return;
+
+ if (tmbId && currentTeamId) {
+ const [activeMember, activeTeam] = await Promise.all([
+ MongoTeamMember.findOne(
+ {
+ _id: tmbId,
+ teamId: currentTeamId,
+ ...(currentUserId ? { userId: currentUserId } : {}),
+ status: 'active'
+ },
+ { _id: 1 }
+ ).lean(),
+ MongoTeam.findOne(
+ {
+ _id: currentTeamId,
+ $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }]
+ },
+ { _id: 1 }
+ ).lean()
+ ]);
+ if (!activeMember || !activeTeam) throw new Error(ERROR_ENUM.unAuthorization);
+ }
+
+ if (userCancellation) throw new Error(UserErrEnum.accountCancellationPending);
+
+ if (teamCancellation) {
+ const isOwnTeam =
+ allowCurrentUserOwnedTeamAccountCancellationPending &&
+ !!currentUserId &&
+ String(teamCancellation.userId) === String(currentUserId);
+ const isCurrentSessionTeam = allowCurrentSessionTeamAccountCancellationPending;
+ if (!isOwnTeam && !isCurrentSessionTeam) {
+ throw new Error(TeamErrEnum.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..69a6d3ca9ac8
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/read.ts
@@ -0,0 +1,67 @@
+import {
+ AccountCancellationStatusEnum,
+ accountCancellationActiveStatuses
+} from '@fastgpt/global/support/user/account/cancellation/constants';
+import { Types } from 'mongoose';
+import { MongoTeam } from '../../team/teamSchema';
+import { MongoAccountCancellation } 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 动态关联注销记录,生命周期集合不保存 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 的注销状态,避免团队列表产生逐条查询。 */
+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();
+ const teamsWithOwners = teams.filter((team) => team.ownerId);
+ const ownerIds = Array.from(new Set(teamsWithOwners.map((team) => String(team.ownerId))));
+ if (ownerIds.length === 0) return [];
+
+ const records = await MongoAccountCancellation.find({
+ userId: { $in: ownerIds },
+ status: accountCancellationActiveStatusFilter
+ }).lean();
+ const recordsByOwnerId = new Map(records.map((record) => [String(record.userId), record]));
+
+ return teamsWithOwners.flatMap((team) => {
+ const record = recordsByOwnerId.get(String(team.ownerId));
+ return record ? [{ teamId: String(team._id), record }] : [];
+ });
+};
+
+export const isAccountCancellationActiveStatus = (status?: string) =>
+ status === AccountCancellationStatusEnum.pending ||
+ status === AccountCancellationStatusEnum.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..abfa876696c6
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/schema.ts
@@ -0,0 +1,53 @@
+import {
+ AccountCancellationStatusEnum,
+ accountCancellationStatusMap
+} from '@fastgpt/global/support/user/account/cancellation/constants';
+import type { AccountCancellationStatus } from '@fastgpt/global/support/user/account/cancellation/type';
+import { connectionMongo, 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: AccountCancellationStatus;
+ 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
+ }
+);
+
+AccountCancellationSchema.index({ userId: 1 }, { unique: true });
+AccountCancellationSchema.index({ status: 1, requestedAt: 1 });
+
+export const MongoAccountCancellation = getMongoModel(
+ accountCancellationCollectionName,
+ AccountCancellationSchema
+);
+
+export { AccountCancellationStatusEnum };
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..b924c8dfc808
--- /dev/null
+++ b/packages/service/support/user/account/cancellation/service.ts
@@ -0,0 +1,85 @@
+import { AccountCancellationStatusEnum } 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 { checkTimerLock, deleteTimerLock } from '../../../../common/system/timerLock/utils';
+import { getAccountCancellationAuthKey } from './formatter';
+import { getActiveAccountCancellationByUserId } from './read';
+import { MongoAccountCancellation } from './schema';
+
+const accountCancellationLockMinutes = 10;
+const accountCancellationTeamLockMinutes = 10;
+
+/**
+ * 在注销用户维度串行化 submit、cancel、cron 和管理员删除,释放锁由 finally 保证。
+ */
+export const withAccountCancellationUserLock = async (userId: string, fn: () => Promise) => {
+ const timerId = getAccountCancellationAuthKey(userId);
+ const locked = await checkTimerLock({
+ timerId,
+ lockMinuted: accountCancellationLockMinutes
+ });
+ if (!locked) throw new Error('Account cancellation operation is busy');
+
+ try {
+ return await fn();
+ } finally {
+ await deleteTimerLock({ timerId }).catch(() => undefined);
+ }
+};
+
+/**
+ * 串行化团队删除、owner 转让和注销 finalizer 的团队部分。
+ * 团队锁独立于用户锁,调用方需遵循“用户锁后团队锁”的顺序避免交叉等待。
+ */
+export const withAccountCancellationTeamLock = async (teamId: string, fn: () => Promise) => {
+ const timerId = `accountCancellation:team:${String(teamId)}`;
+ const locked = await checkTimerLock({
+ timerId,
+ lockMinuted: accountCancellationTeamLockMinutes
+ });
+ if (!locked) throw new Error('Account cancellation team operation is busy');
+
+ try {
+ return await fn();
+ } finally {
+ await deleteTimerLock({ timerId }).catch(() => undefined);
+ }
+};
+
+export const assertAccountCancellationMethod = (method: string) => {
+ if (!isAccountCancellationMethod(method) || method === 'oldPassword') {
+ 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 !== AccountCancellationStatusEnum.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: AccountCancellationStatusEnum.pending
+ });
+ return {
+ cancelled: result.deletedCount === 1,
+ record
+ } as const;
+ });
diff --git a/packages/service/support/user/account/password/service.ts b/packages/service/support/user/account/password/service.ts
new file mode 100644
index 000000000000..9f2303e92027
--- /dev/null
+++ b/packages/service/support/user/account/password/service.ts
@@ -0,0 +1,117 @@
+import jwt from 'jsonwebtoken';
+import { z } from 'zod';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { UserError } from '@fastgpt/global/common/error/utils';
+import { serviceEnv } from '../../../../env';
+import { MongoUser } from '../../schema';
+import { resolveAccountKindByUsername } from '@fastgpt/global/support/user/account/verification/utils';
+
+export const PASSWORD_CHANGE_TOKEN_TTL_SECONDS = 5 * 60;
+
+/** 当前运行时是否开启 SSO 用户禁用密码策略。 */
+export const isSsoPasswordDisabled = () =>
+ Boolean(global.feConfigs?.sso?.url) && global.feConfigs?.sso?.disablePasswordForSsoUsers === true;
+
+/** 使用共享账号分类规则判断持久化 username 是否属于当前 SSO 环境。 */
+export const isSsoUserByUsername = (username: string) =>
+ resolveAccountKindByUsername({
+ username,
+ ssoConfigured: Boolean(global.feConfigs?.sso?.url)
+ }) === 'sso';
+
+/** 返回当前运行时中指定账号是否允许使用或维护平台密码。 */
+export const getUserPasswordAvailability = (username: string) =>
+ !(isSsoPasswordDisabled() && isSsoUserByUsername(username));
+
+/** 在密码比对或最终写入前拒绝受限 SSO 用户。 */
+export const assertUserPasswordAvailable = (username: string) => {
+ if (!getUserPasswordAvailability(username)) {
+ throw new UserError(UserErrEnum.ssoPasswordUnavailable);
+ }
+};
+
+export const PasswordChangeTokenPayloadSchema = z
+ .object({
+ userId: z.string().min(1),
+ purpose: z.literal('changePassword'),
+ iat: z.number().int(),
+ exp: z.number().int()
+ })
+ .strict();
+export type PasswordChangeTokenPayload = z.infer;
+
+type PasswordChangeTokenDependencies = {
+ secret: string;
+ now: () => Date;
+};
+
+/**
+ * 签发和校验修改密码专用 JWT。共享签名密钥不扩大 Token 用途,校验始终强制
+ * `purpose=changePassword`、HS256、固定有效期和当前 Session 用户一致。
+ */
+export class PasswordChangeTokenService {
+ private readonly dependencies: PasswordChangeTokenDependencies;
+
+ constructor(dependencies: Partial = {}) {
+ this.dependencies = {
+ secret: serviceEnv.JWT_SECRET,
+ now: () => new Date(),
+ ...dependencies
+ };
+ }
+
+ sign(userId: string) {
+ const issuedAt = Math.floor(this.dependencies.now().getTime() / 1000);
+ const expiredAt = new Date((issuedAt + PASSWORD_CHANGE_TOKEN_TTL_SECONDS) * 1000);
+ const token = jwt.sign(
+ {
+ userId,
+ purpose: 'changePassword',
+ iat: issuedAt
+ },
+ this.dependencies.secret,
+ {
+ algorithm: 'HS256',
+ expiresIn: PASSWORD_CHANGE_TOKEN_TTL_SECONDS
+ }
+ );
+
+ return { token, expiredAt };
+ }
+
+ verify({ token, userId }: { token: string; userId: string }): PasswordChangeTokenPayload {
+ try {
+ const payload = PasswordChangeTokenPayloadSchema.parse(
+ jwt.verify(token, this.dependencies.secret, {
+ algorithms: ['HS256'],
+ clockTimestamp: Math.floor(this.dependencies.now().getTime() / 1000)
+ })
+ );
+ if (payload.userId !== userId) {
+ throw new Error('Password change token user mismatch');
+ }
+ return payload;
+ } catch {
+ throw new UserError(UserErrEnum.passwordChangeAuthorizationInvalid);
+ }
+ }
+}
+
+export const passwordChangeTokenService = new PasswordChangeTokenService();
+
+/**
+ * 阻止用户侧改密流程复用当前密码。密码查询交由 Mongoose schema setter 处理,
+ * 以兼容客户端摘要和数据库持久化摘要的现有双层哈希协议。
+ */
+export const assertNewPasswordDiffersFromCurrent = async ({
+ userId,
+ newPassword
+}: {
+ userId: string;
+ newPassword: string;
+}) => {
+ const isSamePassword = await MongoUser.exists({ _id: userId, password: newPassword });
+ if (isSamePassword) {
+ throw new UserError(UserErrEnum.newPasswordSameAsOld);
+ }
+};
diff --git a/packages/service/support/user/account/verification/entity.ts b/packages/service/support/user/account/verification/entity.ts
new file mode 100644
index 000000000000..5bd834dfb8fa
--- /dev/null
+++ b/packages/service/support/user/account/verification/entity.ts
@@ -0,0 +1,201 @@
+import type { ClientSession, FilterQuery } from 'mongoose';
+import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants';
+import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type';
+import type { AccountVerificationPurpose } from '@fastgpt/global/support/user/account/verification/type';
+import {
+ MongoAccountVerificationMaterial,
+ type AccountVerificationMaterialSchemaType
+} from './schema';
+import { buildVerificationCodeFilter } from './utils';
+
+type MaterialIdentity = {
+ key: string;
+ type: `${AccountVerificationMaterialTypeEnum}`;
+ scene?: CodeAccountVerificationScene;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ provider?: string;
+ callbackHash?: string;
+};
+
+type CreateVerificationMaterialData = MaterialIdentity & {
+ code?: string;
+ openid?: string;
+ scene?: string;
+ expiredTime: Date;
+ createTime?: Date;
+};
+
+type QueryValidVerificationMaterialData = MaterialIdentity & {
+ code?: string;
+ caseInsensitiveCode?: boolean;
+ requireOpenid?: boolean;
+ now?: Date;
+};
+
+/** 创建随机键材料。调用方负责处理极低概率的唯一键碰撞。 */
+export const createVerificationMaterial = (
+ data: CreateVerificationMaterialData,
+ session?: ClientSession
+) =>
+ MongoAccountVerificationMaterial.create(
+ [
+ {
+ ...data,
+ createTime: data.createTime ?? new Date()
+ }
+ ],
+ { session }
+ );
+
+/** 查询同一 key/type 是否已经存在,用于随机材料创建前的碰撞检测。 */
+export const findVerificationMaterial = (data: MaterialIdentity, session?: ClientSession) =>
+ MongoAccountVerificationMaterial.findOne(data, undefined, { session }).lean();
+
+/** 覆盖同一 key/type 的普通验证码,并同步刷新创建和过期时间。 */
+export const upsertVerificationMaterial = (
+ data: CreateVerificationMaterialData,
+ session?: ClientSession
+) => {
+ const {
+ key,
+ type,
+ code,
+ openid,
+ scene,
+ expiredTime,
+ createTime = new Date(),
+ userIdHash,
+ purpose,
+ provider,
+ callbackHash
+ } = data;
+
+ return MongoAccountVerificationMaterial.updateOne(
+ { key, type },
+ {
+ $set: {
+ code,
+ openid,
+ scene,
+ userIdHash,
+ purpose,
+ provider,
+ callbackHash,
+ createTime,
+ expiredTime
+ }
+ },
+ { upsert: true, session }
+ );
+};
+
+const buildValidMaterialFilter = ({
+ key,
+ type,
+ code,
+ caseInsensitiveCode,
+ requireOpenid,
+ scene,
+ userIdHash,
+ purpose,
+ provider,
+ callbackHash,
+ now = new Date()
+}: QueryValidVerificationMaterialData): FilterQuery => ({
+ key,
+ type,
+ expiredTime: { $gt: now },
+ ...(code !== undefined && {
+ code: buildVerificationCodeFilter({ code, caseInsensitive: caseInsensitiveCode })
+ }),
+ ...(requireOpenid && { openid: { $exists: true, $ne: '' } }),
+ ...(scene !== undefined && { scene }),
+ ...(userIdHash !== undefined && { userIdHash }),
+ ...(purpose !== undefined && { purpose }),
+ ...(provider !== undefined && { provider }),
+ ...(callbackHash !== undefined && { callbackHash })
+});
+
+/** 查询仍在业务有效期内的材料,不依赖 TTL 清理时机。 */
+export const findValidVerificationMaterial = (
+ data: QueryValidVerificationMaterialData,
+ session?: ClientSession
+) =>
+ MongoAccountVerificationMaterial.findOne(buildValidMaterialFilter(data), undefined, {
+ session
+ }).lean();
+
+/** 原子消费仍有效的材料,并返回被删除记录。 */
+export const consumeVerificationMaterial = (
+ data: QueryValidVerificationMaterialData,
+ session?: ClientSession
+) =>
+ MongoAccountVerificationMaterial.findOneAndDelete(buildValidMaterialFilter(data), {
+ session
+ }).lean();
+
+/** 微信 callback 只能把身份写入已存在、未过期且尚未完成的占位材料。 */
+export const updateWechatMaterialIdentity = (
+ {
+ key,
+ openid,
+ materialType = AccountVerificationMaterialTypeEnum.wxLogin,
+ userIdHash,
+ purpose,
+ now = new Date()
+ }: {
+ key: string;
+ openid: string;
+ materialType?: `${AccountVerificationMaterialTypeEnum}`;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ now?: Date;
+ },
+ session?: ClientSession
+) =>
+ MongoAccountVerificationMaterial.findOneAndUpdate(
+ {
+ key,
+ type: materialType,
+ expiredTime: { $gt: now },
+ openid: { $exists: false },
+ ...(userIdHash !== undefined && { userIdHash }),
+ ...(purpose !== undefined && { purpose })
+ },
+ { $set: { openid } },
+ { new: true, session }
+ ).lean();
+
+/** 上游创建失败时按本次材料内容条件清理,避免误删并发重试的新材料。 */
+export const deleteVerificationMaterialIfMatch = (
+ {
+ key,
+ type,
+ scene,
+ code,
+ openid,
+ userIdHash,
+ purpose,
+ provider,
+ callbackHash
+ }: MaterialIdentity & {
+ code?: string;
+ openid?: string;
+ },
+ session?: ClientSession
+) =>
+ MongoAccountVerificationMaterial.deleteOne(
+ {
+ key,
+ type,
+ ...(scene !== undefined && { scene }),
+ ...(code !== undefined && { code }),
+ ...(openid !== undefined && { openid }),
+ ...(userIdHash !== undefined && { userIdHash }),
+ ...(purpose !== undefined && { purpose }),
+ ...(provider !== undefined && { provider }),
+ ...(callbackHash !== undefined && { callbackHash })
+ },
+ { session }
+ );
diff --git a/packages/service/support/user/account/verification/index.ts b/packages/service/support/user/account/verification/index.ts
new file mode 100644
index 000000000000..ac1052ea0399
--- /dev/null
+++ b/packages/service/support/user/account/verification/index.ts
@@ -0,0 +1,5 @@
+export * from './entity';
+export * from './schema';
+export * from './service';
+export * from './utils';
+export * from './password/service';
diff --git a/packages/service/support/user/account/verification/password/service.ts b/packages/service/support/user/account/verification/password/service.ts
new file mode 100644
index 000000000000..c0738bd1b109
--- /dev/null
+++ b/packages/service/support/user/account/verification/password/service.ts
@@ -0,0 +1,137 @@
+import { addSeconds } from 'date-fns';
+import { getNanoid } from '@fastgpt/global/common/string/tools';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { UserError } from '@fastgpt/global/common/error/utils';
+import { UserStatusEnum } from '@fastgpt/global/support/user/constant';
+import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants';
+import type { AccountVerificationPurpose } from '@fastgpt/global/support/user/account/verification/type';
+import { MongoUser } from '../../../schema';
+import { consumeVerificationMaterial, upsertVerificationMaterial } from '../entity';
+import { AccountVerification, type LocalAccountIdentity } from '../service';
+import { assertUserPasswordAvailable } from '../../password/service';
+
+type PasswordVerificationDependencies = {
+ generateCode: () => string;
+ now: () => Date;
+};
+
+/**
+ * 校验预登录材料和本地密码,只返回可信本地身份。
+ * Session、团队加载及 Wecom 登录策略由上层登录应用服务负责。
+ */
+export class PasswordAccountVerification extends AccountVerification<
+ {
+ username: string;
+ materialKey?: string;
+ materialType?: `${AccountVerificationMaterialTypeEnum}`;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ },
+ { code: string },
+ {
+ username: string;
+ password: string;
+ code: string;
+ materialKey?: string;
+ materialType?: `${AccountVerificationMaterialTypeEnum}`;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ },
+ LocalAccountIdentity
+> {
+ private readonly dependencies: PasswordVerificationDependencies;
+
+ constructor(dependencies: Partial = {}) {
+ super();
+ this.dependencies = {
+ generateCode: () => getNanoid(6),
+ now: () => new Date(),
+ ...dependencies
+ };
+ }
+
+ async create({
+ username,
+ materialKey = username,
+ materialType = AccountVerificationMaterialTypeEnum.login,
+ userIdHash,
+ purpose = 'login'
+ }: {
+ username: string;
+ materialKey?: string;
+ materialType?: `${AccountVerificationMaterialTypeEnum}`;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ }) {
+ const code = this.dependencies.generateCode();
+ const now = this.dependencies.now();
+
+ await upsertVerificationMaterial({
+ key: materialKey,
+ type: materialType,
+ code,
+ userIdHash,
+ purpose,
+ createTime: now,
+ expiredTime: addSeconds(now, 30)
+ });
+
+ return { code };
+ }
+
+ async consume({
+ username,
+ password,
+ code,
+ materialKey = username,
+ materialType = AccountVerificationMaterialTypeEnum.login,
+ userIdHash,
+ purpose = 'login'
+ }: {
+ username: string;
+ password: string;
+ code: string;
+ materialKey?: string;
+ materialType?: `${AccountVerificationMaterialTypeEnum}`;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ }): Promise {
+ const material = await consumeVerificationMaterial({
+ key: materialKey,
+ type: materialType,
+ code,
+ userIdHash,
+ purpose,
+ caseInsensitiveCode: true,
+ now: this.dependencies.now()
+ });
+ if (!material) {
+ throw new UserError(UserErrEnum.invalidVerificationCode);
+ }
+
+ const user = await MongoUser.findOne({ username });
+ if (!user) {
+ return Promise.reject(UserErrEnum.account_psw_error);
+ }
+ if (user.status === UserStatusEnum.forbidden) {
+ return Promise.reject('Invalid account!');
+ }
+
+ assertUserPasswordAvailable(user.username);
+
+ const passwordMatched = await MongoUser.exists({ _id: user._id, password });
+ if (!passwordMatched) {
+ return Promise.reject(UserErrEnum.account_psw_error);
+ }
+
+ return {
+ kind: 'local',
+ userId: String(user._id),
+ username: user.username,
+ lastLoginTmbId: user.lastLoginTmbId ? String(user.lastLoginTmbId) : undefined,
+ isRoot: user.username === 'root'
+ };
+ }
+}
+
+export const passwordAccountVerification = new PasswordAccountVerification();
diff --git a/packages/service/support/user/account/verification/schema.ts b/packages/service/support/user/account/verification/schema.ts
new file mode 100644
index 000000000000..d27b9f5eea7b
--- /dev/null
+++ b/packages/service/support/user/account/verification/schema.ts
@@ -0,0 +1,64 @@
+import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants';
+import type {
+ AccountVerificationPurpose,
+ CodeAccountVerificationScene
+} from '@fastgpt/global/support/user/account/verification/type';
+import { connectionMongo, getMongoModel } from '../../../../common/mongo';
+
+const { Schema } = connectionMongo;
+
+export type AccountVerificationMaterialSchemaType = {
+ key: string;
+ type: `${AccountVerificationMaterialTypeEnum}`;
+ code?: string;
+ openid?: string;
+ userIdHash?: string;
+ purpose?: AccountVerificationPurpose;
+ scene?: CodeAccountVerificationScene;
+ provider?: string;
+ callbackHash?: string;
+ createTime: Date;
+ expiredTime: Date;
+};
+
+const AccountVerificationMaterialSchema = new Schema({
+ key: {
+ type: String,
+ required: true
+ },
+ code: {
+ type: String,
+ minLength: 6,
+ maxLength: 6
+ },
+ openid: String,
+ userIdHash: String,
+ purpose: String,
+ scene: String,
+ provider: String,
+ callbackHash: String,
+ type: {
+ type: String,
+ enum: Object.values(AccountVerificationMaterialTypeEnum),
+ required: true
+ },
+ createTime: {
+ type: Date,
+ required: true
+ },
+ expiredTime: {
+ type: Date,
+ required: true
+ }
+});
+
+// 唯一索引需在生产重复数据清理后单独上线,本轮先保持兼容索引。
+AccountVerificationMaterialSchema.index({ key: 1, type: 1 });
+AccountVerificationMaterialSchema.index({ userIdHash: 1 }, { sparse: true });
+AccountVerificationMaterialSchema.index({ expiredTime: 1 }, { expireAfterSeconds: 0 });
+
+export const MongoAccountVerificationMaterial =
+ getMongoModel(
+ 'auth_codes',
+ AccountVerificationMaterialSchema
+ );
diff --git a/packages/service/support/user/account/verification/service.ts b/packages/service/support/user/account/verification/service.ts
new file mode 100644
index 000000000000..197bae7c6ca9
--- /dev/null
+++ b/packages/service/support/user/account/verification/service.ts
@@ -0,0 +1,56 @@
+import type { CodeAccountVerificationScene } from '@fastgpt/global/support/user/account/verification/type';
+import { UserError } from '@fastgpt/global/common/error/utils';
+
+/** 统一账号验证方式的材料创建与消费模型。 */
+export abstract class AccountVerification<
+ TCreateParams,
+ TCreateResult,
+ TConsumeParams,
+ TConsumeResult
+> {
+ abstract create(params: TCreateParams): Promise;
+ abstract consume(params: TConsumeParams): Promise;
+}
+
+export type LocalAccountIdentity = {
+ kind: 'local';
+ userId: string;
+ username: string;
+ lastLoginTmbId?: string;
+ isRoot: boolean;
+};
+
+export type VerifiedContactIdentity = {
+ kind: 'contact';
+ account: string;
+ scene: CodeAccountVerificationScene;
+};
+
+export type ExternalAccountIdentity = {
+ kind: 'external';
+ provider: 'github' | 'google' | 'microsoft' | 'wecom' | 'sso' | 'wechat';
+ subject: string;
+ username: string;
+ avatar?: string;
+ notificationAccount?: string;
+ phonePrefix?: number;
+ teamName?: string;
+ memberName?: string;
+ organizationId?: string;
+};
+
+/**
+ * 敏感业务必须用持久化 username 精确校验外部身份归属。
+ * 该校验同样适用于旧 SSO 的无 state code-only 兼容路径。
+ */
+export const assertExternalAccountIdentityMatchesUsername = ({
+ identity,
+ username
+}: {
+ identity: ExternalAccountIdentity;
+ username: string;
+}) => {
+ if (identity.username !== username) {
+ throw new UserError('Verified external identity does not match the current user');
+ }
+};
diff --git a/packages/service/support/user/account/verification/utils.ts b/packages/service/support/user/account/verification/utils.ts
new file mode 100644
index 000000000000..b6500eb5a995
--- /dev/null
+++ b/packages/service/support/user/account/verification/utils.ts
@@ -0,0 +1,44 @@
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { UserError } from '@fastgpt/global/common/error/utils';
+import { checkFixedWindowQpmLimit } from '../../../../common/system/frequencyLimit/redisFixedWindow';
+
+const CodeVerificationConsumeQpm = 10;
+const CodeVerificationConsumeWindowSeconds = 60;
+
+/** 将用户输入转成可安全用于锚定正则的字面量。 */
+export const escapeVerificationCodeForRegExp = (code: string) =>
+ code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+/** 构造兼容历史验证码大小写行为的精确匹配条件。 */
+export const buildVerificationCodeFilter = ({
+ code,
+ caseInsensitive = false
+}: {
+ code: string;
+ caseInsensitive?: boolean;
+}) =>
+ caseInsensitive
+ ? { $regex: new RegExp(`^${escapeVerificationCodeForRegExp(code)}$`, 'i') }
+ : code;
+
+/**
+ * 按账号和场景累计验证码提交次数,限制同一固定分钟窗口内最多验证 10 次。
+ * 该检查必须在查询验证码前执行,使错误和成功提交都占用尝试次数。
+ */
+export const assertCodeVerificationConsumeFrequency = async ({
+ account,
+ scene
+}: {
+ account: string;
+ scene: string;
+}) => {
+ const allowed = await checkFixedWindowQpmLimit({
+ key: `account-verification:code:consume:${scene}:${account}`,
+ limit: CodeVerificationConsumeQpm,
+ seconds: CodeVerificationConsumeWindowSeconds
+ });
+
+ if (!allowed) {
+ throw new UserError(UserErrEnum.verifyCodeTooFrequently);
+ }
+};
diff --git a/packages/service/support/user/audit/schema.ts b/packages/service/support/user/audit/schema.ts
index 700da18cd72a..cd5eece45880 100644
--- a/packages/service/support/user/audit/schema.ts
+++ b/packages/service/support/user/audit/schema.ts
@@ -1,5 +1,5 @@
import { defineIndex, Schema, getMongoLogModel } from '../../../common/mongo';
-import { type TeamAuditSchemaType } from '@fastgpt/global/support/user/audit/type';
+import { type AuditSchemaType } from '@fastgpt/global/support/user/audit/type';
import { AdminAuditEventEnum, AuditEventEnum } from '@fastgpt/global/support/user/audit/constants';
import {
TeamCollectionName,
@@ -37,7 +37,7 @@ const TeamAuditSchema = new Schema({
defineIndex(TeamAuditSchema, { key: { teamId: 1, tmbId: 1, event: 1 } });
defineIndex(TeamAuditSchema, { key: { timestamp: 1, teamId: 1 } });
-export const MongoTeamAudit = getMongoLogModel(
+export const MongoTeamAudit = getMongoLogModel(
TeamAuditCollectionName,
TeamAuditSchema
);
diff --git a/packages/service/support/user/auth/controller.ts b/packages/service/support/user/auth/controller.ts
index 101aa62d4fca..4ac5b08542ad 100644
--- a/packages/service/support/user/auth/controller.ts
+++ b/packages/service/support/user/auth/controller.ts
@@ -1,9 +1,11 @@
import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants';
-import { MongoUserAuth } from './schema';
-import { i18nT } from '@fastgpt/global/common/i18n/utils';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { MongoAccountVerificationMaterial } from '../account/verification/schema';
import { mongoSessionRun } from '../../../common/mongo/sessionRun';
import { UserError } from '@fastgpt/global/common/error/utils';
import { z } from 'zod';
+import { assertCodeVerificationConsumeFrequency } from '../account/verification/utils';
+import { addMinutes } from 'date-fns';
export const addAuthCode = async ({
key,
@@ -18,15 +20,19 @@ export const addAuthCode = async ({
type: `${UserAuthTypeEnum}`;
expiredTime?: Date;
}) => {
- return MongoUserAuth.updateOne(
+ const createTime = new Date();
+ return MongoAccountVerificationMaterial.updateOne(
{
key,
type
},
{
- code,
- openid,
- expiredTime
+ $set: {
+ code,
+ openid,
+ createTime,
+ expiredTime: expiredTime ?? addMinutes(createTime, 5)
+ }
},
{
upsert: true
@@ -41,8 +47,10 @@ const authCodeSchema = z.object({
});
export const authCode = async (props: z.infer) => {
const { key, type, code } = authCodeSchema.parse(props);
+ await assertCodeVerificationConsumeFrequency({ account: key, scene: type });
+
return mongoSessionRun(async (session) => {
- const result = await MongoUserAuth.findOne(
+ const result = await MongoAccountVerificationMaterial.findOne(
{
key,
type,
@@ -53,7 +61,7 @@ export const authCode = async (props: z.infer) => {
);
if (!result) {
- return Promise.reject(new UserError(i18nT('common:error.code_error')));
+ return Promise.reject(new UserError(UserErrEnum.invalidVerificationCode));
}
await result.deleteOne();
diff --git a/packages/service/support/user/auth/schema.ts b/packages/service/support/user/auth/schema.ts
deleted file mode 100644
index abe78acdfdfa..000000000000
--- a/packages/service/support/user/auth/schema.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { defineIndex, connectionMongo, getMongoModel } from '../../../common/mongo';
-const { Schema } = connectionMongo;
-import type { UserAuthSchemaType } from '@fastgpt/global/support/user/auth/type';
-import { userAuthTypeMap } from '@fastgpt/global/support/user/auth/constants';
-import { addMinutes } from 'date-fns';
-
-const UserAuthSchema = new Schema({
- key: {
- type: String,
- required: true
- },
- code: {
- // auth code
- type: String,
- length: 6
- },
- // wx openid
- openid: String,
- type: {
- type: String,
- enum: Object.keys(userAuthTypeMap),
- required: true
- },
- createTime: {
- type: Date,
- default: () => new Date()
- },
- expiredTime: {
- type: Date,
- default: () => addMinutes(new Date(), 5)
- }
-});
-
-defineIndex(UserAuthSchema, { key: { key: 1, type: 1 } });
-defineIndex(UserAuthSchema, {
- key: { expiredTime: 1 },
- options: { expireAfterSeconds: 0 }
-});
-
-export const MongoUserAuth = getMongoModel('auth_codes', UserAuthSchema);
diff --git a/packages/service/support/user/controller.ts b/packages/service/support/user/controller.ts
index 434347384a8f..7d2484f52a23 100644
--- a/packages/service/support/user/controller.ts
+++ b/packages/service/support/user/controller.ts
@@ -1,8 +1,11 @@
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 { getUserFallbackTeam } from './team/fallback';
+import { hasStoredPassword } from '@fastgpt/global/support/user/utils';
+import { getUserPasswordAvailability } from './account/password/service';
export async function authUserExist({ userId, username }: { userId?: string; username?: string }) {
if (userId) {
@@ -14,28 +17,37 @@ export async function authUserExist({ userId, username }: { userId?: string; use
return null;
}
+/**
+ * 加载用户及团队详情。登录恢复可显式允许注销中的团队作为 fallback,便于用户进入等待页取消注销。
+ */
export async function getUserDetail({
tmbId,
userId,
- isRoot = false
+ isRoot = false,
+ allowAccountCancellationTeamFallback = false
}: {
tmbId?: string;
userId?: string;
isRoot?: boolean;
+ allowAccountCancellationTeamFallback?: boolean;
}): Promise {
const tmb = await (async () => {
if (tmbId) {
try {
const result = await getTmbInfoByTmbId({ tmbId });
return result;
- } catch (error) {}
+ } catch {}
}
if (userId) {
- return getUserDefaultTeam({ userId });
+ const fallback = await getUserFallbackTeam({
+ userId,
+ allowAccountCancellationTeam: allowAccountCancellationTeamFallback
+ });
+ if (fallback) return getTmbInfoByTmbId({ tmbId: fallback.tmbId });
}
return Promise.reject(ERROR_ENUM.unAuthorization);
})();
- const user = await MongoUser.findById(tmb.userId);
+ const user = await MongoUser.findById(tmb.userId).select('+password');
if (!user) {
return Promise.reject(ERROR_ENUM.unAuthorization);
@@ -57,6 +69,8 @@ export async function getUserDetail({
permission,
contact: user.contact,
language: user.language,
- tags: user.tags
+ tags: user.tags,
+ hasPassword: hasStoredPassword(user.password),
+ passwordAvailable: getUserPasswordAvailability(user.username)
};
}
diff --git a/packages/service/support/user/schema.ts b/packages/service/support/user/schema.ts
index f7e178375dea..59de9466f3a6 100644
--- a/packages/service/support/user/schema.ts
+++ b/packages/service/support/user/schema.ts
@@ -8,6 +8,10 @@ import { LangEnum } from '@fastgpt/global/common/i18n/type';
export const userCollectionName = 'users';
+// 历史缺失、null 和空字符串必须保留为“无密码”,不能被哈希成有效摘要。
+const hashPasswordValue = (value: unknown) =>
+ typeof value === 'string' && value.length > 0 ? hashStr(value) : value;
+
const UserSchema = new Schema({
status: {
type: String,
@@ -21,9 +25,8 @@ const UserSchema = new Schema({
},
password: {
type: String,
- required: true,
- set: (val: string) => hashStr(val),
- get: (val: string) => hashStr(val),
+ set: hashPasswordValue,
+ get: hashPasswordValue,
select: false
},
passwordUpdateTime: Date,
diff --git a/packages/service/support/user/session.ts b/packages/service/support/user/session.ts
index 26870cfc0e24..ddb85ecf288c 100644
--- a/packages/service/support/user/session.ts
+++ b/packages/service/support/user/session.ts
@@ -4,6 +4,10 @@ import { ERROR_ENUM } from '@fastgpt/global/common/error/errorCode';
import { getNanoid } from '@fastgpt/global/common/string/tools';
import { getLogger, LogCategories } from '../../common/logger';
import { serviceEnv } from '../../env';
+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);
@@ -19,6 +23,61 @@ type SessionType = {
ip?: string | null;
};
+export type UserSessionTeamFallback = {
+ teamId: string;
+ tmbId: string;
+};
+
+/**
+ * 校验 Session 当前 team/tmb 是否仍有效;团队删除或成员关系失效时,原地迁移到共享 fallback。
+ * 原地更新保留 Session 的过期时间和其它字段,避免旧 Cookie 在下一次请求继续携带已删除上下文。
+ */
+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 delSession(sessionId);
+ throw new Error(ERROR_ENUM.unAuthorization);
+ }
+
+ const redis = getGlobalRedisConnection();
+ await redis.hmset(getSessionKey(sessionId), {
+ teamId: String(fallback.teamId),
+ tmbId: String(fallback.tmbId)
+ });
+ return fallback;
+};
+
/* Session manager */
const setSession = async ({
key,
@@ -98,6 +157,52 @@ export const delUserAllSession = async (userId: string, whiteList?: (string | un
}
};
+export const getUserSessionCount = async (userId: string) => {
+ const redis = getGlobalRedisConnection();
+ const keys = await getAllKeysByPrefix(`${redisPrefix}${String(userId)}`);
+ return keys.length;
+};
+
+/**
+ * 仅处理指向已删除团队的会话。找到 fallback 时更新这部分会话,找不到时删除它们,
+ * 这样成员在其它团队的会话不会因为某一个团队被删除而失效。
+ */
+export const migrateUserSessionsFromTeam = async ({
+ userId,
+ deletedTeamId,
+ fallback
+}: {
+ userId: string;
+ deletedTeamId: string;
+ fallback?: UserSessionTeamFallback;
+}) => {
+ const redis = getGlobalRedisConnection();
+ const keys = await getAllKeysByPrefix(`${redisPrefix}${String(userId)}`);
+ const deletedTeam = String(deletedTeamId);
+ const affectedKeys: string[] = [];
+
+ await Promise.all(
+ keys.map(async (key) => {
+ const data = await redis.hgetall(key);
+ if (!data || data.teamId !== deletedTeam) return;
+ affectedKeys.push(key);
+
+ if (fallback) {
+ await redis.hmset(key, {
+ teamId: String(fallback.teamId),
+ tmbId: String(fallback.tmbId)
+ });
+ }
+ })
+ );
+
+ if (!fallback && affectedKeys.length > 0) {
+ await redis.del(affectedKeys);
+ }
+
+ return { affectedCount: affectedKeys.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 5d7980e931f4..84a8d19f6988 100644
--- a/packages/service/support/user/team/controller.ts
+++ b/packages/service/support/user/team/controller.ts
@@ -19,15 +19,21 @@ 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);
async function getTeamMember(match: Record): Promise {
const tmb = await MongoTeamMember.findOne(match).populate<{ team: TeamSchema }>('team').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,
@@ -54,7 +60,12 @@ async function getTeamMember(match: Record): Promise {
const jobId = `${String(data.teamId)}`;
// Use jobId to automatically prevent duplicate deletion tasks (BullMQ feature)
- return teamDeleteQueue.add('delete_team', data, {
- jobId,
- delay: 1000 // Delay 1 second to ensure API response completes
+ return addOrRequeueFailedJob({
+ queue: teamDeleteQueue,
+ name: 'delete_team',
+ data,
+ opts: {
+ jobId,
+ delay: 1000 // Delay 1 second to ensure API response completes
+ }
});
};
diff --git a/packages/service/support/user/team/delete/processor.ts b/packages/service/support/user/team/delete/processor.ts
index b027695cb4f8..8a775c89b975 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 '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..fe50cd651077
--- /dev/null
+++ b/packages/service/support/user/team/fallback.ts
@@ -0,0 +1,56 @@
+import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant';
+import { getActiveAccountCancellationsByTeamIds } from '../account/cancellation/read';
+import { MongoTeamMember } from './teamMemberSchema';
+import { MongoTeam } from './teamSchema';
+
+/**
+ * 找到用户可继续使用的团队。默认排除已删除团队、无效成员关系和注销中的 owner 团队;
+ * 登录恢复场景可显式允许注销中的团队作为受限 Session 上下文,后续访问仍由注销 guard 控制。
+ */
+export const getUserFallbackTeam = async ({
+ userId,
+ excludedTeamId,
+ allowAccountCancellationTeam = false
+}: {
+ userId: string;
+ excludedTeamId?: string;
+ allowAccountCancellationTeam?: boolean;
+}) => {
+ const members = await MongoTeamMember.find(
+ {
+ userId,
+ status: TeamMemberStatusEnum.active,
+ ...(excludedTeamId ? { teamId: { $ne: excludedTeamId } } : {})
+ },
+ { _id: 1, teamId: 1 }
+ )
+ .sort({ createTime: 1 })
+ .lean();
+
+ if (members.length === 0) return null;
+
+ const teams = await MongoTeam.find(
+ {
+ _id: { $in: members.map((member) => member.teamId) },
+ $or: [{ deleteTime: { $exists: false } }, { deleteTime: null }]
+ },
+ { _id: 1, ownerId: 1 }
+ ).lean();
+ if (teams.length === 0) return null;
+
+ const cancellationTeams = await getActiveAccountCancellationsByTeamIds(
+ teams.map((team) => String(team._id))
+ );
+ 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..b1a1e09638f7
--- /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/service/common/bullmq';
+import { RedisLeaseUnavailableError } from '@fastgpt/service/common/redis/lock';
+
+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/http/entry.test.ts b/packages/service/test/common/http/entry.test.ts
new file mode 100644
index 000000000000..8fd70bf8d689
--- /dev/null
+++ b/packages/service/test/common/http/entry.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
+import { UserError } from '@fastgpt/global/common/error/utils';
+
+const mocks = vi.hoisted(() => ({
+ logger: {
+ info: vi.fn(),
+ error: vi.fn()
+ },
+ span: {
+ setAttribute: vi.fn()
+ },
+ setSpanError: vi.fn()
+}));
+
+vi.mock('@fastgpt/service/common/logger', () => ({
+ getLogger: () => mocks.logger,
+ LogCategories: {
+ HTTP: {
+ REQUEST: 'http.request',
+ RESPONSE: 'http.response',
+ ERROR: 'http.error'
+ }
+ },
+ withContext: (_context: unknown, callback: () => unknown) => callback()
+}));
+
+vi.mock('@fastgpt/service/common/tracing', () => ({
+ setSpanError: mocks.setSpanError,
+ withActiveSpan: (_options: unknown, callback: (span: typeof mocks.span) => unknown) =>
+ callback(mocks.span)
+}));
+
+vi.mock('@fastgpt/service/common/security/clientIp', () => ({
+ getClientIpFromRequest: () => '127.0.0.1'
+}));
+
+vi.mock('@fastgpt/service/support/permission/auth/common', () => ({
+ clearCookie: vi.fn()
+}));
+
+vi.unmock('@fastgpt/service/common/http/entry');
+vi.unmock('@fastgpt/service/common/response');
+
+const { createApiEntry } = await import('../../../common/http/entry');
+
+const createResponse = () => {
+ const headers = new Map();
+ const listeners = new Map void>();
+ const response: any = {
+ statusCode: 200,
+ writableFinished: false,
+ body: undefined,
+ setHeader: vi.fn((key: string, value: unknown) => {
+ headers.set(key.toLowerCase(), value);
+ return response;
+ }),
+ getHeader: vi.fn((key: string) => headers.get(key.toLowerCase())),
+ once: vi.fn((event: string, listener: () => void) => {
+ listeners.set(event, listener);
+ return response;
+ }),
+ status: vi.fn((statusCode: number) => {
+ response.statusCode = statusCode;
+ return response;
+ }),
+ json: vi.fn((body: unknown) => {
+ response.body = body;
+ response.writableFinished = true;
+ listeners.get('finish')?.();
+ return response;
+ })
+ };
+
+ return response;
+};
+
+const request = {
+ method: 'POST',
+ url: '/api/support/user/verification',
+ headers: {},
+ body: {},
+ query: {},
+ socket: { remoteAddress: '127.0.0.1' }
+};
+
+describe('createApiEntry error status', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it.each([
+ [UserErrEnum.invalidVerificationCode, 400],
+ [UserErrEnum.sendVerificationCodeTooFrequently, 429],
+ [UserErrEnum.verifyCodeTooFrequently, 429],
+ [UserErrEnum.newPasswordSameAsOld, 400]
+ ] as const)('returns and traces the configured status for %s', async (errorKey, httpStatus) => {
+ const response = createResponse();
+ const handler = createApiEntry({})(async () => {
+ throw new UserError(errorKey);
+ });
+
+ await handler(request as any, response);
+
+ expect(response.statusCode).toBe(httpStatus);
+ expect(response.body).toMatchObject({
+ code: ERROR_RESPONSE[errorKey].code,
+ statusText: errorKey,
+ message: ERROR_RESPONSE[errorKey].message,
+ errorType: 'UserError'
+ });
+ expect(mocks.span.setAttribute).toHaveBeenCalledWith('http.response.status_code', httpStatus);
+ expect(mocks.setSpanError).not.toHaveBeenCalled();
+ });
+
+ it('keeps unexpected failures as traced 500 responses', async () => {
+ const response = createResponse();
+ const error = new Error('unexpected');
+ const handler = createApiEntry({})(async () => {
+ throw error;
+ });
+
+ await handler(request as any, response);
+
+ expect(response.statusCode).toBe(500);
+ expect(mocks.span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 500);
+ expect(mocks.setSpanError).toHaveBeenCalledWith(mocks.span, error);
+ });
+});
diff --git a/packages/service/test/common/response/index.test.ts b/packages/service/test/common/response/index.test.ts
index 184cbb29f350..7c5dc5a4eaed 100644
--- a/packages/service/test/common/response/index.test.ts
+++ b/packages/service/test/common/response/index.test.ts
@@ -1,8 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
-import { ApiRequestInputParseError } from '../../../common/zod/requestParseError';
-import { UserError } from '@fastgpt/global/common/error/utils';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { SandboxErrEnum } from '@fastgpt/global/common/error/code/sandbox';
import { ERROR_ENUM, ERROR_RESPONSE } from '@fastgpt/global/common/error/errorCode';
+import { UserError } from '@fastgpt/global/common/error/utils';
+import { ApiRequestInputParseError } from '../../../common/zod/requestParseError';
vi.unmock('@fastgpt/service/common/response');
@@ -20,7 +22,7 @@ vi.mock('@fastgpt/service/common/logger', () => ({
}
}));
-const { getSseErrorResponse, processError } = await import('../../../common/response');
+const { getSseErrorResponse, jsonRes, processError } = await import('../../../common/response');
function buildZodError() {
try {
@@ -85,6 +87,69 @@ describe('processError zod logging', () => {
});
});
+describe('jsonRes business HTTP status', () => {
+ const createResponse = () => {
+ const response: any = {
+ statusCode: 200,
+ status: vi.fn((statusCode: number) => {
+ response.statusCode = statusCode;
+ return response;
+ }),
+ json: vi.fn()
+ };
+ return response;
+ };
+
+ it.each([
+ [UserErrEnum.invalidVerificationCode, 400],
+ [UserErrEnum.sendVerificationCodeTooFrequently, 429],
+ [UserErrEnum.verifyCodeTooFrequently, 429],
+ [UserErrEnum.newPasswordSameAsOld, 400]
+ ] as const)('uses the configured HTTP status for %s', (errorKey, httpStatus) => {
+ const response = createResponse();
+
+ jsonRes(response, {
+ code: 500,
+ error: new UserError(errorKey),
+ url: '/api/support/user/verification'
+ });
+
+ expect(response.status).toHaveBeenCalledWith(httpStatus);
+ expect(response.json).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: ERROR_RESPONSE[errorKey].code,
+ statusText: errorKey,
+ message: ERROR_RESPONSE[errorKey].message,
+ errorType: 'UserError'
+ })
+ );
+ });
+
+ it('keeps an unclassified UserError as an internal server error', () => {
+ const response = createResponse();
+
+ jsonRes(response, {
+ code: 500,
+ error: new UserError('unclassified'),
+ url: '/api/test'
+ });
+
+ expect(response.status).toHaveBeenCalledWith(500);
+ });
+
+ it('prefers an explicit HTTP status over a legacy business-code range fallback', () => {
+ const response = createResponse();
+
+ jsonRes(response, {
+ code: 500,
+ error: new UserError(SandboxErrEnum.agentSandboxInitializing),
+ url: '/api/core/ai/sandbox'
+ });
+
+ expect(response.status).toHaveBeenCalledWith(409);
+ });
+});
+
describe('getSseErrorResponse logging', () => {
beforeEach(() => {
logger.info.mockClear();
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/env.test.ts b/packages/service/test/env.test.ts
index 981e481f4c0a..7340e50d187b 100644
--- a/packages/service/test/env.test.ts
+++ b/packages/service/test/env.test.ts
@@ -1,6 +1,7 @@
-import { afterEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const validInvokeTokenSecret = 'fastgpt_test_invoke_token_secret_32';
+const validJwtSecret = 'fastgpt_test_jwt_signing_secret_32_chars';
const originalEnv = {
SYSTEM_MAX_STRING_LENGTH_M: process.env.SYSTEM_MAX_STRING_LENGTH_M,
@@ -12,6 +13,7 @@ const originalEnv = {
SYNC_INDEX: process.env.SYNC_INDEX,
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY,
INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET,
+ JWT_SECRET: process.env.JWT_SECRET,
PRO_URL: process.env.PRO_URL,
PRO_TOKEN: process.env.PRO_TOKEN,
VITEST: process.env.VITEST,
@@ -32,6 +34,10 @@ const importServiceEnv = async () => {
};
describe('serviceEnv', () => {
+ beforeEach(() => {
+ vi.stubEnv('JWT_SECRET', validJwtSecret);
+ });
+
afterEach(() => {
vi.stubEnv('SYSTEM_MAX_STRING_LENGTH_M', originalEnv.SYSTEM_MAX_STRING_LENGTH_M);
vi.stubEnv('AGENT_SANDBOX_DISK_MB', originalEnv.AGENT_SANDBOX_DISK_MB);
@@ -42,6 +48,7 @@ describe('serviceEnv', () => {
vi.stubEnv('SYNC_INDEX', originalEnv.SYNC_INDEX);
vi.stubEnv('AES256_SECRET_KEY', originalEnv.AES256_SECRET_KEY);
vi.stubEnv('INVOKE_TOKEN_SECRET', originalEnv.INVOKE_TOKEN_SECRET);
+ vi.stubEnv('JWT_SECRET', originalEnv.JWT_SECRET);
vi.stubEnv('PRO_URL', originalEnv.PRO_URL);
vi.stubEnv('PRO_TOKEN', originalEnv.PRO_TOKEN);
vi.stubEnv('VITEST', originalEnv.VITEST);
@@ -154,6 +161,30 @@ describe('serviceEnv', () => {
});
});
+ it('requires JWT_SECRET outside tests and uses a test-only default during vitest', async () => {
+ vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
+ vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
+ vi.stubEnv('INVOKE_TOKEN_SECRET', validInvokeTokenSecret);
+ vi.stubEnv('JWT_SECRET', undefined);
+ vi.stubEnv('VITEST', undefined);
+ vi.stubEnv('NODE_ENV', 'production');
+ await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables');
+
+ vi.stubEnv('JWT_SECRET', 'short-secret');
+ await expect(importServiceEnv()).rejects.toThrow('Invalid environment variables');
+
+ vi.stubEnv('JWT_SECRET', validJwtSecret);
+ await expect(importServiceEnv()).resolves.toMatchObject({
+ serviceEnv: { JWT_SECRET: validJwtSecret }
+ });
+
+ vi.stubEnv('JWT_SECRET', undefined);
+ vi.stubEnv('VITEST', 'true');
+ await expect(importServiceEnv()).resolves.toMatchObject({
+ serviceEnv: { JWT_SECRET: validJwtSecret }
+ });
+ });
+
it('normalizes FILE_DOWNLOAD_PUBLIC_URL_PREFIX during service env init', async () => {
vi.stubEnv('FILE_TOKEN_KEY', 'filetokenkey');
vi.stubEnv('AES256_SECRET_KEY', 'fastgptsecret');
diff --git a/packages/service/test/support/outLink/runtime/utils.test.ts b/packages/service/test/support/outLink/runtime/utils.test.ts
index 621cb5f83df9..10210927f7b8 100644
--- a/packages/service/test/support/outLink/runtime/utils.test.ts
+++ b/packages/service/test/support/outLink/runtime/utils.test.ts
@@ -74,6 +74,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/user/account/cancellation/access.test.ts b/packages/service/test/support/user/account/cancellation/access.test.ts
new file mode 100644
index 000000000000..7caa94f116a1
--- /dev/null
+++ b/packages/service/test/support/user/account/cancellation/access.test.ts
@@ -0,0 +1,34 @@
+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);
+ });
+});
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..893e38ea74c7
--- /dev/null
+++ b/packages/service/test/support/user/account/cancellation/formatter.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants';
+import { formatTeamAccountCancellationSummary } from '@fastgpt/service/support/user/account/cancellation/formatter';
+
+describe('formatTeamAccountCancellationSummary', () => {
+ it('keeps pending status and exposes the derived scheduled cleanup time', () => {
+ const summary = formatTeamAccountCancellationSummary({
+ status: AccountCancellationStatusEnum.pending,
+ requestedAt: new Date('2026-07-01T10:20:00.000Z')
+ });
+
+ expect(summary).toEqual({
+ status: AccountCancellationStatusEnum.pending,
+ scheduledCancelAt: new Date('2026-07-16T16:00:00.000Z')
+ });
+ });
+
+ it('keeps finalizing status and hides the scheduled cleanup time', () => {
+ const summary = formatTeamAccountCancellationSummary({
+ status: AccountCancellationStatusEnum.finalizing,
+ requestedAt: new Date('2026-07-01T10:20:00.000Z')
+ });
+
+ expect(summary).toEqual({
+ status: AccountCancellationStatusEnum.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..d10b3074557a
--- /dev/null
+++ b/packages/service/test/support/user/account/cancellation/guard.test.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { AccountCancellationStatusEnum } from '@fastgpt/global/support/user/account/cancellation/constants';
+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: AccountCancellationStatusEnum.pending,
+ requestedAt: new Date()
+ }
+ ]);
+
+ await expect(
+ assertAccountUsable({
+ teamId: String(team._id),
+ tmbId: String(member._id)
+ })
+ ).rejects.toThrow(UserErrEnum.accountCancellationPending);
+ });
+});
diff --git a/packages/service/test/support/user/account/password/service.test.ts b/packages/service/test/support/user/account/password/service.test.ts
new file mode 100644
index 000000000000..466754e042de
--- /dev/null
+++ b/packages/service/test/support/user/account/password/service.test.ts
@@ -0,0 +1,187 @@
+import jwt from 'jsonwebtoken';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import {
+ PASSWORD_CHANGE_TOKEN_TTL_SECONDS,
+ PasswordChangeTokenService,
+ assertUserPasswordAvailable,
+ getUserPasswordAvailability,
+ isSsoPasswordDisabled,
+ isSsoUserByUsername
+} from '@fastgpt/service/support/user/account/password/service';
+
+const secret = 'password_change_test_secret_32_chars_min';
+const otherSecret = 'password_change_other_secret_32_chars_min';
+const issuedAtMs = Date.UTC(2026, 6, 22, 10, 0, 0);
+const issuedAt = Math.floor(issuedAtMs / 1000);
+const originalFeConfigs = global.feConfigs;
+
+const setSsoPasswordPolicy = (enabled: boolean) => {
+ global.feConfigs = {
+ uploadFileMaxAmount: 10,
+ uploadFileMaxSize: 10,
+ ...global.feConfigs,
+ sso: {
+ url: 'https://sso.example.com',
+ disablePasswordForSsoUsers: enabled
+ }
+ };
+};
+
+describe('SSO password policy', () => {
+ beforeEach(() => setSsoPasswordPolicy(false));
+ afterEach(() => {
+ global.feConfigs = originalFeConfigs;
+ });
+
+ it('only disables password for dynamically classified SSO users when the policy is enabled', () => {
+ setSsoPasswordPolicy(true);
+
+ expect(isSsoPasswordDisabled()).toBe(true);
+ expect(isSsoUserByUsername('tenant-user')).toBe(true);
+ expect(getUserPasswordAvailability('tenant-user')).toBe(false);
+ expect(() => assertUserPasswordAvailable('tenant-user')).toThrow(
+ UserErrEnum.ssoPasswordUnavailable
+ );
+ });
+
+ it.each([
+ 'local',
+ 'user-name@example-domain.com',
+ 'wechat-openid',
+ 'git-octocat',
+ 'google-sub',
+ 'microsoft-id',
+ 'wecom-id'
+ ])('keeps password available for non-SSO account %s', (username) => {
+ setSsoPasswordPolicy(true);
+ expect(getUserPasswordAvailability(username)).toBe(true);
+ });
+
+ it('restores password availability when the switch is disabled or SSO is not configured', () => {
+ expect(getUserPasswordAvailability('tenant-user')).toBe(true);
+
+ global.feConfigs.sso = {
+ disablePasswordForSsoUsers: true
+ };
+ expect(isSsoPasswordDisabled()).toBe(false);
+ expect(getUserPasswordAvailability('tenant-user')).toBe(true);
+ });
+});
+
+describe('PasswordChangeTokenService', () => {
+ it('signs a five-minute HS256 token and verifies the current user', () => {
+ const service = new PasswordChangeTokenService({
+ secret,
+ now: () => new Date(issuedAtMs)
+ });
+ const result = service.sign('user-1');
+
+ expect(result.expiredAt.toISOString()).toBe('2026-07-22T10:05:00.000Z');
+ expect(service.verify({ token: result.token, userId: 'user-1' })).toEqual({
+ userId: 'user-1',
+ purpose: 'changePassword',
+ iat: issuedAt,
+ exp: issuedAt + PASSWORD_CHANGE_TOKEN_TTL_SECONDS
+ });
+ expect(jwt.decode(result.token, { complete: true })?.header.alg).toBe('HS256');
+ });
+
+ it('accepts the token immediately before expiry and rejects it at expiry', () => {
+ let nowMs = issuedAtMs;
+ const service = new PasswordChangeTokenService({
+ secret,
+ now: () => new Date(nowMs)
+ });
+ const { token } = service.sign('user-1');
+
+ nowMs = issuedAtMs + PASSWORD_CHANGE_TOKEN_TTL_SECONDS * 1000 - 1;
+ expect(service.verify({ token, userId: 'user-1' }).userId).toBe('user-1');
+
+ nowMs = issuedAtMs + PASSWORD_CHANGE_TOKEN_TTL_SECONDS * 1000;
+ expect(() => service.verify({ token, userId: 'user-1' })).toThrow(
+ UserErrEnum.passwordChangeAuthorizationInvalid
+ );
+ });
+
+ it.each([
+ ['malformed token', 'not-a-token'],
+ [
+ 'wrong signing key',
+ jwt.sign(
+ { userId: 'user-1', purpose: 'changePassword', iat: issuedAt, exp: issuedAt + 300 },
+ otherSecret,
+ { algorithm: 'HS256' }
+ )
+ ],
+ [
+ 'wrong algorithm',
+ jwt.sign(
+ { userId: 'user-1', purpose: 'changePassword', iat: issuedAt, exp: issuedAt + 300 },
+ secret,
+ { algorithm: 'HS384' }
+ )
+ ],
+ [
+ 'missing purpose',
+ jwt.sign({ userId: 'user-1', iat: issuedAt, exp: issuedAt + 300 }, secret, {
+ algorithm: 'HS256'
+ })
+ ],
+ [
+ 'other purpose',
+ jwt.sign(
+ { userId: 'user-1', purpose: 'accountCancellation', iat: issuedAt, exp: issuedAt + 300 },
+ secret,
+ { algorithm: 'HS256' }
+ )
+ ],
+ [
+ 'extra claim',
+ jwt.sign(
+ {
+ userId: 'user-1',
+ purpose: 'changePassword',
+ iat: issuedAt,
+ exp: issuedAt + 300,
+ role: 'admin'
+ },
+ secret,
+ { algorithm: 'HS256' }
+ )
+ ]
+ ])('maps %s to one stable authorization error', (_caseName, token) => {
+ const service = new PasswordChangeTokenService({
+ secret,
+ now: () => new Date(issuedAtMs)
+ });
+ expect(() => service.verify({ token, userId: 'user-1' })).toThrow(
+ UserErrEnum.passwordChangeAuthorizationInvalid
+ );
+ });
+
+ it('rejects a valid token used by a different current user', () => {
+ const service = new PasswordChangeTokenService({
+ secret,
+ now: () => new Date(issuedAtMs)
+ });
+ const { token } = service.sign('user-1');
+
+ expect(() => service.verify({ token, userId: 'user-2' })).toThrow(
+ UserErrEnum.passwordChangeAuthorizationInvalid
+ );
+ });
+
+ it('rejects a modified signature without exposing the verification detail', () => {
+ const service = new PasswordChangeTokenService({
+ secret,
+ now: () => new Date(issuedAtMs)
+ });
+ const { token } = service.sign('user-1');
+ const tampered = `${token.slice(0, -1)}${token.endsWith('a') ? 'b' : 'a'}`;
+
+ expect(() => service.verify({ token: tampered, userId: 'user-1' })).toThrow(
+ UserErrEnum.passwordChangeAuthorizationInvalid
+ );
+ });
+});
diff --git a/packages/service/test/support/user/account/verification/entity.test.ts b/packages/service/test/support/user/account/verification/entity.test.ts
new file mode 100644
index 000000000000..5781937d47b9
--- /dev/null
+++ b/packages/service/test/support/user/account/verification/entity.test.ts
@@ -0,0 +1,189 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { addMinutes } from 'date-fns';
+import { AccountVerificationMaterialTypeEnum } from '@fastgpt/global/support/user/account/verification/constants';
+import {
+ consumeVerificationMaterial,
+ createVerificationMaterial,
+ deleteVerificationMaterialIfMatch,
+ findValidVerificationMaterial,
+ updateWechatMaterialIdentity,
+ upsertVerificationMaterial
+} from '@fastgpt/service/support/user/account/verification/entity';
+import { MongoAccountVerificationMaterial } from '@fastgpt/service/support/user/account/verification/schema';
+import { addAuthCode } from '@fastgpt/service/support/user/auth/controller';
+import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants';
+
+describe('verification material entity', () => {
+ beforeEach(async () => {
+ await MongoAccountVerificationMaterial.deleteMany({});
+ });
+
+ it('upserts only the latest material and refreshes its timestamps', async () => {
+ const firstCreatedAt = new Date('2026-07-14T00:00:00.000Z');
+ const secondCreatedAt = new Date('2026-07-14T00:01:00.000Z');
+
+ await upsertVerificationMaterial({
+ key: 'user@example.com',
+ type: AccountVerificationMaterialTypeEnum.register,
+ code: '111111',
+ createTime: firstCreatedAt,
+ expiredTime: addMinutes(firstCreatedAt, 5)
+ });
+ await upsertVerificationMaterial({
+ key: 'user@example.com',
+ type: AccountVerificationMaterialTypeEnum.register,
+ code: '222222',
+ createTime: secondCreatedAt,
+ expiredTime: addMinutes(secondCreatedAt, 5)
+ });
+
+ const records = await MongoAccountVerificationMaterial.find({}).lean();
+ expect(records).toHaveLength(1);
+ expect(records[0]).toMatchObject({ code: '222222' });
+ expect(records[0].createTime).toEqual(secondCreatedAt);
+ expect(records[0].expiredTime).toEqual(addMinutes(secondCreatedAt, 5));
+ });
+
+ it('shares one model with the legacy auth controller without dropping binding fields', async () => {
+ await addAuthCode({
+ key: 'legacy-account',
+ type: UserAuthTypeEnum.login,
+ code: '123456'
+ });
+
+ await createVerificationMaterial({
+ key: 'accountCancellation:user-hash',
+ type: AccountVerificationMaterialTypeEnum.accountCancellation,
+ code: '654321',
+ userIdHash: 'user-hash',
+ purpose: 'accountCancellation',
+ provider: 'github',
+ callbackHash: 'callback-hash',
+ expiredTime: addMinutes(new Date(), 5)
+ });
+
+ await expect(
+ MongoAccountVerificationMaterial.findOne({
+ key: 'accountCancellation:user-hash',
+ type: AccountVerificationMaterialTypeEnum.accountCancellation
+ }).lean()
+ ).resolves.toMatchObject({
+ userIdHash: 'user-hash',
+ purpose: 'accountCancellation',
+ provider: 'github',
+ callbackHash: 'callback-hash'
+ });
+ });
+
+ it('rejects material at and after the expiration boundary', async () => {
+ const expiredTime = new Date('2026-07-14T00:05:00.000Z');
+ await upsertVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.findPassword,
+ code: '123456',
+ expiredTime
+ });
+
+ await expect(
+ findValidVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.findPassword,
+ code: '123456',
+ now: new Date(expiredTime.getTime() - 1)
+ })
+ ).resolves.toBeTruthy();
+ await expect(
+ findValidVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.findPassword,
+ code: '123456',
+ now: expiredTime
+ })
+ ).resolves.toBeNull();
+ });
+
+ it('allows only one concurrent consumer', async () => {
+ await upsertVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.login,
+ code: '123456',
+ expiredTime: addMinutes(new Date(), 1)
+ });
+
+ const results = await Promise.all(
+ Array.from({ length: 8 }, () =>
+ consumeVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.login,
+ code: '123456'
+ })
+ )
+ );
+ expect(results.filter(Boolean)).toHaveLength(1);
+ });
+
+ it('matches case-insensitive codes literally without regex injection', async () => {
+ await MongoAccountVerificationMaterial.create({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.captcha,
+ code: 'A.[B]C',
+ createTime: new Date(),
+ expiredTime: addMinutes(new Date(), 1)
+ });
+
+ await expect(
+ consumeVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.captcha,
+ code: 'a.[b]c',
+ caseInsensitiveCode: true
+ })
+ ).resolves.toBeTruthy();
+ });
+
+ it('updates only a valid empty WeChat placeholder', async () => {
+ const [placeholder] = await createVerificationMaterial({
+ key: 'wechat-scene',
+ type: AccountVerificationMaterialTypeEnum.wxLogin,
+ expiredTime: addMinutes(new Date(), 1)
+ });
+
+ await expect(
+ updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'openid' })
+ ).resolves.toMatchObject({ openid: 'openid' });
+ await expect(
+ updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'other-openid' })
+ ).resolves.toBeNull();
+
+ await MongoAccountVerificationMaterial.updateOne(
+ { _id: placeholder._id },
+ { expiredTime: new Date(0), $unset: { openid: 1 } }
+ );
+ await expect(
+ updateWechatMaterialIdentity({ key: 'wechat-scene', openid: 'late-openid' })
+ ).resolves.toBeNull();
+ });
+
+ it('conditionally deletes only the material from the failed attempt', async () => {
+ await upsertVerificationMaterial({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.register,
+ code: '222222',
+ expiredTime: addMinutes(new Date(), 1)
+ });
+
+ await deleteVerificationMaterialIfMatch({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.register,
+ code: '111111'
+ });
+ await expect(MongoAccountVerificationMaterial.countDocuments({})).resolves.toBe(1);
+
+ await deleteVerificationMaterialIfMatch({
+ key: 'account',
+ type: AccountVerificationMaterialTypeEnum.register,
+ code: '222222'
+ });
+ await expect(MongoAccountVerificationMaterial.countDocuments({})).resolves.toBe(0);
+ });
+});
diff --git a/packages/service/test/support/user/account/verification/password/service.test.ts b/packages/service/test/support/user/account/verification/password/service.test.ts
new file mode 100644
index 000000000000..7e463eca2cde
--- /dev/null
+++ b/packages/service/test/support/user/account/verification/password/service.test.ts
@@ -0,0 +1,152 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { UserStatusEnum } from '@fastgpt/global/support/user/constant';
+import { MongoUser } from '@fastgpt/service/support/user/schema';
+import { MongoAccountVerificationMaterial } from '@fastgpt/service/support/user/account/verification/schema';
+import { PasswordAccountVerification } from '@fastgpt/service/support/user/account/verification/password/service';
+
+describe('PasswordAccountVerification', () => {
+ beforeEach(async () => {
+ global.feConfigs = {
+ uploadFileMaxAmount: 10,
+ uploadFileMaxSize: 10,
+ ...global.feConfigs,
+ sso: undefined
+ };
+ await MongoAccountVerificationMaterial.deleteMany({});
+ });
+
+ it('creates a deterministic 30-second pre-login material', async () => {
+ const now = new Date('2026-07-14T00:00:00.000Z');
+ const verification = new PasswordAccountVerification({
+ generateCode: () => 'ABC123',
+ now: () => now
+ });
+
+ await expect(verification.create({ username: 'user' })).resolves.toEqual({ code: 'ABC123' });
+ await expect(
+ MongoAccountVerificationMaterial.findOne({ key: 'user' }).lean()
+ ).resolves.toMatchObject({
+ code: 'ABC123',
+ createTime: now,
+ expiredTime: new Date('2026-07-14T00:00:30.000Z')
+ });
+ });
+
+ it('consumes the code and returns a local identity for a third-party username', async () => {
+ await MongoUser.create({
+ username: 'git-user',
+ password: 'password',
+ status: UserStatusEnum.active
+ });
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: 'git-user' });
+
+ await expect(
+ verification.consume({ username: 'git-user', password: 'password', code: 'abc123' })
+ ).resolves.toMatchObject({
+ kind: 'local',
+ username: 'git-user',
+ isRoot: false
+ });
+ await expect(
+ verification.consume({ username: 'git-user', password: 'password', code: 'ABC123' })
+ ).rejects.toThrow(UserErrEnum.invalidVerificationCode);
+ });
+
+ it('uses one account error for an unknown user or wrong password', async () => {
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: 'missing' });
+ await expect(
+ verification.consume({ username: 'missing', password: 'password', code: 'ABC123' })
+ ).rejects.toBe(UserErrEnum.account_psw_error);
+
+ await MongoUser.create({
+ username: 'user',
+ password: 'password',
+ status: UserStatusEnum.active
+ });
+ await verification.create({ username: 'user' });
+ await expect(
+ verification.consume({ username: 'user', password: 'wrong', code: 'ABC123' })
+ ).rejects.toBe(UserErrEnum.account_psw_error);
+ });
+
+ it.each([
+ ['missing', undefined],
+ ['empty', ''],
+ ['null', null]
+ ])('does not reveal that an existing account has a %s password', async (_label, password) => {
+ const user = await MongoUser.create({ username: `user-${_label}` });
+ if (password !== undefined) {
+ await MongoUser.collection.updateOne({ _id: user._id }, { $set: { password } });
+ }
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: user.username });
+
+ await expect(
+ verification.consume({ username: user.username, password: 'password', code: 'ABC123' })
+ ).rejects.toBe(UserErrEnum.account_psw_error);
+ });
+
+ it('rejects forbidden users after consuming their code', async () => {
+ await MongoUser.create({
+ username: 'user',
+ password: 'password',
+ status: UserStatusEnum.forbidden
+ });
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: 'user' });
+
+ await expect(
+ verification.consume({ username: 'user', password: 'password', code: 'ABC123' })
+ ).rejects.toBe('Invalid account!');
+ });
+
+ it.each(['password', 'wrong-password'])(
+ 'rejects an SSO user before comparing the submitted password: %s',
+ async (submittedPassword) => {
+ global.feConfigs.sso = {
+ url: 'https://sso.example.com',
+ disablePasswordForSsoUsers: true
+ };
+ await MongoUser.create({
+ username: 'tenant-user',
+ password: 'password',
+ status: UserStatusEnum.active
+ });
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: 'tenant-user' });
+
+ await expect(
+ verification.consume({
+ username: 'tenant-user',
+ password: submittedPassword,
+ code: 'ABC123'
+ })
+ ).rejects.toThrow(UserErrEnum.ssoPasswordUnavailable);
+ }
+ );
+
+ it('keeps the forbidden-account status ahead of the SSO password policy', async () => {
+ global.feConfigs.sso = {
+ url: 'https://sso.example.com',
+ disablePasswordForSsoUsers: true
+ };
+ await MongoUser.create({
+ username: 'tenant-forbidden',
+ password: 'password',
+ status: UserStatusEnum.forbidden
+ });
+ const verification = new PasswordAccountVerification({ generateCode: () => 'ABC123' });
+ await verification.create({ username: 'tenant-forbidden' });
+
+ await expect(
+ verification.consume({
+ username: 'tenant-forbidden',
+ password: 'password',
+ code: 'ABC123'
+ })
+ ).rejects.toBe('Invalid account!');
+ });
+});
diff --git a/packages/service/test/support/user/account/verification/service.test.ts b/packages/service/test/support/user/account/verification/service.test.ts
new file mode 100644
index 000000000000..d9a61c9d9b4b
--- /dev/null
+++ b/packages/service/test/support/user/account/verification/service.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { assertExternalAccountIdentityMatchesUsername } from '@fastgpt/service/support/user/account/verification/service';
+
+describe('assertExternalAccountIdentityMatchesUsername', () => {
+ const ssoIdentity = {
+ kind: 'external' as const,
+ provider: 'sso' as const,
+ subject: 'customer-user',
+ username: 'customer-user'
+ };
+
+ it('accepts an exact SSO identity match', () => {
+ expect(() =>
+ assertExternalAccountIdentityMatchesUsername({
+ identity: ssoIdentity,
+ username: 'customer-user'
+ })
+ ).not.toThrow();
+ });
+
+ it('rejects a code-only SSO identity that does not belong to the current user', () => {
+ expect(() =>
+ assertExternalAccountIdentityMatchesUsername({
+ identity: ssoIdentity,
+ username: 'customer-other-user'
+ })
+ ).toThrow('Verified external identity does not match the current user');
+ });
+});
diff --git a/packages/service/test/support/user/account/verification/utils.test.ts b/packages/service/test/support/user/account/verification/utils.test.ts
new file mode 100644
index 000000000000..ceb42047dd99
--- /dev/null
+++ b/packages/service/test/support/user/account/verification/utils.test.ts
@@ -0,0 +1,73 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+import {
+ assertCodeVerificationConsumeFrequency,
+ buildVerificationCodeFilter,
+ escapeVerificationCodeForRegExp
+} from '@fastgpt/service/support/user/account/verification/utils';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { getGlobalRedisConnection } from '@fastgpt/service/common/redis';
+
+describe('escapeVerificationCodeForRegExp', () => {
+ it('escapes every regular expression metacharacter', () => {
+ expect(escapeVerificationCodeForRegExp('.*+?^${}()|[]\\')).toBe(
+ '\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\'
+ );
+ });
+});
+
+describe('buildVerificationCodeFilter', () => {
+ it('uses an exact string for case-sensitive codes', () => {
+ expect(buildVerificationCodeFilter({ code: '123456' })).toBe('123456');
+ });
+
+ it('builds an escaped and anchored filter for legacy case-insensitive codes', () => {
+ const filter = buildVerificationCodeFilter({ code: 'a.b[c]', caseInsensitive: true });
+ expect(filter.$regex.test('A.B[C]')).toBe(true);
+ expect(filter.$regex.test('xa.b[c]')).toBe(false);
+ });
+});
+
+describe('assertCodeVerificationConsumeFrequency', () => {
+ const account = 'verification-rate-limit@example.com';
+
+ beforeEach(async () => {
+ await getGlobalRedisConnection().del(
+ `account-verification:code:consume:register:${account}`,
+ `account-verification:code:consume:findPassword:${account}`,
+ 'account-verification:code:consume:register:other@example.com'
+ );
+ });
+
+ it('allows 10 attempts per account and scene, then returns the frequency error', async () => {
+ const params = { account, scene: 'register' };
+
+ for (let index = 0; index < 10; index++) {
+ await expect(assertCodeVerificationConsumeFrequency(params)).resolves.toBeUndefined();
+ }
+
+ await expect(assertCodeVerificationConsumeFrequency(params)).rejects.toThrow(
+ UserErrEnum.verifyCodeTooFrequently
+ );
+ });
+
+ it('counts accounts and scenes independently', async () => {
+ const registerParams = { account, scene: 'register' };
+
+ for (let index = 0; index < 10; index++) {
+ await assertCodeVerificationConsumeFrequency(registerParams);
+ }
+
+ await expect(
+ assertCodeVerificationConsumeFrequency({
+ account: 'other@example.com',
+ scene: 'register'
+ })
+ ).resolves.toBeUndefined();
+ await expect(
+ assertCodeVerificationConsumeFrequency({
+ account,
+ scene: 'findPassword'
+ })
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/packages/service/test/support/user/auth/controller.test.ts b/packages/service/test/support/user/auth/controller.test.ts
new file mode 100644
index 000000000000..de588861f197
--- /dev/null
+++ b/packages/service/test/support/user/auth/controller.test.ts
@@ -0,0 +1,31 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants';
+import { getGlobalRedisConnection } from '@fastgpt/service/common/redis';
+import { authCode } from '@fastgpt/service/support/user/auth/controller';
+
+vi.unmock('@fastgpt/service/support/user/auth/controller');
+
+describe('authCode', () => {
+ const account = 'legacy-rate-limit@example.com';
+
+ beforeEach(async () => {
+ await getGlobalRedisConnection().del(
+ `account-verification:code:consume:${UserAuthTypeEnum.bindNotification}:${account}`
+ );
+ });
+
+ it('applies the shared account and scene frequency limit before legacy code validation', async () => {
+ const params = {
+ key: account,
+ type: UserAuthTypeEnum.bindNotification,
+ code: 'wrong-code'
+ };
+
+ for (let index = 0; index < 10; index++) {
+ await authCode(params).catch(() => undefined);
+ }
+
+ await expect(authCode(params)).rejects.toThrow(UserErrEnum.verifyCodeTooFrequently);
+ });
+});
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..5da8b5a8f7e1
--- /dev/null
+++ b/packages/service/test/support/user/team/delete/index.test.ts
@@ -0,0 +1,36 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ queue: { name: 'teamDelete' },
+ addOrRequeueFailedJob: vi.fn()
+}));
+
+vi.mock('@fastgpt/service/common/bullmq', () => ({
+ QueueNames: { teamDelete: 'teamDelete' },
+ getQueue: vi.fn(() => mocks.queue),
+ getWorker: vi.fn(),
+ addOrRequeueFailedJob: mocks.addOrRequeueFailedJob
+}));
+
+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('uses the team ID as a retryable stable job ID', async () => {
+ await addTeamDeleteJob({ teamId: 'team-1' });
+
+ expect(mocks.addOrRequeueFailedJob).toHaveBeenCalledWith({
+ queue: mocks.queue,
+ name: 'delete_team',
+ data: { teamId: 'team-1' },
+ opts: { jobId: 'team-1', delay: 1000 }
+ });
+ });
+});
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/vitest.config.ts b/packages/service/vitest.config.ts
index 8480c5d9672a..446208d15818 100644
--- a/packages/service/vitest.config.ts
+++ b/packages/service/vitest.config.ts
@@ -23,7 +23,8 @@ export default defineConfig({
'bfd697e7e798f75deaf2d31210bc93a2e41ad4eed9e7831071d77821b7b97cff',
AES256_SECRET_KEY: process.env.AES256_SECRET_KEY ?? 'fastgpt_test_aes256_secret_key',
INVOKE_TOKEN_SECRET: process.env.INVOKE_TOKEN_SECRET ?? 'fastgpt_test_invoke_token_secret_32',
- FE_DOMAIN: process.env.FE_DOMAIN ?? 'https://fastgpt.example.com'
+ FE_DOMAIN: process.env.FE_DOMAIN ?? 'https://fastgpt.example.com',
+ JWT_SECRET: process.env.JWT_SECRET ?? 'fastgpt_test_jwt_signing_secret_32_chars'
},
coverage: {
enabled: true,
diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts
index f1e9a12471b7..390d7053f199 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 3482895539b7..cb121ee915ff 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",
@@ -46,8 +103,7 @@
"package_expiry_time": "Expired",
"package_usage_rules": "Package usage rules: The system will give priority to using more advanced packages, and the original unused packages will take effect later.",
"password": "Password",
- "password_tip": "Password must be at least 8 characters long and contain at least two combinations: numbers, letters, or special characters",
- "password_update_error": "Exception when changing password",
+ "password_not_set": "No password set",
"password_update_success": "Password changed successfully",
"pending_usage": "To be used",
"please_bind_contact": "Please bind the contact information",
@@ -55,6 +111,7 @@
"redeem_coupon": "Redeem coupon",
"resource_usage": "Usages",
"select_avatar": "Click to select avatar",
+ "set_password": "Set",
"standard_package_and_extra_resource_package": "Includes standard and extra plans",
"storage_capacity": "Storage capacity",
"team_balance": "Balance",
@@ -63,7 +120,6 @@
"tokens": "integral",
"type": "type",
"unlimited": "Unlimited",
- "update_password": "Change password",
"update_success_tip": "Update data successfully",
"upgrade_package": "Upgrade",
"usage_balance": "Use balance: Use balance",
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 c701ac5aa9ea..367c69c3ddd4 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.app_error.can_not_edit_admin_permission": "Can not edit admin permission",
"code_error.app_error.invalid_app_type": "Invalid Application Type",
@@ -192,6 +193,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",
@@ -792,8 +794,12 @@
"error.invalid_params": "Invalid parameter",
"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.sso_password_unavailable": "Password is unavailable. Use an identity provider to sign in.",
+ "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.",
+ "error.verification_channel_unavailable": "This verification method is currently unavailable.",
"error.too_many_request": "Too many request",
"error.tool_not_exist": "Tool deleted",
"error.unAuthFile": "Unauthorized to read this file",
@@ -939,6 +945,33 @@
"pay_money": "Amount payable",
"pay_success": "Payment successfully",
"pay_year_tip": "Pay {{count}} months, enjoy 1 year!",
+ "password_authorizing": "Checking password change authorization",
+ "password_code_countdown": "Resend ({{seconds}})",
+ "password_code_sent": "Verification code sent",
+ "password_code_sending": "Sending",
+ "password_confirm_action": "Confirm",
+ "password_confirm_placeholder": "Confirm password",
+ "password_expired_action": "Set password",
+ "password_expired_tip": "It has been a long time since you changed your password. To keep your account secure, set a new password.",
+ "password_new_placeholder": "Enter password",
+ "password_not_match": "The Passwords Entered Do Not Match",
+ "password_oauth_start": "Verify with {{provider}}",
+ "password_old_placeholder": "Enter current password",
+ "password_send_code": "Get verification code",
+ "password_set_success": "Password set successfully",
+ "password_set_title": "Set password",
+ "password_tip": "Use at least 8 characters and include any two types: uppercase letters, lowercase letters, numbers, or special characters",
+ "password_update_error": "Exception when changing password",
+ "password_verification_description": "To protect your account, complete identity verification first.",
+ "password_verification_failed": "Identity verification failed. Try again.",
+ "password_verification_retry": "Retry",
+ "password_verification_title": "Identity verification",
+ "password_verification_unavailable": "No identity verification method is available for this account",
+ "password_verify": "Verify",
+ "password_wechat_expired": "The QR code expired. Get a new one.",
+ "password_wechat_load_failed": "The QR code could not be loaded. Try again.",
+ "password_wechat_qr": "WeChat verification QR code",
+ "password_wechat_scan": "Sign in with WeChat QR code",
"permission.Collaborator": "Collaborator",
"permission.Manage": "Manage",
"permission.No InheritPermission": "Permission Inheritance Restricted",
@@ -1061,6 +1094,7 @@
"support.user.login.Provider error": "Login Error, Please Try Again",
"support.user.login.Username": "Username",
"support.user.login.Wechat": "WeChat Login",
+ "support.user.login.Wecom": "WeCom",
"support.user.login.can_not_login": "Cannot log in?",
"support.user.login.error": "Login Error",
"support.user.login.security_failed": "Security Verification Failed",
@@ -1175,6 +1209,7 @@
"unknow_source": "Unknown Source",
"unusable_variable": "No Usable Variables",
"update_failed": "Update Failed",
+ "update_password": "Change password",
"update_success": "Updated Successfully",
"upgrade": "upgrade",
"upload_file": "Upload File",
@@ -1183,7 +1218,7 @@
"user.Account": "Account",
"user.No_right_to_reset_password": "You do not have the right to reset the password",
"user.Old password is error": "Old Password is Incorrect",
- "user.Password has no change": "New password is the same as the old password",
+ "user.Password has no change": "New password cannot be the same as the old password",
"user.Pay": "Recharge",
"user.Time": "Time",
"user.Update password failed": "Failed to Update Password",
diff --git a/packages/web/i18n/en/user.json b/packages/web/i18n/en/user.json
index 56ee4fa9f756..4c7fcb9894bf 100644
--- a/packages/web/i18n/en/user.json
+++ b/packages/web/i18n/en/user.json
@@ -10,7 +10,7 @@
"password.confirm": "Confirm Password",
"password.email_phone_error": "Invalid Email/Phone Number Format",
"password.email_phone_void": "Email/Phone Number Cannot Be Empty",
- "password.not_match": "Passwords Do Not Match",
+ "password.not_match": "The Passwords Entered Do Not Match",
"password.retrieve": "Retrieve Password",
"password.retrieved": "Password Retrieved",
"password.retrieved_account": "Retrieve {{account}} Account",
diff --git a/packages/web/i18n/zh-CN/account_info.json b/packages/web/i18n/zh-CN/account_info.json
index 0cef97893c26..a1c9e130ba5e 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 积分",
@@ -46,8 +103,7 @@
"package_expiry_time": "套餐到期时间",
"package_usage_rules": "套餐使用规则:系统优先使用更高级的套餐,原未用完的套餐将延后生效",
"password": "密码",
- "password_tip": "密码至少 8 位,且至少包含两种组合:数字、字母或特殊字符",
- "password_update_error": "修改密码异常",
+ "password_not_set": "未设置密码",
"password_update_success": "修改密码成功",
"pending_usage": "待使用",
"please_bind_contact": "请绑定联系方式",
@@ -55,6 +111,7 @@
"redeem_coupon": "兑换码",
"resource_usage": "资源用量",
"select_avatar": "点击选择头像",
+ "set_password": "设置",
"standard_package_and_extra_resource_package": "包含标准套餐与额外资源包",
"storage_capacity": "存储量",
"team_balance": "团队余额",
@@ -63,7 +120,6 @@
"tokens": "积分",
"type": "类型",
"unlimited": "无限制",
- "update_password": "修改密码",
"update_success_tip": "更新数据成功",
"upgrade_package": "升级套餐",
"usage_balance": "使用余额: 使用余额",
diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json
index 7040ad153bd8..580619847d81 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 22629c871425..9f414c93b732 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.app_error.can_not_edit_admin_permission": "不能编辑管理员权限",
"code_error.app_error.invalid_app_type": "错误的应用类型",
@@ -192,6 +193,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": "不能修改根部门",
@@ -792,8 +794,12 @@
"error.invalid_params": "参数无效",
"error.llm_track_expired": "请求详情已过期",
"error.missingParams": "参数缺失",
+ "error.operation_too_frequently": "操作过于频繁,请稍后再试",
"error.s3_upload_invalid_file_type": "文件内容不受支持,或文件后缀与内容不匹配",
"error.send_auth_code_too_frequently": "请勿频繁获取验证码",
+ "error.sso_password_unavailable": "密码不可用,请使用身份验证方式登录",
+ "error.verify_code_too_frequently": "验证过于频繁,请稍后再试",
+ "error.verification_channel_unavailable": "该身份验证方式当前不可用",
"error.too_many_request": "请求太频繁了,请稍后重试",
"error.tool_not_exist": "工具已删除",
"error.unAuthFile": "无权读取该文件",
@@ -939,6 +945,33 @@
"pay_money": "应付金额",
"pay_success": "支付成功",
"pay_year_tip": "支付 {{count}} 个月,畅享一年!",
+ "password_authorizing": "正在确认修改密码权限",
+ "password_code_countdown": "重新获取({{seconds}})",
+ "password_code_sent": "验证码已发送",
+ "password_code_sending": "发送中",
+ "password_confirm_action": "确定",
+ "password_confirm_placeholder": "确认密码",
+ "password_expired_action": "去设置",
+ "password_expired_tip": "您已较长时间未修改密码。为了您的账号安全,请重新设置密码。",
+ "password_new_placeholder": "请输入密码",
+ "password_not_match": "两次密码输入不一致",
+ "password_oauth_start": "前往 {{provider}} 验证",
+ "password_old_placeholder": "填写旧密码",
+ "password_send_code": "获取验证码",
+ "password_set_success": "密码设置成功",
+ "password_set_title": "设置密码",
+ "password_tip": "至少 8 位,需包含任意两类:大写字母、小写字母、数字、特殊字符",
+ "password_update_error": "修改密码异常",
+ "password_verification_description": "为保护账号安全,请先完成身份验证。",
+ "password_verification_failed": "身份验证失败,请重试",
+ "password_verification_retry": "重试",
+ "password_verification_title": "身份验证",
+ "password_verification_unavailable": "当前账号没有可用的身份验证方式",
+ "password_verify": "验证",
+ "password_wechat_expired": "二维码已过期,请重新获取",
+ "password_wechat_load_failed": "二维码加载失败,请重试",
+ "password_wechat_qr": "微信验证二维码",
+ "password_wechat_scan": "微信扫码登录",
"permission.Collaborator": "协作者",
"permission.Manage": "管理",
"permission.No InheritPermission": "已限制权限,不再继承父级文件夹的权限,",
@@ -1061,6 +1094,7 @@
"support.user.login.Provider error": "登录异常,请重试",
"support.user.login.Username": "用户名",
"support.user.login.Wechat": "微信登录",
+ "support.user.login.Wecom": "企业微信登录",
"support.user.login.can_not_login": "无法登录?",
"support.user.login.error": "登录异常",
"support.user.login.security_failed": "安全校验失败",
@@ -1175,6 +1209,7 @@
"unknow_source": "未知来源",
"unusable_variable": "无可用变量",
"update_failed": "更新异常",
+ "update_password": "修改密码",
"update_success": "更新成功",
"upgrade": "升级",
"upload_file": "上传文件",
@@ -1183,7 +1218,7 @@
"user.Account": "账号",
"user.No_right_to_reset_password": "没有重置密码的权限",
"user.Old password is error": "旧密码错误",
- "user.Password has no change": "新密码和旧密码重复",
+ "user.Password has no change": "新密码不能和旧密码相同",
"user.Pay": "充值",
"user.Time": "时间",
"user.Update password failed": "修改密码异常",
diff --git a/packages/web/i18n/zh-CN/user.json b/packages/web/i18n/zh-CN/user.json
index 1ee53910b6d1..68d967fa091c 100644
--- a/packages/web/i18n/zh-CN/user.json
+++ b/packages/web/i18n/zh-CN/user.json
@@ -10,7 +10,7 @@
"password.confirm": "确认密码",
"password.email_phone_error": "邮箱/手机号格式错误",
"password.email_phone_void": "邮箱/手机号不能为空",
- "password.not_match": "两次密码不一致",
+ "password.not_match": "两次密码输入不一致",
"password.retrieve": "找回密码",
"password.retrieved": "密码已找回",
"password.retrieved_account": "找回 {{account}} 账号",
diff --git a/packages/web/i18n/zh-Hant/account_info.json b/packages/web/i18n/zh-Hant/account_info.json
index fa0eef499a90..e08070f97fbb 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 積分",
@@ -46,8 +103,7 @@
"package_expiry_time": "套餐到期時間",
"package_usage_rules": "套餐使用規則:系統優先使用更進階的套餐,原未用完的套餐將延遲生效",
"password": "密碼",
- "password_tip": "密碼至少 8 位,且至少包含兩種組合:數字、字母或特殊字元",
- "password_update_error": "修改密碼異常",
+ "password_not_set": "尚未設定密碼",
"password_update_success": "修改密碼成功",
"pending_usage": "待使用",
"please_bind_contact": "請綁定聯繫方式",
@@ -55,6 +111,7 @@
"redeem_coupon": "兌換代碼",
"resource_usage": "資源用量",
"select_avatar": "點選選擇頭像",
+ "set_password": "設定",
"standard_package_and_extra_resource_package": "包含標準套餐與額外資源包",
"storage_capacity": "儲存量",
"team_balance": "團隊餘額",
@@ -63,7 +120,6 @@
"tokens": "積分",
"type": "類型",
"unlimited": "無限制",
- "update_password": "修改密碼",
"update_success_tip": "更新資料成功",
"upgrade_package": "升級套餐",
"usage_balance": "使用餘額:使用餘額",
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 10b7e4e230b1..2f2ae84eed6c 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.app_error.can_not_edit_admin_permission": "不能編輯管理員權限",
"code_error.app_error.invalid_app_type": "無效的應用程式類型",
@@ -190,6 +191,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": "無法修改根組織",
@@ -786,8 +788,12 @@
"error.invalid_params": "參數無效",
"error.llm_track_expired": "請求詳情已過期",
"error.missingParams": "參數不足",
+ "error.operation_too_frequently": "操作過於頻繁,請稍後再試",
"error.s3_upload_invalid_file_type": "文件內容不受支援,或副檔名與內容不匹配",
"error.send_auth_code_too_frequently": "請勿頻繁取得驗證碼",
+ "error.sso_password_unavailable": "密碼不可用,請使用身分驗證方式登入",
+ "error.verify_code_too_frequently": "驗證過於頻繁,請稍後再試",
+ "error.verification_channel_unavailable": "該身分驗證方式目前不可用",
"error.too_many_request": "請求太頻繁了,請稍後重試",
"error.tool_not_exist": "工具已刪除",
"error.unAuthFile": "無權讀取該文件",
@@ -929,6 +935,33 @@
"pay_money": "應付金額",
"pay_success": "支付成功",
"pay_year_tip": "支付 {{count}} 個月,暢享一年!",
+ "password_authorizing": "正在確認修改密碼權限",
+ "password_code_countdown": "重新取得({{seconds}})",
+ "password_code_sent": "驗證碼已傳送",
+ "password_code_sending": "傳送中",
+ "password_confirm_action": "確定",
+ "password_confirm_placeholder": "確認密碼",
+ "password_expired_action": "前往設定",
+ "password_expired_tip": "您已較長時間未修改密碼。為了您的帳號安全,請重新設定密碼。",
+ "password_new_placeholder": "請輸入密碼",
+ "password_not_match": "兩次密碼輸入不一致",
+ "password_oauth_start": "前往 {{provider}} 驗證",
+ "password_old_placeholder": "填寫舊密碼",
+ "password_send_code": "取得驗證碼",
+ "password_set_success": "密碼設定成功",
+ "password_set_title": "設定密碼",
+ "password_tip": "至少 8 位,需包含任意兩類:大寫字母、小寫字母、數字、特殊字元",
+ "password_update_error": "修改密碼異常",
+ "password_verification_description": "為保護帳號安全,請先完成身分驗證。",
+ "password_verification_failed": "身分驗證失敗,請重試",
+ "password_verification_retry": "重試",
+ "password_verification_title": "身分驗證",
+ "password_verification_unavailable": "目前帳號沒有可用的身分驗證方式",
+ "password_verify": "驗證",
+ "password_wechat_expired": "QR Code 已過期,請重新取得",
+ "password_wechat_load_failed": "QR Code 載入失敗,請重試",
+ "password_wechat_qr": "微信驗證 QR Code",
+ "password_wechat_scan": "微信掃碼登入",
"permission.Collaborator": "協作者",
"permission.Manage": "管理",
"permission.No InheritPermission": "已限制權限,不再繼承上層資料夾的權限",
@@ -1050,6 +1083,7 @@
"support.user.login.Provider error": "登入錯誤,請重試",
"support.user.login.Username": "使用者名稱",
"support.user.login.Wechat": "微信登入",
+ "support.user.login.Wecom": "企業微信登入",
"support.user.login.can_not_login": "無法登入?",
"support.user.login.error": "登入錯誤",
"support.user.login.security_failed": "安全驗證失敗",
@@ -1164,6 +1198,7 @@
"unknow_source": "未知來源",
"unusable_variable": "無可用變數",
"update_failed": "更新失敗",
+ "update_password": "修改密碼",
"update_success": "更新成功",
"upgrade": "升級",
"upload_file": "上傳檔案",
@@ -1171,7 +1206,7 @@
"user.Account": "帳戶",
"user.No_right_to_reset_password": "沒有重置密碼的權限",
"user.Old password is error": "舊密碼錯誤",
- "user.Password has no change": "新密碼和舊密碼重複",
+ "user.Password has no change": "新密碼不能與舊密碼相同",
"user.Pay": "儲值",
"user.Time": "時間",
"user.Update password failed": "更新密碼失敗",
diff --git a/packages/web/i18n/zh-Hant/user.json b/packages/web/i18n/zh-Hant/user.json
index c5752b3cca15..4457e43f9d74 100644
--- a/packages/web/i18n/zh-Hant/user.json
+++ b/packages/web/i18n/zh-Hant/user.json
@@ -10,7 +10,7 @@
"password.confirm": "確認密碼",
"password.email_phone_error": "電子郵件/手機號碼格式錯誤",
"password.email_phone_void": "電子郵件/手機號碼不能空白",
- "password.not_match": "兩次輸入的密碼不相符",
+ "password.not_match": "兩次密碼輸入不一致",
"password.retrieve": "找回密碼",
"password.retrieved": "密碼已找回",
"password.retrieved_account": "找回 {{account}} 帳號",
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/pnpm-lock.yaml b/pnpm-lock.yaml
index 85cd53eca4cf..c2fd676aa394 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,6 +57,9 @@ catalogs:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
+ '@types/jsdom':
+ specifier: ^21.1.7
+ version: 21.1.7
'@types/jsonwebtoken':
specifier: ^9.0.3
version: 9.0.9
@@ -105,6 +108,9 @@ catalogs:
file-type:
specifier: 21.3.0
version: 21.3.0
+ google-auth-library:
+ specifier: 10.6.2
+ version: 10.6.2
gpt-tokenizer:
specifier: 3.4.0
version: 3.4.0
@@ -120,6 +126,9 @@ catalogs:
js-yaml:
specifier: ^4.1.1
version: 4.1.1
+ jsdom:
+ specifier: 26.1.0
+ version: 26.1.0
json5:
specifier: ^2.2.3
version: 2.2.3
@@ -1002,6 +1011,9 @@ importers:
gcp-metadata:
specifier: ^5.3.0
version: 5.3.0(encoding@0.1.13)
+ google-auth-library:
+ specifier: 'catalog:'
+ version: 10.6.2
i18next:
specifier: 'catalog:'
version: 23.16.8
@@ -1267,12 +1279,18 @@ importers:
'@types/express':
specifier: ^4.17.21
version: 4.17.25
+ '@types/node':
+ specifier: 'catalog:'
+ version: 20.17.24
'@types/xml2js':
specifier: ^0.4.14
version: 0.4.14
tsdown:
specifier: 'catalog:'
version: 0.21.10(typescript@6.0.3)
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@20.17.24)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.4))
projects/app:
dependencies:
@@ -1538,6 +1556,9 @@ importers:
'@types/js-yaml':
specifier: 'catalog:'
version: 4.0.9
+ '@types/jsdom':
+ specifier: 'catalog:'
+ version: 21.1.7
'@types/jsonwebtoken':
specifier: 'catalog:'
version: 9.0.9
@@ -1568,6 +1589,9 @@ importers:
eslint-config-next:
specifier: catalog:lint
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3))(eslint@9.39.4(jiti@1.21.7))(typescript@6.0.3)
+ jsdom:
+ specifier: 'catalog:'
+ version: 26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10)
tailwindcss:
specifier: ^3
version: 3.4.18(tsx@4.20.6)(yaml@2.8.4)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 7df15528423c..820448a5bb82 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -32,6 +32,7 @@ catalog:
'@t3-oss/env-core': 0.13.10
'@tanstack/react-query': ^4.24.10
'@types/js-yaml': ^4.0.9
+ '@types/jsdom': ^21.1.7
'@types/jsonwebtoken': ^9.0.3
'@types/lodash-es': ^4
'@types/mime-types': ^3.0.1
@@ -51,12 +52,14 @@ catalog:
debug: ^4.4.3
express: ^4
file-type: 21.3.0
+ google-auth-library: 10.6.2
gpt-tokenizer: 3.4.0
hono: 4.12.27
'@hono/node-server': ^2.0.10
i18next: 23.16.8
ipaddr.js: ^2.4.0
js-yaml: ^4.1.1
+ jsdom: 26.1.0
json5: ^2.2.3
jsonwebtoken: ^9.0.3
lodash-es: ^4.17.21
diff --git a/pro b/pro
index 7027ac732193..a42ce85e5048 160000
--- a/pro
+++ b/pro
@@ -1 +1 @@
-Subproject commit 7027ac732193b6997eac387f556f23eb2e3bab22
+Subproject commit a42ce85e5048bb6e5e4ed1eb1199dcef69b63377
diff --git a/projects/app/.env.template b/projects/app/.env.template
index c0ebbef653f4..371dad12c7d6 100644
--- a/projects/app/.env.template
+++ b/projects/app/.env.template
@@ -14,6 +14,8 @@ FILE_TOKEN_KEY=
AES256_SECRET_KEY=fastgptsecret
# Invoke 反向调用 JWT 密钥,至少 32 位
INVOKE_TOKEN_SECRET=fastgpt_invoke_token_secret_32_chars_min
+# 通用 JWT 签名密钥,至少 32 位;生产环境必须替换
+JWT_SECRET=replace_with_a_random_secret_at_least_32_chars
# root key(最高权限)
ROOT_KEY=fdafasd
diff --git a/projects/app/package.json b/projects/app/package.json
index 963848453b9c..5563b51f6e21 100644
--- a/projects/app/package.json
+++ b/projects/app/package.json
@@ -117,6 +117,7 @@
"@svgr/webpack": "catalog:",
"@types/archiver": "^6.0.2",
"@types/js-yaml": "catalog:",
+ "@types/jsdom": "catalog:",
"@types/jsonwebtoken": "catalog:",
"@types/lodash-es": "catalog:",
"@types/node": "catalog:",
@@ -127,6 +128,7 @@
"@types/react-syntax-highlighter": "^15.5.6",
"eslint": "catalog:lint",
"eslint-config-next": "catalog:lint",
+ "jsdom": "catalog:",
"tailwindcss": "^3",
"tsx": "catalog:",
"typescript": "catalog:",
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/auth.tsx b/projects/app/src/components/Layout/auth.tsx
index d8f54e43ef1b..a90dee6bee1c 100644
--- a/projects/app/src/components/Layout/auth.tsx
+++ b/projects/app/src/components/Layout/auth.tsx
@@ -8,7 +8,6 @@ const unAuthPage: { [key: string]: boolean } = {
'/': true,
'/login': true,
'/login/provider': true,
- '/login/fastlogin': true,
'/login/sso': true,
'/appStore': true,
'/chat': true,
@@ -34,7 +33,7 @@ const Auth = ({ children }: { children: JSX.Element | React.ReactNode }) => {
},
{
refetchInterval: 10 * 60 * 1000,
- onError(error) {
+ onError() {
toast({
status: 'warning',
title: t('common:support.user.Need to login')
diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx
index 070b1334a14d..29d20c27cda3 100644
--- a/projects/app/src/components/Layout/index.tsx
+++ b/projects/app/src/components/Layout/index.tsx
@@ -57,7 +57,7 @@ const pcUnShowLayoutRoute: Record = {
'/': true,
'/login': true,
'/login/provider': true,
- '/login/fastlogin': true,
+ '/account/cancel': true,
'/chat/share': true,
'/app/edit': true,
'/chat': true,
@@ -69,7 +69,7 @@ const phoneUnShowLayoutRoute: Record = {
'/': true,
'/login': true,
'/login/provider': true,
- '/login/fastlogin': true,
+ '/account/cancel': true,
'/chat': true,
'/chat/share': true,
'/tools/price': true,
@@ -164,6 +164,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/support/user/inform/UpdateContactModal.tsx b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx
index 6aae58ddf138..c4ca3f878f18 100644
--- a/projects/app/src/components/support/user/inform/UpdateContactModal.tsx
+++ b/projects/app/src/components/support/user/inform/UpdateContactModal.tsx
@@ -9,7 +9,6 @@ import Icon from '@fastgpt/web/components/common/Icon';
import { useSendCode } from '@/web/support/user/hooks/useSendCode';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useSystemStore } from '@/web/common/system/useSystemStore';
-import { UserAuthTypeEnum } from '@fastgpt/global/support/user/auth/constants';
type FormType = {
contact: string;
@@ -62,7 +61,7 @@ const UpdateContactModal = ({
}
);
- const { SendCodeBox } = useSendCode({ type: UserAuthTypeEnum.bindNotification });
+ const { SendCodeBox } = useSendCode({ type: 'bindNotification' });
const placeholder = feConfigs?.bind_notification_method
?.map((item) => {
diff --git a/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx
new file mode 100644
index 000000000000..73de69b30f56
--- /dev/null
+++ b/projects/app/src/components/support/user/safe/AccountVerificationPanel.tsx
@@ -0,0 +1,464 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import {
+ Box,
+ Button,
+ Center,
+ Image,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Spinner,
+ Text,
+ VStack,
+ useDisclosure
+} from '@chakra-ui/react';
+import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
+import { hashStr } from '@fastgpt/global/common/string/tools';
+import type { AccountVerificationMethod } from '@fastgpt/global/support/user/account/verification/type';
+import { OAuthAccountVerificationProviderSchema } from '@fastgpt/global/support/user/account/verification/type';
+import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants';
+import type {
+ CreatePasswordVerificationBody,
+ CreatePasswordVerificationResponse,
+ PasswordAuthorizationResponse,
+ SensitiveAccountVerificationBody
+} from '@fastgpt/global/openapi/support/user/account/password/api';
+import { useToast } from '@fastgpt/web/hooks/useToast';
+import SendCodeAuthModal from './SendCodeAuthModal';
+import { getClientToken } from '@/web/support/user/hooks/useSendCode';
+import { useSystemStore } from '@/web/common/system/useSystemStore';
+import {
+ isAccountVerificationCodeError,
+ isAccountVerificationRateLimitError
+} from '@/web/support/user/account/verification/error';
+
+type AuthorizedPasswordChange = Extract;
+
+type Props = {
+ method: AccountVerificationMethod;
+ username: string;
+ required: boolean;
+ returnRoute: string;
+ createVerification: (
+ body: CreatePasswordVerificationBody
+ ) => Promise;
+ consumeVerification: (
+ verification: SensitiveAccountVerificationBody
+ ) => Promise;
+ onAuthorized: (authorization: AuthorizedPasswordChange) => void;
+};
+
+const isOAuthMethod = (
+ method: AccountVerificationMethod
+): method is Extract => method.startsWith('oauth/');
+
+/** 渲染服务端指定的唯一账号验证方式,并统一处理材料创建、消费和 Provider 跳转。 */
+export const AccountVerificationPanel = ({
+ method,
+ username,
+ required,
+ returnRoute,
+ createVerification,
+ consumeVerification,
+ onAuthorized
+}: Props) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { feConfigs } = useSystemStore();
+ const { isOpen: isCaptchaOpen, onOpen: onOpenCaptcha, onClose: onCloseCaptcha } = useDisclosure();
+ const [code, setCode] = useState('');
+ const [codeCountDown, setCodeCountDown] = useState(0);
+ const [codeSending, setCodeSending] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [oldPassword, setOldPassword] = useState('');
+ const [preLoginCode, setPreLoginCode] = useState();
+ const [wechatQR, setWechatQR] =
+ useState>();
+ const [wechatNow, setWechatNow] = useState(() => Date.now());
+ const [creating, setCreating] = useState(false);
+ const [createFailed, setCreateFailed] = useState(false);
+ const createRequested = useRef(false);
+ const wechatPolling = useRef(false);
+
+ const showVerificationFailure = useCallback(
+ (error?: unknown) => {
+ toast({
+ status: 'error',
+ title: isAccountVerificationCodeError(error)
+ ? t('common:error.code_error')
+ : isAccountVerificationRateLimitError(error)
+ ? t('common:error.operation_too_frequently')
+ : t('common:password_verification_failed')
+ });
+ },
+ [t, toast]
+ );
+
+ const submitVerification = useCallback(
+ async (verification: SensitiveAccountVerificationBody) => {
+ const result = await consumeVerification(verification);
+ if (result.status === 'authorized') {
+ onAuthorized(result);
+ return true;
+ }
+ return result.status === 'verificationPending' ? false : Promise.reject();
+ },
+ [consumeVerification, onAuthorized]
+ );
+
+ const createBoundVerification = useCallback(async () => {
+ if (method !== 'oldPassword' && method !== 'wechat') return;
+ setCreating(true);
+ setCreateFailed(false);
+ try {
+ const result = await createVerification({ method, payload: {} });
+ if (result.method === 'oldPassword') {
+ setPreLoginCode(result.preLoginCode);
+ } else if (result.method === 'wechat') {
+ setWechatQR(result);
+ setWechatNow(Date.now());
+ }
+ } catch (error) {
+ setCreateFailed(true);
+ showVerificationFailure(error);
+ } finally {
+ setCreating(false);
+ }
+ }, [createVerification, method, showVerificationFailure]);
+
+ useEffect(() => {
+ if ((method !== 'oldPassword' && method !== 'wechat') || createRequested.current) return;
+ createRequested.current = true;
+ void createBoundVerification();
+ }, [createBoundVerification, method]);
+
+ useEffect(() => {
+ if (codeCountDown <= 0) return;
+ const timer = window.setTimeout(() => setCodeCountDown((value) => value - 1), 1000);
+ return () => window.clearTimeout(timer);
+ }, [codeCountDown]);
+
+ const wechatExpired =
+ !!wechatQR?.expiredAt && new Date(wechatQR.expiredAt).getTime() <= wechatNow;
+
+ useEffect(() => {
+ if (!wechatQR?.expiredAt) return;
+ const timer = window.setInterval(() => setWechatNow(Date.now()), 1000);
+ return () => window.clearInterval(timer);
+ }, [wechatQR?.expiredAt]);
+
+ useEffect(() => {
+ if (!wechatQR || wechatExpired) return;
+ let disposed = false;
+
+ const pollVerification = async () => {
+ if (wechatPolling.current) return;
+ wechatPolling.current = true;
+ try {
+ const authorized = await submitVerification({
+ method: 'wechat',
+ payload: { code: wechatQR.code }
+ });
+ if (authorized) disposed = true;
+ } catch (error) {
+ disposed = true;
+ setWechatQR(undefined);
+ setCreateFailed(true);
+ showVerificationFailure(error);
+ } finally {
+ wechatPolling.current = false;
+ }
+ };
+
+ void pollVerification();
+ const timer = window.setInterval(() => {
+ if (!disposed) void pollVerification();
+ }, 2000);
+ return () => {
+ disposed = true;
+ window.clearInterval(timer);
+ };
+ }, [showVerificationFailure, submitVerification, wechatExpired, wechatQR]);
+
+ const sendCode = async ({ captcha }: { username: string; captcha: string }) => {
+ if (method !== 'code') return;
+ setCodeSending(true);
+ try {
+ const googleToken = await getClientToken(feConfigs.googleClientVerKey);
+ const result = await createVerification({
+ method,
+ payload: { captcha, googleToken }
+ });
+ if (result.method !== 'code') throw new Error('Verification method mismatch');
+ setCodeCountDown(60);
+ toast({ status: 'success', title: t('common:password_code_sent') });
+ } catch (error) {
+ showVerificationFailure(error);
+ throw new Error('Failed to send verification code');
+ } finally {
+ setCodeSending(false);
+ }
+ };
+
+ const submitCode = useCallback(
+ async (verificationCode: string) => {
+ if (method !== 'code' || verificationCode.length !== 6 || submitting) return;
+ setSubmitting(true);
+ try {
+ await submitVerification({ method, payload: { code: verificationCode } });
+ } catch (error) {
+ showVerificationFailure(error);
+ } finally {
+ setSubmitting(false);
+ }
+ },
+ [method, showVerificationFailure, submitVerification, submitting]
+ );
+
+ const submitOldPassword = async () => {
+ if (method !== 'oldPassword' || !oldPassword || !preLoginCode) return;
+ setSubmitting(true);
+ try {
+ await submitVerification({
+ method,
+ payload: { password: hashStr(oldPassword), preLoginCode }
+ });
+ } catch (error) {
+ // 预登录材料在密码校验前即被一次性消费,失败后必须重新创建才能再次尝试。
+ setOldPassword('');
+ setPreLoginCode(undefined);
+ showVerificationFailure(error);
+ void createBoundVerification();
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const submitOAuth = async () => {
+ if (!isOAuthMethod(method)) return;
+ setSubmitting(true);
+ try {
+ const callbackUrl = `${window.location.origin}/login/provider`;
+ const result = await createVerification({
+ method,
+ payload: {
+ callbackUrl,
+ isWecomWorkTerminal: checkIsWecomTerminal()
+ }
+ });
+ if (result.method !== method) throw new Error('Verification method mismatch');
+ const provider = OAuthAccountVerificationProviderSchema.parse(method.slice('oauth/'.length));
+ useSystemStore.getState().setLoginStore({
+ provider,
+ lastRoute: returnRoute,
+ state: result.state,
+ callbackUrl,
+ flow: 'passwordChange',
+ passwordChangeRequired: required
+ });
+ await router.replace(result.url);
+ } catch (error) {
+ setSubmitting(false);
+ showVerificationFailure(error);
+ }
+ };
+
+ const retryCreate = () => {
+ createRequested.current = true;
+ void createBoundVerification();
+ };
+
+ if (method === 'code') {
+ return (
+
+
+
+ setCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
+ aria-label={t('common:support.user.info.verification_code')}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') void submitCode(code.trim());
+ }}
+ />
+
+
+
+
+
+ {isCaptchaOpen && (
+
+ )}
+
+ );
+ }
+
+ if (method === 'oldPassword') {
+ return (
+
+
+
+ {creating ? (
+
+
+
+ ) : createFailed || !preLoginCode ? (
+
+
+
+ ) : (
+ <>
+ setOldPassword(event.target.value)}
+ placeholder={t('common:password_old_placeholder')}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') void submitOldPassword();
+ }}
+ />
+
+ >
+ )}
+
+
+ );
+ }
+
+ if (method === 'wechat') {
+ return (
+
+
+ {t('common:password_wechat_scan')}
+
+
+ {creating ? (
+
+ ) : wechatQR && !wechatExpired ? (
+
+ ) : (
+
+
+ {t(
+ createFailed
+ ? 'common:password_wechat_load_failed'
+ : 'common:password_wechat_expired'
+ )}
+
+
+
+ )}
+
+
+ );
+ }
+
+ const provider = method.slice('oauth/'.length).toLowerCase();
+ const providerLabel = (() => {
+ if (provider === 'github') return 'GitHub';
+ if (provider === 'google') return 'Google';
+ if (provider === 'microsoft') return 'Microsoft';
+ if (provider === 'wecom') return 'WeCom';
+ return feConfigs.sso?.title ?? 'SSO';
+ })();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx
new file mode 100644
index 000000000000..796592c87c2a
--- /dev/null
+++ b/projects/app/src/components/support/user/safe/PasswordChangeModal.tsx
@@ -0,0 +1,351 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ Box,
+ Button,
+ Center,
+ Flex,
+ FormControl,
+ FormErrorMessage,
+ Input,
+ Spinner,
+ Text,
+ VStack
+} from '@chakra-ui/react';
+import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+import { UserErrEnum } from '@fastgpt/global/common/error/code/user';
+import { getErrResponse, getErrText } from '@fastgpt/global/common/error/utils';
+import { checkPasswordRule } from '@fastgpt/global/common/string/password';
+import type {
+ PasswordAuthorizationResponse,
+ SensitiveAccountVerificationBody
+} from '@fastgpt/global/openapi/support/user/account/password/api';
+import type { AccountVerificationMethod } from '@fastgpt/global/support/user/account/verification/type';
+import MyModal from '@fastgpt/web/components/common/MyModal';
+import { useToast } from '@fastgpt/web/hooks/useToast';
+import { useUserStore } from '@/web/support/user/useUserStore';
+import {
+ authorizePasswordChange,
+ createPasswordVerification,
+ updatePassword
+} from '@/web/support/user/account/password/api';
+import { usePasswordChangeStore } from '@/web/support/user/account/password/store';
+import { AccountVerificationPanel } from './AccountVerificationPanel';
+
+type Authorization = Extract;
+type Stage =
+ | { type: 'prompt' }
+ | { type: 'authorizing' }
+ | { type: 'verification'; method: AccountVerificationMethod }
+ | { type: 'password'; authorization: Authorization }
+ | { type: 'unavailable' };
+
+type FormType = {
+ newPassword: string;
+ confirmPassword: string;
+};
+
+type Props = {
+ required?: boolean;
+ showExpiredPrompt?: boolean;
+ onClose?: () => void;
+ onSuccess?: () => void | Promise;
+};
+
+const invalidInputStyles = {
+ borderColor: 'red.500',
+ _focus: {
+ borderColor: 'red.500',
+ boxShadow: '0 0 0 1px var(--chakra-colors-red-500)'
+ },
+ _focusVisible: {
+ borderColor: 'red.500',
+ boxShadow: '0 0 0 1px var(--chakra-colors-red-500)'
+ }
+};
+
+/** 统一承接设置、修改和过期重置密码的短期授权状态机。 */
+const PasswordChangeModal = ({
+ required = false,
+ showExpiredPrompt = false,
+ onClose,
+ onSuccess
+}: Props) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { userInfo, initUserInfo } = useUserStore();
+ const storedAuthorization = usePasswordChangeStore((state) => state.authorization);
+ const setStoredAuthorization = usePasswordChangeStore((state) => state.setAuthorization);
+ const initialAuthorization =
+ storedAuthorization?.required === required ? storedAuthorization : undefined;
+ const [stage, setStage] = useState(() => {
+ if (initialAuthorization) {
+ return {
+ type: 'password',
+ authorization: {
+ status: 'authorized',
+ token: initialAuthorization.token,
+ expiredAt: initialAuthorization.expiredAt
+ }
+ };
+ }
+ return showExpiredPrompt ? { type: 'prompt' } : { type: 'authorizing' };
+ });
+ const [submitting, setSubmitting] = useState(false);
+ const {
+ register,
+ handleSubmit,
+ getValues,
+ reset,
+ formState: { errors }
+ } = useForm({
+ defaultValues: { newPassword: '', confirmPassword: '' }
+ });
+
+ useEffect(() => {
+ if (initialAuthorization) setStoredAuthorization(undefined);
+ }, [initialAuthorization, setStoredAuthorization]);
+
+ const requestAuthorization = useCallback(async () => {
+ try {
+ const result = await authorizePasswordChange({ source: 'verificationMethod' });
+ if (result.status === 'authorized') {
+ setStage({ type: 'password', authorization: result });
+ return;
+ }
+ if (result.status === 'verificationRequired') {
+ setStage({ type: 'verification', method: result.method });
+ return;
+ }
+ setStage({ type: 'unavailable' });
+ } catch {
+ setStage({ type: 'unavailable' });
+ toast({ status: 'error', title: t('common:password_verification_failed') });
+ }
+ }, [t, toast]);
+
+ useEffect(() => {
+ if (stage.type !== 'authorizing') return;
+
+ const timer = window.setTimeout(() => void requestAuthorization(), 0);
+ return () => window.clearTimeout(timer);
+ }, [requestAuthorization, stage.type]);
+
+ const consumeVerification = useCallback(
+ (verification: SensitiveAccountVerificationBody) =>
+ authorizePasswordChange({ source: 'accountVerification', verification }),
+ []
+ );
+
+ const handleAuthorized = useCallback((authorization: Authorization) => {
+ setStage({ type: 'password', authorization });
+ }, []);
+
+ const closeFlow = () => {
+ if (required) return;
+ reset();
+ setStoredAuthorization(undefined);
+ onClose?.();
+ };
+
+ const submitNewPassword = async ({ newPassword }: FormType) => {
+ if (stage.type !== 'password') return;
+ setSubmitting(true);
+ try {
+ await updatePassword({
+ newPassword,
+ passwordChangeToken: stage.authorization.token
+ });
+ reset();
+ setStoredAuthorization(undefined);
+ await initUserInfo();
+ toast({ status: 'success', title: t('common:password_set_success') });
+ await onSuccess?.();
+ } catch (error) {
+ const errorResponse = getErrResponse(error);
+ if (errorResponse?.statusText === UserErrEnum.passwordChangeAuthorizationInvalid) {
+ reset();
+ setStoredAuthorization(undefined);
+ setStage({ type: 'authorizing' });
+ return;
+ }
+ const errorTitle =
+ errorResponse?.statusText === UserErrEnum.newPasswordSameAsOld
+ ? t(getErrText(error, t('common:user.Password has no change')) as any)
+ : t('common:password_update_error');
+ toast({ status: 'error', title: errorTitle });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const title = (() => {
+ if (stage.type === 'verification' || stage.type === 'unavailable') {
+ return t('common:password_verification_title');
+ }
+ if (required || !userInfo?.hasPassword) return t('common:password_set_title');
+ return userInfo?.hasPassword ? t('common:update_password') : t('common:password_set_title');
+ })();
+
+ const isWechatVerification = stage.type === 'verification' && stage.method === 'wechat';
+ const modalWidth = isWechatVerification ? '560px' : '400px';
+
+ return (
+
+ {stage.type === 'prompt' && (
+
+
+ {title}
+
+
+ {t('common:password_expired_tip')}
+
+
+
+
+
+ )}
+
+ {stage.type === 'authorizing' && (
+
+
+ {title}
+
+
+
+
+
+ {t('common:password_authorizing')}
+
+
+
+
+ )}
+
+ {stage.type === 'unavailable' && (
+
+
+ {title}
+
+
+ {t('common:password_verification_unavailable')}
+
+
+
+ )}
+
+ {stage.type === 'verification' && (
+
+
+
+ {title}
+
+
+ {t('common:password_verification_description')}
+
+
+
+
+
+
+ )}
+
+ {stage.type === 'password' && (
+
+
+ {title}
+
+
+
+ checkPasswordRule(value) || t('common:password_tip')
+ })}
+ />
+
+ {t('common:password_tip')}
+
+
+
+
+ value === getValues('newPassword') || t('common:password_not_match')
+ })}
+ />
+ {errors.confirmPassword?.message && (
+
+ {errors.confirmPassword.message}
+
+ )}
+
+
+
+
+ )}
+
+ );
+};
+
+export default PasswordChangeModal;
diff --git a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx
index f4e5da9816f6..02e3c1e61f37 100644
--- a/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx
+++ b/projects/app/src/components/support/user/safe/ResetExpiredPswModal.tsx
@@ -1,121 +1,40 @@
import React from 'react';
-import { ModalBody, Box, Flex, Input, ModalFooter, Button, HStack } from '@chakra-ui/react';
-import MyModal from '@fastgpt/web/components/common/MyModal';
-import { useTranslation } from 'next-i18next';
-import { useForm } from 'react-hook-form';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
-import { resetPassword, getCheckPswExpired } from '@/web/support/user/api';
-import { checkPasswordRule } from '@fastgpt/global/common/string/password';
-import { useToast } from '@fastgpt/web/hooks/useToast';
+import { getCheckPswExpired } from '@/web/support/user/api';
import { useUserStore } from '@/web/support/user/useUserStore';
-import Icon from '@fastgpt/web/components/common/Icon';
+import PasswordChangeModal from './PasswordChangeModal';
+import { shouldCheckPasswordExpiration } from '@/pageComponents/account/info/password';
-type FormType = {
- newPsw: string;
- confirmPsw: string;
-};
-
-const ResetPswModal = () => {
- const { t } = useTranslation();
- const { toast } = useToast();
+/** 仅在确有存储密码且已过期时开启不可关闭的统一改密流程。 */
+const ResetExpiredPswModal = () => {
const { userInfo } = useUserStore();
-
- const { register, handleSubmit, getValues } = useForm({
- defaultValues: {
- newPsw: '',
- confirmPsw: ''
- }
- });
-
- const {
- data: passwordExpired = false,
- runAsync,
- loading: isFetching
- } = useRequest(
+ const { data: passwordExpired = false, runAsync: checkPasswordExpired } = useRequest(
async () => {
- if (!userInfo?._id) {
+ if (
+ !shouldCheckPasswordExpiration({
+ userId: userInfo?._id,
+ passwordAvailable: userInfo?.passwordAvailable
+ })
+ ) {
return false;
}
return getCheckPswExpired();
},
{
manual: false,
- refreshDeps: [userInfo?._id]
+ refreshDeps: [userInfo?._id, userInfo?.passwordAvailable]
}
);
- const { runAsync: onSubmit, loading: isSubmitting } = useRequest(resetPassword, {
- onSuccess() {
- runAsync();
- },
- successToast: t('common:user.Update password successful'),
- errorToast: t('common:user.Update password failed')
- });
-
- const onSubmitErr = (err: Record) => {
- const val = Object.values(err)[0];
- if (!val) return;
- if (val.message) {
- toast({
- status: 'warning',
- title: val.message,
- duration: 3000,
- isClosable: true
- });
- }
- };
-
return passwordExpired ? (
-
-
-
-
- {t('common:user.reset_password_tip')}
-
-
-
- {t('common:user.new_password') + ':'}
-
- {
- if (!checkPasswordRule(val)) {
- return t('common:user.password_tip');
- }
- return true;
- }
- })}
- >
-
-
-
- {t('common:user.confirm_password') + ':'}
-
- (getValues('newPsw') === val ? true : t('user:password.not_match'))
- })}
- >
-
-
-
-
-
-
+ {
+ await checkPasswordExpired();
+ }}
+ />
) : null;
};
-export default React.memo(ResetPswModal);
+export default React.memo(ResetExpiredPswModal);
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..0559dc194c43
--- /dev/null
+++ b/projects/app/src/pageComponents/account/cancel/VerificationPanel.tsx
@@ -0,0 +1,440 @@
+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 {
+ AccountVerificationMethod,
+ OAuthAccountVerificationProvider
+} from '@fastgpt/global/support/user/account/verification/type';
+import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants';
+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 { getClientToken } from '@/web/support/user/hooks/useSendCode';
+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 {
+ isAccountVerificationCodeError,
+ isAccountVerificationRateLimitError
+} from '@/web/support/user/account/verification/error';
+
+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: AccountVerificationMethod
+): 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: isAccountVerificationCodeError(error)
+ ? t('common:error.code_error')
+ : isAccountVerificationRateLimitError(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 === 'pending') {
+ onSubmitted(result);
+ }
+ } catch {
+ // 未扫码和 Provider 短暂异常都可能落入轮询失败,二维码有效期内继续等待。
+ } finally {
+ wechatPolling.current = false;
+ }
+ };
+
+ void pollVerification();
+ const timer = window.setInterval(() => void pollVerification(), 2000);
+ return () => {
+ disposed = true;
+ window.clearInterval(timer);
+ };
+ }, [onSubmitted, wechatExpired, wechatQR]);
+
+ const sendCode = async ({ captcha }: { username: string; captcha: string }) => {
+ if (method !== 'code') return;
+ setCodeSending(true);
+ try {
+ const googleToken = await getClientToken(feConfigs.googleClientVerKey);
+ const result = await createAccountCancellationVerification({
+ method,
+ payload: { captcha, googleToken }
+ });
+ 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: isAccountVerificationCodeError(error)
+ ? t('common:error.code_error')
+ : isAccountVerificationRateLimitError(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,
+ isWecomWorkTerminal: checkIsWecomTerminal()
+ }
+ });
+ if (result.method !== method) return;
+ const provider = method.slice('oauth/'.length) as OAuthAccountVerificationProvider;
+ useSystemStore.getState().setLoginStore({
+ provider,
+ 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 ? (
+
+ ) : (
+
+
+ {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/info/UpdatePswModal.tsx b/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx
index 7786127aafb3..2e57be748100 100644
--- a/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx
+++ b/projects/app/src/pageComponents/account/info/UpdatePswModal.tsx
@@ -1,109 +1,7 @@
-import React from 'react';
-import { ModalBody, Box, Flex, Input, ModalFooter, Button } from '@chakra-ui/react';
-import MyModal from '@fastgpt/web/components/common/MyModal';
-import { useTranslation } from 'next-i18next';
-import { useForm } from 'react-hook-form';
-import { useRequest } from '@fastgpt/web/hooks/useRequest';
-import { updatePasswordByOld } from '@/web/support/user/api';
-import { useToast } from '@fastgpt/web/hooks/useToast';
-import { checkPasswordRule } from '@fastgpt/global/common/string/password';
+import PasswordChangeModal from '@/components/support/user/safe/PasswordChangeModal';
-type FormType = {
- oldPsw: string;
- newPsw: string;
- confirmPsw: string;
-};
-
-const UpdatePswModal = ({ onClose }: { onClose: () => void }) => {
- const { t } = useTranslation();
- const { toast } = useToast();
-
- const { register, handleSubmit, getValues } = useForm({
- defaultValues: {
- oldPsw: '',
- newPsw: '',
- confirmPsw: ''
- }
- });
-
- const { runAsync: onSubmit, loading: isLoading } = useRequest(updatePasswordByOld, {
- onSuccess() {
- onClose();
- },
- successToast: t('account_info:password_update_success'),
- errorToast: t('account_info:password_update_error')
- });
- const onSubmitErr = (err: Record) => {
- const val = Object.values(err)[0];
- if (!val) return;
- if (val.message) {
- toast({
- status: 'warning',
- title: val.message,
- duration: 3000,
- isClosable: true
- });
- }
- };
-
- return (
-
-
-
-
- {t('account_info:old_password') + ':'}
-
-
-
-
-
- {t('account_info:new_password') + ':'}
-
- {
- if (!checkPasswordRule(val)) {
- return t('login:password_tip');
- }
- return true;
- }
- })}
- >
-
-
-
- {t('account_info:confirm_password') + ':'}
-
- (getValues('newPsw') === val ? true : t('user:password.not_match'))
- })}
- >
-
-
-
-
-
-
-
- );
-};
+const UpdatePswModal = ({ onClose }: { onClose: () => void }) => (
+
+);
export default UpdatePswModal;
diff --git a/projects/app/src/pageComponents/account/info/password.ts b/projects/app/src/pageComponents/account/info/password.ts
new file mode 100644
index 000000000000..ed0ca7ccadfe
--- /dev/null
+++ b/projects/app/src/pageComponents/account/info/password.ts
@@ -0,0 +1,24 @@
+/** 判断当前账号是否允许从用户信息页进入密码管理。root 和企业微信账号不使用本地密码。 */
+export const canManagePasswordFromAccountInfo = ({
+ isPlus,
+ username,
+ passwordAvailable
+}: {
+ isPlus?: boolean;
+ username?: string;
+ passwordAvailable?: boolean;
+}) =>
+ isPlus === true &&
+ passwordAvailable !== false &&
+ !!username &&
+ username !== 'root' &&
+ !username.startsWith('wecom-');
+
+/** 仅在用户详情已加载且当前账号允许使用密码时检查密码是否过期。 */
+export const shouldCheckPasswordExpiration = ({
+ userId,
+ passwordAvailable
+}: {
+ userId?: string;
+ passwordAvailable?: boolean;
+}) => Boolean(userId) && passwordAvailable !== false;
diff --git a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx
index a6b5da6f8abd..74a6e8c4c4d5 100644
--- a/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx
+++ b/projects/app/src/pageComponents/login/ForgetPasswordForm.tsx
@@ -11,20 +11,21 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { checkPasswordRule } from '@fastgpt/global/common/string/password';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import type { LangEnum } from '@fastgpt/global/common/i18n/type';
+import { AccountContactUsernameSchema } from '@fastgpt/global/support/user/account/verification/type';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise;
-interface Props {
+type Props = {
setPageType: Dispatch<`${LoginPageTypeEnum}`>;
loginSuccess: LoginSuccessHandler;
-}
+};
-interface RegisterType {
+type RegisterType = {
username: string;
code: string;
password: string;
password2: string;
-}
+};
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { toast } = useToast();
@@ -107,11 +108,9 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
placeholder={placeholder}
{...register('username', {
required: t('user:password.email_phone_void'),
- pattern: {
- value:
- /(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
- message: t('user:password.email_phone_error')
- }
+ validate: (value) =>
+ AccountContactUsernameSchema.safeParse(value).success ||
+ t('user:password.email_phone_error')
})}
>
diff --git a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx
index af9585a3bbf3..30222f7561bc 100644
--- a/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx
+++ b/projects/app/src/pageComponents/login/LoginForm/FormLayout.tsx
@@ -1,18 +1,11 @@
-import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
-import { useSystemStore } from '@/web/common/system/useSystemStore';
+import type { LoginPageTypeEnum } from '@/web/support/user/login/constants';
import { Box, Flex, IconButton, Button } from '@chakra-ui/react';
-import { LOGO_ICON } from '@fastgpt/global/common/system/constants';
-import { OAuthEnum } from '@fastgpt/global/support/user/constant';
-import { useRouter } from 'next/router';
-import { type Dispatch, useCallback, useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'next-i18next';
-import MyImage from '@fastgpt/web/components/common/Image/MyImage';
-import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants';
-import { getNanoid } from '@fastgpt/global/common/string/tools';
+import { type Dispatch } from 'react';
import Avatar from '@fastgpt/web/components/common/Avatar';
import dynamic from 'next/dynamic';
-import { POST } from '@/web/common/api/request';
import MyTooltip from '@fastgpt/web/components/common/MyTooltip';
+import LoginBrand from './LoginBrand';
+import { useLoginMethods } from './useLoginMethods';
type Props = {
children: React.ReactNode;
@@ -20,262 +13,63 @@ type Props = {
pageType: `${LoginPageTypeEnum}`;
};
-type OAuthItem = {
- label: string;
- provider: OAuthEnum | LoginPageTypeEnum;
- icon: any;
- pageType?: LoginPageTypeEnum;
- redirectUrl?: string;
-};
-
const FormLayout = ({ children, setPageType, pageType }: Props) => {
- const { t } = useTranslation();
- const router = useRouter();
- const rootLogin = router.query.rootLogin === '1';
-
- const { setLoginStore, feConfigs } = useSystemStore();
-
- const { lastRoute = '/dashboard/agent', lastTmbId = '' } = router.query as {
- lastRoute: string;
- lastTmbId?: string;
- };
- const computedLastRoute = useMemo(() => {
- return router.pathname === '/chat' ? router.asPath : lastRoute;
- }, [lastRoute, router.pathname, router.asPath]);
-
- const [oauthState] = useState(() => getNanoid(8));
- const redirectUri = `${location.origin}/login/provider`;
-
- const isWecomWorkTerminal = checkIsWecomTerminal();
- const canWecomTerminalAutoRedirect =
- !isWecomWorkTerminal || feConfigs?.wecomLoginAutoRedirect === true;
-
- const oAuthList: OAuthItem[] = useMemo(
- () => [
- ...(feConfigs?.sso?.url
- ? [
- {
- label: feConfigs.sso.title || 'Unknown',
- provider: OAuthEnum.sso,
- icon: feConfigs.sso.icon
- }
- ]
- : []),
- ...(feConfigs?.oauth?.wechat && pageType !== LoginPageTypeEnum.wechat
- ? [
- {
- label: t('common:support.user.login.Wechat'),
- provider: OAuthEnum.wechat,
- icon: 'common/wechatFill',
- pageType: LoginPageTypeEnum.wechat
- }
- ]
- : []),
- ...(pageType !== LoginPageTypeEnum.passwordLogin
- ? [
- {
- label: t('common:support.user.login.Password login'),
- provider: LoginPageTypeEnum.passwordLogin,
- icon: 'support/permission/privateLight',
- pageType: LoginPageTypeEnum.passwordLogin
- }
- ]
- : []),
- ...(feConfigs?.oauth?.google
- ? [
- {
- label: t('common:support.user.login.Google'),
- provider: OAuthEnum.google,
- icon: 'common/googleFill',
- redirectUrl: `https://accounts.google.com/o/oauth2/v2/auth?client_id=${feConfigs?.oauth?.google}&redirect_uri=${redirectUri}&state=${oauthState}&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20openid&include_granted_scopes=true`
- }
- ]
- : []),
- ...(feConfigs?.oauth?.github
- ? [
- {
- label: t('common:support.user.login.Github'),
- provider: OAuthEnum.github,
- icon: 'common/gitFill',
- redirectUrl: `https://github.com/login/oauth/authorize?client_id=${feConfigs?.oauth?.github}&redirect_uri=${redirectUri}&state=${oauthState}&scope=user:email%20read:user`
- }
- ]
- : []),
- ...(feConfigs?.oauth?.microsoft
- ? [
- {
- label:
- feConfigs?.oauth?.microsoft?.customButton ||
- t('common:support.user.login.Microsoft'),
- provider: OAuthEnum.microsoft,
- icon: 'common/microsoft',
- redirectUrl: `https://login.microsoftonline.com/${feConfigs?.oauth?.microsoft?.tenantId || 'common'}/oauth2/v2.0/authorize?client_id=${feConfigs?.oauth?.microsoft?.clientId}&response_type=code&redirect_uri=${redirectUri}&response_mode=query&scope=https%3A%2F%2Fgraph.microsoft.com%2Fuser.read&state=${oauthState}`
- }
- ]
- : [])
- ],
- [feConfigs, oauthState, pageType, redirectUri, t]
- );
-
- const show_oauth = !!(feConfigs?.sso?.url || oAuthList.length > 0);
-
- const onClickOauth = useCallback(
- async (item: OAuthItem) => {
- if (item.provider === OAuthEnum.sso) {
- const redirectUrl = await POST('/proApi/support/user/account/login/getAuthURL', {
- redirectUri,
- isWecomWorkTerminal
- });
- setLoginStore({
- provider: item.provider as OAuthEnum,
- lastRoute: computedLastRoute,
- lastTmbId,
- state: oauthState
- });
- router.replace(redirectUrl, '_self');
- return;
- }
-
- if (item.provider === OAuthEnum.wecom) {
- const redirectUrl = await POST(
- '/proApi/support/user/account/login/wecom/getRedirectUrl',
- {
- redirectUri,
- isWecomWorkTerminal,
- state: oauthState
- }
- );
- setLoginStore({
- provider: item.provider as OAuthEnum,
- lastRoute: computedLastRoute,
- lastTmbId,
- state: oauthState
- });
- router.replace(redirectUrl, '_self');
- return;
- }
-
- if (item.redirectUrl) {
- setLoginStore({
- provider: item.provider as OAuthEnum,
- lastRoute: computedLastRoute,
- lastTmbId,
- state: oauthState
- });
- router.replace(item.redirectUrl, '_self');
- }
- item.pageType && setPageType(item.pageType);
- },
- [
- computedLastRoute,
- isWecomWorkTerminal,
- lastTmbId,
- oauthState,
- redirectUri,
- router,
- setLoginStore,
- setPageType
- ]
- );
-
- // Auto login
- useEffect(() => {
- if (rootLogin) return;
- const sso = oAuthList.find((item) => item.provider === OAuthEnum.sso);
- // sso auto login
- if (sso && canWecomTerminalAutoRedirect && (feConfigs?.sso?.autoLogin || isWecomWorkTerminal)) {
- onClickOauth(sso);
- }
- if (feConfigs.oauth?.wecom && isWecomWorkTerminal && canWecomTerminalAutoRedirect) {
- onClickOauth({
- provider: OAuthEnum.wecom
- } as any);
- }
- }, [
- rootLogin,
- canWecomTerminalAutoRedirect,
- feConfigs?.sso?.autoLogin,
- isWecomWorkTerminal,
- onClickOauth,
- oAuthList,
- feConfigs.oauth?.wecom
- ]);
+ const { methods, startLogin } = useLoginMethods({
+ mode: 'alternatives',
+ pageType,
+ setPageType
+ });
return (
-
-
-
-
-
-
- {feConfigs?.systemTitle}
-
-
-
-
+
+
{children}
- {show_oauth && (
-
-
-
-
-
-
+ {methods.length > 0 && (
+
+
+
+
or
-
+
- {oAuthList.length > 2 ? (
-
- {oAuthList.map((item) => (
-
+ {methods.length > 2 ? (
+
+ {methods.map((method) => (
+
}
- onClick={() => onClickOauth(item)}
+ size="lgSquare"
+ borderRadius="50%"
+ aria-label={method.label}
+ variant="whitePrimary"
+ icon={}
+ onClick={() => void startLogin(method).catch(() => undefined)}
/>
))}
) : (
-
- {oAuthList.map((item) => (
-
+
+ {methods.map((method) => (
+
}
- onClick={() => onClickOauth(item)}
+ variant="whitePrimary"
+ w="100%"
+ h="40px"
+ borderRadius="sm"
+ fontWeight="medium"
+ leftIcon={}
+ onClick={() => void startLogin(method).catch(() => undefined)}
>
- {item.label}
+ {method.label}
))}
diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx
new file mode 100644
index 000000000000..c52ed550a24e
--- /dev/null
+++ b/projects/app/src/pageComponents/login/LoginForm/LoginBrand.tsx
@@ -0,0 +1,38 @@
+import { Box, Flex } from '@chakra-ui/react';
+import { LOGO_ICON } from '@fastgpt/global/common/system/constants';
+import MyImage from '@fastgpt/web/components/common/Image/MyImage';
+import { useSystemStore } from '@/web/common/system/useSystemStore';
+
+/** 登录表单与登录方式选择页共用的品牌区域。 */
+const LoginBrand = () => {
+ const { feConfigs } = useSystemStore();
+
+ return (
+
+
+
+
+
+
+ {feConfigs?.systemTitle}
+
+
+
+ );
+};
+
+export default LoginBrand;
diff --git a/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx b/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx
new file mode 100644
index 000000000000..9061fb6e0e59
--- /dev/null
+++ b/projects/app/src/pageComponents/login/LoginForm/LoginGuideLink.tsx
@@ -0,0 +1,31 @@
+import { Link, type LinkProps } from '@chakra-ui/react';
+import { useTranslation } from 'next-i18next';
+import { useSystemStore } from '@/web/common/system/useSystemStore';
+
+/** 仅在配置了登录引导地址时渲染帮助入口,不为缺失配置保留占位。 */
+const LoginGuideLink = ({ mt = 8 }: Pick) => {
+ const { t } = useTranslation();
+ const { feConfigs } = useSystemStore();
+ const loginGuideDocUrl = feConfigs?.loginGuideDocUrl?.trim();
+
+ if (!loginGuideDocUrl) return null;
+
+ return (
+
+ {t('common:support.user.login.can_not_login')}
+
+ );
+};
+
+export default LoginGuideLink;
diff --git a/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx b/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx
index 5313c750e852..f6b934941a23 100644
--- a/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx
+++ b/projects/app/src/pageComponents/login/LoginForm/PolicyTip.tsx
@@ -1,11 +1,11 @@
import { getDocPath } from '@/web/common/system/doc';
import { useSystemStore } from '@/web/common/system/useSystemStore';
-import { Box, Link } from '@chakra-ui/react';
+import { Box, Link, type BoxProps } from '@chakra-ui/react';
import React, { useEffect, useRef, useState } from 'react';
import { Trans, useTranslation } from 'next-i18next';
import { i18nT } from '@fastgpt/global/common/i18n/utils';
-const PolicyTip = () => {
+const PolicyTip = ({ textAlign }: { textAlign?: BoxProps['textAlign'] }) => {
const { feConfigs } = useSystemStore();
const { i18n } = useTranslation();
const tipRef = useRef(null);
@@ -43,7 +43,7 @@ const PolicyTip = () => {
ref={tipRef}
display={'block'}
position={'relative'}
- textAlign={isMultiline ? 'center' : 'left'}
+ textAlign={textAlign ?? (isMultiline ? 'center' : 'left')}
mt={6}
fontSize={'mini'}
lineHeight={'16px'}
diff --git a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx
index 23f2e70910cf..f3c2bca8db5a 100644
--- a/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx
+++ b/projects/app/src/pageComponents/login/LoginForm/WechatForm.tsx
@@ -1,4 +1,4 @@
-import React, { type Dispatch } from 'react';
+import React, { type Dispatch, useEffect } from 'react';
import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
import { Box, Center } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
@@ -31,14 +31,27 @@ const WechatForm = ({ setPageType, loginSuccess }: Props) => {
const { t, i18n } = useTranslation();
const { toast } = useToast();
- const { data: wechatInfo } = useQuery(['getWXLoginQR'], getWXLoginQR, {
- onError(err) {
- toast({
- status: 'warning',
- title: getErrText(err, t('common:get_QR_failed'))
- });
+ const { data: wechatInfo, refetch: refetchWechatInfo } = useQuery(
+ ['getWXLoginQR'],
+ getWXLoginQR,
+ {
+ onError(err) {
+ toast({
+ status: 'warning',
+ title: getErrText(err, t('common:get_QR_failed'))
+ });
+ }
}
- });
+ );
+
+ useEffect(() => {
+ if (!wechatInfo?.expiredAt) return;
+ const remainingMs = Math.max(new Date(wechatInfo.expiredAt).getTime() - Date.now(), 0);
+ const timer = window.setTimeout(() => {
+ void refetchWechatInfo();
+ }, remainingMs);
+ return () => window.clearTimeout(timer);
+ }, [refetchWechatInfo, wechatInfo?.expiredAt]);
useQuery(
['getWXLoginResult', wechatInfo?.code, i18n.language],
diff --git a/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts b/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts
new file mode 100644
index 000000000000..bc0b63165b2e
--- /dev/null
+++ b/projects/app/src/pageComponents/login/LoginForm/useLoginMethods.ts
@@ -0,0 +1,92 @@
+import { useCallback, useMemo, type Dispatch } from 'react';
+import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
+import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants';
+import { getErrText } from '@fastgpt/global/common/error/utils';
+import { useToast } from '@fastgpt/web/hooks/useToast';
+import { createOauthLogin } from '@/web/support/user/api';
+import { useSystemStore } from '@/web/common/system/useSystemStore';
+import type { LoginPageTypeEnum } from '@/web/support/user/login/constants';
+import { getLoginMethodItems, type LoginMethodItem } from '@/web/support/user/login/utils';
+
+/**
+ * 统一发起页面切换或 OAuth,并将纯函数生成的渠道列表绑定到当前登录上下文。
+ */
+export const useLoginMethods = ({
+ mode,
+ pageType,
+ setPageType
+}: {
+ mode: 'selection' | 'alternatives';
+ pageType?: `${LoginPageTypeEnum}`;
+ setPageType: Dispatch<`${LoginPageTypeEnum}`>;
+}) => {
+ const { t } = useTranslation();
+ const router = useRouter();
+ const { toast } = useToast();
+ const { feConfigs, setLoginStore } = useSystemStore();
+ const { lastRoute = '/dashboard/agent', lastTmbId = '' } = router.query as {
+ lastRoute: string;
+ lastTmbId?: string;
+ };
+
+ const computedLastRoute = useMemo(
+ () => (router.pathname === '/chat' ? router.asPath : lastRoute),
+ [lastRoute, router.asPath, router.pathname]
+ );
+
+ const methods = useMemo(
+ () =>
+ getLoginMethodItems({
+ mode,
+ pageType,
+ feConfigs,
+ labels: {
+ wechat: t('common:support.user.login.Wechat'),
+ wecom: t('common:support.user.login.Wecom'),
+ password: t('common:support.user.login.Password login'),
+ google: t('common:support.user.login.Google'),
+ github: t('common:support.user.login.Github'),
+ microsoft: t('common:support.user.login.Microsoft')
+ }
+ }),
+ [feConfigs, mode, pageType, t]
+ );
+
+ const startLogin = useCallback(
+ async (method: LoginMethodItem) => {
+ if (method.type === 'page') {
+ setPageType(method.pageType);
+ return;
+ }
+
+ try {
+ const callbackUrl = `${window.location.origin}/login/provider`;
+ const isWecomWorkTerminal = checkIsWecomTerminal();
+ const { state, url } = await createOauthLogin({
+ provider: method.provider,
+ callbackUrl,
+ isWecomWorkTerminal
+ });
+
+ setLoginStore({
+ provider: method.provider,
+ lastRoute: computedLastRoute,
+ lastTmbId,
+ state,
+ callbackUrl
+ });
+ await router.replace(url, '_self');
+ } catch (error) {
+ toast({
+ status: 'warning',
+ title: getErrText(error, t('common:support.user.login.error'))
+ });
+ throw error;
+ }
+ },
+ [computedLastRoute, lastTmbId, router, setLoginStore, setPageType, t, toast]
+ );
+
+ return { methods, startLogin };
+};
diff --git a/projects/app/src/pageComponents/login/LoginMethodSelection.tsx b/projects/app/src/pageComponents/login/LoginMethodSelection.tsx
new file mode 100644
index 000000000000..3a781556e779
--- /dev/null
+++ b/projects/app/src/pageComponents/login/LoginMethodSelection.tsx
@@ -0,0 +1,60 @@
+import { Button, Flex } from '@chakra-ui/react';
+import Avatar from '@fastgpt/web/components/common/Avatar';
+import { useState, type Dispatch } from 'react';
+import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
+import LoginBrand from './LoginForm/LoginBrand';
+import LoginGuideLink from './LoginForm/LoginGuideLink';
+import PolicyTip from './LoginForm/PolicyTip';
+import { useLoginMethods } from './LoginForm/useLoginMethods';
+
+type Props = {
+ setPageType: Dispatch<`${LoginPageTypeEnum}`>;
+};
+
+/** Figma 登录方式选择页:完整宽度展示所有可用渠道,密码登录固定在末尾。 */
+const LoginMethodSelection = ({ setPageType }: Props) => {
+ const [pendingMethod, setPendingMethod] = useState();
+ const { methods, startLogin } = useLoginMethods({
+ mode: 'selection',
+ pageType: LoginPageTypeEnum.methodSelection,
+ setPageType
+ });
+
+ return (
+
+
+
+ {methods.map((method) => (
+ }
+ isLoading={pendingMethod === method.id}
+ isDisabled={pendingMethod !== undefined && pendingMethod !== method.id}
+ onClick={async () => {
+ setPendingMethod(method.id);
+ try {
+ await startLogin(method);
+ } catch {
+ setPendingMethod(undefined);
+ }
+ }}
+ >
+ {method.label}
+
+ ))}
+
+
+
+
+ );
+};
+
+export default LoginMethodSelection;
diff --git a/projects/app/src/pageComponents/login/LoginModal.tsx b/projects/app/src/pageComponents/login/LoginModal.tsx
index 78b6de2eacb7..04061fb3bf96 100644
--- a/projects/app/src/pageComponents/login/LoginModal.tsx
+++ b/projects/app/src/pageComponents/login/LoginModal.tsx
@@ -32,7 +32,7 @@ const LoginModal = ({ onSuccess }: LoginModalProps) => {
{
minH={['100vh', '720px']}
bg={['transparent', 'white']}
borderRadius={[0, '24px']}
- overflow={'hidden'}
+ overflowX={'hidden'}
+ overflowY={'auto'}
>
void | Promise;
-interface Props {
+type Props = {
loginSuccess: LoginSuccessHandler;
setPageType: Dispatch<`${LoginPageTypeEnum}`>;
-}
+};
-interface RegisterType {
+type RegisterType = {
username: string;
password: string;
password2: string;
code: string;
-}
+};
const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
const { toast } = useToast();
@@ -119,11 +120,9 @@ const RegisterForm = ({ setPageType, loginSuccess }: Props) => {
placeholder={placeholder}
{...register('username', {
required: t('user:password.email_phone_void'),
- pattern: {
- value:
- /(^1[3456789]\d{9}$)|(^[A-Za-z0-9]+([_\.][A-Za-z0-9]+)*@([A-Za-z0-9\-]+\.)+[A-Za-z]{2,6}$)/,
- message: t('user:password.email_phone_error')
- }
+ validate: (value) =>
+ AccountContactUsernameSchema.safeParse(value).success ||
+ t('user:password.email_phone_error')
})}
>
diff --git a/projects/app/src/pageComponents/login/components/LoginFormPanel.tsx b/projects/app/src/pageComponents/login/components/LoginFormPanel.tsx
index cd6875a2a8ed..bd1510029c77 100644
--- a/projects/app/src/pageComponents/login/components/LoginFormPanel.tsx
+++ b/projects/app/src/pageComponents/login/components/LoginFormPanel.tsx
@@ -3,8 +3,9 @@ import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
import dynamic from 'next/dynamic';
import Loading from '@fastgpt/web/components/common/MyLoading';
import LoginForm from '@/pageComponents/login/LoginForm/LoginForm';
-import { type Dispatch, useMemo } from 'react';
+import { type ComponentType, type Dispatch, useMemo } from 'react';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
+import LoginMethodSelection from '@/pageComponents/login/LoginMethodSelection';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise;
@@ -13,7 +14,7 @@ const ForgetPasswordForm = dynamic(() => import('@/pageComponents/login/ForgetPa
const WechatForm = dynamic(() => import('@/pageComponents/login/LoginForm/WechatForm'));
type LoginFormPanelProps = {
- pageType: `${LoginPageTypeEnum}`;
+ pageType?: `${LoginPageTypeEnum}`;
setPageType: Dispatch<`${LoginPageTypeEnum}`>;
loginSuccess: LoginSuccessHandler;
reserveLoginGuideSpace?: boolean;
@@ -28,8 +29,15 @@ const LoginFormPanel = ({
const DynamicComponent = useMemo(() => {
if (!pageType) return null;
- const TypeMap = {
+ const TypeMap: Record<
+ LoginPageTypeEnum,
+ ComponentType<{
+ setPageType: Dispatch<`${LoginPageTypeEnum}`>;
+ loginSuccess: LoginSuccessHandler;
+ }>
+ > = {
[LoginPageTypeEnum.passwordLogin]: LoginForm,
+ [LoginPageTypeEnum.methodSelection]: LoginMethodSelection,
[LoginPageTypeEnum.register]: RegisterForm,
[LoginPageTypeEnum.forgetPassword]: ForgetPasswordForm,
[LoginPageTypeEnum.wechat]: WechatForm
diff --git a/projects/app/src/pageComponents/login/index.tsx b/projects/app/src/pageComponents/login/index.tsx
index f3e93c1f4fd4..315007229a14 100644
--- a/projects/app/src/pageComponents/login/index.tsx
+++ b/projects/app/src/pageComponents/login/index.tsx
@@ -1,20 +1,27 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Box, Flex } from '@chakra-ui/react';
import { LoginPageTypeEnum } from '@/web/support/user/login/constants';
+import {
+ resolveAutoLoginProvider,
+ resolveInitialLoginPageType
+} from '@/web/support/user/login/utils';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useChatStore } from '@/web/core/chat/context/useChatStore';
import Script from 'next/script';
-import { useTranslation } from 'next-i18next';
import ChineseRedirectModal from './components/ChineseRedirectModal';
import CookieConsentModal from './components/CookieConsentModal';
import LoginFormPanel from './components/LoginFormPanel';
import type { LoginSuccessResponseType } from '@fastgpt/global/openapi/support/user/account/login/api';
import I18nLngSelector from '@/components/Select/I18nLngSelector';
import { useSystem } from '@fastgpt/web/hooks/useSystem';
+import { useRouter } from 'next/router';
+import { checkIsWecomTerminal } from '@fastgpt/global/support/user/login/constants';
+import { useLoginMethods } from './LoginForm/useLoginMethods';
+import LoginGuideLink from './LoginForm/LoginGuideLink';
type LoginSuccessHandler = (res: LoginSuccessResponseType) => void | Promise;
-// login container component
+/** 登录容器先完成自动跳转判断,再暴露可见页面,避免方式选择页短暂闪现。 */
export const LoginContainer = ({
children,
onSuccess
@@ -22,15 +29,44 @@ export const LoginContainer = ({
children?: React.ReactNode;
onSuccess: LoginSuccessHandler;
}) => {
- const { t } = useTranslation();
- const { feConfigs } = useSystemStore();
+ const router = useRouter();
+ const { initd, feConfigs } = useSystemStore();
const { resetChatCache } = useChatStore();
const { isPc } = useSystem();
const loginGuideDocUrl = feConfigs?.loginGuideDocUrl?.trim();
+ const initializedRef = useRef(false);
- const [pageType, setPageType] = useState<`${LoginPageTypeEnum}`>(LoginPageTypeEnum.passwordLogin);
+ const [selectedPageType, setPageType] = useState<`${LoginPageTypeEnum}`>();
+ const [autoLoginFailed, setAutoLoginFailed] = useState(false);
+ const { methods, startLogin } = useLoginMethods({
+ mode: 'selection',
+ pageType: LoginPageTypeEnum.methodSelection,
+ setPageType
+ });
+ const rootLogin = router.query.rootLogin === '1';
+ const isWecomTerminal = typeof navigator === 'undefined' ? false : checkIsWecomTerminal();
+ const initialPageType = resolveInitialLoginPageType({
+ rootLogin,
+ ssoAvailable: Boolean(feConfigs?.sso?.url),
+ disablePasswordForSsoUsers: feConfigs?.sso?.disablePasswordForSsoUsers === true
+ });
+ const autoLoginProvider = resolveAutoLoginProvider({
+ rootLogin,
+ ssoAvailable: Boolean(feConfigs?.sso?.url),
+ ssoAutoLogin: feConfigs?.sso?.autoLogin === true,
+ wecomAvailable: Boolean(feConfigs?.oauth?.wecom),
+ isWecomTerminal,
+ canWecomTerminalAutoRedirect: !isWecomTerminal || feConfigs?.wecomLoginAutoRedirect === true
+ });
+ const autoLoginMethod = methods.find(
+ (method) => method.type === 'oauth' && method.provider === autoLoginProvider
+ );
+ const pageType =
+ selectedPageType ??
+ (initd && router.isReady && (!autoLoginMethod || autoLoginFailed)
+ ? initialPageType
+ : undefined);
- // login success handler
const loginSuccess = useCallback(
async (res: LoginSuccessResponseType) => {
await onSuccess?.(res);
@@ -38,15 +74,21 @@ export const LoginContainer = ({
[onSuccess]
);
- // initialization logic
useEffect(() => {
- // reset chat state
resetChatCache();
}, [feConfigs?.oauth?.wechat, resetChatCache]);
+ useEffect(() => {
+ if (!initd || !router.isReady || initializedRef.current) return;
+ initializedRef.current = true;
+
+ if (autoLoginMethod) {
+ void startLogin(autoLoginMethod).catch(() => setAutoLoginFailed(true));
+ }
+ }, [autoLoginMethod, initd, router.isReady, startLogin]);
+
return (
<>
- {/* Google reCAPTCHA Script */}
{feConfigs.googleClientVerKey && (