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('common:password_wechat_qr')} + ) : ( + + + {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 ? ( + {t('account_info:account_cancellation_wechat_qr', + ) : ( + + + {wechatLoadFailed + ? t( + 'account_info:account_cancellation_wechat_load_failed', + '二维码加载失败,请重试。' + ) + : t( + 'account_info:account_cancellation_wechat_expired', + '二维码已过期,请重新获取。' + )} + + + + )} +
+
+ )} + + {isOAuthMethod(method) && ( + + + + + )} +
+ ); +}; diff --git a/projects/app/src/pageComponents/account/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) => ( + ))} 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) => ( + + ))} + + + + + ); +}; + +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 && (