diff --git a/packages/global/common/error/code/team.ts b/packages/global/common/error/code/team.ts index 592d7995a1f6..92ee7ad6eaea 100644 --- a/packages/global/common/error/code/team.ts +++ b/packages/global/common/error/code/team.ts @@ -1,3 +1,4 @@ +import { EnterpriseAuthErrEnum } from '../../../support/user/team/enterpriseAuth/constant'; import { i18nT } from '../../i18n/utils'; import type { ErrType } from '../errorCode'; /* team: 500000 */ @@ -154,17 +155,76 @@ const teamErr = [ { statusText: TeamErrEnum.sandboxNotSupport, message: i18nT('common:code_error.team_error.sandbox_not_support') + }, + { + statusText: EnterpriseAuthErrEnum.disabled, + message: i18nT('common:enterprise_auth.error.disabled') + }, + { + statusText: EnterpriseAuthErrEnum.serviceNotConfigured, + message: i18nT('common:enterprise_auth.error.service_not_configured') + }, + { + statusText: EnterpriseAuthErrEnum.noRemainingTimes, + message: i18nT('common:enterprise_auth.error.no_remaining_times') + }, + { + statusText: EnterpriseAuthErrEnum.alreadyVerified, + message: i18nT('common:enterprise_auth.error.already_verified') + }, + { + statusText: EnterpriseAuthErrEnum.enterpriseOccupied, + message: i18nT('common:enterprise_auth.error.enterprise_occupied') + }, + { + statusText: EnterpriseAuthErrEnum.tooFrequent, + message: i18nT('common:enterprise_auth.error.too_frequent') + }, + { + statusText: EnterpriseAuthErrEnum.serviceError, + message: i18nT('common:enterprise_auth.error.service_error') + }, + { + statusText: EnterpriseAuthErrEnum.serviceTimeout, + message: i18nT('common:enterprise_auth.error.service_timeout') + }, + { + statusText: EnterpriseAuthErrEnum.infoFailed, + message: i18nT('common:enterprise_auth.error.info_failed') + }, + { + statusText: EnterpriseAuthErrEnum.taskNotFound, + message: i18nT('common:enterprise_auth.error.task_not_found') + }, + { + statusText: EnterpriseAuthErrEnum.taskExpired, + message: i18nT('common:enterprise_auth.error.task_expired') + }, + { + statusText: EnterpriseAuthErrEnum.amountError, + message: i18nT('common:enterprise_auth.error.amount_error') + }, + { + statusText: EnterpriseAuthErrEnum.amountFailed, + message: i18nT('common:enterprise_auth.error.amount_failed') + }, + { + statusText: EnterpriseAuthErrEnum.processing, + message: i18nT('common:enterprise_auth.error.processing') } ]; -export default teamErr.reduce((acc, cur, index) => { - return { - ...acc, - [cur.statusText]: { - code: 500000 + index, - statusText: cur.statusText, - message: cur.message, - data: null - } - }; -}, {} as ErrType<`${TeamErrEnum}`>); +export default teamErr.reduce( + (acc, cur, index) => { + return { + ...acc, + [cur.statusText]: { + code: 500000 + index, + statusText: cur.statusText, + message: cur.message, + data: null + } + }; + }, + {} as ErrType<`${TeamErrEnum}` | `${EnterpriseAuthErrEnum}`> +); diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index e731ac343cd0..714dd4b11a65 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -61,6 +61,7 @@ export type FastGPTFeConfigsType = { show_aiproxy?: boolean; show_coupon?: boolean; show_discount_coupon?: boolean; + show_enterprise_auth?: boolean; showWecomConfig?: boolean; show_dataset_feishu?: boolean; diff --git a/packages/global/openapi/support/user/team/enterpriseAuth/api.ts b/packages/global/openapi/support/user/team/enterpriseAuth/api.ts new file mode 100644 index 000000000000..211854bd7f22 --- /dev/null +++ b/packages/global/openapi/support/user/team/enterpriseAuth/api.ts @@ -0,0 +1,205 @@ +import z from 'zod'; +import { IntSchema } from '../../../../../common/zod'; +import { + EnterpriseAuthAmountMaxErrorTimes, + EnterpriseAuthMaxTimes, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '../../../../../support/user/team/enterpriseAuth/constant'; + +/* ============================================================================ + * API: 获取企业认证状态 + * Route: GET /api/support/user/team/enterpriseAuth/status + * Method: GET + * Description: 获取当前团队企业认证入口开关、认证状态和可恢复任务信息 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const EnterpriseAuthLightTaskSchema = z.object({ + taskId: z.string().meta({ description: '认证任务 ID' }), + status: z.enum(TeamEnterpriseAuthTaskStatusEnum).meta({ description: '当前任务状态' }), + amountErrorTimes: z.number().int().meta({ + description: '当前任务金额填写错误次数', + example: 0 + }), + expireAt: z.date().optional().meta({ description: '金额验证过期时间' }) +}); + +export const GetEnterpriseAuthStatusResponseSchema = z.object({ + enabled: z.boolean().meta({ description: '企业认证入口是否开启' }), + status: z.enum(TeamEnterpriseAuthStatusEnum).optional().meta({ description: '团队认证状态' }), + usedTimes: z + .number() + .int() + .optional() + .meta({ description: `已使用认证次数,最多 ${EnterpriseAuthMaxTimes} 次`, example: 0 }), + canManage: z.boolean().optional().meta({ description: '当前成员是否可管理企业认证' }), + verifiedEnterpriseName: z.string().optional().meta({ description: '认证通过企业名称' }), + currentTask: EnterpriseAuthLightTaskSchema.optional().meta({ + description: '未完成认证任务;expireAt 仅金额验证阶段存在' + }), + lastErrorCode: z.string().optional().meta({ description: '最近一次失败错误码' }), + lastErrorMessage: z.string().optional().meta({ description: '最近一次失败提示' }) +}); +export type GetEnterpriseAuthStatusResponseType = z.infer< + typeof GetEnterpriseAuthStatusResponseSchema +>; + +/* ============================================================================ + * API: 获取当前企业认证任务详情 + * Route: GET /api/support/user/team/enterpriseAuth/currentTaskDetail + * Method: GET + * Description: 获取待金额验证任务的完整展示信息,不返回验证金额 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const GetEnterpriseAuthCurrentTaskDetailResponseSchema = z.object({ + taskId: z.string().meta({ description: '认证任务 ID' }), + status: z.enum(TeamEnterpriseAuthTaskStatusEnum).meta({ description: '当前任务状态' }), + enterpriseName: z.string().meta({ description: '企业名称' }), + unifiedCreditCode: z.string().meta({ description: '统一社会信用代码' }), + legalPersonName: z.string().meta({ description: '法人姓名' }), + bankName: z.string().meta({ description: '开户银行总行名称' }), + bankAccount: z.string().meta({ description: '企业银行账号,仅此接口返回完整值' }), + contactName: z.string().meta({ description: '联系人姓名' }), + contactTitle: z.string().meta({ description: '联系人职位' }), + contactPhone: z.string().meta({ description: '联系人手机号' }), + demand: z.string().meta({ description: '需求描述' }), + amountErrorTimes: z.number().int().meta({ description: '金额错误次数' }), + expireAt: z.date().meta({ description: '金额验证过期时间' }) +}); +export type GetEnterpriseAuthCurrentTaskDetailResponseType = z.infer< + typeof GetEnterpriseAuthCurrentTaskDetailResponseSchema +>; + +/* ============================================================================ + * API: 获取企业认证银行列表 + * Route: GET /api/support/user/team/enterpriseAuth/banks + * Method: GET + * Description: 从小额汇款服务获取银行编码到总行名称映射 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const GetEnterpriseAuthBanksResponseSchema = z.record(z.string(), z.string()).meta({ + description: '银行编码到银行名称的映射' +}); +export type GetEnterpriseAuthBanksResponseType = z.infer< + typeof GetEnterpriseAuthBanksResponseSchema +>; + +const EnterpriseAuthRequiredStringSchema = z.string().trim().min(1); +const BankAccountSchema = EnterpriseAuthRequiredStringSchema.transform((account) => + account.replace(/\s+/g, '') +) + .pipe(z.string().regex(/^\d{1,64}$/)) + .meta({ + description: '企业银行账号,仅支持 1-64 位数字,可输入空格分隔', + example: '6222000000000000000' + }); +const UnifiedCreditCodeSchema = EnterpriseAuthRequiredStringSchema.transform((code) => + code.toUpperCase() +) + .pipe(z.string().regex(/^[0-9A-HJ-NPQRTUWXY]{18}$/)) + .meta({ + description: '统一社会信用代码,18 位大写数字或字母(不包含 I/O/Z/S/V)', + example: '91310000MA1K000000' + }); + +export const StartEnterpriseAuthBodySchema = z.object({ + enterpriseName: EnterpriseAuthRequiredStringSchema.max(100).meta({ + description: '企业全称,需与银行开户名一致', + example: '示例科技有限公司' + }), + unifiedCreditCode: UnifiedCreditCodeSchema, + legalPersonName: EnterpriseAuthRequiredStringSchema.max(50).meta({ + description: '法人姓名', + example: '张三' + }), + bankAccount: BankAccountSchema, + bankName: EnterpriseAuthRequiredStringSchema.max(80).meta({ + description: '开户银行总行名称', + example: '中国工商银行' + }), + contactName: EnterpriseAuthRequiredStringSchema.max(50).meta({ + description: '联系人姓名', + example: '李四' + }), + contactTitle: EnterpriseAuthRequiredStringSchema.max(50).meta({ + description: '联系人职位', + example: '产品负责人' + }), + contactPhone: EnterpriseAuthRequiredStringSchema.max(30).meta({ + description: '联系人手机号', + example: '13800000000' + }), + demand: EnterpriseAuthRequiredStringSchema.max(500).meta({ + description: '使用需求描述', + example: '希望了解企业知识库和工作流能力' + }) +}); +export type StartEnterpriseAuthBodyType = z.infer; + +/* ============================================================================ + * API: 发起企业认证 + * Route: POST /api/support/user/team/enterpriseAuth/start + * Method: POST + * Description: 创建小额汇款认证任务,打款成功后返回待金额验证任务 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const StartEnterpriseAuthResponseSchema = z.object({ + status: z.enum(TeamEnterpriseAuthStatusEnum).meta({ description: '团队认证状态' }), + currentTask: EnterpriseAuthLightTaskSchema.optional().meta({ + description: '未完成认证任务;仅 pending_amount/amount_failed 可进入金额验证页' + }), + usedTimes: z + .number() + .int() + .meta({ description: `已使用认证次数,最多 ${EnterpriseAuthMaxTimes} 次` }), + message: z.string().optional().meta({ description: '流程提示' }) +}); +export type StartEnterpriseAuthResponseType = z.infer; + +export const VerifyEnterpriseAuthAmountBodySchema = z.object({ + taskId: z.string().min(1).meta({ description: '认证任务 ID' }), + amountFen: IntSchema.meta({ + description: '用户填写的到账金额,单位为分', + example: 123 + }) +}); +export type VerifyEnterpriseAuthAmountBodyType = z.infer< + typeof VerifyEnterpriseAuthAmountBodySchema +>; + +/* ============================================================================ + * API: 验证企业认证打款金额 + * Route: POST /api/support/user/team/enterpriseAuth/verifyAmount + * Method: POST + * Description: 校验到账金额并在成功后发放企业认证赠送权益 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const VerifyEnterpriseAuthAmountResponseSchema = z.object({ + status: z.enum(TeamEnterpriseAuthStatusEnum).meta({ description: '团队认证状态' }), + verifiedEnterpriseName: z.string().optional().meta({ description: '认证通过企业名称' }), + grantExpiredAt: z.date().optional().meta({ + description: '认证赠送高级版权益到期时间;真实权益以套餐记录 advancedSub.expiredTime 为准' + }), + amountMaxErrorTimes: z.literal(EnterpriseAuthAmountMaxErrorTimes).meta({ + description: '金额最大错误次数' + }) +}); +export type VerifyEnterpriseAuthAmountResponseType = z.infer< + typeof VerifyEnterpriseAuthAmountResponseSchema +>; + +/* ============================================================================ + * API: 重置企业认证待金额验证任务 + * Route: POST /api/support/user/team/enterpriseAuth/reset + * Method: POST + * Description: 用户确认信息有误后取消当前待金额验证任务 + * Tags: ['团队管理'] + * ============================================================================ */ + +export const ResetEnterpriseAuthResponseSchema = z.undefined().meta({ description: '操作成功' }); +export type ResetEnterpriseAuthResponseType = z.infer; diff --git a/packages/global/openapi/support/user/team/enterpriseAuth/index.ts b/packages/global/openapi/support/user/team/enterpriseAuth/index.ts new file mode 100644 index 000000000000..b70b78bcf68b --- /dev/null +++ b/packages/global/openapi/support/user/team/enterpriseAuth/index.ts @@ -0,0 +1,125 @@ +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { + GetEnterpriseAuthBanksResponseSchema, + GetEnterpriseAuthCurrentTaskDetailResponseSchema, + GetEnterpriseAuthStatusResponseSchema, + ResetEnterpriseAuthResponseSchema, + StartEnterpriseAuthBodySchema, + StartEnterpriseAuthResponseSchema, + VerifyEnterpriseAuthAmountBodySchema, + VerifyEnterpriseAuthAmountResponseSchema +} from './api'; + +export const EnterpriseAuthPath: OpenAPIPath = { + '/api/support/user/team/enterpriseAuth/status': { + get: { + summary: '获取企业认证状态', + tags: [DevApiTagsMap.teamManage], + responses: { + 200: { + description: '企业认证状态', + content: { + 'application/json': { + schema: GetEnterpriseAuthStatusResponseSchema + } + } + } + } + } + }, + '/api/support/user/team/enterpriseAuth/currentTaskDetail': { + get: { + summary: '获取当前企业认证任务详情', + tags: [DevApiTagsMap.teamManage], + responses: { + 200: { + description: '当前待金额验证任务详情', + content: { + 'application/json': { + schema: GetEnterpriseAuthCurrentTaskDetailResponseSchema + } + } + } + } + } + }, + '/api/support/user/team/enterpriseAuth/banks': { + get: { + summary: '获取企业认证银行列表', + tags: [DevApiTagsMap.teamManage], + responses: { + 200: { + description: '银行编码到总行名称映射', + content: { + 'application/json': { + schema: GetEnterpriseAuthBanksResponseSchema + } + } + } + } + } + }, + '/api/support/user/team/enterpriseAuth/start': { + post: { + summary: '发起企业认证', + tags: [DevApiTagsMap.teamManage], + requestBody: { + content: { + 'application/json': { + schema: StartEnterpriseAuthBodySchema + } + } + }, + responses: { + 200: { + description: '企业认证任务状态', + content: { + 'application/json': { + schema: StartEnterpriseAuthResponseSchema + } + } + } + } + } + }, + '/api/support/user/team/enterpriseAuth/verifyAmount': { + post: { + summary: '验证企业认证打款金额', + tags: [DevApiTagsMap.teamManage], + requestBody: { + content: { + 'application/json': { + schema: VerifyEnterpriseAuthAmountBodySchema + } + } + }, + responses: { + 200: { + description: '金额验证结果', + content: { + 'application/json': { + schema: VerifyEnterpriseAuthAmountResponseSchema + } + } + } + } + } + }, + '/api/support/user/team/enterpriseAuth/reset': { + post: { + summary: '取消当前企业认证任务并重新填写', + tags: [DevApiTagsMap.teamManage], + responses: { + 200: { + description: '取消成功', + content: { + 'application/json': { + schema: ResetEnterpriseAuthResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/support/user/team/index.ts b/packages/global/openapi/support/user/team/index.ts index 0080fab3a88f..484123dd190f 100644 --- a/packages/global/openapi/support/user/team/index.ts +++ b/packages/global/openapi/support/user/team/index.ts @@ -1,8 +1,10 @@ import type { OpenAPIPath } from '../../../type'; import { DevApiTagsMap } from '../../../tag'; import { UpdateTeamBodySchema } from './api'; +import { EnterpriseAuthPath } from './enterpriseAuth'; export const TeamPath: OpenAPIPath = { + ...EnterpriseAuthPath, '/api/support/user/team/update': { post: { summary: '更新团队信息', diff --git a/packages/global/support/user/team/enterpriseAuth/constant.ts b/packages/global/support/user/team/enterpriseAuth/constant.ts new file mode 100644 index 000000000000..d18e9054f40b --- /dev/null +++ b/packages/global/support/user/team/enterpriseAuth/constant.ts @@ -0,0 +1,51 @@ +export const EnterpriseAuthMaxTimes = 3; +export const EnterpriseAuthTrialDays = 15; +export const EnterpriseAuthGrantPoints = 25000; +export const EnterpriseAuthAmountMaxErrorTimes = 3; + +export enum TeamEnterpriseAuthStatusEnum { + unverified = 'unverified', + verifying = 'verifying', + verified = 'verified', + failed = 'failed' +} + +export enum TeamEnterpriseAuthTaskStatusEnum { + starting = 'starting', + info_failed = 'info_failed', + pending_amount = 'pending_amount', + amount_failed = 'amount_failed', + /** + * 事务内临时态:仅用于金额验证成功后防止并发重复发放权益。 + * 不允许作为长期业务状态存在,也不参与 pending 任务恢复或统一社会信用代码锁。 + */ + granting = 'granting', + canceled = 'canceled', + expired = 'expired', + failed = 'failed', + verified = 'verified', + service_failed = 'service_failed' +} + +export const EnterpriseAuthPendingTaskStatuses = [ + TeamEnterpriseAuthTaskStatusEnum.starting, + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed +] as const; + +export enum EnterpriseAuthErrEnum { + disabled = 'enterpriseAuthDisabled', + serviceNotConfigured = 'enterpriseAuthServiceNotConfigured', + noRemainingTimes = 'enterpriseAuthNoRemainingTimes', + alreadyVerified = 'enterpriseAuthAlreadyVerified', + enterpriseOccupied = 'enterpriseAuthEnterpriseOccupied', + tooFrequent = 'enterpriseAuthTooFrequent', + serviceError = 'enterpriseAuthServiceError', + serviceTimeout = 'enterpriseAuthServiceTimeout', + infoFailed = 'enterpriseAuthInfoFailed', + taskNotFound = 'enterpriseAuthTaskNotFound', + taskExpired = 'enterpriseAuthTaskExpired', + amountError = 'enterpriseAuthAmountError', + amountFailed = 'enterpriseAuthAmountFailed', + processing = 'enterpriseAuthProcessing' +} diff --git a/packages/global/support/user/team/enterpriseAuth/type.ts b/packages/global/support/user/team/enterpriseAuth/type.ts new file mode 100644 index 000000000000..2d2dea35dfa0 --- /dev/null +++ b/packages/global/support/user/team/enterpriseAuth/type.ts @@ -0,0 +1,52 @@ +import z from 'zod'; +import { ObjectIdSchema } from '../../../../common/type/mongo'; +import { TeamEnterpriseAuthTaskStatusEnum } from './constant'; + +export const EnterpriseAuthTaskSchema = z.object({ + _id: ObjectIdSchema, + teamId: ObjectIdSchema, + taskId: z.string(), + status: z.enum(TeamEnterpriseAuthTaskStatusEnum), + enterpriseName: z.string(), + unifiedCreditCode: z.string(), + legalPersonName: z.string(), + bankName: z.string(), + bankAccount: z.string(), + contactName: z.string(), + contactTitle: z.string(), + contactPhone: z.string(), + demand: z.string(), + orderId: z.string().optional(), + transferAmountFen: z.number().int().optional(), + transferRespCode: z.string().optional(), + transferRespMsg: z.string().optional(), + grantExpiredAt: z.date().optional(), + amountErrorTimes: z.number().int(), + usedTimes: z.number().int(), + lastErrorCode: z.string().optional(), + lastErrorMessage: z.string().optional(), + startedAt: z.date(), + expireAt: z.date().optional(), + endedAt: z.date().optional(), + createTime: z.date(), + updateTime: z.date() +}); +export type EnterpriseAuthTaskType = z.infer; + +export const TeamEnterpriseAuthSchema = z.object({ + _id: ObjectIdSchema, + teamId: ObjectIdSchema, + enterpriseName: z.string(), + unifiedCreditCode: z.string(), + legalPersonName: z.string(), + bankName: z.string(), + bankAccount: z.string(), + contactName: z.string(), + contactTitle: z.string(), + contactPhone: z.string(), + demand: z.string(), + verifiedAt: z.date(), + createTime: z.date(), + updateTime: z.date() +}); +export type TeamEnterpriseAuthType = z.infer; diff --git a/packages/service/env.ts b/packages/service/env.ts index e25d6d3d1966..e4699477af96 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -310,6 +310,9 @@ export const serviceEnv = createEnv({ // ==================== 第三方数据源 ==================== FEISHU_BASE_URL: UrlSchema.default('https://open.feishu.cn'), + ENTERPRISE_AUTH_SERVICE_URL: UrlSchema.optional(), + ENTERPRISE_AUTH_SERVICE_API_KEY: z.string().optional(), + ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS: IntSchema.default(30000), DINGTALK_BASE_URL: UrlSchema.default('https://api.dingtalk.com'), DINGTALK_OAPI_BASE_URL: UrlSchema.default('https://oapi.dingtalk.com'), YUQUE_DATASET_BASE_URL: UrlSchema.default('https://www.yuque.com'), diff --git a/packages/service/support/user/team/enterpriseAuth/common.ts b/packages/service/support/user/team/enterpriseAuth/common.ts new file mode 100644 index 000000000000..57b96b2d6d9c --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/common.ts @@ -0,0 +1,76 @@ +import { getNanoid } from '@fastgpt/global/common/string/tools'; +import { + EnterpriseAuthErrEnum, + EnterpriseAuthMaxTimes, + EnterpriseAuthPendingTaskStatuses, + TeamEnterpriseAuthStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import type { + EnterpriseAuthTaskType, + TeamEnterpriseAuthType +} from '@fastgpt/global/support/user/team/enterpriseAuth/type'; +import { Types } from '../../../../common/mongo'; +import { hasEnterpriseAuthServiceConfig } from './transferClient'; + +export type AuthOperator = { + teamId: string; + userId: string; + tmbId: string; +}; + +export type EnterpriseAuthTaskOwner = { + currentTask?: EnterpriseAuthTaskType; +}; + +export type EnterpriseAuthReadonlyStatusRecord = { + status: TeamEnterpriseAuthStatusEnum; + usedTimes: number; + verifiedEnterpriseName?: string; + currentTask?: EnterpriseAuthTaskType; + lastErrorCode?: string; + lastErrorMessage?: string; +}; + +export const pendingTaskStatuses = [...EnterpriseAuthPendingTaskStatuses]; + +export const enabledGuard = () => { + if (!hasEnterpriseAuthServiceConfig()) { + throw new Error(EnterpriseAuthErrEnum.disabled); + } +}; + +export const serviceConfigGuard = () => { + if (!hasEnterpriseAuthServiceConfig()) { + throw new Error(EnterpriseAuthErrEnum.serviceNotConfigured); + } +}; + +export const toObjectId = (id: string) => new Types.ObjectId(String(id)); + +export const normalizeUnifiedCreditCode = (code: string) => code.trim().toUpperCase(); + +export const normalizeBankAccount = (account: string) => account.replace(/\s+/g, ''); + +export const maskBankAccount = (account: string) => { + const normalized = normalizeBankAccount(account); + if (normalized.length <= 4) return normalized; + return `${'*'.repeat(Math.max(normalized.length - 4, 4))}${normalized.slice(-4)}`; +}; + +export const isMongoDuplicateKeyError = (error: any) => error?.code === 11000; + +export const getDefaultAuthStatusRecord = (): EnterpriseAuthReadonlyStatusRecord => ({ + status: TeamEnterpriseAuthStatusEnum.unverified, + usedTimes: 0 +}); + +export const createEnterpriseAuthTaskId = () => getNanoid(24); + +export const getRemainingAuthTimes = (usedTimes?: number) => + Math.max(EnterpriseAuthMaxTimes - (usedTimes ?? 0), 0); + +export const isEnterpriseAuthTimesExhausted = (usedTimes?: number) => + getRemainingAuthTimes(usedTimes) <= 0; + +export const buildVerifiedEnterpriseName = (auth?: TeamEnterpriseAuthType | null) => + auth?.enterpriseName; diff --git a/packages/service/support/user/team/enterpriseAuth/controller.ts b/packages/service/support/user/team/enterpriseAuth/controller.ts new file mode 100644 index 000000000000..4301cc4b9c02 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/controller.ts @@ -0,0 +1,4 @@ +export { getEnterpriseAuthCurrentTaskDetail, getEnterpriseAuthStatus } from './readModel'; +export { startEnterpriseAuth } from './startTask'; +export { verifyEnterpriseAuthAmount } from './verifyAmount'; +export { resetEnterpriseAuthTask } from './taskExpire'; diff --git a/packages/service/support/user/team/enterpriseAuth/grantTrial.ts b/packages/service/support/user/team/enterpriseAuth/grantTrial.ts new file mode 100644 index 000000000000..6e5d475b6f10 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/grantTrial.ts @@ -0,0 +1,212 @@ +import { addDays } from 'date-fns'; +import { + EnterpriseAuthGrantPoints, + EnterpriseAuthTrialDays, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { + StandardSubLevelEnum, + SubModeEnum, + SubTypeEnum, + standardSubLevelMap +} from '@fastgpt/global/support/wallet/sub/constants'; +import type { ClientSession } from '../../../../common/mongo'; +import { MongoTeamSub } from '../../../wallet/sub/schema'; +import { reComputeStandPlans } from '../../../wallet/sub/utils'; +import { MongoTeamEnterpriseAuth, MongoTeamEnterpriseAuthTask } from './schema'; +import { toObjectId, type AuthOperator } from './common'; + +/** + * 企业认证成功后赠送 15 天高级版套餐和 25000 积分。 + * + * 发放高级版后会重排未过期标准套餐,确保低等级套餐的生效时间接到高级版之后; + * 当前定制版或更高等级套餐继续优先生效,赠送高级版接到该高等级套餐过期后。 + */ +export const grantEnterpriseAuthBenefit = async ({ + teamId, + grantedAt, + session +}: { + teamId: string; + grantedAt: Date; + session: ClientSession; +}) => { + let teamSub = await MongoTeamSub.findOne({ + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.advanced + }).session(session); + const activeStandardPlans = await MongoTeamSub.find({ + teamId, + type: SubTypeEnum.standard, + startTime: { $lte: grantedAt }, + expiredTime: { $gt: grantedAt } + }).session(session); + activeStandardPlans.sort( + (a, b) => + standardSubLevelMap[b.currentSubLevel].weight - standardSubLevelMap[a.currentSubLevel].weight + ); + + const highestActivePlan = activeStandardPlans[0]; + const advancedWeight = standardSubLevelMap[StandardSubLevelEnum.advanced].weight; + const highestActiveWeight = highestActivePlan + ? standardSubLevelMap[highestActivePlan.currentSubLevel].weight + : 0; + const grantStartAt = + highestActivePlan && highestActiveWeight > advancedWeight + ? highestActivePlan.expiredTime + : grantedAt; + + if (teamSub) { + teamSub.totalPoints = (teamSub.totalPoints || 0) + EnterpriseAuthGrantPoints; + teamSub.surplusPoints = (teamSub.surplusPoints || 0) + EnterpriseAuthGrantPoints; + + if (teamSub.expiredTime.getTime() <= grantStartAt.getTime()) { + teamSub.startTime = grantStartAt; + teamSub.expiredTime = addDays(grantStartAt, EnterpriseAuthTrialDays); + } else { + if (teamSub.startTime.getTime() > grantStartAt.getTime()) { + teamSub.startTime = grantStartAt; + } + teamSub.expiredTime = addDays(teamSub.expiredTime, EnterpriseAuthTrialDays); + } + await teamSub.save({ session }); + } else { + const [created] = await MongoTeamSub.create( + [ + { + teamId, + type: SubTypeEnum.standard, + startTime: grantStartAt, + expiredTime: addDays(grantStartAt, EnterpriseAuthTrialDays), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + currentSubLevel: StandardSubLevelEnum.advanced, + nextSubLevel: StandardSubLevelEnum.advanced, + totalPoints: EnterpriseAuthGrantPoints, + surplusPoints: EnterpriseAuthGrantPoints + } + ], + { session } + ); + teamSub = created; + } + + await reComputeStandPlans(teamId, session); + + return teamSub; +}; + +export const lockAmountVerification = async ({ + teamId, + taskId, + amountFen, + now, + session +}: { + teamId: string; + taskId: string; + amountFen: number; + now: Date; + session: ClientSession; +}) => { + const locking = await MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + teamId, + taskId, + status: { + $in: [ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ] + }, + expireAt: { $gt: now }, + transferAmountFen: amountFen + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.granting, + updateTime: now + } + }, + { + new: true, + session + } + ); + + return locking; +}; + +/** + * 将认证成功结果同时落入最终信息表和任务历史表。 + * + * 最终信息表只保存完整企业认证快照;任务表只保存流程状态,权益以团队套餐表为准。 + * 认证次数已在认证服务返回成功、进入金额验证页时消耗;金额验证成功只负责最终落库和发放权益。 + */ +export const markVerified = async ({ + operator, + taskId, + grantedAt, + grantExpiredAt, + bankAccount, + session +}: { + operator: AuthOperator; + taskId: string; + grantedAt: Date; + grantExpiredAt: Date; + bankAccount: string; + session: ClientSession; +}) => { + const task = await MongoTeamEnterpriseAuthTask.findOne({ + teamId: operator.teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.granting + }) + .session(session) + .lean(); + + if (!task) return; + + await MongoTeamEnterpriseAuth.create( + [ + { + teamId: toObjectId(operator.teamId), + enterpriseName: task.enterpriseName, + unifiedCreditCode: task.unifiedCreditCode, + legalPersonName: task.legalPersonName, + bankName: task.bankName, + bankAccount, + contactName: task.contactName, + contactTitle: task.contactTitle, + contactPhone: task.contactPhone, + demand: task.demand, + verifiedAt: grantedAt, + createTime: grantedAt, + updateTime: grantedAt + } + ], + { session } + ); + + return MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + teamId: operator.teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.granting + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt, + endedAt: grantedAt, + updateTime: grantedAt + } + }, + { + new: true, + session + } + ); +}; diff --git a/packages/service/support/user/team/enterpriseAuth/readModel.ts b/packages/service/support/user/team/enterpriseAuth/readModel.ts new file mode 100644 index 000000000000..d9f56358c8fa --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/readModel.ts @@ -0,0 +1,150 @@ +import { + EnterpriseAuthErrEnum, + TeamEnterpriseAuthStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import type { + EnterpriseAuthTaskType, + TeamEnterpriseAuthType +} from '@fastgpt/global/support/user/team/enterpriseAuth/type'; +import { serviceEnv } from '../../../../env'; +import { + buildVerifiedEnterpriseName, + enabledGuard, + getDefaultAuthStatusRecord, + pendingTaskStatuses, + type EnterpriseAuthReadonlyStatusRecord +} from './common'; +import { MongoTeamEnterpriseAuth, MongoTeamEnterpriseAuthTask } from './schema'; +import { + buildLightTask, + deriveExpiredTaskPatch, + isPendingAmountTask, + toTerminalTaskError +} from './status'; +import { expireCurrentTaskIfNeeded } from './taskExpire'; +import { hasEnterpriseAuthServiceConfig } from './transferClient'; + +/** + * 构建只读的企业认证状态记录。 + * + * 根据当前时间推导前端展示所需的认证状态,不执行任何数据库写操作 + * 真正的认证初始化、状态变更及过期落库逻辑由 start、verify、reset 等写入口负责 + * + * @param auth - 可选的企业认证记录,若不存在则返回默认未认证状态 + * @returns 推导后的只读状态记录 + */ +const buildReadonlyAuthStatusRecord = ( + auth: TeamEnterpriseAuthType | null | undefined, + latestTask: EnterpriseAuthTaskType | null | undefined +): EnterpriseAuthReadonlyStatusRecord => { + const usedTimes = latestTask?.usedTimes ?? 0; + + if (auth) { + return { + status: TeamEnterpriseAuthStatusEnum.verified, + usedTimes, + verifiedEnterpriseName: buildVerifiedEnterpriseName(auth) + }; + } + + if (!latestTask) return getDefaultAuthStatusRecord(); + + const baseStatus = pendingTaskStatuses.includes(latestTask.status as any) + ? TeamEnterpriseAuthStatusEnum.verifying + : TeamEnterpriseAuthStatusEnum.failed; + + const baseRecord: EnterpriseAuthReadonlyStatusRecord = { + status: baseStatus, + usedTimes, + currentTask: pendingTaskStatuses.includes(latestTask.status as any) ? latestTask : undefined, + lastErrorCode: latestTask.lastErrorCode, + lastErrorMessage: latestTask.lastErrorMessage + }; + + const expiredPatch = deriveExpiredTaskPatch({ + task: latestTask, + now: new Date(), + serviceTimeoutMs: serviceEnv.ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS + }); + if (!expiredPatch) return baseRecord; + + return { + ...baseRecord, + status: expiredPatch.status, + currentTask: { + ...latestTask, + status: expiredPatch.taskStatus, + endedAt: expiredPatch.endedAt + }, + lastErrorCode: expiredPatch.lastErrorCode, + lastErrorMessage: expiredPatch.lastErrorMessage + }; +}; + +/** + * 获取团队企业认证状态(只读)。 + * 若认证服务 URL 未配置则直接返回 disabled;否则查询当前认证记录并推导展示态,不触发写库。 + */ +export const getEnterpriseAuthStatus = async ({ + teamId, + canManage +}: { + teamId: string; + canManage: boolean; +}) => { + if (!hasEnterpriseAuthServiceConfig()) { + return { enabled: false }; + } + + const [auth, latestTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ teamId }).lean(), + MongoTeamEnterpriseAuthTask.findOne({ teamId }).lean() + ]); + const record = buildReadonlyAuthStatusRecord(auth, latestTask); + + return { + enabled: true, + status: record.status, + usedTimes: record.usedTimes, + canManage, + verifiedEnterpriseName: record.verifiedEnterpriseName, + currentTask: buildLightTask(record), + lastErrorCode: record.lastErrorCode, + lastErrorMessage: record.lastErrorMessage + }; +}; + +/** + * 获取当前对公打款任务的敏感详情。 + * 仅在对公打款待确认阶段可调用,会先尝试过期超时任务;无有效任务时抛出 taskNotFound。 + */ +export const getEnterpriseAuthCurrentTaskDetail = async (teamId: string) => { + enabledGuard(); + + const task = + (await expireCurrentTaskIfNeeded(teamId)) || + (await MongoTeamEnterpriseAuthTask.findOne({ teamId }).lean()); + if (!task) throw new Error(EnterpriseAuthErrEnum.taskNotFound); + + const terminalError = toTerminalTaskError(task); + if (terminalError) throw new Error(terminalError); + + if (!isPendingAmountTask(task) || !task.expireAt) + throw new Error(EnterpriseAuthErrEnum.taskNotFound); + + return { + taskId: task.taskId, + status: task.status, + enterpriseName: task.enterpriseName, + unifiedCreditCode: task.unifiedCreditCode, + legalPersonName: task.legalPersonName, + bankName: task.bankName, + bankAccount: task.bankAccount, + contactName: task.contactName, + contactTitle: task.contactTitle, + contactPhone: task.contactPhone, + demand: task.demand, + amountErrorTimes: task.amountErrorTimes, + expireAt: task.expireAt + }; +}; diff --git a/packages/service/support/user/team/enterpriseAuth/schema.ts b/packages/service/support/user/team/enterpriseAuth/schema.ts new file mode 100644 index 000000000000..7258d218a886 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/schema.ts @@ -0,0 +1,126 @@ +import { TeamCollectionName } from '@fastgpt/global/support/user/team/constant'; +import { + EnterpriseAuthPendingTaskStatuses, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import type { + EnterpriseAuthTaskType, + TeamEnterpriseAuthType +} from '@fastgpt/global/support/user/team/enterpriseAuth/type'; +import { connectionMongo, getMongoModel } from '../../../../common/mongo'; +import { getLogger, LogCategories } from '../../../../common/logger'; + +const { Schema } = connectionMongo; + +const teamEnterpriseAuthCollectionName = 'team_enterprise_auths'; +const teamEnterpriseAuthTaskCollectionName = 'team_enterprise_auth_tasks'; + +const TeamEnterpriseAuthSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + enterpriseName: { type: String, required: true }, + unifiedCreditCode: { type: String, required: true }, + legalPersonName: { type: String, required: true }, + bankName: { type: String, required: true }, + bankAccount: { type: String, required: true }, + contactName: { type: String, required: true }, + contactTitle: { type: String, required: true }, + contactPhone: { type: String, required: true }, + demand: { type: String, required: true }, + verifiedAt: { type: Date, required: true }, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + } +}); + +const TeamEnterpriseAuthTaskSchema = new Schema({ + teamId: { + type: Schema.Types.ObjectId, + ref: TeamCollectionName, + required: true + }, + taskId: { type: String, required: true }, + status: { + type: String, + enum: Object.values(TeamEnterpriseAuthTaskStatusEnum), + required: true + }, + enterpriseName: { type: String, required: true }, + unifiedCreditCode: { type: String, required: true }, + legalPersonName: { type: String, required: true }, + bankName: { type: String, required: true }, + bankAccount: { type: String, required: true }, + contactName: { type: String, required: true }, + contactTitle: { type: String, required: true }, + contactPhone: { type: String, required: true }, + demand: { type: String, required: true }, + orderId: String, + transferAmountFen: Number, + transferRespCode: String, + transferRespMsg: String, + grantExpiredAt: Date, + amountErrorTimes: { type: Number, required: true, default: 0 }, + usedTimes: { type: Number, required: true, default: 0 }, + lastErrorCode: String, + lastErrorMessage: String, + startedAt: { type: Date, required: true }, + expireAt: Date, + endedAt: Date, + createTime: { + type: Date, + default: () => new Date() + }, + updateTime: { + type: Date, + default: () => new Date() + } +}); + +TeamEnterpriseAuthSchema.pre('save', function (next) { + this.updateTime = new Date(); + next(); +}); + +TeamEnterpriseAuthTaskSchema.pre('save', function (next) { + this.updateTime = new Date(); + next(); +}); + +try { + TeamEnterpriseAuthSchema.index({ teamId: 1 }, { unique: true }); + TeamEnterpriseAuthSchema.index({ unifiedCreditCode: 1 }, { unique: true }); + + TeamEnterpriseAuthTaskSchema.index({ teamId: 1 }, { unique: true }); + TeamEnterpriseAuthTaskSchema.index({ teamId: 1, taskId: 1 }); + TeamEnterpriseAuthTaskSchema.index({ unifiedCreditCode: 1, status: 1 }); + TeamEnterpriseAuthTaskSchema.index( + { unifiedCreditCode: 1 }, + { + unique: true, + partialFilterExpression: { + status: { $in: [...EnterpriseAuthPendingTaskStatuses] } + } + } + ); +} catch (error) { + const logger = getLogger(LogCategories.INFRA.MONGO); + logger.error('Failed to build team enterprise auth indexes', { error }); +} + +export const MongoTeamEnterpriseAuth = getMongoModel( + teamEnterpriseAuthCollectionName, + TeamEnterpriseAuthSchema +); + +export const MongoTeamEnterpriseAuthTask = getMongoModel( + teamEnterpriseAuthTaskCollectionName, + TeamEnterpriseAuthTaskSchema +); diff --git a/packages/service/support/user/team/enterpriseAuth/startTask.ts b/packages/service/support/user/team/enterpriseAuth/startTask.ts new file mode 100644 index 000000000000..0e7a3f79b24e --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/startTask.ts @@ -0,0 +1,384 @@ +import { addDays } from 'date-fns'; +import type { StartEnterpriseAuthBodyType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { + EnterpriseAuthErrEnum, + EnterpriseAuthMaxTimes, + EnterpriseAuthTrialDays, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { + createEnterpriseAuthTaskId, + enabledGuard, + isEnterpriseAuthTimesExhausted, + isMongoDuplicateKeyError, + normalizeBankAccount, + normalizeUnifiedCreditCode, + pendingTaskStatuses, + serviceConfigGuard, + toObjectId +} from './common'; +import { MongoTeamEnterpriseAuth, MongoTeamEnterpriseAuthTask } from './schema'; +import { createEnterpriseAuthTransfer } from './transferClient'; +import { buildLightTask } from './status'; +import { + expireActiveUnifiedCreditCodeLocksIfNeeded, + expireCurrentTaskIfNeeded +} from './taskExpire'; + +const getTeamEnterpriseAuthTask = (teamId: string) => + MongoTeamEnterpriseAuthTask.findOne({ teamId }).lean(); + +const getCurrentEnterpriseAuthTask = async (teamId: string) => { + const task = await MongoTeamEnterpriseAuthTask.findOne({ + teamId, + status: { $in: pendingTaskStatuses } + }).lean(); + return task; +}; + +const checkStartPreconditions = async ({ + teamId, + normalizedUnifiedCreditCode +}: { + teamId: string; + normalizedUnifiedCreditCode: string; +}) => { + await expireCurrentTaskIfNeeded(teamId); + const [verifiedAuth, currentTask, teamTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ teamId }).lean(), + getCurrentEnterpriseAuthTask(teamId), + getTeamEnterpriseAuthTask(teamId) + ]); + + if (verifiedAuth) { + throw new Error(EnterpriseAuthErrEnum.alreadyVerified); + } + + const usedTimes = teamTask?.usedTimes ?? 0; + if (currentTask) { + return { + restore: true as const, + task: currentTask + }; + } + + if (isEnterpriseAuthTimesExhausted(usedTimes)) { + throw new Error(EnterpriseAuthErrEnum.noRemainingTimes); + } + + if (teamTask?.startedAt && Date.now() - teamTask.startedAt.getTime() < 60 * 1000) { + throw new Error(EnterpriseAuthErrEnum.tooFrequent); + } + + const verified = await MongoTeamEnterpriseAuth.exists({ + unifiedCreditCode: normalizedUnifiedCreditCode + }); + if (verified) { + throw new Error(EnterpriseAuthErrEnum.enterpriseOccupied); + } + + return { + restore: false as const, + usedTimes + }; +}; + +const startingTaskCleanupUnset = { + orderId: 1, + transferAmountFen: 1, + transferRespCode: 1, + transferRespMsg: 1, + grantExpiredAt: 1, + lastErrorCode: 1, + lastErrorMessage: 1, + expireAt: 1, + endedAt: 1 +}; + +const buildStartConflictResponse = async ({ + teamId, + normalizedUnifiedCreditCode +}: { + teamId: string; + normalizedUnifiedCreditCode: string; +}) => { + const [verifiedAfterPrecheck, currentTask, teamTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ + $or: [{ teamId }, { unifiedCreditCode: normalizedUnifiedCreditCode }] + }).lean(), + getCurrentEnterpriseAuthTask(teamId), + getTeamEnterpriseAuthTask(teamId) + ]); + + if (verifiedAfterPrecheck?.teamId?.toString() === teamId) { + throw new Error(EnterpriseAuthErrEnum.alreadyVerified); + } + if (verifiedAfterPrecheck?.unifiedCreditCode === normalizedUnifiedCreditCode) { + throw new Error(EnterpriseAuthErrEnum.enterpriseOccupied); + } + if (currentTask) { + return { + status: TeamEnterpriseAuthStatusEnum.verifying, + currentTask: buildLightTask({ currentTask }), + usedTimes: currentTask.usedTimes, + message: '已恢复当前认证任务' + }; + } + if (isEnterpriseAuthTimesExhausted(teamTask?.usedTimes ?? 0)) { + throw new Error(EnterpriseAuthErrEnum.noRemainingTimes); + } + if (teamTask?.startedAt && Date.now() - teamTask.startedAt.getTime() < 60 * 1000) { + throw new Error(EnterpriseAuthErrEnum.tooFrequent); + } + + throw new Error(EnterpriseAuthErrEnum.enterpriseOccupied); +}; + +const markStartAsFailed = async ({ + teamId, + taskId, + status, + lastErrorCode, + lastErrorMessage, + transferRespCode, + transferRespMsg +}: { + teamId: string; + taskId: string; + status: + | TeamEnterpriseAuthTaskStatusEnum.service_failed + | TeamEnterpriseAuthTaskStatusEnum.info_failed; + lastErrorCode: string; + lastErrorMessage: string; + transferRespCode?: string; + transferRespMsg?: string; +}) => { + const now = new Date(); + await MongoTeamEnterpriseAuthTask.updateOne( + { + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.starting + }, + { + $set: { + status, + endedAt: now, + ...(transferRespCode && { transferRespCode }), + ...(transferRespMsg && { transferRespMsg }), + lastErrorCode, + lastErrorMessage, + updateTime: now + } + } + ); +}; + +/** + * 发起企业认证。外部打款调用不放进 Mongo 事务,抢到本地 starting 任务后再调用服务。 + */ +export const startEnterpriseAuth = async ({ + teamId, + data +}: { + teamId: string; + data: StartEnterpriseAuthBodyType; +}) => { + enabledGuard(); + serviceConfigGuard(); + + const normalizedUnifiedCreditCode = normalizeUnifiedCreditCode(data.unifiedCreditCode); + const bankAccount = normalizeBankAccount(data.bankAccount); + const precheck = await checkStartPreconditions({ teamId, normalizedUnifiedCreditCode }); + if (precheck.restore) { + return { + status: TeamEnterpriseAuthStatusEnum.verifying, + currentTask: buildLightTask({ currentTask: precheck.task }), + usedTimes: precheck.task.usedTimes, + message: '已恢复当前认证任务' + }; + } + await expireActiveUnifiedCreditCodeLocksIfNeeded(normalizedUnifiedCreditCode); + + const now = new Date(); + const taskId = createEnterpriseAuthTaskId(); + const startingTask = { + teamId: toObjectId(teamId), + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.starting, + enterpriseName: data.enterpriseName.trim(), + unifiedCreditCode: normalizedUnifiedCreditCode, + legalPersonName: data.legalPersonName.trim(), + bankName: data.bankName.trim(), + bankAccount, + contactName: data.contactName.trim(), + contactTitle: data.contactTitle.trim(), + contactPhone: data.contactPhone.trim(), + demand: data.demand.trim(), + amountErrorTimes: 0, + usedTimes: precheck.usedTimes, + startedAt: now, + createTime: now, + updateTime: now + }; + + try { + const [verifiedAfterPrecheck, teamTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ + $or: [{ teamId }, { unifiedCreditCode: normalizedUnifiedCreditCode }] + }).lean(), + getTeamEnterpriseAuthTask(teamId) + ]); + + if (verifiedAfterPrecheck?.teamId?.toString() === teamId) { + throw new Error(EnterpriseAuthErrEnum.alreadyVerified); + } + if (verifiedAfterPrecheck?.unifiedCreditCode === normalizedUnifiedCreditCode) { + throw new Error(EnterpriseAuthErrEnum.enterpriseOccupied); + } + const currentTask = await getCurrentEnterpriseAuthTask(teamId); + if (currentTask) { + return { + status: TeamEnterpriseAuthStatusEnum.verifying, + currentTask: buildLightTask({ currentTask }), + usedTimes: currentTask.usedTimes, + message: '已恢复当前认证任务' + }; + } + if (isEnterpriseAuthTimesExhausted(teamTask?.usedTimes ?? 0)) { + throw new Error(EnterpriseAuthErrEnum.noRemainingTimes); + } + if (teamTask?.startedAt && Date.now() - teamTask.startedAt.getTime() < 60 * 1000) { + throw new Error(EnterpriseAuthErrEnum.tooFrequent); + } + + const claimedTask = await MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + teamId, + $and: [ + { status: { $nin: pendingTaskStatuses } }, + { usedTimes: { $lt: EnterpriseAuthMaxTimes } } + ], + $or: [ + { startedAt: { $exists: false } }, + { startedAt: { $lte: new Date(now.getTime() - 60 * 1000) } } + ] + }, + { + $set: startingTask, + $unset: startingTaskCleanupUnset + }, + { + upsert: true, + new: true, + setDefaultsOnInsert: true + } + ).lean(); + if (!claimedTask || claimedTask.taskId !== taskId) { + return await buildStartConflictResponse({ teamId, normalizedUnifiedCreditCode }); + } + } catch (error) { + if (isMongoDuplicateKeyError(error)) { + return await buildStartConflictResponse({ teamId, normalizedUnifiedCreditCode }); + } + throw error; + } + + const verifiedAfterLock = await MongoTeamEnterpriseAuth.exists({ + unifiedCreditCode: normalizedUnifiedCreditCode + }); + if (verifiedAfterLock) { + await markStartAsFailed({ + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: EnterpriseAuthErrEnum.enterpriseOccupied, + lastErrorMessage: '该企业正在认证或已被认证' + }); + throw new Error(EnterpriseAuthErrEnum.enterpriseOccupied); + } + + const transferResult = await createEnterpriseAuthTransfer({ + enterpriseName: data.enterpriseName.trim(), + unifiedCreditCode: normalizedUnifiedCreditCode, + legalPersonName: data.legalPersonName.trim(), + bankName: data.bankName.trim(), + bankAccount + }); + + if (transferResult.type === 'timeout' || transferResult.type === 'service_failed') { + await markStartAsFailed({ + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: + transferResult.type === 'timeout' + ? EnterpriseAuthErrEnum.serviceTimeout + : EnterpriseAuthErrEnum.serviceError, + lastErrorMessage: + transferResult.type === 'timeout' ? '服务网络超时,请稍后重试' : '验证服务错误,请稍后重试' + }); + throw new Error( + transferResult.type === 'timeout' + ? EnterpriseAuthErrEnum.serviceTimeout + : EnterpriseAuthErrEnum.serviceError + ); + } + + if (transferResult.type === 'info_failed') { + await markStartAsFailed({ + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.info_failed, + lastErrorCode: EnterpriseAuthErrEnum.infoFailed, + lastErrorMessage: '认证信息错误,请重新填写', + transferRespCode: transferResult.transferRespCode, + transferRespMsg: transferResult.transferRespMsg + }); + throw new Error(EnterpriseAuthErrEnum.infoFailed); + } + + if (transferResult.type !== 'success') { + throw new Error(EnterpriseAuthErrEnum.serviceError); + } + + const expireAt = addDays(now, EnterpriseAuthTrialDays); + const authTask = await MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.starting, + usedTimes: { $lt: EnterpriseAuthMaxTimes } + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.pending_amount, + orderId: transferResult.orderId, + transferAmountFen: transferResult.transferAmountFen, + transferRespCode: transferResult.transferRespCode, + transferRespMsg: transferResult.transferRespMsg, + expireAt, + updateTime: new Date() + }, + // 认证服务已确认企业信息并进入金额验证页,此时消耗一次认证次数;金额验证成功不再重复计数。 + $inc: { + usedTimes: 1 + } + }, + { + new: true + } + ).lean(); + + if (!authTask) { + throw new Error(EnterpriseAuthErrEnum.taskNotFound); + } + + return { + status: TeamEnterpriseAuthStatusEnum.verifying, + currentTask: buildLightTask({ currentTask: authTask }), + usedTimes: authTask.usedTimes, + message: '已成功打款,请确认打款金额' + }; +}; diff --git a/packages/service/support/user/team/enterpriseAuth/status.ts b/packages/service/support/user/team/enterpriseAuth/status.ts new file mode 100644 index 000000000000..38529fe36235 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/status.ts @@ -0,0 +1,101 @@ +import { + EnterpriseAuthErrEnum, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import type { EnterpriseAuthTaskType } from '@fastgpt/global/support/user/team/enterpriseAuth/type'; +import type { EnterpriseAuthTaskOwner } from './common'; +import { pendingTaskStatuses } from './common'; + +export type EnterpriseAuthExpiredTaskPatch = { + status: TeamEnterpriseAuthStatusEnum.failed; + taskStatus: + | TeamEnterpriseAuthTaskStatusEnum.expired + | TeamEnterpriseAuthTaskStatusEnum.service_failed; + endedAt: Date; + lastErrorCode: EnterpriseAuthErrEnum.taskExpired | EnterpriseAuthErrEnum.serviceTimeout; + lastErrorMessage: string; +}; + +export const isPendingAmountTask = (task?: EnterpriseAuthTaskType | null) => { + const status = task?.status; + return ( + status === TeamEnterpriseAuthTaskStatusEnum.pending_amount || + status === TeamEnterpriseAuthTaskStatusEnum.amount_failed + ); +}; + +/** + * 将已经落库的终态任务还原为对外错误语义。 + * 过期和服务超时需要让前端区分原因;其他终态仍按无可恢复任务处理。 + */ +export const toTerminalTaskError = (task?: EnterpriseAuthTaskType | null) => { + if (!task) return; + if (task.status === TeamEnterpriseAuthTaskStatusEnum.expired) { + return EnterpriseAuthErrEnum.taskExpired; + } + if (task.status === TeamEnterpriseAuthTaskStatusEnum.service_failed) { + return task.lastErrorCode === EnterpriseAuthErrEnum.serviceTimeout + ? EnterpriseAuthErrEnum.serviceTimeout + : undefined; + } +}; + +export const hasUnfinishedTask = (auth?: EnterpriseAuthTaskOwner | null) => + !!auth?.currentTask && pendingTaskStatuses.includes(auth.currentTask.status as any); + +export const buildLightTask = (auth?: EnterpriseAuthTaskOwner | null) => { + const task = auth?.currentTask; + if (!task || !hasUnfinishedTask(auth)) return; + + return { + taskId: task.taskId, + status: task.status, + amountErrorTimes: task.amountErrorTimes, + expireAt: task.expireAt + }; +}; + +/** + * 统一推导当前任务是否已经过期或服务超时。 + * + * 该函数不读写数据库,只根据 auth、当前时间和服务超时时间返回状态补丁。只读状态接口 + * 和写入口落库过期状态都必须复用它,避免新增任务状态时出现两套判断。 + */ +export const deriveExpiredTaskPatch = ({ + task, + now, + serviceTimeoutMs +}: { + task?: EnterpriseAuthTaskType | null; + now: Date; + serviceTimeoutMs: number; +}): EnterpriseAuthExpiredTaskPatch | undefined => { + if (!task || !pendingTaskStatuses.includes(task.status as any)) return; + + const shouldExpireAmountTask = + isPendingAmountTask(task) && task.expireAt && task.expireAt.getTime() <= now.getTime(); + const shouldExpireStartingTask = + task.status === TeamEnterpriseAuthTaskStatusEnum.starting && + now.getTime() - task.startedAt.getTime() > serviceTimeoutMs; + + if (!shouldExpireAmountTask && !shouldExpireStartingTask) return; + + if (shouldExpireAmountTask) { + return { + status: TeamEnterpriseAuthStatusEnum.failed, + taskStatus: TeamEnterpriseAuthTaskStatusEnum.expired, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + lastErrorMessage: '认证任务已过期,请重新填写' + }; + } + + return { + status: TeamEnterpriseAuthStatusEnum.failed, + taskStatus: TeamEnterpriseAuthTaskStatusEnum.service_failed, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout, + lastErrorMessage: '服务网络超时,请稍后重试' + }; +}; diff --git a/packages/service/support/user/team/enterpriseAuth/taskExpire.ts b/packages/service/support/user/team/enterpriseAuth/taskExpire.ts new file mode 100644 index 000000000000..593b24a31668 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/taskExpire.ts @@ -0,0 +1,202 @@ +import { + EnterpriseAuthErrEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { serviceEnv } from '../../../../env'; +import { enabledGuard, pendingTaskStatuses } from './common'; +import { MongoTeamEnterpriseAuthTask } from './schema'; +import { deriveExpiredTaskPatch, isPendingAmountTask } from './status'; + +const buildExpiredTaskUpdate = ( + patch: NonNullable>, + now: Date +) => ({ + $set: { + status: patch.taskStatus, + endedAt: patch.endedAt, + lastErrorCode: patch.lastErrorCode, + lastErrorMessage: patch.lastErrorMessage, + updateTime: now + } +}); + +/** + * 清理历史遗留的 granting 临时态。 + * + * granting 只允许存在于金额验证成功事务内;若事务异常中断留下该状态,需要按金额验证窗口 + * 恢复成可重试或已过期状态,避免长期占用统一社会信用代码唯一锁。 + */ +const restoreStaleGrantingTasksIfNeeded = async (filter: Record, now: Date) => { + const staleBefore = new Date(now.getTime() - serviceEnv.ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS); + + const expiredGrantingTaskResult = await MongoTeamEnterpriseAuthTask.updateMany( + { + ...filter, + status: TeamEnterpriseAuthTaskStatusEnum.granting, + expireAt: { $lte: now } + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.expired, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + lastErrorMessage: '认证任务已过期,请重新填写', + updateTime: now + } + } + ); + + const retryableGrantingTaskResult = await MongoTeamEnterpriseAuthTask.updateMany( + { + ...filter, + status: TeamEnterpriseAuthTaskStatusEnum.granting, + expireAt: { $gt: now }, + $or: [{ updateTime: { $lt: staleBefore } }, { updateTime: { $exists: false } }] + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.amount_failed, + lastErrorCode: EnterpriseAuthErrEnum.processing, + lastErrorMessage: '认证处理中断,请重新提交金额', + updateTime: now + } + } + ); + + return ( + (expiredGrantingTaskResult.modifiedCount ?? 0) + + (retryableGrantingTaskResult.modifiedCount ?? 0) + ); +}; + +/** + * 按统一社会信用代码释放已经失效的 active 锁。 + * + * activeUnifiedCreditCode 依赖唯一索引阻止同一企业被并发认证;如果持锁团队后续不再访问 + * 自己的认证接口,按 teamId 触发的过期清理不会执行,其他团队会一直被唯一索引挡住。 + * 因此新团队抢锁前需要按信用代码主动清理已过期的金额验证任务和已超时的 starting 任务。 + */ +export const expireActiveUnifiedCreditCodeLocksIfNeeded = async ( + normalizedUnifiedCreditCode: string +) => { + const now = new Date(); + + const expiredAmountTaskResult = await MongoTeamEnterpriseAuthTask.updateMany( + { + unifiedCreditCode: normalizedUnifiedCreditCode, + status: { + $in: [ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ] + }, + expireAt: { $lte: now } + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.expired, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + lastErrorMessage: '认证任务已过期,请重新填写', + updateTime: now + } + } + ); + + const timeoutStartingTaskResult = await MongoTeamEnterpriseAuthTask.updateMany( + { + unifiedCreditCode: normalizedUnifiedCreditCode, + status: TeamEnterpriseAuthTaskStatusEnum.starting, + startedAt: { + $lt: new Date(now.getTime() - serviceEnv.ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS) + } + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout, + lastErrorMessage: '服务网络超时,请稍后重试', + updateTime: now + } + } + ); + + const staleGrantingTaskCount = await restoreStaleGrantingTasksIfNeeded( + { + unifiedCreditCode: normalizedUnifiedCreditCode + }, + now + ); + + return ( + (expiredAmountTaskResult.modifiedCount ?? 0) + + (timeoutStartingTaskResult.modifiedCount ?? 0) + + staleGrantingTaskCount + ); +}; + +/** + * 读取或写入当前认证任务前先处理过期任务,避免依赖定时任务才能结束金额验证窗口。 + */ +export const expireCurrentTaskIfNeeded = async (teamId: string) => { + const now = new Date(); + const task = await MongoTeamEnterpriseAuthTask.findOne({ + teamId, + status: { + $in: pendingTaskStatuses + } + }).lean(); + if (!task) return; + + const expiredPatch = deriveExpiredTaskPatch({ + task, + now, + serviceTimeoutMs: serviceEnv.ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS + }); + if (!expiredPatch) return task; + + await MongoTeamEnterpriseAuthTask.updateOne( + { + teamId, + taskId: task.taskId, + status: task.status + }, + buildExpiredTaskUpdate(expiredPatch, now) + ); + + return MongoTeamEnterpriseAuthTask.findOne({ teamId, taskId: task.taskId }).lean(); +}; + +export const resetEnterpriseAuthTask = async (teamId: string) => { + enabledGuard(); + const task = await expireCurrentTaskIfNeeded(teamId); + if (!task || !isPendingAmountTask(task)) { + return; + } + + const now = new Date(); + await MongoTeamEnterpriseAuthTask.updateOne( + { + teamId, + taskId: task.taskId, + status: { + $in: [ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ] + } + }, + { + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.canceled, + endedAt: now, + updateTime: now + }, + $unset: { + lastErrorCode: 1, + lastErrorMessage: 1 + } + } + ); +}; diff --git a/packages/service/support/user/team/enterpriseAuth/transferClient.ts b/packages/service/support/user/team/enterpriseAuth/transferClient.ts new file mode 100644 index 000000000000..7b6a81557398 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/transferClient.ts @@ -0,0 +1,144 @@ +import z from 'zod'; +import { EnterpriseAuthErrEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import type { StartEnterpriseAuthBodyType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { serviceEnv } from '../../../../env'; +import { axios } from '../../../../common/api/axios'; + +const BankListResponseSchema = z.object({ + success: z.literal(true), + data: z.record(z.string(), z.string()) +}); + +const EnterpriseAuthTransferResponseSchema = z.object({ + success: z.boolean(), + data: z + .object({ + isTransactionSuccess: z.boolean(), + orderId: z.string().optional(), + transAmt: z.union([z.string(), z.number()]).optional(), + respCode: z.string().optional(), + respMsg: z.string().optional() + }) + .optional(), + message: z.string().optional() +}); + +export type EnterpriseAuthTransferResult = + | { + type: 'success'; + orderId?: string; + transferAmountFen: number; + transferRespCode?: string; + transferRespMsg?: string; + } + | { + type: 'info_failed'; + message?: string; + transferRespCode?: string; + transferRespMsg?: string; + } + | { + type: 'service_failed' | 'timeout'; + message?: string; + }; + +export const hasEnterpriseAuthServiceConfig = () => !!serviceEnv.ENTERPRISE_AUTH_SERVICE_URL; + +const getEnterpriseAuthServiceConfig = () => { + if (!hasEnterpriseAuthServiceConfig()) { + throw new Error(EnterpriseAuthErrEnum.serviceNotConfigured); + } + + return { + baseURL: serviceEnv.ENTERPRISE_AUTH_SERVICE_URL, + timeout: serviceEnv.ENTERPRISE_AUTH_SERVICE_TIMEOUT_MS, + headers: { + ...(serviceEnv.ENTERPRISE_AUTH_SERVICE_API_KEY && { + 'X-API-Key': serviceEnv.ENTERPRISE_AUTH_SERVICE_API_KEY + }), + 'Content-Type': 'application/json' + } + }; +}; + +export const getEnterpriseAuthBanks = async () => { + const config = getEnterpriseAuthServiceConfig(); + const res = await axios.get('/v1/banks', config); + return BankListResponseSchema.parse(res.data).data; +}; + +const parseFenAmount = (amount: string | number | undefined) => { + if (amount === undefined || amount === '') return; + const num = Number(amount); + if (!Number.isInteger(num) || num <= 0) return; + return num; +}; + +export const createEnterpriseAuthTransfer = async ( + data: Pick< + StartEnterpriseAuthBodyType, + 'enterpriseName' | 'unifiedCreditCode' | 'legalPersonName' | 'bankName' | 'bankAccount' + > +): Promise => { + const config = getEnterpriseAuthServiceConfig(); + + try { + const res = await axios.post( + '/v1/enterprise-auth', + { + key: data.unifiedCreditCode, + accountBank: data.bankName, + keyName: data.enterpriseName, + usrName: data.legalPersonName, + accountNo: data.bankAccount + }, + config + ); + + const parsed = EnterpriseAuthTransferResponseSchema.safeParse(res.data); + if (!parsed.success || !parsed.data.success || !parsed.data.data) { + return { + type: 'service_failed', + message: parsed.success ? parsed.data.message : undefined + }; + } + + const result = parsed.data.data; + if (!result.isTransactionSuccess) { + return { + type: 'info_failed', + message: result.respMsg || parsed.data.message, + transferRespCode: result.respCode, + transferRespMsg: result.respMsg + }; + } + + const amountFen = parseFenAmount(result.transAmt); + if (amountFen === undefined) { + return { + type: 'service_failed', + message: 'Invalid transfer amount' + }; + } + + return { + type: 'success', + orderId: result.orderId, + transferAmountFen: amountFen, + transferRespCode: result.respCode, + transferRespMsg: result.respMsg + }; + } catch (error: any) { + if (error?.code === 'ECONNABORTED') { + return { + type: 'timeout', + message: EnterpriseAuthErrEnum.serviceTimeout + }; + } + + return { + type: 'service_failed', + message: error?.message + }; + } +}; diff --git a/packages/service/support/user/team/enterpriseAuth/verifyAmount.ts b/packages/service/support/user/team/enterpriseAuth/verifyAmount.ts new file mode 100644 index 000000000000..63ff07bf8404 --- /dev/null +++ b/packages/service/support/user/team/enterpriseAuth/verifyAmount.ts @@ -0,0 +1,312 @@ +import type { VerifyEnterpriseAuthAmountBodyType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { retryFn } from '@fastgpt/global/common/system/utils'; +import type { ClientSession } from '../../../../common/mongo'; +import { + EnterpriseAuthAmountMaxErrorTimes, + EnterpriseAuthErrEnum, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { mongoSessionRun } from '../../../../common/mongo/sessionRun'; +import { getLogger, LogCategories } from '../../../../common/logger'; +import { clearTeamPlanCache } from '../../../wallet/sub/utils'; +import { enabledGuard, type AuthOperator } from './common'; +import { MongoTeamEnterpriseAuth, MongoTeamEnterpriseAuthTask } from './schema'; +import { expireCurrentTaskIfNeeded } from './taskExpire'; +import { isPendingAmountTask, toTerminalTaskError } from './status'; +import { grantEnterpriseAuthBenefit, lockAmountVerification, markVerified } from './grantTrial'; + +const logger = getLogger(LogCategories.MODULE.USER.TEAM); + +const retryClearTeamPlanCache = ({ teamId, taskId }: { teamId: string; taskId: string }) => { + void retryFn(() => clearTeamPlanCache(teamId), 2).catch((error) => { + logger.warn('Failed to retry clearing team plan cache after enterprise auth verified', { + teamId, + taskId, + error + }); + }); +}; + +const getVerifiedTaskResult = async ({ + teamId, + taskId, + amountFen, + session +}: { + teamId: string; + taskId: string; + amountFen: number; + session?: ClientSession; +}) => { + const query = MongoTeamEnterpriseAuthTask.findOne({ + teamId, + taskId, + status: TeamEnterpriseAuthTaskStatusEnum.verified, + transferAmountFen: amountFen + }); + if (session) { + return query.session(session); + } + return query.lean(); +}; + +/** + * 金额验证失败路径只做金额错误次数原子递增,不进入权益发放事务。 + */ +const verifyWrongAmount = async ({ + teamId, + taskId, + amountFen +}: { + teamId: string; + taskId: string; + amountFen: number; +}) => { + const now = new Date(); + const amountErrorBaseFilter = { + teamId, + taskId, + status: { + $in: [ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ] + }, + expireAt: { $gt: now }, + transferAmountFen: { $ne: amountFen } + }; + + /** + * 金额错误次数必须在数据库内原子递增。 + * 最后一次错误需要和失败状态写入同一个 update,避免并发正确金额请求插入成功。 + */ + const markFinalAmountFailed = () => + MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + ...amountErrorBaseFilter, + amountErrorTimes: { + $gte: EnterpriseAuthAmountMaxErrorTimes - 1, + $lt: EnterpriseAuthAmountMaxErrorTimes + } + }, + { + $inc: { + amountErrorTimes: 1 + }, + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.failed, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.amountFailed, + lastErrorMessage: '验证金额错误次数已达上限,本次认证失败', + updateTime: now + } + }, + { new: true } + ).lean(); + + const markRecoverableAmountError = () => + MongoTeamEnterpriseAuthTask.findOneAndUpdate( + { + ...amountErrorBaseFilter, + amountErrorTimes: { + $lt: EnterpriseAuthAmountMaxErrorTimes - 1 + } + }, + { + $inc: { + amountErrorTimes: 1 + }, + $set: { + status: TeamEnterpriseAuthTaskStatusEnum.amount_failed, + lastErrorCode: EnterpriseAuthErrEnum.amountError, + lastErrorMessage: '验证金额错误', + updateTime: now + } + }, + { new: true } + ).lean(); + + const finalFailedAuth = await markFinalAmountFailed(); + if (finalFailedAuth) { + throw new Error(EnterpriseAuthErrEnum.amountFailed); + } + + const recoverableFailedAuth = await markRecoverableAmountError(); + if (recoverableFailedAuth) { + throw new Error(EnterpriseAuthErrEnum.amountError); + } + + // 并发请求可能在第一次最终失败检查后,把错误次数推进到最后一次阈值。 + const retryFinalFailedAuth = await markFinalAmountFailed(); + if (retryFinalFailedAuth) { + throw new Error(EnterpriseAuthErrEnum.amountFailed); + } + + const latest = await MongoTeamEnterpriseAuthTask.findOne({ + teamId, + taskId + }).lean(); + + if ( + (latest?.amountErrorTimes ?? 0) >= EnterpriseAuthAmountMaxErrorTimes || + latest?.status === TeamEnterpriseAuthTaskStatusEnum.failed + ) { + throw new Error(EnterpriseAuthErrEnum.amountFailed); + } + + throw new Error(EnterpriseAuthErrEnum.taskNotFound); +}; + +/** + * 认证成功应用服务:金额锁定 -> 套餐/积分发放 -> 成功信息落库。 + * + * 全流程在同一个 Mongo 事务内完成。飞书同步已移除,外部运营系统不会影响认证权益发放。 + */ +export const verifyEnterpriseAuthAmount = async ({ + operator, + data +}: { + operator: AuthOperator; + data: VerifyEnterpriseAuthAmountBodyType; +}) => { + enabledGuard(); + + const task = + (await expireCurrentTaskIfNeeded(operator.teamId)) || + (await MongoTeamEnterpriseAuthTask.findOne({ + teamId: operator.teamId, + taskId: data.taskId + }).lean()); + if (!task) { + const [verifiedAuth, verifiedTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ teamId: operator.teamId }).lean(), + getVerifiedTaskResult({ + teamId: operator.teamId, + taskId: data.taskId, + amountFen: data.amountFen + }) + ]); + if (verifiedAuth && verifiedTask) { + return { + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt: verifiedTask.grantExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }; + } + throw new Error(EnterpriseAuthErrEnum.taskNotFound); + } + if (task.taskId === data.taskId) { + const terminalError = toTerminalTaskError(task); + if (terminalError) throw new Error(terminalError); + + if ( + task.status === TeamEnterpriseAuthTaskStatusEnum.verified && + task.transferAmountFen === data.amountFen + ) { + const verifiedAuth = await MongoTeamEnterpriseAuth.findOne({ + teamId: operator.teamId + }).lean(); + if (verifiedAuth) { + return { + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt: task.grantExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }; + } + } + } + + if (!isPendingAmountTask(task) || !task.expireAt || task.taskId !== data.taskId) { + throw new Error(EnterpriseAuthErrEnum.taskNotFound); + } + + if (task.transferAmountFen !== data.amountFen) { + await verifyWrongAmount({ + teamId: operator.teamId, + taskId: data.taskId, + amountFen: data.amountFen + }); + } + + const { auth: verifiedAuth, grantExpiredAt } = await mongoSessionRun(async (session) => { + const now = new Date(); + const locking = await lockAmountVerification({ + teamId: operator.teamId, + taskId: data.taskId, + amountFen: data.amountFen, + now, + session + }); + + if (!locking) { + const [latestAuth, latestTask] = await Promise.all([ + MongoTeamEnterpriseAuth.findOne({ teamId: operator.teamId }).session(session), + MongoTeamEnterpriseAuthTask.findOne({ + teamId: operator.teamId, + taskId: data.taskId + }).session(session) + ]); + if ( + latestAuth && + latestTask?.status === TeamEnterpriseAuthTaskStatusEnum.verified && + latestTask.transferAmountFen === data.amountFen + ) { + return { auth: latestAuth, grantExpiredAt: latestTask.grantExpiredAt }; + } + throw new Error(EnterpriseAuthErrEnum.processing); + } + + const grantedAt = now; + const advancedSub = await grantEnterpriseAuthBenefit({ + teamId: operator.teamId, + grantedAt, + session + }); + + const verified = await markVerified({ + operator, + taskId: data.taskId, + grantedAt, + grantExpiredAt: advancedSub.expiredTime, + bankAccount: locking.bankAccount, + session + }); + + if (!verified) { + throw new Error(EnterpriseAuthErrEnum.processing); + } + + const auth = await MongoTeamEnterpriseAuth.findOne({ teamId: operator.teamId }).session( + session + ); + if (!auth) { + throw new Error(EnterpriseAuthErrEnum.processing); + } + + return { auth, grantExpiredAt: advancedSub.expiredTime }; + }); + + try { + await clearTeamPlanCache(operator.teamId); + } catch (error) { + logger.warn('Failed to clear team plan cache after enterprise auth verified', { + teamId: operator.teamId, + taskId: data.taskId, + error + }); + retryClearTeamPlanCache({ + teamId: operator.teamId, + taskId: data.taskId + }); + } + + return { + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }; +}; diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index 6fe1d841ab49..9b0673bc87ed 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -41,6 +41,40 @@ export const sortStandPlans = (plans: TeamSubSchemaType[]) => { standardSubLevelMap[b.currentSubLevel].weight - standardSubLevelMap[a.currentSubLevel].weight ); }; + +/** + * 按套餐等级重新编排标准套餐的生效窗口。 + * + * 最高等级套餐保留当前时间窗口,后续低等级套餐按原有时长依次接到上一档套餐之后。 + * 只重排未过期套餐,避免历史过期套餐把新发放权益排到过去。 + */ +export const reComputeStandPlans = async (teamId: string, session: ClientSession) => { + const plans = await MongoTeamSub.find({ + teamId, + type: SubTypeEnum.standard, + expiredTime: { $gt: new Date() } + }).session(session); + + sortStandPlans(plans); + + for (let i = 1; i < plans.length; i++) { + const plan = plans[i]; + const lastPlan = plans[i - 1]; + const duration = Math.abs(plan.expiredTime.getTime() - plan.startTime.getTime()); + plan.startTime = lastPlan.expiredTime; + plan.expiredTime = new Date(plan.startTime.getTime() + duration); + } + + for await (const plan of plans) { + await plan.save({ session }); + } +}; + +const isActiveStandardSub = (sub: TeamSubSchemaType, now: Date) => + sub.type === SubTypeEnum.standard && + !dayjs(sub.startTime).isAfter(now) && + dayjs(sub.expiredTime).isAfter(now); + export const buildStandardPlan = ( standard: TeamSubSchemaType, standardConstants: TeamStandardSubPlanItemType @@ -158,8 +192,30 @@ export const initTeamFreePlan = async ({ ); }; +const normalizeInitTeamFreePlanResult = ( + plan: TeamSubSchemaType | TeamSubSchemaType[] +): TeamSubSchemaType => (Array.isArray(plan) ? plan[0] : plan); + +const getStandardPlanConstants = ( + standard: Pick | undefined, + standardPlans: ReturnType +) => + standard?.currentSubLevel && standardPlans + ? standardPlans[ + standard.currentSubLevel === StandardSubLevelEnum.custom + ? StandardSubLevelEnum.advanced + : standard.currentSubLevel + ] + : undefined; + // 获取团队标准套餐 -export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { +export const getTeamStandPlan = async ({ + teamId +}: { + teamId: string; +}): Promise<{ + [SubTypeEnum.standard]: TeamPlanStandardType | undefined; +}> => { const plans = await MongoTeamSub.find( { teamId, @@ -170,19 +226,19 @@ export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { ...readFromSecondary } ).lean(); - sortStandPlans(plans); + const activeStandardPlans = sortStandPlans( + plans.filter((plan) => isActiveStandardSub(plan, new Date())) + ); const standardPlans = global.subPlans?.standard; - const standard = plans[0]; + let standard = activeStandardPlans[0]; - const standardConstants = - standard.currentSubLevel && standardPlans - ? standardPlans[ - standard.currentSubLevel === StandardSubLevelEnum.custom - ? StandardSubLevelEnum.advanced - : standard.currentSubLevel - ] - : undefined; + if (!standard) { + logger.info('Initializing free standard plan for stand plan query', { teamId }); + standard = normalizeInitTeamFreePlanResult(await initTeamFreePlan({ teamId })); + } + + const standardConstants = getStandardPlanConstants(standard, standardPlans); return { [SubTypeEnum.standard]: standardConstants @@ -204,11 +260,11 @@ export const getTeamPlanStatus = async ({ const plans = await MongoTeamSub.find({ teamId }).lean(); /* Get all standardPlans and active standardPlan */ - const teamStandardPlans = sortStandPlans( - plans.filter((plan) => plan.type === SubTypeEnum.standard) + const activeStandardPlans = sortStandPlans( + plans.filter((plan) => isActiveStandardSub(plan, new Date())) ); /** 数据库里的,用户目前 active 的套餐 */ - const standardPlan = teamStandardPlans[0]; + const standardPlan = activeStandardPlans[0]; const extraDatasetSize = plans.filter((plan) => plan.type === SubTypeEnum.extraDatasetSize); const extraPoints = plans.filter((plan) => plan.type === SubTypeEnum.extraPoints); @@ -219,7 +275,7 @@ export const getTeamPlanStatus = async ({ standardPlan.expiredTime && standardPlan.currentSubLevel === StandardSubLevelEnum.free && dayjs(standardPlan.expiredTime).isBefore(new Date())) || - teamStandardPlans.length === 0 + activeStandardPlans.length === 0 ) { logger.info('Initializing free standard plan', { teamId }); await initTeamFreePlan({ teamId }); diff --git a/packages/service/support/wallet/usage/schema.ts b/packages/service/support/wallet/usage/schema.ts index e35e9583421d..3b87def85ded 100644 --- a/packages/service/support/wallet/usage/schema.ts +++ b/packages/service/support/wallet/usage/schema.ts @@ -73,6 +73,7 @@ UsageSchema.virtual('usageItems', { try { UsageSchema.index({ teamId: 1, tmbId: 1, source: 1, time: 1, appName: 1, _id: -1 }); + UsageSchema.index({ teamId: 1, time: 1 }); UsageSchema.index({ time: 1 }, { expireAfterSeconds: 360 * 24 * 60 * 60 }); } catch (error) { diff --git a/packages/service/test/support/user/team/enterpriseAuth/controller.start.test.ts b/packages/service/test/support/user/team/enterpriseAuth/controller.start.test.ts new file mode 100644 index 000000000000..fe09461963ae --- /dev/null +++ b/packages/service/test/support/user/team/enterpriseAuth/controller.start.test.ts @@ -0,0 +1,499 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EnterpriseAuthErrEnum, + EnterpriseAuthMaxTimes, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { StartEnterpriseAuthBodySchema } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; + +const mocks = vi.hoisted(() => ({ + findAuth: vi.fn(), + existsAuth: vi.fn(), + createTask: vi.fn(), + findTask: vi.fn(), + updateTask: vi.fn(), + updateManyTask: vi.fn(), + findOneAndUpdateTask: vi.fn(), + createTransfer: vi.fn(), + hasServiceConfig: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/schema', () => ({ + MongoTeamEnterpriseAuth: { + findOne: mocks.findAuth, + exists: mocks.existsAuth + }, + MongoTeamEnterpriseAuthTask: { + create: mocks.createTask, + findOne: mocks.findTask, + updateOne: mocks.updateTask, + updateMany: mocks.updateManyTask, + findOneAndUpdate: mocks.findOneAndUpdateTask + } +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/transferClient', () => ({ + createEnterpriseAuthTransfer: mocks.createTransfer, + hasEnterpriseAuthServiceConfig: mocks.hasServiceConfig, + getEnterpriseAuthBanks: vi.fn() +})); + +const { getEnterpriseAuthStatus, resetEnterpriseAuthTask, startEnterpriseAuth } = + await import('@fastgpt/service/support/user/team/enterpriseAuth/controller'); + +const teamId = '507f1f77bcf86cd799439011'; + +const validStartBody = { + enterpriseName: '示例科技有限公司', + unifiedCreditCode: '91310000MA1K000000', + legalPersonName: '张三', + bankAccount: '6222 0000 0000 0000', + bankName: '中国工商银行', + contactName: '李四', + contactTitle: '产品负责人', + contactPhone: '13800000000', + demand: '企业知识库试用' +}; + +const mockLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value) +}); + +const mockSortedLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value), + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(value) + }) +}); + +const buildTask = (status: TeamEnterpriseAuthTaskStatusEnum, overrides: Record = {}) => + ({ + teamId, + taskId: `task_${status}`, + status, + enterpriseName: '示例科技有限公司', + unifiedCreditCode: '91310000MA1K000000', + legalPersonName: '张三', + bankName: '中国工商银行', + bankAccount: '6222000000000000', + contactName: '李四', + contactTitle: '产品负责人', + contactPhone: '13800000000', + demand: '企业知识库试用', + amountErrorTimes: 0, + usedTimes: 1, + startedAt: new Date(Date.now() - 60 * 1000), + expireAt: new Date(Date.now() + 60 * 60 * 1000), + createTime: new Date(Date.now() - 60 * 1000), + updateTime: new Date(Date.now() - 60 * 1000), + ...overrides + }) as any; + +const findTaskUpdateByStatus = (status: TeamEnterpriseAuthTaskStatusEnum) => + mocks.findOneAndUpdateTask.mock.calls.find(([, update]) => update?.$set?.status === status) as + | any[] + | undefined; + +describe('startEnterpriseAuth', () => { + let mockedTaskRow: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockedTaskRow = undefined; + mocks.findAuth.mockReturnValue(mockLean(null)); + mocks.existsAuth.mockResolvedValue(null); + mocks.findTask.mockReturnValue(mockSortedLean(null)); + mocks.createTask.mockResolvedValue([buildTask(TeamEnterpriseAuthTaskStatusEnum.starting)]); + mocks.updateManyTask.mockResolvedValue({ + modifiedCount: 0 + }); + mocks.findOneAndUpdateTask.mockImplementation((filter, update) => { + const status = update?.$set?.status ?? TeamEnterpriseAuthTaskStatusEnum.pending_amount; + const baseUsedTimes = update?.$set?.usedTimes ?? mockedTaskRow?.usedTimes ?? 0; + mockedTaskRow = buildTask(status, { + ...mockedTaskRow, + ...update?.$set, + taskId: update?.$set?.taskId ?? filter?.taskId ?? mockedTaskRow?.taskId ?? 'task_1', + usedTimes: baseUsedTimes + (update?.$inc?.usedTimes ?? 0) + }); + return mockLean(mockedTaskRow); + }); + mocks.hasServiceConfig.mockReturnValue(true); + mocks.createTransfer.mockResolvedValue({ + type: 'success', + orderId: 'order_1', + transferAmountFen: 13, + transferRespCode: 'SUCCESS', + transferRespMsg: 'ok' + }); + }); + + it('发起认证时按 teamId 写入唯一任务行,认证服务返回成功后递增认证次数', async () => { + const result = await startEnterpriseAuth({ + teamId, + data: validStartBody + }); + + expect(mocks.createTask).not.toHaveBeenCalled(); + const startingCall = findTaskUpdateByStatus(TeamEnterpriseAuthTaskStatusEnum.starting); + expect(startingCall).toBeTruthy(); + const [startingFilter, startingUpdate, startingOptions] = startingCall!; + expect(startingFilter).toEqual( + expect.objectContaining({ + teamId, + $and: [ + { status: { $nin: expect.any(Array) } }, + { usedTimes: { $lt: EnterpriseAuthMaxTimes } } + ] + }) + ); + expect(startingUpdate.$set).toEqual( + expect.objectContaining({ + taskId: expect.any(String), + status: TeamEnterpriseAuthTaskStatusEnum.starting, + unifiedCreditCode: '91310000MA1K000000', + bankAccount: '6222000000000000', + usedTimes: 0 + }) + ); + expect(startingUpdate.$unset).toEqual( + expect.objectContaining({ + orderId: 1, + transferAmountFen: 1, + transferRespCode: 1, + transferRespMsg: 1, + grantExpiredAt: 1, + lastErrorCode: 1, + lastErrorMessage: 1, + expireAt: 1, + endedAt: 1 + }) + ); + expect(startingOptions).toEqual( + expect.objectContaining({ + upsert: true, + new: true, + setDefaultsOnInsert: true + }) + ); + + const pendingAmountCall = findTaskUpdateByStatus( + TeamEnterpriseAuthTaskStatusEnum.pending_amount + ); + expect(pendingAmountCall).toBeTruthy(); + const [, pendingAmountUpdate] = pendingAmountCall!; + expect(pendingAmountUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.pending_amount, + orderId: 'order_1', + transferAmountFen: 13 + }) + ); + expect(pendingAmountUpdate.$inc).toEqual({ + usedTimes: 1 + }); + expect(pendingAmountUpdate.$set).not.toHaveProperty('transferAmountRaw'); + expect(pendingAmountUpdate.$set).not.toHaveProperty('isCharged'); + expect(result).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthStatusEnum.verifying, + usedTimes: 1, + currentTask: expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.pending_amount + }) + }) + ); + }); + + it('非首次认证覆盖同团队旧任务行并清理上一轮任务字段', async () => { + const previousTask = buildTask(TeamEnterpriseAuthTaskStatusEnum.info_failed, { + taskId: 'task_old', + usedTimes: 1, + orderId: 'order_old', + transferAmountFen: 99, + transferRespCode: 'OLD_CODE', + transferRespMsg: 'old msg', + grantExpiredAt: new Date('2026-07-01T00:00:00.000Z'), + lastErrorCode: EnterpriseAuthErrEnum.infoFailed, + lastErrorMessage: 'old error', + expireAt: new Date('2026-06-20T00:00:00.000Z'), + endedAt: new Date('2026-06-16T00:00:00.000Z'), + startedAt: new Date(Date.now() - 2 * 60 * 1000) + }); + mockedTaskRow = previousTask; + mocks.findTask + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce(mockSortedLean(previousTask)) + .mockReturnValueOnce(mockSortedLean(previousTask)) + .mockReturnValueOnce(mockSortedLean(null)); + + const result = await startEnterpriseAuth({ + teamId, + data: validStartBody + }); + + expect(mocks.createTask).not.toHaveBeenCalled(); + const startingCall = findTaskUpdateByStatus(TeamEnterpriseAuthTaskStatusEnum.starting); + expect(startingCall).toBeTruthy(); + const [, startingUpdate] = startingCall!; + expect(startingUpdate.$set).toEqual( + expect.objectContaining({ + taskId: expect.any(String), + status: TeamEnterpriseAuthTaskStatusEnum.starting, + bankAccount: '6222000000000000', + amountErrorTimes: 0, + usedTimes: previousTask.usedTimes + }) + ); + expect(startingUpdate.$unset).toEqual( + expect.objectContaining({ + orderId: 1, + transferAmountFen: 1, + transferRespCode: 1, + transferRespMsg: 1, + grantExpiredAt: 1, + lastErrorCode: 1, + lastErrorMessage: 1, + expireAt: 1, + endedAt: 1 + }) + ); + + const pendingAmountCall = findTaskUpdateByStatus( + TeamEnterpriseAuthTaskStatusEnum.pending_amount + ); + expect(pendingAmountCall).toBeTruthy(); + const [, pendingAmountUpdate] = pendingAmountCall!; + expect(pendingAmountUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.pending_amount, + orderId: 'order_1', + transferAmountFen: 13 + }) + ); + expect(pendingAmountUpdate.$inc).toEqual({ + usedTimes: 1 + }); + expect(result).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthStatusEnum.verifying, + usedTimes: previousTask.usedTimes + 1 + }) + ); + }); + + it('认证信息错误只结束任务,不消耗认证次数', async () => { + mocks.createTransfer.mockResolvedValueOnce({ + type: 'info_failed', + transferRespCode: 'INFO_ERROR', + transferRespMsg: '企业信息不匹配' + }); + + await expect( + startEnterpriseAuth({ + teamId, + data: validStartBody + }) + ).rejects.toThrow(EnterpriseAuthErrEnum.infoFailed); + + const [, updateDoc] = mocks.updateTask.mock.calls.at(-1)!; + expect(updateDoc.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.info_failed, + lastErrorCode: EnterpriseAuthErrEnum.infoFailed, + transferRespCode: 'INFO_ERROR', + transferRespMsg: '企业信息不匹配' + }) + ); + expect(updateDoc.$set).not.toHaveProperty('usedTimes'); + }); + + it('发起认证入参会规范化银行账号并拒绝非数字账号', () => { + expect(StartEnterpriseAuthBodySchema.parse(validStartBody).bankAccount).toBe( + '6222000000000000' + ); + + expect(() => + StartEnterpriseAuthBodySchema.parse({ + ...validStartBody, + bankAccount: '6222-0000' + }) + ).toThrow(); + }); + + it('抢锁前按统一信用代码释放已过期或超时任务', async () => { + await startEnterpriseAuth({ + teamId, + data: validStartBody + }); + + expect(mocks.updateManyTask).toHaveBeenCalledTimes(4); + const [expiredAmountFilter, expiredAmountUpdate] = mocks.updateManyTask.mock.calls[0]; + expect(expiredAmountFilter).toEqual( + expect.objectContaining({ + unifiedCreditCode: '91310000MA1K000000', + status: { + $in: [ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ] + } + }) + ); + expect(expiredAmountFilter.expireAt.$lte).toBeInstanceOf(Date); + expect(expiredAmountUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.expired, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired + }) + ); + + const [timeoutStartingFilter, timeoutStartingUpdate] = mocks.updateManyTask.mock.calls[1]; + expect(timeoutStartingFilter).toEqual( + expect.objectContaining({ + unifiedCreditCode: '91310000MA1K000000', + status: TeamEnterpriseAuthTaskStatusEnum.starting + }) + ); + expect(timeoutStartingFilter.startedAt.$lt).toBeInstanceOf(Date); + expect(timeoutStartingUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout + }) + ); + }); + + it('抢锁前清理历史 granting 临时态,且新任务锁集合不包含 granting', async () => { + await startEnterpriseAuth({ + teamId, + data: validStartBody + }); + + const startingCall = findTaskUpdateByStatus(TeamEnterpriseAuthTaskStatusEnum.starting); + expect(startingCall).toBeTruthy(); + const [startingFilter] = startingCall!; + const pendingStatuses = startingFilter.$and[0].status.$nin; + expect(pendingStatuses).not.toContain(TeamEnterpriseAuthTaskStatusEnum.granting); + + expect(mocks.updateManyTask).toHaveBeenCalledTimes(4); + const [expiredGrantingFilter, expiredGrantingUpdate] = mocks.updateManyTask.mock.calls[2]; + expect(expiredGrantingFilter).toEqual( + expect.objectContaining({ + unifiedCreditCode: '91310000MA1K000000', + status: TeamEnterpriseAuthTaskStatusEnum.granting + }) + ); + expect(expiredGrantingFilter.expireAt.$lte).toBeInstanceOf(Date); + expect(expiredGrantingUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.expired, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired + }) + ); + + const [retryGrantingFilter, retryGrantingUpdate] = mocks.updateManyTask.mock.calls[3]; + expect(retryGrantingFilter).toEqual( + expect.objectContaining({ + unifiedCreditCode: '91310000MA1K000000', + status: TeamEnterpriseAuthTaskStatusEnum.granting + }) + ); + expect(retryGrantingFilter.expireAt.$gt).toBeInstanceOf(Date); + expect(retryGrantingUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.amount_failed, + lastErrorCode: EnterpriseAuthErrEnum.processing + }) + ); + }); + + it('已有未完成任务时发起认证直接恢复轻量任务', async () => { + const currentTask = buildTask(TeamEnterpriseAuthTaskStatusEnum.starting); + mocks.findTask + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce(mockSortedLean(currentTask)) + .mockReturnValueOnce(mockSortedLean(null)); + + await expect( + startEnterpriseAuth({ + teamId, + data: validStartBody + }) + ).resolves.toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthStatusEnum.verifying, + currentTask: expect.objectContaining({ + taskId: 'task_starting', + status: TeamEnterpriseAuthTaskStatusEnum.starting + }), + usedTimes: 1 + }) + ); + expect(mocks.createTask).not.toHaveBeenCalled(); + }); + + it('次数达到上限时拒绝继续发起认证', async () => { + mocks.findTask + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce( + mockSortedLean( + buildTask(TeamEnterpriseAuthTaskStatusEnum.info_failed, { + usedTimes: EnterpriseAuthMaxTimes + }) + ) + ); + + await expect( + startEnterpriseAuth({ + teamId, + data: validStartBody + }) + ).rejects.toThrow(EnterpriseAuthErrEnum.noRemainingTimes); + }); + + it('状态接口聚合成功信息表和最新任务,不创建未认证状态行', async () => { + mocks.findAuth.mockReturnValueOnce(mockLean(null)); + mocks.findTask.mockReturnValueOnce(mockSortedLean(null)); + + await expect(getEnterpriseAuthStatus({ teamId, canManage: true })).resolves.toEqual({ + enabled: true, + status: TeamEnterpriseAuthStatusEnum.unverified, + usedTimes: 0, + canManage: true, + verifiedEnterpriseName: undefined, + currentTask: undefined, + lastErrorCode: undefined, + lastErrorMessage: undefined + }); + expect(mocks.createTask).not.toHaveBeenCalled(); + }); + + it('重置信息时取消金额验证任务并清理旧错误提示', async () => { + const task = buildTask(TeamEnterpriseAuthTaskStatusEnum.amount_failed, { + amountErrorTimes: 1, + lastErrorCode: EnterpriseAuthErrEnum.amountError, + lastErrorMessage: '验证金额错误' + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)).mockReturnValueOnce(mockLean(task)); + mocks.updateTask.mockResolvedValueOnce({ + matchedCount: 1 + }); + + await resetEnterpriseAuthTask(teamId); + + const [, updateDoc] = mocks.updateTask.mock.calls.at(-1)!; + expect(updateDoc.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.canceled + }) + ); + expect(updateDoc.$unset).toEqual({ + lastErrorCode: 1, + lastErrorMessage: 1 + }); + }); +}); diff --git a/packages/service/test/support/user/team/enterpriseAuth/controller.verifyAmount.test.ts b/packages/service/test/support/user/team/enterpriseAuth/controller.verifyAmount.test.ts new file mode 100644 index 000000000000..dd8004dccdea --- /dev/null +++ b/packages/service/test/support/user/team/enterpriseAuth/controller.verifyAmount.test.ts @@ -0,0 +1,686 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EnterpriseAuthAmountMaxErrorTimes, + EnterpriseAuthErrEnum, + EnterpriseAuthGrantPoints, + EnterpriseAuthTrialDays, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { + StandardSubLevelEnum, + SubModeEnum, + SubTypeEnum +} from '@fastgpt/global/support/wallet/sub/constants'; + +const mocks = vi.hoisted(() => ({ + findAuth: vi.fn(), + createAuth: vi.fn(), + findTask: vi.fn(), + updateTask: vi.fn(), + findOneAndUpdateTask: vi.fn(), + mongoSessionRun: vi.fn(), + findSub: vi.fn(), + findOneSub: vi.fn(), + createSub: vi.fn(), + reComputeStandPlans: vi.fn(), + clearTeamPlanCache: vi.fn(), + hasServiceConfig: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/schema', () => ({ + MongoTeamEnterpriseAuth: { + findOne: mocks.findAuth, + create: mocks.createAuth + }, + MongoTeamEnterpriseAuthTask: { + findOne: mocks.findTask, + updateOne: mocks.updateTask, + findOneAndUpdate: mocks.findOneAndUpdateTask + } +})); + +vi.mock('@fastgpt/service/common/mongo/sessionRun', () => ({ + mongoSessionRun: mocks.mongoSessionRun +})); + +vi.mock('@fastgpt/service/support/wallet/sub/schema', () => ({ + MongoTeamSub: { + find: mocks.findSub, + findOne: mocks.findOneSub, + create: mocks.createSub + } +})); + +vi.mock('@fastgpt/service/support/wallet/sub/utils', () => ({ + reComputeStandPlans: mocks.reComputeStandPlans, + clearTeamPlanCache: mocks.clearTeamPlanCache +})); + +vi.mock('@fastgpt/service/common/secret/aes256gcm', () => ({ + encryptSecret: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/transferClient', () => ({ + createEnterpriseAuthTransfer: vi.fn(), + hasEnterpriseAuthServiceConfig: mocks.hasServiceConfig, + getEnterpriseAuthBanks: vi.fn() +})); + +const { getEnterpriseAuthStatus, verifyEnterpriseAuthAmount } = + await import('@fastgpt/service/support/user/team/enterpriseAuth/controller'); + +const teamId = '507f1f77bcf86cd799439011'; +const userId = '507f1f77bcf86cd799439012'; +const tmbId = '507f1f77bcf86cd799439013'; +const taskId = 'task_1'; + +const mockLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value) +}); + +const mockSortedLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value), + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(value) + }) +}); + +const mockSession = (value: T) => ({ + session: vi.fn().mockResolvedValue(value) +}); + +const mockSessionLean = (value: T) => ({ + session: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(value) + }) +}); + +const addDays = (date: Date, days: number) => new Date(date.getTime() + days * 24 * 60 * 60 * 1000); + +const buildTask = (amountErrorTimes: number, overrides: Record = {}) => + ({ + teamId, + taskId, + status: + amountErrorTimes > 0 + ? TeamEnterpriseAuthTaskStatusEnum.amount_failed + : TeamEnterpriseAuthTaskStatusEnum.pending_amount, + enterpriseName: '示例科技有限公司', + unifiedCreditCode: '91310000TEST00000', + legalPersonName: '张三', + bankName: '中国工商银行', + bankAccount: '6222000000000000', + contactName: '李四', + contactTitle: '产品负责人', + contactPhone: '13800000000', + demand: '企业知识库试用', + transferAmountFen: 13, + amountErrorTimes, + usedTimes: 1, + startedAt: new Date(Date.now() - 60 * 1000), + expireAt: new Date(Date.now() + 60 * 60 * 1000), + createTime: new Date(Date.now() - 60 * 1000), + updateTime: new Date(Date.now() - 60 * 1000), + ...overrides + }) as any; + +const buildVerifiedAuth = (verifiedAt: Date) => + ({ + teamId, + enterpriseName: '示例科技有限公司', + unifiedCreditCode: '91310000TEST00000', + legalPersonName: '张三', + bankName: '中国工商银行', + bankAccount: '6222000000000000', + contactName: '李四', + contactTitle: '产品负责人', + contactPhone: '13800000000', + demand: '企业知识库试用', + verifiedAt, + createTime: verifiedAt, + updateTime: verifiedAt + }) as any; + +const buildStandardSub = ({ + level, + startTime, + expiredTime, + totalPoints = 1000, + surplusPoints = 100 +}: { + level: StandardSubLevelEnum; + startTime: Date; + expiredTime: Date; + totalPoints?: number; + surplusPoints?: number; +}) => ({ + _id: `507f1f77bcf86cd7994390${level.length}`.slice(0, 24), + teamId, + type: SubTypeEnum.standard, + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + currentSubLevel: level, + nextSubLevel: level, + startTime, + expiredTime, + totalPoints, + surplusPoints, + save: vi.fn().mockResolvedValue(undefined) +}); + +const setupPendingTask = (task = buildTask(0)) => { + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)); +}; + +const verifyWrongAmount = () => + verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 12 + } + }); + +describe('getEnterpriseAuthStatus readonly behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.hasServiceConfig.mockReturnValue(true); + }); + + it('任务已过期时只推导展示态,不回写过期状态', async () => { + const expiredTask = buildTask(0, { + expireAt: new Date(Date.now() - 1000) + }); + mocks.findAuth.mockReturnValueOnce(mockLean(null)); + mocks.findTask.mockReturnValueOnce(mockSortedLean(expiredTask)); + + const result = await getEnterpriseAuthStatus({ + teamId, + canManage: false + }); + + expect(result).toMatchObject({ + enabled: true, + status: TeamEnterpriseAuthStatusEnum.failed, + usedTimes: expiredTask.usedTimes, + canManage: false, + currentTask: undefined, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + lastErrorMessage: '认证任务已过期,请重新填写' + }); + expect(mocks.updateTask).not.toHaveBeenCalled(); + }); +}); + +describe('verifyEnterpriseAuthAmount', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.hasServiceConfig.mockReturnValue(true); + mocks.mongoSessionRun.mockImplementation(async (fn) => fn({})); + mocks.clearTeamPlanCache.mockResolvedValue(undefined); + mocks.findAuth.mockReturnValue(mockSession(null)); + mocks.findTask.mockReturnValue(mockSortedLean(null)); + mocks.findOneAndUpdateTask.mockReturnValue(mockLean(null)); + mocks.findSub.mockReturnValue(mockSession([])); + mocks.findOneSub.mockReturnValue(mockSession(null)); + mocks.reComputeStandPlans.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('金额错误但未达上限时使用原子递增保留任务', async () => { + setupPendingTask(buildTask(0)); + mocks.findOneAndUpdateTask + .mockReturnValueOnce(mockLean(null)) + .mockReturnValueOnce(mockLean(buildTask(1))); + + await expect(verifyWrongAmount()).rejects.toThrow(EnterpriseAuthErrEnum.amountError); + + expect(mocks.findOneAndUpdateTask).toHaveBeenCalledTimes(2); + const [, recoverableUpdate] = mocks.findOneAndUpdateTask.mock.calls[1]; + expect(recoverableUpdate).toEqual( + expect.objectContaining({ + $inc: { + amountErrorTimes: 1 + }, + $set: expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.amount_failed, + lastErrorCode: EnterpriseAuthErrEnum.amountError + }) + }) + ); + expect(recoverableUpdate.$set).not.toHaveProperty('amountErrorTimes'); + }); + + it('最后一次金额错误时同一次原子更新写入失败状态', async () => { + setupPendingTask(buildTask(EnterpriseAuthAmountMaxErrorTimes - 1)); + mocks.findOneAndUpdateTask.mockReturnValueOnce( + mockLean( + buildTask(EnterpriseAuthAmountMaxErrorTimes, { + status: TeamEnterpriseAuthTaskStatusEnum.failed, + endedAt: new Date() + }) + ) + ); + + await expect(verifyWrongAmount()).rejects.toThrow(EnterpriseAuthErrEnum.amountFailed); + + expect(mocks.findOneAndUpdateTask).toHaveBeenCalledTimes(1); + const [, finalUpdate] = mocks.findOneAndUpdateTask.mock.calls[0]; + expect(finalUpdate).toEqual( + expect.objectContaining({ + $inc: { + amountErrorTimes: 1 + }, + $set: expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.failed, + lastErrorCode: EnterpriseAuthErrEnum.amountFailed + }) + }) + ); + }); + + it('金额验证遇到已过期任务时保留 taskExpired 错误语义', async () => { + const expiredTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.expired, + expireAt: new Date(Date.now() - 1000), + lastErrorCode: EnterpriseAuthErrEnum.taskExpired + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(expiredTask)); + + await expect( + verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }) + ).rejects.toThrow(EnterpriseAuthErrEnum.taskExpired); + }); + + it('金额验证遇到已超时 starting 任务时保留 serviceTimeout 错误语义', async () => { + const serviceFailedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(serviceFailedTask)); + + await expect( + verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }) + ).rejects.toThrow(EnterpriseAuthErrEnum.serviceTimeout); + }); + + it('金额验证成功时写入纯成功信息表,任务表只记录状态且不触发飞书同步', async () => { + const grantedAt = new Date('2026-06-15T00:00:00.000Z'); + const trialExpiredAt = addDays(grantedAt, EnterpriseAuthTrialDays); + vi.useFakeTimers(); + vi.setSystemTime(grantedAt); + + const pendingTask = buildTask(0); + const grantingTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.granting + }); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: trialExpiredAt + }); + const createdAdvancedSub = buildStandardSub({ + level: StandardSubLevelEnum.advanced, + startTime: grantedAt, + expiredTime: trialExpiredAt, + totalPoints: EnterpriseAuthGrantPoints, + surplusPoints: EnterpriseAuthGrantPoints + }); + const verifiedAuth = buildVerifiedAuth(grantedAt); + + setupPendingTask(pendingTask); + mocks.findTask.mockReturnValueOnce(mockSessionLean(grantingTask)); + mocks.findOneAndUpdateTask + .mockResolvedValueOnce(grantingTask) + .mockResolvedValueOnce(verifiedTask); + mocks.findSub.mockReturnValueOnce(mockSession([])); + mocks.findOneSub.mockReturnValueOnce(mockSession(null)); + mocks.createSub.mockResolvedValueOnce([createdAdvancedSub]); + mocks.createAuth.mockResolvedValueOnce([verifiedAuth]); + mocks.findAuth.mockReturnValueOnce(mockSession(verifiedAuth)); + + const result = await verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }); + + expect(mocks.createAuth).toHaveBeenCalledWith( + [ + expect.objectContaining({ + enterpriseName: pendingTask.enterpriseName, + unifiedCreditCode: pendingTask.unifiedCreditCode, + bankAccount: '6222000000000000', + verifiedAt: grantedAt + }) + ], + { session: {} } + ); + const [, verifiedUpdate] = mocks.findOneAndUpdateTask.mock.calls[1]; + expect(verifiedUpdate.$set).toEqual( + expect.objectContaining({ + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: trialExpiredAt, + endedAt: grantedAt, + updateTime: grantedAt + }) + ); + expect(verifiedUpdate.$set).not.toHaveProperty('grant'); + expect(verifiedUpdate.$set).not.toHaveProperty('feishuSync'); + expect(verifiedUpdate.$set).not.toHaveProperty('trialMetrics'); + expect(verifiedUpdate.$inc).toBeUndefined(); + expect(mocks.reComputeStandPlans).toHaveBeenCalledWith(teamId, {}); + expect(result).toEqual({ + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt: trialExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }); + }); + + it('重复提交已验证任务时返回首次认证的套餐到期时间', async () => { + const verifiedAt = new Date('2026-06-15T00:00:00.000Z'); + const grantExpiredAt = addDays(verifiedAt, EnterpriseAuthTrialDays); + const verifiedAuth = buildVerifiedAuth(verifiedAt); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt + }); + + mocks.findTask + .mockReturnValueOnce(mockSortedLean(null)) + .mockReturnValueOnce(mockLean(verifiedTask)); + mocks.findAuth.mockReturnValueOnce(mockLean(verifiedAuth)); + + const result = await verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }); + + expect(result).toEqual({ + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }); + expect(mocks.mongoSessionRun).not.toHaveBeenCalled(); + expect(mocks.clearTeamPlanCache).not.toHaveBeenCalled(); + }); + + it('认证成功后清理套餐缓存失败不影响接口返回', async () => { + const grantedAt = new Date('2026-06-15T00:00:00.000Z'); + const trialExpiredAt = addDays(grantedAt, EnterpriseAuthTrialDays); + vi.useFakeTimers(); + vi.setSystemTime(grantedAt); + + const pendingTask = buildTask(0); + const grantingTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.granting + }); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: trialExpiredAt + }); + const createdAdvancedSub = buildStandardSub({ + level: StandardSubLevelEnum.advanced, + startTime: grantedAt, + expiredTime: trialExpiredAt, + totalPoints: EnterpriseAuthGrantPoints, + surplusPoints: EnterpriseAuthGrantPoints + }); + const verifiedAuth = buildVerifiedAuth(grantedAt); + + setupPendingTask(pendingTask); + mocks.findTask.mockReturnValueOnce(mockSessionLean(grantingTask)); + mocks.findOneAndUpdateTask + .mockResolvedValueOnce(grantingTask) + .mockResolvedValueOnce(verifiedTask); + mocks.findSub.mockReturnValueOnce(mockSession([])); + mocks.findOneSub.mockReturnValueOnce(mockSession(null)); + mocks.createSub.mockResolvedValueOnce([createdAdvancedSub]); + mocks.createAuth.mockResolvedValueOnce([verifiedAuth]); + mocks.findAuth.mockReturnValue(mockSession(verifiedAuth)); + mocks.clearTeamPlanCache + .mockRejectedValueOnce(new Error('redis down')) + .mockResolvedValueOnce(undefined); + + await expect( + verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }) + ).resolves.toEqual({ + status: TeamEnterpriseAuthStatusEnum.verified, + verifiedEnterpriseName: verifiedAuth.enterpriseName, + grantExpiredAt: trialExpiredAt, + amountMaxErrorTimes: EnterpriseAuthAmountMaxErrorTimes + }); + await vi.advanceTimersByTimeAsync(500); + expect(mocks.clearTeamPlanCache).toHaveBeenCalledTimes(2); + }); + + it('当前定制版优先生效时高级赠送接到定制版之后', async () => { + const grantedAt = new Date('2026-06-15T00:00:00.000Z'); + const customExpiredAt = new Date('2026-07-10T00:00:00.000Z'); + const expectedAdvancedExpiredAt = addDays(customExpiredAt, EnterpriseAuthTrialDays); + vi.useFakeTimers(); + vi.setSystemTime(grantedAt); + + const pendingTask = buildTask(0); + const grantingTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.granting + }); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: expectedAdvancedExpiredAt + }); + const customSub = buildStandardSub({ + level: StandardSubLevelEnum.custom, + startTime: new Date('2026-06-01T00:00:00.000Z'), + expiredTime: customExpiredAt + }); + const createdAdvancedSub = buildStandardSub({ + level: StandardSubLevelEnum.advanced, + startTime: customExpiredAt, + expiredTime: expectedAdvancedExpiredAt, + totalPoints: EnterpriseAuthGrantPoints, + surplusPoints: EnterpriseAuthGrantPoints + }); + const verifiedAuth = buildVerifiedAuth(grantedAt); + + setupPendingTask(pendingTask); + mocks.findTask.mockReturnValueOnce(mockSessionLean(grantingTask)); + mocks.findOneAndUpdateTask + .mockResolvedValueOnce(grantingTask) + .mockResolvedValueOnce(verifiedTask); + mocks.findSub.mockReturnValueOnce(mockSession([customSub])); + mocks.findOneSub.mockReturnValueOnce(mockSession(null)); + mocks.createSub.mockResolvedValueOnce([createdAdvancedSub]); + mocks.createAuth.mockResolvedValueOnce([verifiedAuth]); + mocks.findAuth.mockReturnValueOnce(mockSession(verifiedAuth)); + + const result = await verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }); + + expect(mocks.createSub).toHaveBeenCalledWith( + [ + expect.objectContaining({ + startTime: customExpiredAt, + expiredTime: expectedAdvancedExpiredAt, + currentSubLevel: StandardSubLevelEnum.advanced + }) + ], + { session: {} } + ); + expect(mocks.reComputeStandPlans).toHaveBeenCalledWith(teamId, {}); + + const [, verifiedUpdate] = mocks.findOneAndUpdateTask.mock.calls[1]; + expect(verifiedUpdate.$set).not.toHaveProperty('grant'); + expect(result.grantExpiredAt).toEqual(expectedAdvancedExpiredAt); + }); + + it('已有生效 advanced 套餐时累加积分并延长有效期', async () => { + const grantedAt = new Date('2026-06-15T00:00:00.000Z'); + const advancedExpiredAt = new Date('2026-06-25T00:00:00.000Z'); + const expectedAdvancedExpiredAt = addDays(advancedExpiredAt, EnterpriseAuthTrialDays); + vi.useFakeTimers(); + vi.setSystemTime(grantedAt); + + const pendingTask = buildTask(0); + const grantingTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.granting + }); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: expectedAdvancedExpiredAt + }); + const advancedSub = buildStandardSub({ + level: StandardSubLevelEnum.advanced, + startTime: new Date('2026-06-01T00:00:00.000Z'), + expiredTime: advancedExpiredAt, + totalPoints: 1000, + surplusPoints: 100 + }); + const verifiedAuth = buildVerifiedAuth(grantedAt); + + setupPendingTask(pendingTask); + mocks.findTask.mockReturnValueOnce(mockSessionLean(grantingTask)); + mocks.findOneAndUpdateTask + .mockResolvedValueOnce(grantingTask) + .mockResolvedValueOnce(verifiedTask); + mocks.findSub.mockReturnValueOnce(mockSession([advancedSub])); + mocks.findOneSub.mockReturnValueOnce(mockSession(advancedSub)); + mocks.createAuth.mockResolvedValueOnce([verifiedAuth]); + mocks.findAuth.mockReturnValueOnce(mockSession(verifiedAuth)); + + const result = await verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }); + + expect(advancedSub.totalPoints).toBe(1000 + EnterpriseAuthGrantPoints); + expect(advancedSub.surplusPoints).toBe(100 + EnterpriseAuthGrantPoints); + expect(advancedSub.startTime).toEqual(new Date('2026-06-01T00:00:00.000Z')); + expect(advancedSub.expiredTime).toEqual(expectedAdvancedExpiredAt); + expect(advancedSub.save).toHaveBeenCalledWith({ session: {} }); + expect(mocks.createSub).not.toHaveBeenCalled(); + expect(result.grantExpiredAt).toEqual(expectedAdvancedExpiredAt); + }); + + it('已有过期 advanced 套餐时从当前时间重新发放并累加积分', async () => { + const grantedAt = new Date('2026-06-15T00:00:00.000Z'); + const expectedAdvancedExpiredAt = addDays(grantedAt, EnterpriseAuthTrialDays); + vi.useFakeTimers(); + vi.setSystemTime(grantedAt); + + const pendingTask = buildTask(0); + const grantingTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.granting + }); + const verifiedTask = buildTask(0, { + status: TeamEnterpriseAuthTaskStatusEnum.verified, + grantExpiredAt: expectedAdvancedExpiredAt + }); + const advancedSub = buildStandardSub({ + level: StandardSubLevelEnum.advanced, + startTime: new Date('2026-05-01T00:00:00.000Z'), + expiredTime: new Date('2026-05-20T00:00:00.000Z'), + totalPoints: 1000, + surplusPoints: 100 + }); + const verifiedAuth = buildVerifiedAuth(grantedAt); + + setupPendingTask(pendingTask); + mocks.findTask.mockReturnValueOnce(mockSessionLean(grantingTask)); + mocks.findOneAndUpdateTask + .mockResolvedValueOnce(grantingTask) + .mockResolvedValueOnce(verifiedTask); + mocks.findSub.mockReturnValueOnce(mockSession([])); + mocks.findOneSub.mockReturnValueOnce(mockSession(advancedSub)); + mocks.createAuth.mockResolvedValueOnce([verifiedAuth]); + mocks.findAuth.mockReturnValueOnce(mockSession(verifiedAuth)); + + const result = await verifyEnterpriseAuthAmount({ + operator: { + teamId, + userId, + tmbId + }, + data: { + taskId, + amountFen: 13 + } + }); + + expect(advancedSub.totalPoints).toBe(1000 + EnterpriseAuthGrantPoints); + expect(advancedSub.surplusPoints).toBe(100 + EnterpriseAuthGrantPoints); + expect(advancedSub.startTime).toEqual(grantedAt); + expect(advancedSub.expiredTime).toEqual(expectedAdvancedExpiredAt); + expect(advancedSub.save).toHaveBeenCalledWith({ session: {} }); + expect(mocks.createSub).not.toHaveBeenCalled(); + expect(result.grantExpiredAt).toEqual(expectedAdvancedExpiredAt); + }); +}); diff --git a/packages/service/test/support/user/team/enterpriseAuth/readModel.test.ts b/packages/service/test/support/user/team/enterpriseAuth/readModel.test.ts new file mode 100644 index 000000000000..5757a90f0e51 --- /dev/null +++ b/packages/service/test/support/user/team/enterpriseAuth/readModel.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + EnterpriseAuthErrEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; + +const mocks = vi.hoisted(() => ({ + findAuth: vi.fn(), + findTask: vi.fn(), + updateTask: vi.fn(), + hasServiceConfig: vi.fn() +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/schema', () => ({ + MongoTeamEnterpriseAuth: { + findOne: mocks.findAuth + }, + MongoTeamEnterpriseAuthTask: { + findOne: mocks.findTask, + updateOne: mocks.updateTask + } +})); + +vi.mock('@fastgpt/service/support/user/team/enterpriseAuth/transferClient', () => ({ + hasEnterpriseAuthServiceConfig: mocks.hasServiceConfig +})); + +const { getEnterpriseAuthCurrentTaskDetail } = + await import('@fastgpt/service/support/user/team/enterpriseAuth/readModel'); + +const teamId = '507f1f77bcf86cd799439011'; + +const mockLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value) +}); + +const mockSortedLean = (value: T) => ({ + lean: vi.fn().mockResolvedValue(value), + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(value) + }) +}); + +const buildTask = (status: TeamEnterpriseAuthTaskStatusEnum, overrides: Record = {}) => + ({ + teamId, + taskId: `task_${status}`, + status, + enterpriseName: '示例科技有限公司', + unifiedCreditCode: '91310000MA1K000000', + legalPersonName: '张三', + bankName: '中国工商银行', + bankAccount: '6222000000000000', + contactName: '李四', + contactTitle: '产品负责人', + contactPhone: '13800000000', + demand: '企业知识库试用', + transferAmountFen: 13, + amountErrorTimes: status === TeamEnterpriseAuthTaskStatusEnum.amount_failed ? 1 : 0, + usedTimes: 1, + startedAt: new Date(Date.now() - 60 * 1000), + expireAt: new Date(Date.now() + 60 * 60 * 1000), + createTime: new Date(Date.now() - 60 * 1000), + updateTime: new Date(Date.now() - 60 * 1000), + ...overrides + }) as any; + +describe('getEnterpriseAuthCurrentTaskDetail', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.hasServiceConfig.mockReturnValue(true); + mocks.findTask.mockReturnValue(mockSortedLean(null)); + mocks.updateTask.mockResolvedValue({ matchedCount: 0 }); + }); + + it.each([ + TeamEnterpriseAuthTaskStatusEnum.pending_amount, + TeamEnterpriseAuthTaskStatusEnum.amount_failed + ])('%s 任务可读取完整任务详情并返回明文账号', async (status) => { + const task = buildTask(status); + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)); + + const result = await getEnterpriseAuthCurrentTaskDetail(teamId); + + expect(result).toEqual( + expect.objectContaining({ + taskId: task.taskId, + status, + enterpriseName: task.enterpriseName, + bankAccount: '6222000000000000', + amountErrorTimes: task.amountErrorTimes, + expireAt: task.expireAt + }) + ); + }); + + it.each([TeamEnterpriseAuthTaskStatusEnum.starting, TeamEnterpriseAuthTaskStatusEnum.granting])( + '%s 任务不允许读取金额验证详情', + async (status) => { + const task = buildTask(status, { + ...(status === TeamEnterpriseAuthTaskStatusEnum.starting ? { startedAt: new Date() } : {}) + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)); + + await expect(getEnterpriseAuthCurrentTaskDetail(teamId)).rejects.toThrow( + EnterpriseAuthErrEnum.taskNotFound + ); + } + ); + + it('已落库过期任务读取详情时返回 taskExpired', async () => { + const task = buildTask(TeamEnterpriseAuthTaskStatusEnum.expired, { + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + expireAt: new Date(Date.now() - 1000) + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)); + + await expect(getEnterpriseAuthCurrentTaskDetail(teamId)).rejects.toThrow( + EnterpriseAuthErrEnum.taskExpired + ); + }); + + it('已落库服务超时任务读取详情时返回 serviceTimeout', async () => { + const task = buildTask(TeamEnterpriseAuthTaskStatusEnum.service_failed, { + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout + }); + mocks.findTask.mockReturnValueOnce(mockSortedLean(task)); + + await expect(getEnterpriseAuthCurrentTaskDetail(teamId)).rejects.toThrow( + EnterpriseAuthErrEnum.serviceTimeout + ); + }); +}); diff --git a/packages/service/test/support/user/team/enterpriseAuth/status.test.ts b/packages/service/test/support/user/team/enterpriseAuth/status.test.ts new file mode 100644 index 000000000000..5520726a9ce0 --- /dev/null +++ b/packages/service/test/support/user/team/enterpriseAuth/status.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { + EnterpriseAuthErrEnum, + TeamEnterpriseAuthStatusEnum, + TeamEnterpriseAuthTaskStatusEnum +} from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import { + deriveExpiredTaskPatch, + toTerminalTaskError +} from '@fastgpt/service/support/user/team/enterpriseAuth/status'; + +const now = new Date('2026-06-16T00:00:00.000Z'); +const serviceTimeoutMs = 30 * 1000; + +const buildTask = (task: Record) => + ({ + teamId: '507f1f77bcf86cd799439011', + taskId: 'task_1', + usedTimes: 1, + amountErrorTimes: 0, + startedAt: new Date(now.getTime() - 10 * 1000), + createTime: new Date(now.getTime() - 10 * 1000), + updateTime: new Date(now.getTime() - 10 * 1000), + ...task + }) as any; + +describe('deriveExpiredTaskPatch', () => { + it('金额验证任务过期时返回统一失败补丁', () => { + const patch = deriveExpiredTaskPatch({ + task: buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.pending_amount, + expireAt: new Date(now.getTime() - 1) + }), + now, + serviceTimeoutMs + }); + + expect(patch).toEqual({ + status: TeamEnterpriseAuthStatusEnum.failed, + taskStatus: TeamEnterpriseAuthTaskStatusEnum.expired, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired, + lastErrorMessage: '认证任务已过期,请重新填写' + }); + }); + + it('starting 任务服务超时时返回统一失败补丁', () => { + const patch = deriveExpiredTaskPatch({ + task: buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.starting, + startedAt: new Date(now.getTime() - serviceTimeoutMs - 1) + }), + now, + serviceTimeoutMs + }); + + expect(patch).toEqual({ + status: TeamEnterpriseAuthStatusEnum.failed, + taskStatus: TeamEnterpriseAuthTaskStatusEnum.service_failed, + endedAt: now, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout, + lastErrorMessage: '服务网络超时,请稍后重试' + }); + }); + + it('未过期或已结束任务不返回补丁', () => { + expect( + deriveExpiredTaskPatch({ + task: buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.amount_failed, + expireAt: new Date(now.getTime() + 1) + }), + now, + serviceTimeoutMs + }) + ).toBeUndefined(); + + expect( + deriveExpiredTaskPatch({ + task: buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.canceled, + expireAt: new Date(now.getTime() - 1) + }), + now, + serviceTimeoutMs + }) + ).toBeUndefined(); + }); + + it('granting 是事务内临时态,不参与过期恢复推导', () => { + expect( + deriveExpiredTaskPatch({ + task: buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.granting, + expireAt: new Date(now.getTime() - 1) + }), + now, + serviceTimeoutMs + }) + ).toBeUndefined(); + }); +}); + +describe('toTerminalTaskError', () => { + it('已落库过期和服务超时任务保留对外错误语义', () => { + expect( + toTerminalTaskError( + buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.expired, + lastErrorCode: EnterpriseAuthErrEnum.taskExpired + }) + ) + ).toBe(EnterpriseAuthErrEnum.taskExpired); + + expect( + toTerminalTaskError( + buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: EnterpriseAuthErrEnum.serviceTimeout + }) + ) + ).toBe(EnterpriseAuthErrEnum.serviceTimeout); + }); + + it('非超时 service_failed 不对外伪装成服务超时', () => { + expect( + toTerminalTaskError( + buildTask({ + status: TeamEnterpriseAuthTaskStatusEnum.service_failed, + lastErrorCode: EnterpriseAuthErrEnum.enterpriseOccupied + }) + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/service/test/support/wallet/sub/utils.test.ts b/packages/service/test/support/wallet/sub/utils.test.ts index b30ba87ce406..7030aa14bd46 100644 --- a/packages/service/test/support/wallet/sub/utils.test.ts +++ b/packages/service/test/support/wallet/sub/utils.test.ts @@ -14,6 +14,7 @@ import { getStandardPlansConfig, getStandardPlanConfig, sortStandPlans, + reComputeStandPlans, initTeamFreePlan, getTeamStandPlan, getTeamPlanStatus, @@ -26,6 +27,10 @@ import { MongoTeamSub } from '@fastgpt/service/support/wallet/sub/schema'; // Valid ObjectId for testing const mockTeamId = '507f1f77bcf86cd799439011'; const mockPlanId = '507f1f77bcf86cd799439012'; +const activePlanWindow = () => ({ + startTime: new Date(Date.now() - 24 * 60 * 60 * 1000), + expiredTime: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) +}); const baseStandard: TeamSubSchemaType = { _id: mockPlanId, @@ -672,6 +677,98 @@ describe('initTeamFreePlan', () => { }); }); +describe('reComputeStandPlans', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const buildPlan = ({ + level, + startTime, + expiredTime + }: { + level: StandardSubLevelEnum; + startTime: Date; + expiredTime: Date; + }) => ({ + _id: `plan-${level}`, + teamId: mockTeamId, + type: SubTypeEnum.standard, + currentSubLevel: level, + nextSubLevel: level, + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + totalPoints: 100, + surplusPoints: 100, + startTime, + expiredTime, + save: vi.fn().mockResolvedValue(undefined) + }); + + it('按套餐等级把低等级套餐顺延到高等级套餐之后', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-18T00:00:00.000Z')); + + const advanced = buildPlan({ + level: StandardSubLevelEnum.advanced, + startTime: new Date('2026-06-18T00:00:00.000Z'), + expiredTime: new Date('2026-07-03T00:00:00.000Z') + }); + const free = buildPlan({ + level: StandardSubLevelEnum.free, + startTime: new Date('2026-06-18T00:00:00.000Z'), + expiredTime: new Date('2026-07-18T00:00:00.000Z') + }); + const mockQuery = { + session: vi.fn().mockResolvedValue([free, advanced]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + await reComputeStandPlans(mockTeamId, {} as any); + + expect(MongoTeamSub.find).toHaveBeenCalledWith({ + teamId: mockTeamId, + type: SubTypeEnum.standard, + expiredTime: { $gt: new Date('2026-06-18T00:00:00.000Z') } + }); + expect(advanced.startTime).toEqual(new Date('2026-06-18T00:00:00.000Z')); + expect(advanced.expiredTime).toEqual(new Date('2026-07-03T00:00:00.000Z')); + expect(free.startTime).toEqual(new Date('2026-07-03T00:00:00.000Z')); + expect(free.expiredTime).toEqual(new Date('2026-08-02T00:00:00.000Z')); + expect(advanced.save).toHaveBeenCalledWith({ session: {} }); + expect(free.save).toHaveBeenCalledWith({ session: {} }); + + vi.useRealTimers(); + }); + + it('只重排未过期套餐,避免历史套餐影响新权益排期', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-18T00:00:00.000Z')); + + const activeAdvanced = buildPlan({ + level: StandardSubLevelEnum.advanced, + startTime: new Date('2026-06-18T00:00:00.000Z'), + expiredTime: new Date('2026-07-03T00:00:00.000Z') + }); + const mockQuery = { + session: vi.fn().mockResolvedValue([activeAdvanced]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + await reComputeStandPlans(mockTeamId, {} as any); + + expect(MongoTeamSub.find).toHaveBeenCalledWith({ + teamId: mockTeamId, + type: SubTypeEnum.standard, + expiredTime: { $gt: new Date('2026-06-18T00:00:00.000Z') } + }); + expect(activeAdvanced.startTime).toEqual(new Date('2026-06-18T00:00:00.000Z')); + expect(activeAdvanced.expiredTime).toEqual(new Date('2026-07-03T00:00:00.000Z')); + + vi.useRealTimers(); + }); +}); + describe('getTeamStandPlan', () => { beforeEach(() => { vi.clearAllMocks(); @@ -687,8 +784,7 @@ describe('getTeamStandPlan', () => { currentSubLevel: StandardSubLevelEnum.basic, totalPoints: 2000, surplusPoints: 1500, - startTime: new Date('2024-01-01'), - expiredTime: new Date('2025-01-01'), + ...activePlanWindow(), currentMode: SubModeEnum.month, nextMode: SubModeEnum.month, nextSubLevel: StandardSubLevelEnum.basic, @@ -722,6 +818,57 @@ describe('getTeamStandPlan', () => { expect(result[SubTypeEnum.standard]).toBeDefined(); expect(result[SubTypeEnum.standard]?.name).toBe('Basic Plan'); }); + + it('无有效标准套餐时基于初始化结果直接返回,避免递归重查', async () => { + const teamId = mockTeamId; + const mockCreatedPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.free, + totalPoints: 100, + surplusPoints: 100, + ...activePlanWindow(), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.free, + currentExtraDatasetSize: 0 + }; + + const mockQuery = { + lean: vi.fn().mockResolvedValue([]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + vi.spyOn(MongoTeamSub, 'findOne').mockResolvedValue(null); + vi.spyOn(MongoTeamSub, 'create').mockResolvedValue([mockCreatedPlan] as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.free]: { + name: 'Free Plan', + price: 0, + totalPoints: 100, + maxTeamMember: 1, + maxAppAmount: 5, + maxDatasetAmount: 2, + maxDatasetSize: 10, + chatHistoryStoreDuration: 7 + } + } + }; + + const result = await getTeamStandPlan({ teamId }); + + expect(MongoTeamSub.find).toHaveBeenCalledTimes(1); + expect(MongoTeamSub.create).toHaveBeenCalledTimes(1); + expect(result[SubTypeEnum.standard]).toEqual( + expect.objectContaining({ + currentSubLevel: StandardSubLevelEnum.free, + name: 'Free Plan', + totalPoints: 100 + }) + ); + }); }); describe('getTeamPlanStatus', () => { @@ -739,8 +886,7 @@ describe('getTeamPlanStatus', () => { currentSubLevel: StandardSubLevelEnum.basic, totalPoints: 2000, surplusPoints: 1500, - startTime: new Date('2024-01-01'), - expiredTime: new Date('2025-01-01'), + ...activePlanWindow(), currentMode: SubModeEnum.month, nextMode: SubModeEnum.month, nextSubLevel: StandardSubLevelEnum.basic, @@ -777,6 +923,76 @@ describe('getTeamPlanStatus', () => { expect(result[SubTypeEnum.standard]).toBeDefined(); }); + it('忽略已过期的高权重标准套餐,使用当前有效套餐', async () => { + const teamId = mockTeamId; + const expiredCustomPlan = { + _id: '507f1f77bcf86cd799439020', + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.custom, + totalPoints: 90000, + surplusPoints: 90000, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.custom, + currentExtraDatasetSize: 0, + maxDatasetSize: 2000 + }; + const activeAdvancedPlan = { + _id: '507f1f77bcf86cd799439021', + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.advanced, + totalPoints: 25000, + surplusPoints: 20000, + ...activePlanWindow(), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.advanced, + currentExtraDatasetSize: 0, + maxDatasetSize: 500 + }; + + const mockQuery = { + lean: vi.fn().mockResolvedValue([expiredCustomPlan, activeAdvancedPlan]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.advanced]: { + name: 'Advanced Plan', + price: 299, + totalPoints: 25000, + maxTeamMember: 50, + maxAppAmount: 200, + maxDatasetAmount: 100, + maxDatasetSize: 500, + chatHistoryStoreDuration: 90 + }, + [StandardSubLevelEnum.custom]: { + name: 'Custom Plan', + price: 999, + totalPoints: 90000, + maxTeamMember: 200, + maxAppAmount: 1000, + maxDatasetAmount: 500, + maxDatasetSize: 2000, + chatHistoryStoreDuration: 365 + } + } + }; + + const result = await getTeamPlanStatus({ teamId }); + + expect(result[SubTypeEnum.standard]?.currentSubLevel).toBe(StandardSubLevelEnum.advanced); + expect(result.totalPoints).toBe(25000); + expect(result.usedPoints).toBe(5000); + expect(result.datasetMaxSize).toBe(500); + }); + it('包含额外积分套餐', async () => { const teamId = mockTeamId; const mockStandardPlan = { @@ -786,8 +1002,7 @@ describe('getTeamPlanStatus', () => { currentSubLevel: StandardSubLevelEnum.basic, totalPoints: 2000, surplusPoints: 1500, - startTime: new Date('2024-01-01'), - expiredTime: new Date('2025-01-01'), + ...activePlanWindow(), currentMode: SubModeEnum.month, nextMode: SubModeEnum.month, nextSubLevel: StandardSubLevelEnum.basic, @@ -840,8 +1055,7 @@ describe('getTeamPlanStatus', () => { currentSubLevel: StandardSubLevelEnum.basic, totalPoints: 2000, surplusPoints: 1500, - startTime: new Date('2024-01-01'), - expiredTime: new Date('2025-01-01'), + ...activePlanWindow(), currentMode: SubModeEnum.month, nextMode: SubModeEnum.month, nextSubLevel: StandardSubLevelEnum.basic, diff --git a/packages/web/components/common/MySelect/index.tsx b/packages/web/components/common/MySelect/index.tsx index 37bec69a9f84..b474838744a0 100644 --- a/packages/web/components/common/MySelect/index.tsx +++ b/packages/web/components/common/MySelect/index.tsx @@ -4,8 +4,7 @@ import React, { useMemo, useEffect, useImperativeHandle, - type ForwardedRef, - useState + type ForwardedRef } from 'react'; import { Menu, @@ -25,6 +24,8 @@ import MyDivider from '../MyDivider'; import type { useScrollPagination } from '../../../hooks/useScrollPagination'; import Avatar from '../Avatar'; import EmptyTip from '../EmptyTip'; +import { useSearchMenu } from './useSearchMenu'; +import type { SelectOption } from './type'; /** 选择组件 Props 类型 * value: 选中的值 @@ -40,15 +41,8 @@ export type SelectProps = Omit & { valueLabel?: string | React.ReactNode; placeholder?: string; isSearch?: boolean; - list: { - alias?: string | React.ReactNode; - icon?: string; - iconSize?: string; - label: string | React.ReactNode; - description?: string; - value: T; - showBorder?: boolean; - }[]; + showAliasInValue?: boolean; + list: SelectOption[]; isLoading?: boolean; onChange?: (val: T) => any | Promise; ScrollData?: ReturnType['ScrollData']; @@ -80,6 +74,7 @@ const MySelect = ( value, valueLabel, isSearch = false, + showAliasInValue = true, width = '100%', list = [], onChange, @@ -104,27 +99,36 @@ const MySelect = ( const { isOpen, onOpen: defaultOnOpen, onClose: defaultOnClose } = useDisclosure(); const selectItem = useMemo(() => list.find((item) => item.value === value), [list, value]); - const onOpen = () => { - defaultOnOpen(); - customOnOpen?.(); - }; - const onClose = () => { defaultOnClose(); customOnClose?.(); }; - const [search, setSearch] = useState(''); - const filterList = useMemo(() => { - if (!isSearch || !search) { - return list; - } - return list.filter((item) => { - const text = `${item.label?.toString()}${item.alias}${item.value}`; - const regx = new RegExp(search, 'gi'); - return regx.test(text); - }); - }, [list, search, isSearch]); + const { + search, + setSearch, + resetSearch, + menuMinW, + fallbackMenuMinW, + handleSearchInputKeyDown, + handleSearchInputKeyUp, + filterList + } = useSearchMenu({ + isSearch, + isOpen, + width, + list, + buttonRef: ButtonRef, + searchInputRef: SearchInputRef, + onClose, + focusButton: () => ButtonRef.current?.focus() + }); + + const onOpen = () => { + resetSearch(); + defaultOnOpen(); + customOnOpen?.(); + }; useImperativeHandle(ref, () => ({ focus() { @@ -138,12 +142,8 @@ const MySelect = ( const menu = MenuListRef.current; const selectedItem = SelectedItemRef.current; menu.scrollTop = selectedItem.offsetTop - menu.offsetTop - 100; - - if (isSearch) { - setSearch(''); - } } - }, [isSearch, isOpen]); + }, [isOpen]); const { runAsync: onClickChange, loading } = useRequest((val: T) => onChange?.(val)); @@ -250,52 +250,23 @@ const MySelect = ( <>{valueLabel} ) : ( <> - {isSearch && isOpen ? ( - setSearch(e.target.value)} - placeholder={ - (typeof selectItem?.alias === 'string' ? selectItem?.alias : '') || - (typeof selectItem?.label === 'string' ? selectItem?.label : placeholder) - } - _placeholder={{ - color: 'myGray.500' - }} - size={'sm'} - w={'100%'} - color={'myGray.700'} - onBlur={() => { - setTimeout(() => { - SearchInputRef?.current?.focus(); - }, 0); - }} - /> - ) : ( - <> - {selectItem?.icon && ( - - )} - { - - {selectItem?.alias || selectItem?.label || placeholder} - - } - + {selectItem?.icon && ( + )} + { + + {(showAliasInValue ? selectItem?.alias : undefined) || + selectItem?.label || + placeholder} + + } )} @@ -305,15 +276,7 @@ const MySelect = ( { - const w = ButtonRef.current?.clientWidth; - if (w) { - return `${w}px !important`; - } - return Array.isArray(width) - ? width.map((item) => `${item} !important`) - : `${width} !important`; - })()} + minW={menuMinW ?? fallbackMenuMinW} w={'max-content'} px={'6px'} py={'6px'} @@ -328,6 +291,30 @@ const MySelect = ( e.stopPropagation(); }} > + {isSearch && ( + + setSearch(e.target.value)} + placeholder={ + (showAliasInValue && typeof selectItem?.alias === 'string' + ? selectItem?.alias + : '') || + (typeof selectItem?.label === 'string' ? selectItem?.label : placeholder) + } + size={'sm'} + w={'100%'} + color={'myGray.700'} + borderColor={'myGray.200'} + _placeholder={{ + color: 'myGray.500' + }} + onKeyDown={handleSearchInputKeyDown} + onKeyUp={handleSearchInputKeyUp} + /> + + )} {ScrollData ? {ListRender} : ListRender} diff --git a/packages/web/components/common/MySelect/type.ts b/packages/web/components/common/MySelect/type.ts index 8a9c03867eea..b674cb4a4e02 100644 --- a/packages/web/components/common/MySelect/type.ts +++ b/packages/web/components/common/MySelect/type.ts @@ -22,3 +22,13 @@ export type MultipleArraySelectProps = Omit & { value?: any[][]; onSelect: (val: any[][]) => void; }; + +export type SelectOption = { + alias?: string | React.ReactNode; + icon?: string; + iconSize?: string; + label: string | React.ReactNode; + description?: string; + value: T; + showBorder?: boolean; +}; diff --git a/packages/web/components/common/MySelect/useSearchMenu.ts b/packages/web/components/common/MySelect/useSearchMenu.ts new file mode 100644 index 000000000000..e12c16149b25 --- /dev/null +++ b/packages/web/components/common/MySelect/useSearchMenu.ts @@ -0,0 +1,104 @@ +import { useEffect, useMemo, useState } from 'react'; +import type React from 'react'; +import type { ButtonProps } from '@chakra-ui/react'; +import type { SelectOption } from './type'; + +type UseSearchMenuParams = { + isSearch: boolean; + isOpen: boolean; + width: ButtonProps['width']; + list: SelectOption[]; + buttonRef: React.RefObject; + searchInputRef: React.RefObject; + onClose: () => void; + focusButton: () => void; +}; + +export const useSearchMenu = ({ + isSearch, + isOpen, + width, + list, + buttonRef, + searchInputRef, + onClose, + focusButton +}: UseSearchMenuParams) => { + const [search, setSearch] = useState(''); + const [menuMinW, setMenuMinW] = useState(); + const fallbackMenuMinW = useMemo(() => { + return Array.isArray(width) ? width.map((item) => `${item} !important`) : `${width} !important`; + }, [width]); + + const resetSearch = () => { + if (isSearch) { + setSearch(''); + } + }; + + const handleSearchInputKeyDown = (e: React.KeyboardEvent) => { + // 搜索框在菜单内独立处理输入,避免 Chakra Menu 用键盘事件切换焦点或关闭菜单。 + e.stopPropagation(); + + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + focusButton(); + } + }; + const handleSearchInputKeyUp = (e: React.KeyboardEvent) => { + e.stopPropagation(); + }; + + const filterList = useMemo(() => { + if (!isSearch || !search) { + return list; + } + const keyword = search.toLowerCase(); + return list.filter((item) => { + const text = [item.label, item.alias, item.value] + .filter((value): value is string | number => { + return typeof value === 'string' || typeof value === 'number'; + }) + .join('') + .toLowerCase(); + + return text.includes(keyword); + }); + }, [list, search, isSearch]); + + useEffect(() => { + if (isOpen && isSearch) { + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } + }, [isSearch, isOpen, searchInputRef]); + + useEffect(() => { + if (!isOpen) return; + + const updateMenuMinW = () => { + const buttonWidth = buttonRef.current?.clientWidth; + setMenuMinW(buttonWidth ? `${buttonWidth}px !important` : undefined); + }; + const frameId = window.requestAnimationFrame(updateMenuMinW); + + window.addEventListener('resize', updateMenuMinW); + return () => { + window.cancelAnimationFrame(frameId); + window.removeEventListener('resize', updateMenuMinW); + }; + }, [buttonRef, isOpen]); + + return { + search, + setSearch, + resetSearch, + menuMinW, + fallbackMenuMinW, + handleSearchInputKeyDown, + handleSearchInputKeyUp, + filterList + }; +}; diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index bc6622218379..fffbcd214d0e 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -296,5 +296,73 @@ "log_transfer_skill_ownership": "[{{name}}] Transferred ownership of [{{skillType}}] named [{{skillName}}] from [{{oldOwnerName}}] to [{{newOwnerName}}]", "log_update_skill": "[{{name}}] Updated [{{skillType}}] named [{{skillName}}]", "log_update_skill_collaborator": "[{{name}}] Updated collaborators for [{{skillType}}] named [{{skillName}}] to: Organization: [{{orgList}}], Group: [{{groupList}}], Member [{{tmbList}}]; permissions updated to: [{{permission}}]", + "enterprise_auth_title": "Enterprise verification", + "enterprise_auth_verified_label": "Verification completed", + "enterprise_auth_pending_amount_label": "Waiting for transfer amount", + "enterprise_auth_processing_label": "Verification is processing", + "enterprise_auth_continue_button": "Continue", + "enterprise_auth_unverified_label": "Not verified (verify to get Advanced trial)", + "enterprise_auth_button": "Verify", + "enterprise_auth_contact_admin_tip": "Contact a team admin to continue", + "enterprise_auth_bank_load_failed": "Failed to load bank list. Try again later.", + "enterprise_auth_bank_retry": "Retry", + "enterprise_auth_task_load_failed": "Failed to load verification task", + "enterprise_auth_submit_failed": "Failed to submit verification", + "enterprise_auth_no_remaining_times_tip": "Contact sales to complete enterprise verification.", + "enterprise_auth_verify_failed": "Verification failed", + "enterprise_auth_operation_failed": "Operation failed", + "enterprise_auth_success_grant_tip": "Enterprise verified. Advanced benefits have been granted.", + "enterprise_auth_transfer_sent_tip": "Transfer sent. Please confirm the received amount.", + "enterprise_auth_invalid_amount_tip": "Enter a valid received amount", + "enterprise_auth_modal_desc": "After enterprise verification is approved, you will receive a 15-day Advanced plan trial.", + "enterprise_auth_enterprise_info": "Enterprise information", + "enterprise_auth_enterprise_name": "Enterprise name", + "enterprise_auth_enterprise_name_placeholder": "Enter enterprise name", + "enterprise_auth_unified_credit_code": "Unified social credit code", + "enterprise_auth_unified_credit_code_placeholder": "Enter unified credit code", + "enterprise_auth_legal_person": "Legal representative", + "enterprise_auth_legal_person_placeholder": "Enter the legal representative", + "enterprise_auth_bank_account": "Bank account", + "enterprise_auth_bank_account_placeholder": "Enter bank account", + "enterprise_auth_bank_name": "Bank", + "enterprise_auth_bank_name_placeholder": "Select the bank head office", + "enterprise_auth_bank_loading_placeholder": "Loading bank list", + "enterprise_auth_bank_account_first_placeholder": "Enter bank account first", + "enterprise_auth_contact_info": "Personal information", + "enterprise_auth_contact_name": "Your name", + "enterprise_auth_contact_name_placeholder": "Enter how we should address you", + "enterprise_auth_contact_title": "Your title", + "enterprise_auth_contact_title_placeholder": "Enter your title", + "enterprise_auth_contact_phone": "Contact phone", + "enterprise_auth_contact_phone_placeholder": "Enter your phone number, only for business contact", + "enterprise_auth_demand": "Your needs", + "enterprise_auth_demand_placeholder": "Describe how you and your company use FastGPT so we can provide better service", + "enterprise_auth_amount_sent_prefix": "A transfer has been initiated to the account above. Please wait for it to arrive, then enter the verification amount. The amount will be sent by Shanghai UnionPay to the corporate account you submitted. It is random and usually arrives in real time. If it has not arrived within 3 business days, please ", + "enterprise_auth_contact_business": "contact sales", + "enterprise_auth_amount_sent_suffix": ".", + "enterprise_auth_amount_label": "Verification amount", + "enterprise_auth_amount_placeholder": "Enter the verification amount in cents", + "enterprise_auth_amount_error_tip": "Incorrect amount", + "enterprise_auth_reset_info": "Refill enterprise info", + "enterprise_auth_cancel": "Cancel", + "enterprise_auth_start": "Start verification", + "enterprise_auth_submit": "Submit verification", + "enterprise_auth_notice_title": "System notice", + "enterprise_auth_notice_headline": "Enterprise verification benefit: verify now to receive a 15-day Advanced plan", + "enterprise_auth_notice_greeting": "Dear user,", + "enterprise_auth_notice_intro": "To support your business, we are offering an enterprise benefit. Complete verification to receive a 15-day Advanced plan for free.", + "enterprise_auth_notice_benefit_intro": "After verification succeeds, your account will be upgraded with these benefits:", + "enterprise_auth_notice_benefit_advanced": "Unlock Advanced with more premium capabilities", + "enterprise_auth_notice_benefit_points": "25,000 AI points", + "enterprise_auth_notice_benefit_support": "Dedicated enterprise service support", + "enterprise_auth_notice_entry": "Verification entry: Account - Personal info - Enterprise verification", + "enterprise_auth_notice_or_click": ", or click ", + "enterprise_auth_notice_link": "this link", + "enterprise_auth_notice_link_suffix": " to go there directly", + "enterprise_auth_notice_activity_icon": "⏰", + "enterprise_auth_notice_activity": "This offer is long-term and available immediately after verification. Complete verification now to unlock more efficient work.", + "enterprise_auth_notice_help": "If you have any questions, contact support at any time. Thank you for your support.", + "enterprise_auth_notice_footer": "Professional tools for enterprise growth", + "enterprise_auth_notice_read": "Read", "move_skill": "Move skill" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 2422bbfce10f..102ad3fd11f0 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -190,6 +190,20 @@ "code_error.system_error.license_app_amount_limit": "Exceed the maximum number of applications in the system", "code_error.system_error.license_dataset_amount_limit": "Exceed the maximum number of knowledge bases in the system", "code_error.system_error.license_user_amount_limit": "Exceed the maximum number of users in the system", + "enterprise_auth.error.disabled": "Enterprise verification is disabled", + "enterprise_auth.error.service_not_configured": "Enterprise verification service is not configured", + "enterprise_auth.error.no_remaining_times": "No verification attempts remaining", + "enterprise_auth.error.already_verified": "This team or enterprise has already been verified", + "enterprise_auth.error.enterprise_occupied": "This enterprise is being verified or has already been verified", + "enterprise_auth.error.too_frequent": "Too many attempts. Please try again later.", + "enterprise_auth.error.service_error": "Verification service error. Please try again later.", + "enterprise_auth.error.service_timeout": "Service request timed out. Please try again later.", + "enterprise_auth.error.info_failed": "Verification information is incorrect. Please fill it in again.", + "enterprise_auth.error.task_not_found": "Verification task does not exist or has ended", + "enterprise_auth.error.task_expired": "Verification task has expired. Please fill it in again.", + "enterprise_auth.error.amount_error": "Incorrect verification amount", + "enterprise_auth.error.amount_failed": "Too many incorrect amount attempts. This verification failed.", + "enterprise_auth.error.processing": "Verification is processing. Please try again later.", "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", diff --git a/packages/web/i18n/zh-CN/account_team.json b/packages/web/i18n/zh-CN/account_team.json index 653273ed3cfe..51c36847a628 100644 --- a/packages/web/i18n/zh-CN/account_team.json +++ b/packages/web/i18n/zh-CN/account_team.json @@ -296,5 +296,73 @@ "log_transfer_skill_ownership": "【{{name}}】将名为【{{skillName}}】的【{{skillType}}】的所有权从【{{oldOwnerName}}】转移到【{{newOwnerName}}】", "log_update_skill": "【{{name}}】更新了名为【{{skillName}}】的【{{skillType}}】", "log_update_skill_collaborator": "【{{name}}】将名为【{{skillName}}】的【{{skillType}}】的合作者更新为:组织:【{{orgList}}】,群组:【{{groupList}}】,成员【{{tmbList}}】;权限更新为:【{{permission}}】", + "enterprise_auth_title": "企业认证", + "enterprise_auth_verified_label": "已完成认证", + "enterprise_auth_pending_amount_label": "待确认打款金额", + "enterprise_auth_processing_label": "认证处理中", + "enterprise_auth_continue_button": "继续认证", + "enterprise_auth_unverified_label": "未认证(认证享高级套餐)", + "enterprise_auth_button": "认证", + "enterprise_auth_contact_admin_tip": "请联系团队管理员操作", + "enterprise_auth_bank_load_failed": "获取银行列表失败,请稍后重试", + "enterprise_auth_bank_retry": "重试", + "enterprise_auth_task_load_failed": "获取认证任务失败", + "enterprise_auth_submit_failed": "认证提交失败", + "enterprise_auth_no_remaining_times_tip": "请联系商务人员进行企业认证", + "enterprise_auth_verify_failed": "验证失败", + "enterprise_auth_operation_failed": "操作失败", + "enterprise_auth_success_grant_tip": "企业认证通过,已发放高级版权益", + "enterprise_auth_transfer_sent_tip": "已成功打款,请确认打款金额", + "enterprise_auth_invalid_amount_tip": "请输入正确的到账金额", + "enterprise_auth_modal_desc": "企业认证审核通过后,将为您发放15天的高级版套餐", + "enterprise_auth_enterprise_info": "企业信息", + "enterprise_auth_enterprise_name": "企业名称", + "enterprise_auth_enterprise_name_placeholder": "请填写企业名称", + "enterprise_auth_unified_credit_code": "统一社会信用代码", + "enterprise_auth_unified_credit_code_placeholder": "请填写统一信用代码", + "enterprise_auth_legal_person": "法人姓名", + "enterprise_auth_legal_person_placeholder": "请输入法人姓名", + "enterprise_auth_bank_account": "银行账号", + "enterprise_auth_bank_account_placeholder": "请填写银行账号", + "enterprise_auth_bank_name": "开户银行", + "enterprise_auth_bank_name_placeholder": "请选择开户银行总行", + "enterprise_auth_bank_loading_placeholder": "银行列表加载中", + "enterprise_auth_bank_account_first_placeholder": "请先填写银行账号", + "enterprise_auth_contact_info": "个人信息", + "enterprise_auth_contact_name": "您的姓名", + "enterprise_auth_contact_name_placeholder": "请填写您的称呼方式", + "enterprise_auth_contact_title": "您的职位", + "enterprise_auth_contact_title_placeholder": "请填写您的职位身份", + "enterprise_auth_contact_phone": "联系方式", + "enterprise_auth_contact_phone_placeholder": "请填写您的手机号码,仅用于商务人员联系", + "enterprise_auth_demand": "您的需求", + "enterprise_auth_demand_placeholder": "请描述您及贵司使用FastGPT的场景,以便我们更好地为您提供服务", + "enterprise_auth_amount_sent_prefix": "已向以上账号成功发起打款,请耐心等待打款到账,到账后填写验证金额。验证金额将由上海银联打入您提交的对公账号,金额随机,一般实时到账。若3个工作日内未到账,请", + "enterprise_auth_contact_business": "联系商务", + "enterprise_auth_amount_sent_suffix": "。", + "enterprise_auth_amount_label": "验证金额", + "enterprise_auth_amount_placeholder": "请填写验证金额,单位:分", + "enterprise_auth_amount_error_tip": "金额错误", + "enterprise_auth_reset_info": "重新填写企业信息", + "enterprise_auth_cancel": "取消", + "enterprise_auth_start": "开始认证", + "enterprise_auth_submit": "提交认证", + "enterprise_auth_notice_title": "系统通知", + "enterprise_auth_notice_headline": "【企业认证福利】立即认证,领取15天高级套餐", + "enterprise_auth_notice_greeting": "尊敬的用户:", + "enterprise_auth_notice_intro": "为助力企业发展,现推出企业专属福利,完成认证,即可免费获得15天高级套餐!", + "enterprise_auth_notice_benefit_intro": "认证成功后,您的账号将自动升级,享受以下权益:", + "enterprise_auth_notice_benefit_advanced": "解锁高级版,享受更多高级功能权限", + "enterprise_auth_notice_benefit_points": "25000 AI 积分", + "enterprise_auth_notice_benefit_support": "专属企业级服务支持", + "enterprise_auth_notice_entry": "【认证入口:账号-个人信息-企业认证】", + "enterprise_auth_notice_or_click": ",或点击", + "enterprise_auth_notice_link": "链接", + "enterprise_auth_notice_link_suffix": ",一键跳转认证", + "enterprise_auth_notice_activity_icon": "⏰", + "enterprise_auth_notice_activity": "活动长期有效,认证即享!立即完成认证,解锁高效办公,助您事半功倍!", + "enterprise_auth_notice_help": "如有疑问,可随时联系客服协助处理。感谢您的支持!", + "enterprise_auth_notice_footer": "让专业工具,助力企业成长", + "enterprise_auth_notice_read": "已读", "move_skill": "移动技能" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index ea3a28ede5b0..8657ad2f1b01 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -190,6 +190,20 @@ "code_error.system_error.license_app_amount_limit": "超出系统最大应用数量", "code_error.system_error.license_dataset_amount_limit": "超出系统最大知识库数量", "code_error.system_error.license_user_amount_limit": "超出系统最大用户数量", + "enterprise_auth.error.disabled": "企业认证功能未开启", + "enterprise_auth.error.service_not_configured": "企业认证服务未配置", + "enterprise_auth.error.no_remaining_times": "认证次数已用完", + "enterprise_auth.error.already_verified": "该团队或企业已完成认证", + "enterprise_auth.error.enterprise_occupied": "该企业正在认证或已被认证", + "enterprise_auth.error.too_frequent": "操作过于频繁,请稍后再试", + "enterprise_auth.error.service_error": "验证服务错误,请稍后重试", + "enterprise_auth.error.service_timeout": "服务网络超时,请稍后重试", + "enterprise_auth.error.info_failed": "认证信息错误,请重新填写", + "enterprise_auth.error.task_not_found": "认证任务不存在或已结束", + "enterprise_auth.error.task_expired": "认证任务已过期,请重新填写", + "enterprise_auth.error.amount_error": "验证金额错误", + "enterprise_auth.error.amount_failed": "验证金额错误次数已达上限,本次认证失败", + "enterprise_auth.error.processing": "认证处理中,请稍后重试", "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": "文件夹数量已达上限~", diff --git a/packages/web/i18n/zh-Hant/account_team.json b/packages/web/i18n/zh-Hant/account_team.json index ed3f550a6ded..75be3f2c76f2 100644 --- a/packages/web/i18n/zh-Hant/account_team.json +++ b/packages/web/i18n/zh-Hant/account_team.json @@ -292,5 +292,73 @@ "log_transfer_skill_ownership": "【{{name}}】將名為【{{skillName}}】的【{{skillType}}】的所有權從【{{oldOwnerName}}】轉移到【{{newOwnerName}}】", "log_update_skill": "【{{name}}】更新了名為【{{skillName}}】的【{{skillType}}】", "log_update_skill_collaborator": "【{{name}}】將名為【{{skillName}}】的【{{skillType}}】的合作者更新為:組織:【{{orgList}}】,群組:【{{groupList}}】,成員【{{tmbList}}】;權限更新為:【{{permission}}】", + "enterprise_auth_title": "企業認證", + "enterprise_auth_verified_label": "已完成認證", + "enterprise_auth_pending_amount_label": "待確認打款金額", + "enterprise_auth_processing_label": "認證處理中", + "enterprise_auth_continue_button": "繼續認證", + "enterprise_auth_unverified_label": "未認證(認證享高級套餐)", + "enterprise_auth_button": "認證", + "enterprise_auth_contact_admin_tip": "請聯絡團隊管理員操作", + "enterprise_auth_bank_load_failed": "獲取銀行列表失敗,請稍後重試", + "enterprise_auth_bank_retry": "重試", + "enterprise_auth_task_load_failed": "獲取認證任務失敗", + "enterprise_auth_submit_failed": "認證提交失敗", + "enterprise_auth_no_remaining_times_tip": "請聯絡商務人員進行企業認證", + "enterprise_auth_verify_failed": "驗證失敗", + "enterprise_auth_operation_failed": "操作失敗", + "enterprise_auth_success_grant_tip": "企業認證通過,已發放高級版權益", + "enterprise_auth_transfer_sent_tip": "已成功打款,請確認打款金額", + "enterprise_auth_invalid_amount_tip": "請輸入正確的到賬金額", + "enterprise_auth_modal_desc": "企業認證審核通過後,將為您發放15天的高級版套餐", + "enterprise_auth_enterprise_info": "企業資訊", + "enterprise_auth_enterprise_name": "企業名稱", + "enterprise_auth_enterprise_name_placeholder": "請填寫企業名稱", + "enterprise_auth_unified_credit_code": "統一社會信用代碼", + "enterprise_auth_unified_credit_code_placeholder": "請填寫統一信用代碼", + "enterprise_auth_legal_person": "法人姓名", + "enterprise_auth_legal_person_placeholder": "請輸入法人姓名", + "enterprise_auth_bank_account": "銀行帳號", + "enterprise_auth_bank_account_placeholder": "請填寫銀行帳號", + "enterprise_auth_bank_name": "開戶銀行", + "enterprise_auth_bank_name_placeholder": "請選擇開戶銀行總行", + "enterprise_auth_bank_loading_placeholder": "銀行列表載入中", + "enterprise_auth_bank_account_first_placeholder": "請先填寫銀行帳號", + "enterprise_auth_contact_info": "個人資訊", + "enterprise_auth_contact_name": "您的姓名", + "enterprise_auth_contact_name_placeholder": "請填寫您的稱呼方式", + "enterprise_auth_contact_title": "您的職位", + "enterprise_auth_contact_title_placeholder": "請填寫您的職位身分", + "enterprise_auth_contact_phone": "聯絡方式", + "enterprise_auth_contact_phone_placeholder": "請填寫您的手機號碼,僅用於商務人員聯繫", + "enterprise_auth_demand": "您的需求", + "enterprise_auth_demand_placeholder": "請描述您及貴司使用FastGPT的場景,以便我們更好地為您提供服務", + "enterprise_auth_amount_sent_prefix": "已向以上帳號成功發起打款,請耐心等待打款到賬,到賬後填寫驗證金額。驗證金額將由上海銀聯打入您提交的對公帳號,金額隨機,一般即時到賬。若3個工作日內未到賬,請", + "enterprise_auth_contact_business": "聯絡商務", + "enterprise_auth_amount_sent_suffix": "。", + "enterprise_auth_amount_label": "驗證金額", + "enterprise_auth_amount_placeholder": "請填寫驗證金額,單位:分", + "enterprise_auth_amount_error_tip": "金額錯誤", + "enterprise_auth_reset_info": "重新填寫企業資訊", + "enterprise_auth_cancel": "取消", + "enterprise_auth_start": "開始認證", + "enterprise_auth_submit": "提交認證", + "enterprise_auth_notice_title": "系統通知", + "enterprise_auth_notice_headline": "【企業認證福利】立即認證,領取15天高級套餐", + "enterprise_auth_notice_greeting": "尊敬的使用者:", + "enterprise_auth_notice_intro": "為助力企業發展,現推出企業專屬福利,完成認證,即可免費獲得15天高級套餐!", + "enterprise_auth_notice_benefit_intro": "認證成功後,您的帳號將自動升級,享受以下權益:", + "enterprise_auth_notice_benefit_advanced": "解鎖高級版,享受更多高級功能權限", + "enterprise_auth_notice_benefit_points": "25000 AI 點數", + "enterprise_auth_notice_benefit_support": "專屬企業級服務支援", + "enterprise_auth_notice_entry": "【認證入口:帳號-個人資訊-企業認證】", + "enterprise_auth_notice_or_click": ",或點擊", + "enterprise_auth_notice_link": "連結", + "enterprise_auth_notice_link_suffix": ",一鍵跳轉認證", + "enterprise_auth_notice_activity_icon": "⏰", + "enterprise_auth_notice_activity": "活動長期有效,認證即享!立即完成認證,解鎖高效辦公,助您事半功倍!", + "enterprise_auth_notice_help": "如有疑問,可隨時聯絡客服協助處理。感謝您的支持!", + "enterprise_auth_notice_footer": "讓專業工具,助力企業成長", + "enterprise_auth_notice_read": "已讀", "move_skill": "移動技能" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 142845d30ac7..329d88511968 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -188,6 +188,20 @@ "code_error.system_error.license_app_amount_limit": "超出系統最大應用數量", "code_error.system_error.license_dataset_amount_limit": "超出系統最大知識庫數量", "code_error.system_error.license_user_amount_limit": "超出系統最大用戶數量", + "enterprise_auth.error.disabled": "企業認證功能未開啟", + "enterprise_auth.error.service_not_configured": "企業認證服務未配置", + "enterprise_auth.error.no_remaining_times": "認證次數已用完", + "enterprise_auth.error.already_verified": "該團隊或企業已完成認證", + "enterprise_auth.error.enterprise_occupied": "該企業正在認證或已被認證", + "enterprise_auth.error.too_frequent": "操作過於頻繁,請稍後再試", + "enterprise_auth.error.service_error": "驗證服務錯誤,請稍後重試", + "enterprise_auth.error.service_timeout": "服務網路逾時,請稍後重試", + "enterprise_auth.error.info_failed": "認證資訊錯誤,請重新填寫", + "enterprise_auth.error.task_not_found": "認證任務不存在或已結束", + "enterprise_auth.error.task_expired": "認證任務已過期,請重新填寫", + "enterprise_auth.error.amount_error": "驗證金額錯誤", + "enterprise_auth.error.amount_failed": "驗證金額錯誤次數已達上限,本次認證失敗", + "enterprise_auth.error.processing": "認證處理中,請稍後重試", "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": "已達資料夾數量上限", diff --git a/projects/app/src/components/Layout/index.tsx b/projects/app/src/components/Layout/index.tsx index 425a06d31b89..6ad421222407 100644 --- a/projects/app/src/components/Layout/index.tsx +++ b/projects/app/src/components/Layout/index.tsx @@ -16,6 +16,8 @@ import { useTranslation } from 'next-i18next'; import { useToast } from '@fastgpt/web/hooks/useToast'; import { useCheckCoupon } from './hooks/checkCoupon'; import HelperBot from './HelperBot'; +import { getEnterpriseAuthStatus } from '@/web/support/user/team/enterpriseAuth/api'; +import { TeamEnterpriseAuthStatusEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; const Navbar = dynamic(() => import('./navbar')); const NavbarPhone = dynamic(() => import('./navbarPhone')); @@ -30,6 +32,12 @@ const NotSufficientModal = dynamic(() => import('@/components/support/wallet/Not const SystemMsgModal = dynamic(() => import('@/components/support/user/inform/SystemMsgModal'), { ssr: false }); +const EnterpriseAuthNoticeModal = dynamic( + () => import('@/components/support/user/inform/EnterpriseAuthNoticeModal'), + { + ssr: false + } +); const ImportantInform = dynamic(() => import('@/components/support/user/inform/ImportantInform'), { ssr: false }); @@ -88,7 +96,12 @@ const Layout = ({ children }: { children: JSX.Element }) => { setShowProModal } = useSystemStore(); const { isPc } = useSystem(); - const { userInfo, isUpdateNotification, setIsUpdateNotification } = useUserStore(); + const { + userInfo, + isUpdateNotification, + setIsUpdateNotification, + enterpriseAuthNoticeReadTeamIds + } = useUserStore(); const { setUserDefaultLng, setShareDefaultLng } = useI18nLng(); // Auto redeem coupon @@ -114,6 +127,25 @@ const Layout = ({ children }: { children: JSX.Element }) => { feConfigs?.bind_notification_method.length > 0 && !userInfo?.contact && !!userInfo?.team.permission.isOwner; + const shouldCheckEnterpriseAuthNotice = + router.pathname === '/dashboard/agent' && + !!feConfigs?.show_enterprise_auth && + !!userInfo?.team?.teamId && + (userInfo.team.permission.isOwner || userInfo.team.permission.hasManagePer) && + !enterpriseAuthNoticeReadTeamIds?.includes(userInfo.team.teamId); + const { data: enterpriseAuthStatus } = useQuery( + ['getEnterpriseAuthNoticeStatus', userInfo?.team?.teamId], + getEnterpriseAuthStatus, + { + enabled: shouldCheckEnterpriseAuthNotice, + staleTime: 30000 + } + ); + const showEnterpriseAuthNotice = + shouldCheckEnterpriseAuthNotice && + enterpriseAuthStatus?.enabled !== false && + !!enterpriseAuthStatus?.status && + enterpriseAuthStatus.status !== TeamEnterpriseAuthStatusEnum.verified; useMount(() => { if (router.pathname === '/chat/share') { @@ -133,13 +165,17 @@ const Layout = ({ children }: { children: JSX.Element }) => { status: 'warning', title: t('common:llm_model_not_config') }); - router.pathname !== '/account/model' && router.push('/account/model'); + if (router.pathname !== '/account/model') { + router.push('/account/model'); + } } else if (embeddingModelList.length === 0) { toast({ status: 'warning', title: t('common:embedding_model_not_config') }); - router.pathname !== '/account/model' && router.push('/account/model'); + if (router.pathname !== '/account/model') { + router.push('/account/model'); + } } } }, @@ -152,7 +188,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { // Route watch useEffect(() => { setLastRoute(router.pathname); - }, [router.pathname]); + }, [router.pathname, setLastRoute]); return ( <> @@ -206,6 +242,7 @@ const Layout = ({ children }: { children: JSX.Element }) => { )} + {showEnterpriseAuthNotice && } diff --git a/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx b/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx new file mode 100644 index 000000000000..b5180fdd9302 --- /dev/null +++ b/projects/app/src/components/support/user/inform/EnterpriseAuthNoticeModal.tsx @@ -0,0 +1,206 @@ +import React, { useCallback, useState } from 'react'; +import { + Box, + Button, + Flex, + Link, + ModalBody, + ModalCloseButton, + ModalFooter, + Text +} from '@chakra-ui/react'; +import { useRouter } from 'next/router'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useUserStore } from '@/web/support/user/useUserStore'; +import { useTranslation } from 'next-i18next'; + +const certificationHref = '/account/info#certification'; + +const NoteIcon = () => ( + + + +); + +const BenefitItem = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + +); + +const EnterpriseAuthNoticeModal = () => { + const router = useRouter(); + const { t } = useTranslation(); + const { userInfo, setEnterpriseAuthNoticeRead } = useUserStore(); + const [isClosed, setIsClosed] = useState(false); + + const teamId = userInfo?.team?.teamId; + + const markAsRead = useCallback(() => { + if (teamId) { + setEnterpriseAuthNoticeRead(teamId); + } + }, [setEnterpriseAuthNoticeRead, teamId]); + + const onClickRead = useCallback(() => { + markAsRead(); + }, [markAsRead]); + + const onClickCertificationLink = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + markAsRead(); + await router.push(certificationHref); + }, + [markAsRead, router] + ); + + if (isClosed) return null; + + return ( + setIsClosed(true)} + isCentered + w={['90vw', '580px']} + maxW={'90vw'} + maxH="85vh" + borderRadius={'10px'} + overflow={'hidden'} + boxShadow={'0px 0px 1px rgba(19, 51, 107, 0.1), 0px 4px 10px rgba(19, 51, 107, 0.1)'} + showCloseButton={false} + > + + + + + + {t('account_team:enterprise_auth_notice_title')} + + + + + {t('account_team:enterprise_auth_notice_headline')} + + + {t('account_team:enterprise_auth_notice_greeting')} + {t('account_team:enterprise_auth_notice_intro')} + + + + + {t('account_team:enterprise_auth_notice_benefit_intro')} + + + + + {t('account_team:enterprise_auth_notice_benefit_advanced')} + + {t('account_team:enterprise_auth_notice_benefit_points')} + + {t('account_team:enterprise_auth_notice_benefit_support')} + + + + + + + {t('account_team:enterprise_auth_notice_entry')} + + {t('account_team:enterprise_auth_notice_or_click')} + + {t('account_team:enterprise_auth_notice_link')} + + {t('account_team:enterprise_auth_notice_link_suffix')} + + + + {t('account_team:enterprise_auth_notice_activity_icon')} + {t('account_team:enterprise_auth_notice_activity')} + + + {t('account_team:enterprise_auth_notice_help')} + + + + {t('account_team:enterprise_auth_notice_footer')} + + + + + + + + + ); +}; + +export default React.memo(EnterpriseAuthNoticeModal); diff --git a/projects/app/src/components/support/user/inform/SystemMsgModal.tsx b/projects/app/src/components/support/user/inform/SystemMsgModal.tsx index dc21d97477c5..9216da075923 100644 --- a/projects/app/src/components/support/user/inform/SystemMsgModal.tsx +++ b/projects/app/src/components/support/user/inform/SystemMsgModal.tsx @@ -10,7 +10,7 @@ import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { webPushTrack } from '@/web/common/middle/tracks/utils'; const Markdown = dynamic(() => import('@/components/Markdown'), { ssr: false }); -const SystemMsgModal = ({}: {}) => { +const SystemMsgModal = () => { const { t } = useTranslation(); const { userInfo, systemMsgReadId, setSysMsgReadId } = useUserStore(); diff --git a/projects/app/src/pageComponents/account/team/EnterpriseAuthContactBusinessModal.tsx b/projects/app/src/pageComponents/account/team/EnterpriseAuthContactBusinessModal.tsx new file mode 100644 index 000000000000..0011a286e82b --- /dev/null +++ b/projects/app/src/pageComponents/account/team/EnterpriseAuthContactBusinessModal.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from 'react'; +import { Box, Button, Flex, ModalBody, ModalCloseButton, ModalFooter } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { enterpriseAuthContactBusinessUrl } from './utils'; + +type EnterpriseAuthContactBusinessModalProps = { + onClose: () => void; +}; + +const EnterpriseAuthContactBusinessModal = ({ + onClose +}: EnterpriseAuthContactBusinessModalProps) => { + const { t } = useTranslation(); + + const openContactBusiness = useCallback(() => { + window.open(enterpriseAuthContactBusinessUrl, '_blank', 'noopener,noreferrer'); + onClose(); + }, [onClose]); + + return ( + + + + + + {t('account_team:enterprise_auth_title')} + + + {t('account_team:enterprise_auth_no_remaining_times_tip')} + + + + + + + + + ); +}; + +export default React.memo(EnterpriseAuthContactBusinessModal); diff --git a/projects/app/src/pageComponents/account/team/EnterpriseAuthModal.tsx b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal.tsx new file mode 100644 index 000000000000..8d2431f67b11 --- /dev/null +++ b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { Button, ModalBody, ModalCloseButton, ModalFooter } from '@chakra-ui/react'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import type { GetEnterpriseAuthStatusResponseType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { EnterpriseAuthAmountMaxErrorTimes } from '@fastgpt/global/support/user/team/enterpriseAuth/constant'; +import EnterpriseAuthInfoForm from './EnterpriseAuthModal/EnterpriseAuthInfoForm'; +import EnterpriseAuthAmountForm from './EnterpriseAuthModal/EnterpriseAuthAmountForm'; +import { useEnterpriseAuthFormFlow } from './EnterpriseAuthModal/useEnterpriseAuthFormFlow'; + +type EnterpriseAuthModalProps = { + defaultStatus: GetEnterpriseAuthStatusResponseType; + onClose: () => void; + onSuccess: () => void; +}; + +const EnterpriseAuthModal = ({ defaultStatus, onClose, onSuccess }: EnterpriseAuthModalProps) => { + const flow = useEnterpriseAuthFormFlow({ + defaultStatus, + onClose, + onSuccess + }); + + if (flow.shouldBlockEnterpriseAuthForm) return null; + + return ( + + + + {flow.step === 'form' ? ( + + ) : ( + + )} + + + {flow.step === 'form' ? ( + <> + + + + ) : ( + <> + + + + )} + + + ); +}; + +export default React.memo(EnterpriseAuthModal); diff --git a/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthAmountForm.tsx b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthAmountForm.tsx new file mode 100644 index 000000000000..7b9b87db06ad --- /dev/null +++ b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthAmountForm.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Box, Flex, Input } from '@chakra-ui/react'; +import type { UseFormReturn } from 'react-hook-form'; +import type { TFunction } from 'next-i18next'; +import type { GetEnterpriseAuthCurrentTaskDetailResponseType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { enterpriseAuthContactBusinessUrl } from '../utils'; +import { + AmountInfoRow, + formatBankAccountForDisplay, + inputStyles, + type AmountFormType +} from './shared'; + +type EnterpriseAuthAmountFormProps = { + t: TFunction; + amountForm: UseFormReturn; + taskDetail?: GetEnterpriseAuthCurrentTaskDetailResponseType; + shouldShowAmountError: boolean; + setShowAmountError: (show: boolean) => void; +}; + +const EnterpriseAuthAmountForm = ({ + t, + amountForm, + taskDetail, + shouldShowAmountError, + setShowAmountError +}: EnterpriseAuthAmountFormProps) => { + const amountField = amountForm.register('amountFen', { + required: true, + pattern: /^[1-9]\d*$/ + }); + + return ( + + + + {t('account_team:enterprise_auth_title')} + + + {t('account_team:enterprise_auth_modal_desc')} + + + + + + + + + + + + + + + + {t('account_team:enterprise_auth_amount_sent_prefix')} + + {t('account_team:enterprise_auth_contact_business')} + + {t('account_team:enterprise_auth_amount_sent_suffix')} + + + + + {t('account_team:enterprise_auth_amount_label')} + + + { + event.target.value = event.target.value.replace(/\D/g, ''); + void amountField.onChange(event); + setShowAmountError(false); + }} + /> + + {t('account_team:enterprise_auth_amount_error_tip')} + + + + + + ); +}; + +export default React.memo(EnterpriseAuthAmountForm); diff --git a/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthInfoForm.tsx b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthInfoForm.tsx new file mode 100644 index 000000000000..86e40d4f8db3 --- /dev/null +++ b/projects/app/src/pageComponents/account/team/EnterpriseAuthModal/EnterpriseAuthInfoForm.tsx @@ -0,0 +1,240 @@ +import React, { useMemo, useState } from 'react'; +import { Box, Button, Flex, Grid, Input, Text, Textarea } from '@chakra-ui/react'; +import { Controller, type UseFormReturn, useWatch } from 'react-hook-form'; +import type { TFunction } from 'next-i18next'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import type { StartEnterpriseAuthBodyType } from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api'; +import { + Field, + Section, + UnifiedCreditCodePattern, + fieldRules, + inputStyles, + normalizeUnifiedCreditCode, + textareaStyles +} from './shared'; + +type EnterpriseAuthInfoFormProps = { + t: TFunction; + startForm: UseFormReturn; + bankOptions: { + label: string; + value: string; + alias: string; + }[]; + canSelectBank: boolean; + hasBankLoadError: boolean; + isBankLoading: boolean; + reloadBanks: () => void; +}; + +const EnterpriseAuthInfoForm = ({ + t, + startForm, + bankOptions, + canSelectBank, + hasBankLoadError, + isBankLoading, + reloadBanks +}: EnterpriseAuthInfoFormProps) => { + const [hasBlurredUnifiedCreditCode, setHasBlurredUnifiedCreditCode] = useState(false); + const unifiedCreditCode = useWatch({ + control: startForm.control, + name: 'unifiedCreditCode' + }); + const unifiedCreditCodeRegister = useMemo( + () => + startForm.register('unifiedCreditCode', { + ...fieldRules.unifiedCreditCode, + setValueAs: normalizeUnifiedCreditCode + }), + [startForm] + ); + const shouldShowUnifiedCreditCodeError = + hasBlurredUnifiedCreditCode && + !UnifiedCreditCodePattern.test(normalizeUnifiedCreditCode(unifiedCreditCode || '')); + + return ( + + + + {t('account_team:enterprise_auth_title')} + + + {t('account_team:enterprise_auth_modal_desc')} + + + +
+ + + + + + { + setHasBlurredUnifiedCreditCode(true); + unifiedCreditCodeRegister.onBlur(event); + }} + /> + + + + + + + + + ( + + value={field.value} + showAliasInValue={false} + list={bankOptions} + isSearch + isDisabled={!canSelectBank || hasBankLoadError} + isLoading={isBankLoading} + placeholder={ + !canSelectBank + ? t('account_team:enterprise_auth_bank_account_first_placeholder') + : hasBankLoadError + ? t('account_team:enterprise_auth_bank_load_failed') + : bankOptions.length + ? t('account_team:enterprise_auth_bank_name_placeholder') + : isBankLoading + ? t('account_team:enterprise_auth_bank_loading_placeholder') + : t('account_team:enterprise_auth_bank_name_placeholder') + } + opacity={1} + _disabled={{ + opacity: 1, + cursor: 'not-allowed', + bg: '#FBFBFC', + borderColor: '#F4F4F7', + color: '#8A95A7' + }} + _hover={ + canSelectBank + ? { + borderColor: 'primary.300' + } + : { + borderColor: '#F4F4F7' + } + } + h={'32px'} + borderRadius={'6px'} + fontSize={'12px'} + lineHeight={'16px'} + letterSpacing={'0.048px'} + borderColor={'#E8EBF0'} + onChange={(value) => field.onChange(value)} + /> + )} + /> + {hasBankLoadError && ( + + + {t('account_team:enterprise_auth_bank_load_failed')} + + + + )} + + +
+ + + + + +
+ + + + + + + + + + + +