Commit 402cf7fd by light5980 Committed by GitHub

feat: company certification (#7122)

* feat: company certification

# Conflicts:
#	packages/service/common/system/timerLock/constants.ts

* fix:prevent other team members from resuming enterprise auth tasks

* chore: migrate enterprise auth backend to pro

# Conflicts:
#	pro

# Conflicts:
#	pro

# Conflicts:
#	pro

# Conflicts:
#	pro

* feat: add enterprise auth audit and tracking

* fix: improve enterprise auth bank selection

* fix(certification): fix something

* fix: reuse standard sub grant for enterprise auth

* fix(certification): fix something

* fix certification ui

* fix:add enterprise auth task cleanup

* fix(certification): remove two fields

* fix(certification): tracks set and bill add
parent 81d39199
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}`>
);
......@@ -11,6 +11,8 @@ export enum TrackEnum {
clickOperationalAd = 'clickOperationalAd',
closeOperationalAd = 'closeOperationalAd',
teamChatQPM = 'teamChatQPM',
enterpriseAuthStart = 'enterpriseAuthStart',
enterpriseAuthBenefitGrant = 'enterpriseAuthBenefitGrant',
// Admin cron job tracks
subscriptionDeleted = 'subscriptionDeleted',
......
......@@ -65,6 +65,7 @@ export type FastGPTFeConfigsType = {
show_aiproxy?: boolean;
show_coupon?: boolean;
show_discount_coupon?: boolean;
show_enterprise_auth?: boolean;
showWecomConfig?: boolean;
show_dataset_feishu?: boolean;
......
import z from 'zod';
import {
EnterpriseAuthAmountMaxErrorTimes,
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthStatusSchema,
TeamEnterpriseAuthTaskStatusSchema
} from '../../../../../support/user/team/enterpriseAuth/constant';
import {
isBankAccount,
isUnifiedCreditCode,
normalizeBankAccount,
normalizeUnifiedCreditCode
} from '../../../../../support/user/team/enterpriseAuth/utils';
/* ============================================================================
* API: 获取企业认证状态
* Route: GET /api/proApi/support/user/team/enterpriseAuth/status
* Method: GET
* Description: 获取当前团队企业认证入口开关、认证状态和可恢复任务信息
* Tags: ['团队管理']
* ============================================================================ */
export const EnterpriseAuthLightTaskSchema = z.object({
taskId: z.string().meta({ description: '认证任务 ID' }),
status: TeamEnterpriseAuthTaskStatusSchema.meta({ description: '当前任务状态' }),
amountErrorTimes: z.number().int().meta({
description: '当前任务金额填写错误次数',
example: 0
})
});
export const GetEnterpriseAuthStatusResponseSchema = z.object({
enabled: z.boolean().meta({ description: '企业认证入口是否开启' }),
status: TeamEnterpriseAuthStatusSchema.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: '未完成认证任务'
}),
lastErrorCode: z.string().optional().meta({ description: '最近一次失败错误码' }),
lastErrorMessage: z.string().optional().meta({ description: '最近一次失败提示' })
});
export type GetEnterpriseAuthStatusResponseType = z.infer<
typeof GetEnterpriseAuthStatusResponseSchema
>;
/* ============================================================================
* API: 获取当前企业认证任务详情
* Route: GET /api/proApi/support/user/team/enterpriseAuth/currentTaskDetail
* Method: GET
* Description: 获取待金额验证任务的完整展示信息,不返回验证金额
* Tags: ['团队管理']
* ============================================================================ */
export const GetEnterpriseAuthCurrentTaskDetailResponseSchema = z.object({
taskId: z.string().meta({ description: '认证任务 ID' }),
status: TeamEnterpriseAuthTaskStatusSchema.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: '金额错误次数' })
});
export type GetEnterpriseAuthCurrentTaskDetailResponseType = z.infer<
typeof GetEnterpriseAuthCurrentTaskDetailResponseSchema
>;
/* ============================================================================
* API: 获取企业认证银行列表
* Route: GET /api/proApi/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) =>
normalizeBankAccount(account)
)
.pipe(z.string().refine(isBankAccount))
.meta({
description: '企业银行账号,15-19 位数字,可输入空格分隔',
example: '4111111111111111'
});
const UnifiedCreditCodeSchema = EnterpriseAuthRequiredStringSchema.transform((code) =>
normalizeUnifiedCreditCode(code)
)
.pipe(z.string().refine(isUnifiedCreditCode))
.meta({
description:
'统一社会信用代码,18 位大写数字或字母(不包含 I/O/S/V/Z),需通过 GB 32100-2015 校验码校验',
example: '91310000MA1K000006'
});
const VerifyEnterpriseAuthAmountCentSchema = z.number().int().positive();
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<typeof StartEnterpriseAuthBodySchema>;
/* ============================================================================
* API: 发起企业认证
* Route: POST /api/proApi/support/user/team/enterpriseAuth/start
* Method: POST
* Description: 创建小额汇款认证任务,打款成功后返回待金额验证任务
* Tags: ['团队管理']
* ============================================================================ */
export const StartEnterpriseAuthResponseSchema = z.object({
status: TeamEnterpriseAuthStatusSchema.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<typeof StartEnterpriseAuthResponseSchema>;
export const VerifyEnterpriseAuthAmountBodySchema = z.object({
taskId: z.string().min(1).meta({ description: '认证任务 ID' }),
amountCent: VerifyEnterpriseAuthAmountCentSchema.meta({
description: '用户填写的到账金额,单位为分',
example: 123
})
});
export type VerifyEnterpriseAuthAmountBodyType = z.infer<
typeof VerifyEnterpriseAuthAmountBodySchema
>;
/* ============================================================================
* API: 验证企业认证打款金额
* Route: POST /api/proApi/support/user/team/enterpriseAuth/verifyAmount
* Method: POST
* Description: 校验到账金额并在成功后发放企业认证赠送权益
* Tags: ['团队管理']
* ============================================================================ */
export const VerifyEnterpriseAuthAmountResponseSchema = z.object({
status: TeamEnterpriseAuthStatusSchema.meta({ description: '团队认证状态' }),
verifiedEnterpriseName: z.string().optional().meta({ description: '认证通过企业名称' }),
amountMaxErrorTimes: z.literal(EnterpriseAuthAmountMaxErrorTimes).meta({
description: '金额最大错误次数'
})
});
export type VerifyEnterpriseAuthAmountResponseType = z.infer<
typeof VerifyEnterpriseAuthAmountResponseSchema
>;
/* ============================================================================
* API: 重置企业认证待金额验证任务
* Route: POST /api/proApi/support/user/team/enterpriseAuth/reset
* Method: POST
* Description: 用户确认信息有误后取消当前待金额验证任务
* Tags: ['团队管理']
* ============================================================================ */
export const ResetEnterpriseAuthResponseSchema = z.undefined().meta({ description: '操作成功' });
export type ResetEnterpriseAuthResponseType = z.infer<typeof ResetEnterpriseAuthResponseSchema>;
import type { OpenAPIPath } from '../../../../type';
import { DevApiTagsMap } from '../../../../tag';
import {
GetEnterpriseAuthBanksResponseSchema,
GetEnterpriseAuthCurrentTaskDetailResponseSchema,
GetEnterpriseAuthStatusResponseSchema,
ResetEnterpriseAuthResponseSchema,
StartEnterpriseAuthBodySchema,
StartEnterpriseAuthResponseSchema,
VerifyEnterpriseAuthAmountBodySchema,
VerifyEnterpriseAuthAmountResponseSchema
} from './api';
export const EnterpriseAuthPath: OpenAPIPath = {
'/proApi/support/user/team/enterpriseAuth/status': {
get: {
summary: '获取企业认证状态',
tags: [DevApiTagsMap.teamManage],
responses: {
200: {
description: '企业认证状态',
content: {
'application/json': {
schema: GetEnterpriseAuthStatusResponseSchema
}
}
}
}
}
},
'/proApi/support/user/team/enterpriseAuth/currentTaskDetail': {
get: {
summary: '获取当前企业认证任务详情',
tags: [DevApiTagsMap.teamManage],
responses: {
200: {
description: '当前待金额验证任务详情',
content: {
'application/json': {
schema: GetEnterpriseAuthCurrentTaskDetailResponseSchema
}
}
}
}
}
},
'/proApi/support/user/team/enterpriseAuth/banks': {
get: {
summary: '获取企业认证银行列表',
tags: [DevApiTagsMap.teamManage],
responses: {
200: {
description: '银行编码到总行名称映射',
content: {
'application/json': {
schema: GetEnterpriseAuthBanksResponseSchema
}
}
}
}
}
},
'/proApi/support/user/team/enterpriseAuth/start': {
post: {
summary: '发起企业认证',
tags: [DevApiTagsMap.teamManage],
requestBody: {
content: {
'application/json': {
schema: StartEnterpriseAuthBodySchema
}
}
},
responses: {
200: {
description: '企业认证任务状态',
content: {
'application/json': {
schema: StartEnterpriseAuthResponseSchema
}
}
}
}
}
},
'/proApi/support/user/team/enterpriseAuth/verifyAmount': {
post: {
summary: '验证企业认证打款金额',
tags: [DevApiTagsMap.teamManage],
requestBody: {
content: {
'application/json': {
schema: VerifyEnterpriseAuthAmountBodySchema
}
}
},
responses: {
200: {
description: '金额验证结果',
content: {
'application/json': {
schema: VerifyEnterpriseAuthAmountResponseSchema
}
}
}
}
}
},
'/proApi/support/user/team/enterpriseAuth/reset': {
post: {
summary: '取消当前企业认证任务并重新填写',
tags: [DevApiTagsMap.teamManage],
responses: {
200: {
description: '取消成功',
content: {
'application/json': {
schema: ResetEnterpriseAuthResponseSchema
}
}
}
}
}
}
};
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: '更新团队信息',
......
......@@ -91,6 +91,9 @@ export enum AuditEventEnum {
EXPORT_BILL_RECORDS = 'EXPORT_BILL_RECORDS',
CREATE_INVOICE = 'CREATE_INVOICE',
SET_INVOICE_HEADER = 'SET_INVOICE_HEADER',
START_ENTERPRISE_AUTH = 'START_ENTERPRISE_AUTH',
VERIFY_ENTERPRISE_AUTH_AMOUNT = 'VERIFY_ENTERPRISE_AUTH_AMOUNT',
RESET_ENTERPRISE_AUTH_TASK = 'RESET_ENTERPRISE_AUTH_TASK',
CREATE_API_KEY = 'CREATE_API_KEY',
UPDATE_API_KEY = 'UPDATE_API_KEY',
COPY_API_KEY = 'COPY_API_KEY',
......
import z from 'zod';
export const EnterpriseAuthMaxTimes = 3;
export const EnterpriseAuthTrialDays = 15;
export const EnterpriseAuthTaskExpireHours = 24;
export const EnterpriseAuthAmountMaxErrorTimes = 3;
export const TeamEnterpriseAuthStatusSchema = z.enum([
'unverified',
'verifying',
'verified',
'failed'
]);
export const TeamEnterpriseAuthStatusEnum = TeamEnterpriseAuthStatusSchema.enum;
export type TeamEnterpriseAuthStatusEnum = z.infer<typeof TeamEnterpriseAuthStatusSchema>;
export const TeamEnterpriseAuthTaskStatusSchema = z.enum([
'starting',
'info_failed',
'pending_amount',
'amount_failed',
/**
* 事务内临时态:仅用于金额验证成功后防止并发重复发放权益。
* 不允许作为长期业务状态存在,也不参与 pending 任务恢复或统一社会信用代码锁。
*/
'granting',
'canceled',
'expired',
'failed',
'verified',
'service_failed'
]);
export const TeamEnterpriseAuthTaskStatusEnum = TeamEnterpriseAuthTaskStatusSchema.enum;
export type TeamEnterpriseAuthTaskStatusEnum = z.infer<typeof TeamEnterpriseAuthTaskStatusSchema>;
export const EnterpriseAuthPendingTaskStatuses = [
TeamEnterpriseAuthTaskStatusEnum.starting,
TeamEnterpriseAuthTaskStatusEnum.pending_amount,
TeamEnterpriseAuthTaskStatusEnum.amount_failed
] as const;
const EnterpriseAuthErrValueSchema = z.enum([
'enterpriseAuthDisabled',
'enterpriseAuthServiceNotConfigured',
'enterpriseAuthNoRemainingTimes',
'enterpriseAuthAlreadyVerified',
'enterpriseAuthEnterpriseOccupied',
'enterpriseAuthTooFrequent',
'enterpriseAuthServiceError',
'enterpriseAuthServiceTimeout',
'enterpriseAuthInfoFailed',
'enterpriseAuthTaskNotFound',
'enterpriseAuthTaskExpired',
'enterpriseAuthAmountError',
'enterpriseAuthAmountFailed',
'enterpriseAuthProcessing'
]);
export const EnterpriseAuthErrEnum = {
disabled: EnterpriseAuthErrValueSchema.enum.enterpriseAuthDisabled,
serviceNotConfigured: EnterpriseAuthErrValueSchema.enum.enterpriseAuthServiceNotConfigured,
noRemainingTimes: EnterpriseAuthErrValueSchema.enum.enterpriseAuthNoRemainingTimes,
alreadyVerified: EnterpriseAuthErrValueSchema.enum.enterpriseAuthAlreadyVerified,
enterpriseOccupied: EnterpriseAuthErrValueSchema.enum.enterpriseAuthEnterpriseOccupied,
tooFrequent: EnterpriseAuthErrValueSchema.enum.enterpriseAuthTooFrequent,
serviceError: EnterpriseAuthErrValueSchema.enum.enterpriseAuthServiceError,
serviceTimeout: EnterpriseAuthErrValueSchema.enum.enterpriseAuthServiceTimeout,
infoFailed: EnterpriseAuthErrValueSchema.enum.enterpriseAuthInfoFailed,
taskNotFound: EnterpriseAuthErrValueSchema.enum.enterpriseAuthTaskNotFound,
taskExpired: EnterpriseAuthErrValueSchema.enum.enterpriseAuthTaskExpired,
amountError: EnterpriseAuthErrValueSchema.enum.enterpriseAuthAmountError,
amountFailed: EnterpriseAuthErrValueSchema.enum.enterpriseAuthAmountFailed,
processing: EnterpriseAuthErrValueSchema.enum.enterpriseAuthProcessing
} as const;
export const EnterpriseAuthErrSchema = z.enum(EnterpriseAuthErrEnum);
export type EnterpriseAuthErrEnum = z.infer<typeof EnterpriseAuthErrSchema>;
import z from 'zod';
export const EnterpriseAuthInfoSchema = z.object({
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()
});
export type EnterpriseAuthInfoType = z.infer<typeof EnterpriseAuthInfoSchema>;
const UnifiedCreditCodeChars = '0123456789ABCDEFGHJKLMNPQRTUWXY';
const UnifiedCreditCodeWeights = [1, 3, 9, 27, 19, 26, 16, 17, 20, 29, 25, 13, 8, 24, 10, 30, 28];
const UnifiedCreditCodePattern = /^[0-9A-HJ-NP-RTUWXY]{18}$/;
const BankAccountPattern = /^\d{15,19}$/;
export const normalizeUnifiedCreditCode = (code: string) => code.trim().toUpperCase();
export const normalizeBankAccount = (account: string) => account.replace(/\s+/g, '');
/**
* 按 GB 32100-2015 校验统一社会信用代码第 18 位校验码。
*/
export const isUnifiedCreditCode = (code: string) => {
const normalizedCode = normalizeUnifiedCreditCode(code);
if (!UnifiedCreditCodePattern.test(normalizedCode)) return false;
const sum = UnifiedCreditCodeWeights.reduce((total, weight, index) => {
const value = UnifiedCreditCodeChars.indexOf(normalizedCode[index]);
return total + value * weight;
}, 0);
const checkValue = (() => {
const value = 31 - (sum % 31);
if (value === 31) return 0;
return value;
})();
return normalizedCode[17] === UnifiedCreditCodeChars[checkValue];
};
/**
* 校验企业银行账号:去除空格后需为 15-19 位数字。
*/
export const isBankAccount = (account: string) => {
const normalizedAccount = normalizeBankAccount(account);
return BankAccountPattern.test(normalizedAccount);
};
......@@ -4,7 +4,8 @@ export enum BillTypeEnum {
balance = 'balance',
standSubPlan = 'standSubPlan',
extraDatasetSub = 'extraDatasetSub',
extraPoints = 'extraPoints'
extraPoints = 'extraPoints',
activityGift = 'activityGift'
}
export const billTypeMap = {
[BillTypeEnum.balance]: {
......@@ -18,6 +19,9 @@ export const billTypeMap = {
},
[BillTypeEnum.extraPoints]: {
label: i18nT('common:support.wallet.subscription.type.extraPoints')
},
[BillTypeEnum.activityGift]: {
label: i18nT('common:support.wallet.bill.type.activityGift')
}
};
......@@ -48,6 +52,7 @@ export enum BillPayWayEnum {
alipay = 'alipay',
bank = 'bank',
coupon = 'coupon',
enterpriseAuth = 'enterpriseAuth',
wecom = 'wecom'
}
......@@ -67,6 +72,9 @@ export const billPayWayMap = {
[BillPayWayEnum.coupon]: {
label: i18nT('account_bill:payway_coupon')
},
[BillPayWayEnum.enterpriseAuth]: {
label: i18nT('common:support.wallet.bill.payWay.enterpriseAuth')
},
[BillPayWayEnum.wecom]: {
label: i18nT('common:support.wallet.bill.payWay.wecom')
}
......
......@@ -26,7 +26,12 @@ export const BillSchema = z.object({
standSubLevel: z.enum(StandardSubLevelEnum).optional().meta({ description: '订阅等级' }),
month: z.number().optional().meta({ description: '月数' }),
datasetSize: z.number().optional().meta({ description: '数据集大小' }),
extraPoints: z.number().optional().meta({ description: '额外积分' })
extraPoints: z.number().optional().meta({ description: '额外积分' }),
activitySource: z.literal('enterpriseAuth').optional().meta({ description: '活动赠送来源' }),
taskId: z.string().optional().meta({ description: '企业认证任务 ID' }),
durationDay: z.number().optional().meta({ description: '权益发放天数' }),
totalPoints: z.number().optional().meta({ description: '权益发放积分数' }),
grantedPlanCount: z.number().optional().meta({ description: '权益发放套餐数' })
})
.meta({ description: '元数据' }),
refundData: z
......
import { describe, expect, it } from 'vitest';
import {
StartEnterpriseAuthBodySchema,
VerifyEnterpriseAuthAmountBodySchema
} from '../../../../../../openapi/support/user/team/enterpriseAuth/api';
describe('VerifyEnterpriseAuthAmountBodySchema', () => {
it('只接受严格正整数金额,避免无效金额消耗验证次数', () => {
expect(
VerifyEnterpriseAuthAmountBodySchema.parse({
taskId: 'task-1',
amountCent: 123
})
).toEqual({
taskId: 'task-1',
amountCent: 123
});
[0, -1, 1.5, '', '123', null, undefined, false].forEach((amountCent) => {
expect(
VerifyEnterpriseAuthAmountBodySchema.safeParse({
taskId: 'task-1',
amountCent
}).success
).toBe(false);
});
});
});
describe('StartEnterpriseAuthBodySchema', () => {
const validBody = {
enterpriseName: '示例科技有限公司',
unifiedCreditCode: '91310000MA1K000006',
legalPersonName: '张三',
bankAccount: '4111 1111 1111 1111',
bankName: '中国工商银行',
contactName: '李四',
contactTitle: '产品负责人',
contactPhone: '13800000000',
demand: '企业知识库试用'
};
it('按 GB 32100-2015 校验统一社会信用代码,并规范化大小写', () => {
expect(
StartEnterpriseAuthBodySchema.parse({
...validBody,
unifiedCreditCode: '91310000ma1k000006'
}).unifiedCreditCode
).toBe('91310000MA1K000006');
expect(
StartEnterpriseAuthBodySchema.safeParse({
...validBody,
unifiedCreditCode: '91310000MA1K000000'
}).success
).toBe(false);
});
it('银行账号需为 15-19 位数字,并在入参解析时去除空格', () => {
expect(StartEnterpriseAuthBodySchema.parse(validBody).bankAccount).toBe('4111111111111111');
expect(
StartEnterpriseAuthBodySchema.parse({
...validBody,
bankAccount: '571919104910201'
}).bankAccount
).toBe('571919104910201');
['4111-1111-1111-1111', '1'.repeat(14), '1'.repeat(20)].forEach((bankAccount) => {
expect(
StartEnterpriseAuthBodySchema.safeParse({
...validBody,
bankAccount
}).success
).toBe(false);
});
});
});
......@@ -9,6 +9,11 @@ import { type ShortUrlParams } from '@fastgpt/global/support/marketing/type';
import { getRedisCache, setRedisCache } from '../../redis/cache';
import { differenceInDays } from 'date-fns';
import { getLogger, LogCategories } from '../../logger';
import type {
TeamEnterpriseAuthStatusEnum,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import type { StandardSubLevelEnum } from '@fastgpt/global/support/wallet/sub/constants';
const logger = getLogger(LogCategories.EVENT.TRACK);
......@@ -160,6 +165,37 @@ export const pushTrack = {
}
});
},
enterpriseAuthStart: (
data: PushTrackCommonType & {
result: 'success' | 'failed';
status?: `${TeamEnterpriseAuthStatusEnum}`;
taskStatus?: `${TeamEnterpriseAuthTaskStatusEnum}`;
errorCode?: string;
usedTimes?: number;
hasCurrentTask?: boolean;
}
) => {
return createTrack({
event: TrackEnum.enterpriseAuthStart,
data
});
},
enterpriseAuthBenefitGrant: (
data: PushTrackCommonType & {
status?: `${TeamEnterpriseAuthStatusEnum}`;
taskId?: string;
billId?: string;
standSubLevel: `${StandardSubLevelEnum}`;
durationDay: number;
totalPoints: number;
grantedPlanCount: number;
}
) => {
return createTrack({
event: TrackEnum.enterpriseAuthBenefitGrant,
data
});
},
// Admin cron job tracks
subscriptionDeleted: (data: {
......
......@@ -16,6 +16,7 @@ export enum TimerIdEnum {
datasetSyncSchedulerReconcile = 'datasetSyncSchedulerReconcile',
archiveInactiveSandboxes = 'archiveInactiveSandboxes',
clearStaleArchivingSandboxes = 'clearStaleArchivingSandboxes',
enterpriseAuthTaskCleanup = 'enterpriseAuthTaskCleanup',
/** 纠正长时间卡在 generating 的会话状态 */
cleanStaleGeneratingChat = 'cleanStaleGeneratingChat'
}
......
......@@ -100,6 +100,7 @@ const MySelect = <T = any,>(
const MenuListRef = useRef<HTMLDivElement>(null);
const SelectedItemRef = useRef<HTMLDivElement>(null);
const SearchInputRef = useRef<HTMLInputElement>(null);
const ignoreNextSearchSpaceClickRef = useRef(false);
const { isOpen, onOpen: defaultOnOpen, onClose: defaultOnClose } = useDisclosure();
const selectItem = useMemo(() => list.find((item) => item.value === value), [list, value]);
......@@ -115,6 +116,47 @@ const MySelect = <T = any,>(
};
const [search, setSearch] = useState('');
const isComposingSearch = (e: React.KeyboardEvent<HTMLInputElement>) =>
e.nativeEvent.isComposing || e.keyCode === 229;
const handleSearchSpaceKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isSearch || !isOpen || (e.key !== ' ' && e.code !== 'Space')) return;
e.stopPropagation();
ignoreNextSearchSpaceClickRef.current = true;
if (isComposingSearch(e)) {
return;
}
e.preventDefault();
const input = e.currentTarget;
const selectionStart = input.selectionStart ?? search.length;
const selectionEnd = input.selectionEnd ?? selectionStart;
const nextSearch = `${search.slice(0, selectionStart)} ${search.slice(selectionEnd)}`;
setSearch(nextSearch);
window.requestAnimationFrame(() => {
const nextPosition = selectionStart + 1;
input.setSelectionRange(nextPosition, nextPosition);
ignoreNextSearchSpaceClickRef.current = false;
});
};
const handleSearchSpaceKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isSearch || !isOpen || (e.key !== ' ' && e.code !== 'Space')) return;
e.stopPropagation();
ignoreNextSearchSpaceClickRef.current = true;
if (!isComposingSearch(e)) {
e.preventDefault();
}
window.setTimeout(() => {
ignoreNextSearchSpaceClickRef.current = false;
}, 0);
};
const filterList = useMemo(() => {
if (!isSearch || !search) {
return list;
......@@ -140,6 +182,7 @@ const MySelect = <T = any,>(
menu.scrollTop = selectedItem.offsetTop - menu.offsetTop - 100;
if (isSearch) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSearch('');
}
}
......@@ -242,6 +285,14 @@ const MySelect = <T = any,>(
opacity={isDisabled ? 0.4 : 1}
_hover={isInvalid ? { borderColor: 'red.400' } : { borderColor: 'primary.300' }}
{...props}
onClickCapture={(e) => {
props.onClickCapture?.(e);
if (e.isPropagationStopped() || !ignoreNextSearchSpaceClickRef.current) return;
ignoreNextSearchSpaceClickRef.current = false;
e.preventDefault();
e.stopPropagation();
}}
>
<Flex alignItems={'center'} justifyContent="space-between" w="100%" minW={0}>
<Flex alignItems={'center'} flex={'1 1 0'} minW={0} overflow={'hidden'}>
......@@ -267,6 +318,8 @@ const MySelect = <T = any,>(
size={'sm'}
w={'100%'}
color={'myGray.700'}
onKeyDown={handleSearchSpaceKeyDown}
onKeyUp={handleSearchSpaceKeyUp}
onBlur={() => {
setTimeout(() => {
SearchInputRef?.current?.focus();
......@@ -306,10 +359,12 @@ const MySelect = <T = any,>(
ref={MenuListRef}
className={props.className}
minW={(() => {
/* eslint-disable react-hooks/refs */
const w = ButtonRef.current?.clientWidth;
if (w) {
return `${w}px !important`;
}
/* eslint-enable react-hooks/refs */
return Array.isArray(width)
? width.map((item) => `${item} !important`)
: `${width} !important`;
......
......@@ -43,6 +43,9 @@
"copy_api_key": "Copy API key",
"copy_link": "Copy link",
"create_api_key": "Create API key",
"start_enterprise_auth": "Start enterprise verification",
"verify_enterprise_auth_amount": "Verify enterprise amount",
"reset_enterprise_auth_task": "Refill enterprise verification info",
"create_app": "Create an application",
"create_app_copy": "Create a copy of the application",
"create_app_folder": "Create an application folder",
......@@ -149,6 +152,9 @@
"log_change_password": "【{{name}}】The password change operation was performed",
"log_copy_api_key": "【{{name}}】Copied the API key named [{{keyName}}]",
"log_create_api_key": "【{{name}}】Create an API key named [{{keyName}}]",
"log_start_enterprise_auth": "【{{name}}】Started verification for enterprise 【{{enterpriseName}}】",
"log_verify_enterprise_auth_amount": "【{{name}}】Verified the transfer amount for enterprise 【{{enterpriseName}}】",
"log_reset_enterprise_auth_task": "【{{name}}】Refilled verification info for enterprise 【{{enterpriseName}}】",
"log_create_app": "【{{name}}】Created [{{appType}}] named [{{appName}}]",
"log_create_app_copy": "【{{name}}] Created a copy of [{{appType}}] named [{{appName}}]",
"log_create_app_folder": "【{{name}}】Create a folder named [{{folderName}}]",
......@@ -295,5 +301,55 @@
"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": "Your team's verification attempts have been used up. 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": "Complete enterprise verification with a small transfer. After verification succeeds, 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_invalid_format_tip": "Enter valid information",
"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. The verification amount will be sent by Shanghai UnionPay, is random, usually arrives in real time, and is valid for 24 hours. If it has not arrived, please ",
"enterprise_auth_contact_business": "contact sales",
"enterprise_auth_amount_sent_suffix": ".",
"enterprise_auth_amount_label": "Verification amount ¥",
"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_verify_with_remaining": "Verify ({{count}} attempts left)",
"move_skill": "Move skill"
}
......@@ -189,6 +189,35 @@
"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.",
"enterprise_auth_notice_title": "Enterprise verification benefits",
"enterprise_auth_notice_headline": "Verify now to claim an Advanced plan",
"enterprise_auth_notice_greeting": "Dear user,",
"enterprise_auth_notice_intro": "To support enterprise growth, FastGPT is now offering enterprise verification benefits.",
"enterprise_auth_notice_benefit_intro": "After successful verification, your account will be automatically upgraded with Advanced benefits worth RMB 450:",
"enterprise_auth_notice_benefit_advanced": "Unlock a 15-day Advanced plan with more premium capabilities",
"enterprise_auth_notice_benefit_points": "AI points included with the Advanced plan",
"enterprise_auth_notice_benefit_support": "Dedicated enterprise-level 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_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",
"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",
......@@ -1030,12 +1059,14 @@
"support.wallet.bill.payWay.alipay": "Alipay Payment",
"support.wallet.bill.payWay.balance": "Balance Payment",
"support.wallet.bill.payWay.bank": "Bank Transfer",
"support.wallet.bill.payWay.enterpriseAuth": "Enterprise verification grant",
"support.wallet.bill.payWay.wecom": "WeChat Work Payment",
"support.wallet.bill.payWay.wx": "WeChat Payment",
"support.wallet.bill.status.closed": "Closed",
"support.wallet.bill.status.notpay": "Unpaid",
"support.wallet.bill.status.refund": "Refunded",
"support.wallet.bill.status.success": "Payment Successful",
"support.wallet.bill.type.activityGift": "Activity Gift",
"support.wallet.buy_dataset_capacity": "Buy Knowledge Base Index",
"support.wallet.subscription.AI points usage": "AI Points Usage",
"support.wallet.subscription.Activity expiration time": "Activity ends on {{month}}/{{day}}/{{year}} at {{hour}}:{{minute}}",
......
......@@ -43,6 +43,9 @@
"copy_api_key": "复制api密钥",
"copy_link": "复制链接",
"create_api_key": "创建api密钥",
"start_enterprise_auth": "发起企业认证",
"verify_enterprise_auth_amount": "验证企业认证金额",
"reset_enterprise_auth_task": "重新填写企业认证信息",
"create_app": "创建应用",
"create_app_copy": "创建应用副本",
"create_app_folder": "创建应用文件夹",
......@@ -147,6 +150,9 @@
"log_change_password": "【{{name}}】进行了变更密码操作",
"log_copy_api_key": "【{{name}}】复制了名为【{{keyName}}】的api密钥",
"log_create_api_key": "【{{name}}】创建了名为【{{keyName}}】的api密钥",
"log_start_enterprise_auth": "【{{name}}】发起了企业【{{enterpriseName}}】认证",
"log_verify_enterprise_auth_amount": "【{{name}}】通过了企业【{{enterpriseName}}】认证金额验证",
"log_reset_enterprise_auth_task": "【{{name}}】重新填写企业【{{enterpriseName}}】认证信息",
"log_create_app": "【{{name}}】创建了名为【{{appName}}】的【{{appType}}】",
"log_create_app_copy": "【{{name}}】给名为【{{appName}}】的【{{appType}}】创建了一个副本",
"log_create_app_folder": "【{{name}}】创建了名为【{{folderName}}】的文件夹",
......@@ -295,5 +301,55 @@
"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_invalid_format_tip": "请填写正确的信息",
"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": "已向以上账号成功发起打款,验证金额将由上海银联打入,金额随机,一般实时到账,有效期24小时。若未到账,请",
"enterprise_auth_contact_business": "联系商务",
"enterprise_auth_amount_sent_suffix": "。",
"enterprise_auth_amount_label": "验证金额¥",
"enterprise_auth_amount_error_tip": "金额错误",
"enterprise_auth_reset_info": "重新填写企业信息",
"enterprise_auth_cancel": "取消",
"enterprise_auth_start": "开始认证",
"enterprise_auth_verify_with_remaining": "验证(剩余{{count}}次)",
"move_skill": "移动技能"
}
......@@ -189,6 +189,35 @@
"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": "认证处理中,请稍后重试",
"enterprise_auth_notice_title": "企业认证福利",
"enterprise_auth_notice_headline": "立即认证,领取高级套餐",
"enterprise_auth_notice_greeting": "尊敬的用户:",
"enterprise_auth_notice_intro": "为助力企业发展,FastGPT现推出企业认证福利",
"enterprise_auth_notice_benefit_intro": "认证成功后,您的账号将自动升级,享受价值450元的高级权益:",
"enterprise_auth_notice_benefit_advanced": "解锁15天高级版套餐,享受更多高级功能权限",
"enterprise_auth_notice_benefit_points": "高级套餐配套 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_help": "如有疑问,可随时联系客服协助处理。感谢您的支持!",
"enterprise_auth_notice_footer": "让专业工具,助力企业成长",
"enterprise_auth_notice_read": "已读",
"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": "文件夹数量已达上限~",
......@@ -1030,12 +1059,14 @@
"support.wallet.bill.payWay.alipay": "支付宝支付",
"support.wallet.bill.payWay.balance": "余额支付",
"support.wallet.bill.payWay.bank": "对公支付",
"support.wallet.bill.payWay.enterpriseAuth": "企业认证赠送",
"support.wallet.bill.payWay.wecom": "企业微信支付",
"support.wallet.bill.payWay.wx": "微信支付",
"support.wallet.bill.status.closed": "已关闭",
"support.wallet.bill.status.notpay": "未支付",
"support.wallet.bill.status.refund": "已退款",
"support.wallet.bill.status.success": "支付成功",
"support.wallet.bill.type.activityGift": "活动赠送",
"support.wallet.buy_dataset_capacity": "购买知识库索引量",
"support.wallet.subscription.AI points usage": "AI 积分使用量",
"support.wallet.subscription.Activity expiration time": "活动截至{{year}}年{{month}}月{{day}}日{{hour}}:{{minute}}",
......
......@@ -43,6 +43,9 @@
"copy_api_key": "複製api密鑰",
"copy_link": "複製連結",
"create_api_key": "創建api密鑰",
"start_enterprise_auth": "發起企業認證",
"verify_enterprise_auth_amount": "驗證企業認證金額",
"reset_enterprise_auth_task": "重新填寫企業認證資訊",
"create_app": "創建應用",
"create_app_copy": "創建應用副本",
"create_app_folder": "創建應用文件夾",
......@@ -147,6 +150,9 @@
"log_change_password": "【{{name}}】進行了變更密碼操作",
"log_copy_api_key": "【{{name}}】複製了名為【{{keyName}}】的api密鑰",
"log_create_api_key": "【{{name}}】創建了名為【{{keyName}}】的api密鑰",
"log_start_enterprise_auth": "【{{name}}】發起了企業【{{enterpriseName}}】認證",
"log_verify_enterprise_auth_amount": "【{{name}}】通過了企業【{{enterpriseName}}】認證金額驗證",
"log_reset_enterprise_auth_task": "【{{name}}】重新填寫企業【{{enterpriseName}}】認證資訊",
"log_create_app": "【{{name}}】創建了名為【{{appName}}】的【{{appType}}】",
"log_create_app_copy": "【{{name}}】給名為【{{appName}}】的【{{appType}}】創建了一個副本",
"log_create_app_folder": "【{{name}}】創建了名為【{{folderName}}】的文件夾",
......@@ -291,5 +297,55 @@
"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_invalid_format_tip": "請填寫正確的資訊",
"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": "已向以上帳號成功發起打款,驗證金額將由上海銀聯打入,金額隨機,一般即時到賬,有效期24小時。若未到賬,請",
"enterprise_auth_contact_business": "聯絡商務",
"enterprise_auth_amount_sent_suffix": "。",
"enterprise_auth_amount_label": "驗證金額¥",
"enterprise_auth_amount_error_tip": "金額錯誤",
"enterprise_auth_reset_info": "重新填寫企業資訊",
"enterprise_auth_cancel": "取消",
"enterprise_auth_start": "開始認證",
"enterprise_auth_verify_with_remaining": "驗證(剩餘{{count}}次)",
"move_skill": "移動技能"
}
......@@ -187,6 +187,35 @@
"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": "認證處理中,請稍後重試",
"enterprise_auth_notice_title": "企業認證福利",
"enterprise_auth_notice_headline": "立即認證,領取高級套餐",
"enterprise_auth_notice_greeting": "尊敬的使用者:",
"enterprise_auth_notice_intro": "為助力企業發展,FastGPT 現推出企業認證福利",
"enterprise_auth_notice_benefit_intro": "認證成功後,您的帳號將自動升級,享受價值450元的高級權益:",
"enterprise_auth_notice_benefit_advanced": "解鎖15天高級版套餐,享受更多高級功能權限",
"enterprise_auth_notice_benefit_points": "高級套餐配套 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_help": "如有疑問,可隨時聯絡客服協助處理。感謝您的支持!",
"enterprise_auth_notice_footer": "讓專業工具,助力企業成長",
"enterprise_auth_notice_read": "已讀",
"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": "已達資料夾數量上限",
......@@ -1019,12 +1048,14 @@
"support.wallet.bill.payWay.alipay": "支付寶支付",
"support.wallet.bill.payWay.balance": "餘額支付",
"support.wallet.bill.payWay.bank": "對公支付",
"support.wallet.bill.payWay.enterpriseAuth": "企業認證贈送",
"support.wallet.bill.payWay.wecom": "企業微信支付",
"support.wallet.bill.payWay.wx": "微信支付",
"support.wallet.bill.status.closed": "已關閉",
"support.wallet.bill.status.notpay": "未付款",
"support.wallet.bill.status.refund": "已退款",
"support.wallet.bill.status.success": "付款成功",
"support.wallet.bill.type.activityGift": "活動贈送",
"support.wallet.buy_dataset_capacity": "購買知識庫索引量",
"support.wallet.subscription.AI points usage": "AI 點數使用量",
"support.wallet.subscription.Activity expiration time": "活動截至{{year}}年{{month}}月{{day}}日{{hour}}:{{minute}}",
......
......@@ -496,6 +496,21 @@ export const auditLogMap = {
typeLabel: i18nT('account_team:set_invoice_header'),
params: {} as { name?: string }
},
[AuditEventEnum.START_ENTERPRISE_AUTH]: {
content: i18nT('account_team:log_start_enterprise_auth'),
typeLabel: i18nT('account_team:start_enterprise_auth'),
params: {} as { name?: string; enterpriseName: string }
},
[AuditEventEnum.VERIFY_ENTERPRISE_AUTH_AMOUNT]: {
content: i18nT('account_team:log_verify_enterprise_auth_amount'),
typeLabel: i18nT('account_team:verify_enterprise_auth_amount'),
params: {} as { name?: string; enterpriseName: string }
},
[AuditEventEnum.RESET_ENTERPRISE_AUTH_TASK]: {
content: i18nT('account_team:log_reset_enterprise_auth_task'),
typeLabel: i18nT('account_team:reset_enterprise_auth_task'),
params: {} as { name?: string; enterpriseName: string }
},
[AuditEventEnum.CREATE_API_KEY]: {
content: i18nT('account_team:log_create_api_key'),
typeLabel: i18nT('account_team:create_api_key'),
......
Subproject commit 2b3e11934332dcefae9ee6847f65a2e15d32095a
Subproject commit 32ff3fa6772fa67463ad64a080a568e620433e8f
......@@ -30,6 +30,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
});
......@@ -133,13 +139,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 +162,7 @@ const Layout = ({ children }: { children: JSX.Element }) => {
// Route watch
useEffect(() => {
setLastRoute(router.pathname);
}, [router.pathname]);
}, [router.pathname, setLastRoute]);
return (
<>
......@@ -206,6 +216,7 @@ const Layout = ({ children }: { children: JSX.Element }) => {
<HelperBot />
</>
)}
<EnterpriseAuthNoticeModal key={`${router.pathname}-${userInfo?.team?.teamId ?? ''}`} />
<ManualCopyModal />
<ActivityAdModal />
......
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Button, Flex, Link, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useUserStore } from '@/web/support/user/useUserStore';
import { useTranslation } from 'next-i18next';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useQuery } from '@tanstack/react-query';
import { getEnterpriseAuthStatus } from '@/web/support/user/team/enterpriseAuth/api';
import { TeamEnterpriseAuthStatusEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import { canManageEnterpriseAuth } from '@/pageComponents/account/team/EnterpriseAuth/utils';
const certificationHref = '/account/info#certification';
const NoteIcon = () => (
<Flex
alignItems="center"
justifyContent="center"
flexShrink={0}
mt="1px"
w="18px"
h="18px"
borderRadius="5px"
bg="blue.50"
color="blue.600"
>
<MyIcon name="common/info" w="13px" h="13px" />
</Flex>
);
const BenefitItem = ({ children }: { children: React.ReactNode }) => (
<Flex as="li" alignItems="flex-start" gap={2}>
<Flex
alignItems="center"
justifyContent="center"
flexShrink={0}
mt="2px"
w="16px"
h="16px"
borderRadius="4px"
bg="green.500"
color="white"
>
<MyIcon name="common/check" w="11px" h="11px" />
</Flex>
<Box>{children}</Box>
</Flex>
);
const EnterpriseAuthNoticeModal = () => {
const router = useRouter();
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { userInfo, enterpriseAuthNoticeReadTeamIds, setEnterpriseAuthNoticeRead } = useUserStore();
const [isClosed, setIsClosed] = useState(false);
const teamId = userInfo?.team?.teamId;
const canCheckEnterpriseAuthNotice = canManageEnterpriseAuth({
isTeamOwner: userInfo?.team?.permission?.isOwner,
hasTeamManagePer: userInfo?.team?.permission?.hasManagePer
});
const shouldCheckEnterpriseAuthNotice =
router.pathname === '/dashboard/agent' &&
!!feConfigs?.show_enterprise_auth &&
!!teamId &&
canCheckEnterpriseAuthNotice &&
!enterpriseAuthNoticeReadTeamIds?.includes(teamId);
const { data: enterpriseAuthStatus } = useQuery(
['getEnterpriseAuthNoticeStatus', teamId],
getEnterpriseAuthStatus,
{
enabled: shouldCheckEnterpriseAuthNotice,
staleTime: 30000
}
);
const canShowEnterpriseAuthNotice = canManageEnterpriseAuth({
statusCanManage: enterpriseAuthStatus?.canManage,
isTeamOwner: userInfo?.team?.permission?.isOwner,
hasTeamManagePer: userInfo?.team?.permission?.hasManagePer
});
const showEnterpriseAuthNotice = useMemo(
() =>
shouldCheckEnterpriseAuthNotice &&
canShowEnterpriseAuthNotice &&
enterpriseAuthStatus?.enabled !== false &&
!!enterpriseAuthStatus?.status &&
enterpriseAuthStatus.status !== TeamEnterpriseAuthStatusEnum.verified,
[
canShowEnterpriseAuthNotice,
enterpriseAuthStatus?.enabled,
enterpriseAuthStatus?.status,
shouldCheckEnterpriseAuthNotice
]
);
const markAsRead = useCallback(() => {
if (teamId) {
setEnterpriseAuthNoticeRead(teamId);
}
}, [setEnterpriseAuthNoticeRead, teamId]);
const onClickRead = useCallback(() => {
markAsRead();
setIsClosed(true);
}, [markAsRead]);
const onClickCertificationLink = useCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
markAsRead();
await router.push(certificationHref);
},
[markAsRead, router]
);
if (!showEnterpriseAuthNotice || isClosed) return null;
return (
<MyModal
isOpen
onClose={() => setIsClosed(true)}
isCentered
size={'md'}
title={t('common:enterprise_auth_notice_title')}
footer={<Button onClick={onClickRead}>{t('common:enterprise_auth_notice_read')}</Button>}
>
<Flex flexDirection={'column'} gap={6} overflow={'hidden'}>
<Box>
<Text fontSize="16px" fontWeight={600} lineHeight="24px" mb={4}>
{t('common:enterprise_auth_notice_headline')}
</Text>
<Text mb={2}>{t('common:enterprise_auth_notice_greeting')}</Text>
<Text mb={4}>{t('common:enterprise_auth_notice_intro')}</Text>
<Box as="section" mb={4}>
<Flex alignItems="flex-start" gap={2}>
<NoteIcon />
<Text>{t('common:enterprise_auth_notice_benefit_intro')}</Text>
</Flex>
<Flex as="ul" direction="column" gap={1} mt={1} pl={0} listStyleType="none">
<BenefitItem>{t('common:enterprise_auth_notice_benefit_advanced')}</BenefitItem>
<BenefitItem>{t('common:enterprise_auth_notice_benefit_points')}</BenefitItem>
<BenefitItem>{t('common:enterprise_auth_notice_benefit_support')}</BenefitItem>
</Flex>
</Box>
<Text mb={4}>
<Box as="span" fontWeight={600}>
{t('common:enterprise_auth_notice_entry')}
</Box>
{t('common:enterprise_auth_notice_or_click')}
<Link
href={certificationHref}
color="primary.600"
fontWeight={500}
textDecoration="underline"
onClick={onClickCertificationLink}
>
{t('common:enterprise_auth_notice_link')}
</Link>
{t('common:enterprise_auth_notice_link_suffix')}
</Text>
<Text mb={6}>{t('common:enterprise_auth_notice_help')}</Text>
<Box my={6} h="1px" bg="myGray.200" />
<Text>{t('common:enterprise_auth_notice_footer')}</Text>
</Box>
</Flex>
</MyModal>
);
};
export default React.memo(EnterpriseAuthNoticeModal);
......@@ -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();
......
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,
AmountYuanPattern,
formatBankAccountForDisplay,
formErrorTextStyles,
inputStyles,
type AmountFormType
} from './shared';
type EnterpriseAuthAmountFormProps = {
t: TFunction;
amountForm: UseFormReturn<AmountFormType>;
taskDetail?: GetEnterpriseAuthCurrentTaskDetailResponseType;
shouldShowAmountError: boolean;
setShowAmountError: (show: boolean) => void;
};
const EnterpriseAuthAmountForm = ({
t,
amountForm,
taskDetail,
shouldShowAmountError,
setShowAmountError
}: EnterpriseAuthAmountFormProps) => {
const amountField = amountForm.register('amountYuan', {
required: true,
pattern: AmountYuanPattern
});
return (
<Flex flexDirection={'column'} gap={6}>
<Flex flexDirection={'column'} gap={6}>
<Flex flexDirection={'column'} gap={4} w={'full'}>
<AmountInfoRow
label={t('account_team:enterprise_auth_enterprise_name')}
value={taskDetail?.enterpriseName}
/>
<AmountInfoRow
label={t('account_team:enterprise_auth_bank_account')}
value={formatBankAccountForDisplay(taskDetail?.bankAccount)}
/>
<AmountInfoRow
label={t('account_team:enterprise_auth_bank_name')}
value={taskDetail?.bankName}
/>
</Flex>
<Flex h={'4px'} alignItems={'center'}>
<Box h={'1px'} w={'full'} bg={'myGray.200'} />
</Flex>
<Box color={'myGray.500'}>
{t('account_team:enterprise_auth_amount_sent_prefix')}
<Box
as={'a'}
href={enterpriseAuthContactBusinessUrl}
target={'_blank'}
rel={'noreferrer'}
color={'primary.600'}
cursor={'pointer'}
_hover={{ textDecoration: 'none' }}
>
{t('account_team:enterprise_auth_contact_business')}
</Box>
{t('account_team:enterprise_auth_amount_sent_suffix')}
</Box>
<Flex alignItems={'flex-start'} gap={'16px'} w={'full'}>
<Flex
h={'32px'}
alignItems={'center'}
color={'myGray.800'}
fontSize={'sm'}
fontWeight={500}
lineHeight={'20px'}
letterSpacing={'0.1px'}
flexShrink={0}
w={'72px'}
>
{t('account_team:enterprise_auth_amount_label')}
</Flex>
<Flex flexDirection={'column'} gap={'10px'} flex={['1 1 0', '0 0 647px']} minW={0}>
<Input
inputMode={'decimal'}
pattern={'[0-9]+(\\.[0-9]{0,2})?'}
{...inputStyles}
{...amountField}
onChange={(event) => {
const [yuan = '', ...centParts] = event.target.value
.replace(/[^\d.]/g, '')
.split('.');
event.target.value = centParts.length
? `${yuan}.${centParts.join('').slice(0, 2)}`
: yuan;
void amountField.onChange(event);
setShowAmountError(false);
}}
/>
<Box
{...formErrorTextStyles}
minH={'14px'}
visibility={shouldShowAmountError ? 'visible' : 'hidden'}
>
{t('account_team:enterprise_auth_amount_error_tip')}
</Box>
</Flex>
</Flex>
</Flex>
</Flex>
);
};
export default React.memo(EnterpriseAuthAmountForm);
import React, { useCallback } from 'react';
import { Box, Button } from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
import MyModal from '@fastgpt/web/components/v2/common/MyModal';
import { enterpriseAuthContactBusinessUrl } from './utils';
import { enterpriseAuthFooterButtonStyles } from './shared';
type EnterpriseAuthContactBusinessModalProps = {
onClose: () => void;
};
const EnterpriseAuthContactBusinessModal = ({
onClose
}: EnterpriseAuthContactBusinessModalProps) => {
const { t } = useTranslation();
const openContactBusiness = useCallback(() => {
window.open(enterpriseAuthContactBusinessUrl, '_blank', 'noopener,noreferrer');
onClose();
}, [onClose]);
return (
<MyModal
isOpen
onClose={onClose}
isCentered
size={'sm'}
title={t('account_team:enterprise_auth_title')}
footer={
<>
<Button
variant={'whiteBase'}
w={'64px'}
onClick={onClose}
{...enterpriseAuthFooterButtonStyles}
>
{t('account_team:enterprise_auth_cancel')}
</Button>
<Button onClick={openContactBusiness} {...enterpriseAuthFooterButtonStyles}>
{t('account_team:enterprise_auth_contact_business')}
</Button>
</>
}
>
<Box>{t('account_team:enterprise_auth_no_remaining_times_tip')}</Box>
</MyModal>
);
};
export default React.memo(EnterpriseAuthContactBusinessModal);
import React from 'react';
import { Box, Button, Flex } from '@chakra-ui/react';
import MyModal from '@fastgpt/web/components/v2/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 './InfoForm';
import EnterpriseAuthAmountForm from './AmountForm';
import { useEnterpriseAuthFormFlow } from './useEnterpriseAuthFormFlow';
import { enterpriseAuthFooterButtonStyles } from './shared';
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;
const remainingAmountVerifyTimes = Math.max(
EnterpriseAuthAmountMaxErrorTimes -
(flow.taskDetail?.amountErrorTimes ?? defaultStatus.currentTask?.amountErrorTimes ?? 0),
0
);
const title = flow.t('account_team:enterprise_auth_title');
const modalTitle = (
<Flex flexDirection={'column'} gap={'10px'} w={'full'}>
<Box>{title}</Box>
<Box
color={'myGray.500'}
fontSize={'14px'}
fontWeight={400}
lineHeight={'20px'}
letterSpacing={'0.25px'}
>
{flow.t('account_team:enterprise_auth_modal_desc')}
</Box>
</Flex>
);
return (
<MyModal
isOpen
onClose={onClose}
isCentered
size={'lg'}
title={modalTitle}
footer={
flow.step === 'form' ? (
<>
<Button
variant={'whiteBase'}
w={'64px'}
onClick={onClose}
{...enterpriseAuthFooterButtonStyles}
>
{flow.t('account_team:enterprise_auth_cancel')}
</Button>
<Button
isLoading={flow.starting}
onClick={flow.handleStartClick}
{...enterpriseAuthFooterButtonStyles}
>
{flow.t('account_team:enterprise_auth_start')}
</Button>
</>
) : (
<>
<Button
variant={'whiteBase'}
mr={'auto'}
isLoading={flow.resetting}
onClick={flow.handleReset}
{...enterpriseAuthFooterButtonStyles}
>
{flow.t('account_team:enterprise_auth_reset_info')}
</Button>
<Button
isDisabled={
!flow.canSubmitAmount ||
!flow.hasLoadedTaskDetail ||
(flow.taskDetail?.amountErrorTimes ?? 0) >= EnterpriseAuthAmountMaxErrorTimes
}
isLoading={flow.verifying || flow.loadingTaskDetail}
onClick={flow.amountForm.handleSubmit(flow.handleVerify)}
{...enterpriseAuthFooterButtonStyles}
>
{flow.t('account_team:enterprise_auth_verify_with_remaining', {
count: remainingAmountVerifyTimes
})}
</Button>
</>
)
}
>
{flow.step === 'form' ? (
<EnterpriseAuthInfoForm
t={flow.t}
startForm={flow.startForm}
bankOptions={flow.bankOptions}
hasSubmittedStartForm={flow.hasSubmittedStartForm}
hasBankLoadError={flow.hasBankLoadError}
isBankLoading={flow.isBankLoading}
reloadBanks={flow.reloadBanks}
/>
) : (
<EnterpriseAuthAmountForm
t={flow.t}
amountForm={flow.amountForm}
taskDetail={flow.taskDetail}
shouldShowAmountError={flow.shouldShowAmountError}
setShowAmountError={flow.setShowAmountError}
/>
)}
</MyModal>
);
};
export default React.memo(EnterpriseAuthModal);
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import {
Box,
Button,
Flex,
Skeleton,
useDisclosure,
type BoxProps,
type ButtonProps
} from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import { useTranslation } from 'next-i18next';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import { useSystemStore } from '@/web/common/system/useSystemStore';
import { useUserStore } from '@/web/support/user/useUserStore';
import { getEnterpriseAuthStatus } from '@/web/support/user/team/enterpriseAuth/api';
import {
TeamEnterpriseAuthStatusEnum,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import {
canManageEnterpriseAuth,
canOpenEnterpriseAuthAmountStep,
shouldShowEnterpriseAuthContactBusinessModal
} from './utils';
const EnterpriseAuthModal = dynamic(() => import('./Modal'), { ssr: false });
const EnterpriseAuthContactBusinessModal = dynamic(() => import('./ContactBusinessModal'), {
ssr: false
});
type EnterpriseAuthStatusRowProps = BoxProps & {
labelStyles?: BoxProps;
buttonProps?: ButtonProps;
autoOpen?: boolean;
onAutoOpenFinish?: () => void;
};
const getStatusCopy = ({
t,
status,
taskStatus,
verifiedEnterpriseName,
hasTask
}: {
t: (key: string, options?: Record<string, any>) => string;
status?: `${TeamEnterpriseAuthStatusEnum}`;
taskStatus?: `${TeamEnterpriseAuthTaskStatusEnum}`;
verifiedEnterpriseName?: string;
hasTask?: boolean;
}) => {
if (status === TeamEnterpriseAuthStatusEnum.verified) {
return {
label: verifiedEnterpriseName || t('account_team:enterprise_auth_verified_label')
};
}
if (
taskStatus === TeamEnterpriseAuthTaskStatusEnum.starting ||
taskStatus === TeamEnterpriseAuthTaskStatusEnum.granting
) {
return {
label: t('account_team:enterprise_auth_processing_label')
};
}
if (hasTask || status === TeamEnterpriseAuthStatusEnum.verifying) {
return {
label: t('account_team:enterprise_auth_pending_amount_label'),
buttonText: t('account_team:enterprise_auth_continue_button')
};
}
return {
label: t('account_team:enterprise_auth_unverified_label'),
buttonText: t('account_team:enterprise_auth_button')
};
};
const EnterpriseAuthStatusRow = ({
labelStyles,
buttonProps,
autoOpen = false,
onAutoOpenFinish,
...props
}: EnterpriseAuthStatusRowProps) => {
const { t } = useTranslation();
const { feConfigs } = useSystemStore();
const { userInfo, initUserInfo } = useUserStore();
const { toast } = useToast();
const { isOpen, onOpen, onClose } = useDisclosure();
const {
isOpen: isOpenContactBusiness,
onOpen: onOpenContactBusiness,
onClose: onCloseContactBusiness
} = useDisclosure();
const autoOpenHandledRef = useRef(false);
const {
data,
loading,
error,
refresh: refreshStatus
} = useRequest(getEnterpriseAuthStatus, {
manual: false,
ready: !!feConfigs?.show_enterprise_auth && !!userInfo?.team?.teamId,
refreshDeps: [userInfo?.team?.teamId],
errorToast: ''
});
const statusCopy = useMemo(
() =>
getStatusCopy({
t,
status: data?.status,
taskStatus: data?.currentTask?.status,
verifiedEnterpriseName: data?.verifiedEnterpriseName,
hasTask: !!data?.currentTask
}),
[data?.currentTask, data?.status, data?.verifiedEnterpriseName, t]
);
const needContactBusiness = shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: data?.usedTimes,
hasCurrentTask: !!data?.currentTask
});
const canOpenCurrentTask = canOpenEnterpriseAuthAmountStep(data?.currentTask?.status);
const hasOtherMemberProcessingTask =
data?.status === TeamEnterpriseAuthStatusEnum.verifying && !data?.currentTask;
const canManageCurrentEnterpriseAuth = canManageEnterpriseAuth({
statusCanManage: data?.canManage,
isTeamOwner: userInfo?.team?.permission?.isOwner,
hasTeamManagePer: userInfo?.team?.permission?.hasManagePer
});
const handleOpen = useCallback(() => {
if (data?.status === TeamEnterpriseAuthStatusEnum.verified) return;
if (!canManageCurrentEnterpriseAuth) {
toast({
title: t('account_team:enterprise_auth_contact_admin_tip'),
status: 'warning'
});
return;
}
if (hasOtherMemberProcessingTask) {
toast({
title: t('common:enterprise_auth.error.processing'),
status: 'warning'
});
return;
}
if (needContactBusiness) {
onOpenContactBusiness();
return;
}
if (data?.currentTask && !canOpenCurrentTask) {
return;
}
onOpen();
}, [
canOpenCurrentTask,
canManageCurrentEnterpriseAuth,
data?.currentTask,
data?.status,
hasOtherMemberProcessingTask,
needContactBusiness,
onOpen,
onOpenContactBusiness,
t,
toast
]);
useEffect(() => {
if (!autoOpen || autoOpenHandledRef.current) return;
if (error) {
autoOpenHandledRef.current = true;
onAutoOpenFinish?.();
return;
}
if (!feConfigs?.show_enterprise_auth || data?.enabled === false) {
autoOpenHandledRef.current = true;
onAutoOpenFinish?.();
return;
}
if (loading || !data?.enabled) return;
autoOpenHandledRef.current = true;
if (data.status === TeamEnterpriseAuthStatusEnum.verified) {
onAutoOpenFinish?.();
return;
}
if (!canManageCurrentEnterpriseAuth) {
toast({
title: t('account_team:enterprise_auth_contact_admin_tip'),
status: 'warning'
});
onAutoOpenFinish?.();
return;
}
if (hasOtherMemberProcessingTask) {
toast({
title: t('common:enterprise_auth.error.processing'),
status: 'warning'
});
onAutoOpenFinish?.();
return;
}
if (needContactBusiness) {
onOpenContactBusiness();
onAutoOpenFinish?.();
return;
}
if (data.currentTask && !canOpenEnterpriseAuthAmountStep(data.currentTask.status)) {
onAutoOpenFinish?.();
return;
}
window.setTimeout(() => {
onOpen();
onAutoOpenFinish?.();
}, 0);
}, [
autoOpen,
canManageCurrentEnterpriseAuth,
data?.currentTask,
data?.enabled,
data?.status,
error,
feConfigs?.show_enterprise_auth,
hasOtherMemberProcessingTask,
loading,
needContactBusiness,
onAutoOpenFinish,
onOpen,
onOpenContactBusiness,
t,
toast
]);
useEffect(() => {
if (autoOpen) return;
autoOpenHandledRef.current = false;
}, [autoOpen]);
if (!feConfigs?.show_enterprise_auth || data?.enabled === false) return null;
if (loading && !data) {
return <Skeleton mt={4} h={'32px'} borderRadius={'8px'} {...props} />;
}
if (!data?.enabled) return null;
return (
<>
<Flex mt={4} alignItems={'center'} minW={0} {...props}>
<Box {...labelStyles}>{t('account_team:enterprise_auth_title')}&nbsp;</Box>
<Flex flex={'1 1 auto'} minW={0} align={'center'}>
<Box
flex={'1 1 auto'}
minW={0}
color={'#383F50'}
fontFamily={'"PingFang SC"'}
fontSize={'14px'}
fontWeight={400}
lineHeight={'20px'}
letterSpacing={'0.25px'}
noOfLines={1}
>
{statusCopy.label}
</Box>
{data.status !== TeamEnterpriseAuthStatusEnum.verified && statusCopy.buttonText && (
<Button
size={'sm'}
variant={'primary'}
flexShrink={0}
ml={3}
{...buttonProps}
onClick={handleOpen}
>
{statusCopy.buttonText}
</Button>
)}
</Flex>
</Flex>
{isOpen && (
<EnterpriseAuthModal
defaultStatus={data}
onClose={onClose}
onSuccess={() => {
refreshStatus();
initUserInfo();
}}
/>
)}
{isOpenContactBusiness && (
<EnterpriseAuthContactBusinessModal onClose={onCloseContactBusiness} />
)}
</>
);
};
export default React.memo(EnterpriseAuthStatusRow);
import React from 'react';
import { Box, Flex } from '@chakra-ui/react';
import {
isBankAccount,
isUnifiedCreditCode,
normalizeBankAccount,
normalizeUnifiedCreditCode
} from '@fastgpt/global/support/user/team/enterpriseAuth/utils';
export { normalizeBankAccount, normalizeUnifiedCreditCode };
export type AmountFormType = {
amountYuan: string;
};
export type EnterpriseAuthBankOption = {
label: string;
value: string;
};
export const EnterpriseAuthAmountMaxCent = 10000 * 100;
export const AmountYuanPattern = /^(?:0\.(?:0[1-9]|[1-9]\d?)|[1-9]\d*(?:\.\d{1,2})?)$/;
/**
* 将用户输入的元转换为服务端需要的分,避免 `0.28 * 100` 这类浮点计算误差。
*/
export const parseEnterpriseAuthAmountCent = (amountYuan: string) => {
const normalized = amountYuan.trim();
if (!AmountYuanPattern.test(normalized)) return;
const [yuan, cent = ''] = normalized.split('.');
const yuanNumber = Number(yuan);
const centNumber = Number(cent.padEnd(2, '0'));
if (!Number.isSafeInteger(yuanNumber) || !Number.isSafeInteger(centNumber)) return;
const amountCent = yuanNumber * 100 + centNumber;
if (!Number.isSafeInteger(amountCent) || amountCent > EnterpriseAuthAmountMaxCent) return;
return amountCent;
};
export const formatBankAccountForDisplay = (account?: string) => {
if (!account) return account;
return normalizeBankAccount(account).replace(/(.{4})(?=.)/g, '$1 ');
};
/**
* 将后端返回的“银行简称 -> 银行公司全称”映射转成下拉选项。
* 下拉只展示并提交银行简称;公司全称由后端在发起认证服务请求时转换。
*/
export const formatEnterpriseAuthBankOptions = (
banks: Record<string, string>
): EnterpriseAuthBankOption[] =>
Object.keys(banks).map((bankName) => ({
label: bankName,
value: bankName
}));
export const fieldRules = {
enterpriseName: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 100
},
unifiedCreditCode: {
required: true,
validate: isUnifiedCreditCode
},
legalPersonName: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 50
},
bankAccount: {
required: true,
validate: isBankAccount
},
bankName: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 80
},
contactName: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 50
},
contactTitle: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 50
},
contactPhone: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 30
},
demand: {
required: true,
validate: (value: string) => value.trim().length > 0 && value.trim().length <= 500
}
};
export const formLabelStyles = {
color: 'myGray.800',
fontSize: '10px',
fontWeight: 500,
lineHeight: '16px',
letterSpacing: '0.5px'
};
export const formErrorTextStyles = {
...formLabelStyles,
color: 'red.600'
};
export const fieldErrorTextStyles = {
...formErrorTextStyles,
fontSize: '10px',
lineHeight: '16px'
};
export const invalidInputStyles = {
borderColor: 'red.600'
};
export const inputStyles = {
size: 'sm' as const,
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0.048px'
};
export const textareaStyles = {
...inputStyles,
minH: '106px',
resize: 'vertical' as const
};
export const enterpriseAuthFooterButtonStyles = {
h: '32px',
minH: '32px',
px: '14px',
fontSize: '12px',
lineHeight: '16px',
letterSpacing: '0.5px'
};
export const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<Flex
flexDirection={['column', 'row']}
alignItems={'flex-start'}
gap={['12px', '32px']}
w={'full'}
>
<Box
color={'myGray.800'}
fontSize={'sm'}
fontWeight={500}
lineHeight={'20px'}
letterSpacing={'0.1px'}
w={['full', '160px']}
flexShrink={0}
>
{title}
</Box>
<Box flex={'1 1 0'} minW={0} w={'full'}>
{children}
</Box>
</Flex>
);
export const Field = ({
label,
errorText,
children,
colSpan = 1
}: {
label: string;
errorText?: string;
children: React.ReactNode;
colSpan?: number;
}) => (
<Box gridColumn={['span 1', `span ${colSpan}`]} minW={0}>
<Flex mb={'8px'} alignItems={'center'} justifyContent={'space-between'} gap={'8px'}>
<Box {...formLabelStyles}>{label}</Box>
{errorText && (
<Box {...fieldErrorTextStyles} flexShrink={0}>
{errorText}
</Box>
)}
</Flex>
{children}
</Box>
);
export const AmountInfoRow = ({ label, value }: { label: string; value?: string }) => (
<Flex alignItems={'center'} gap={'32px'} w={'full'} minW={0}>
<Box
color={'myGray.800'}
fontSize={'sm'}
fontWeight={500}
lineHeight={'20px'}
letterSpacing={'0.1px'}
flexShrink={0}
maxW={'160px'}
minW={'57px'}
>
{label}
</Box>
<Box
color={'myGray.600'}
fontSize={'sm'}
fontWeight={500}
lineHeight={'20px'}
letterSpacing={'0.1px'}
minW={0}
noOfLines={1}
>
{value || '-'}
</Box>
</Flex>
);
export const getErrorCode = (error: unknown) => {
if (typeof error === 'string') return error;
if (!error || typeof error !== 'object') return '';
const err = error as {
statusText?: string;
message?: string;
response?: {
data?: {
statusText?: string;
message?: string;
};
statusText?: string;
message?: string;
};
};
return (
err.response?.data?.statusText ||
err.statusText ||
err.response?.data?.message ||
err.response?.message ||
err.message ||
''
);
};
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'next-i18next';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { useRequest } from '@fastgpt/web/hooks/useRequest';
import { useToast } from '@fastgpt/web/hooks/useToast';
import type {
GetEnterpriseAuthStatusResponseType,
StartEnterpriseAuthBodyType
} from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api';
import { EnterpriseAuthErrEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import {
getEnterpriseAuthBanks,
getEnterpriseAuthCurrentTaskDetail,
resetEnterpriseAuthTask,
startEnterpriseAuth,
verifyEnterpriseAuthAmount
} from '@/web/support/user/team/enterpriseAuth/api';
import {
canOpenEnterpriseAuthAmountStep,
shouldShowEnterpriseAuthAmountError,
shouldShowEnterpriseAuthContactBusinessModal
} from './utils';
import {
formatEnterpriseAuthBankOptions,
getErrorCode,
parseEnterpriseAuthAmountCent,
type AmountFormType
} from './shared';
type UseEnterpriseAuthFormFlowProps = {
defaultStatus: GetEnterpriseAuthStatusResponseType;
onClose: () => void;
onSuccess: () => void;
};
export const useEnterpriseAuthFormFlow = ({
defaultStatus,
onClose,
onSuccess
}: UseEnterpriseAuthFormFlowProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const shouldBlockEnterpriseAuthForm = shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: defaultStatus.usedTimes,
hasCurrentTask: !!defaultStatus.currentTask
});
const canOpenInitialAmountStep = canOpenEnterpriseAuthAmountStep(
defaultStatus.currentTask?.status
);
const [step, setStep] = useState<'form' | 'amount'>(
defaultStatus.currentTask && canOpenInitialAmountStep ? 'amount' : 'form'
);
// 历史 amount_failed 只表示本任务曾填错金额;重新打开弹窗时不应继续展示上次的输入错误。
const [showAmountError, setShowAmountError] = useState(false);
const [hasSubmittedStartForm, setHasSubmittedStartForm] = useState(false);
const startForm = useForm<StartEnterpriseAuthBodyType>({
mode: 'onChange',
defaultValues: {
enterpriseName: '',
unifiedCreditCode: '',
legalPersonName: '',
bankAccount: '',
bankName: '',
contactName: '',
contactTitle: '',
contactPhone: '',
demand: ''
}
});
const amountForm = useForm<AmountFormType>({
mode: 'onChange',
defaultValues: {
amountYuan: ''
}
});
const {
data: banks = {},
loading: loadingBanks,
error: bankLoadError,
run: reloadBanks
} = useRequest(getEnterpriseAuthBanks, {
manual: false,
ready: step === 'form' && !shouldBlockEnterpriseAuthForm,
errorToast: t('account_team:enterprise_auth_bank_load_failed')
});
const {
data: taskDetail,
runAsync: loadTaskDetail,
loading: loadingTaskDetail
} = useRequest(getEnterpriseAuthCurrentTaskDetail, {
manual: !defaultStatus.currentTask,
// 首次提交成功后也需要手动拉取任务详情,不能被初始 currentTask 为空的 ready 状态拦截。
ready: true,
errorToast: t('account_team:enterprise_auth_task_load_failed')
});
const { runAsync: onStart, loading: starting } = useRequest(startEnterpriseAuth, {
errorToast: t('account_team:enterprise_auth_submit_failed')
});
const { runAsync: onVerify, loading: verifying } = useRequest(verifyEnterpriseAuthAmount, {
errorToast: ''
});
const { runAsync: onReset, loading: resetting } = useRequest(resetEnterpriseAuthTask, {
errorToast: t('account_team:enterprise_auth_operation_failed')
});
const bankOptions = useMemo(() => formatEnterpriseAuthBankOptions(banks), [banks]);
const hasBankLoadError = !!bankLoadError && !bankOptions.length;
const isBankLoading = loadingBanks;
const amountYuanValue = useWatch({ control: amountForm.control, name: 'amountYuan' });
const hasLoadedTaskDetail = !!taskDetail?.taskId;
const canSubmitAmount =
hasLoadedTaskDetail &&
parseEnterpriseAuthAmountCent(String(amountYuanValue ?? '')) !== undefined;
const shouldShowAmountError = shouldShowEnterpriseAuthAmountError({
taskStatus: taskDetail?.status,
showCurrentSubmitError: showAmountError
});
/**
* 认证次数耗尽且没有待验证任务时,认证表单本身也不应展示。
* 外层入口已做拦截,这里作为弹窗边界的兜底保护,避免旧状态或自动打开误触发。
*/
useEffect(() => {
if (!shouldBlockEnterpriseAuthForm) return;
onClose();
}, [onClose, shouldBlockEnterpriseAuthForm]);
useEffect(() => {
if (!defaultStatus.currentTask || canOpenInitialAmountStep) return;
onClose();
}, [canOpenInitialAmountStep, defaultStatus.currentTask, onClose]);
const handleStart = useCallback(
async (data: StartEnterpriseAuthBodyType) => {
const result = await onStart(data);
onSuccess();
if (canOpenEnterpriseAuthAmountStep(result.currentTask?.status)) {
await loadTaskDetail();
setStep('amount');
setShowAmountError(false);
return;
}
toast({
title: result.message || t('account_team:enterprise_auth_processing_label'),
status: 'info'
});
onClose();
},
[loadTaskDetail, onClose, onStart, onSuccess, t, toast]
);
/**
* 点击开始认证时一次性校验全部字段,并禁止 react-hook-form 自动聚焦首个错误项。
* 这样空值红框不会被“先聚焦企业信用代码/银行卡号”的流程卡住。
*/
const handleStartClick = useCallback(async () => {
setHasSubmittedStartForm(true);
const isValid = await startForm.trigger(undefined, { shouldFocus: false });
if (!isValid) return;
await handleStart(startForm.getValues());
}, [handleStart, startForm]);
const handleVerify = useCallback(
async ({ amountYuan }: AmountFormType) => {
if (!taskDetail?.taskId) {
toast({
title: t('account_team:enterprise_auth_task_load_failed'),
status: 'warning'
});
return;
}
const amountCent = parseEnterpriseAuthAmountCent(amountYuan);
if (amountCent === undefined) {
toast({
title: t('account_team:enterprise_auth_invalid_amount_tip'),
status: 'warning'
});
return;
}
try {
await onVerify({
taskId: taskDetail.taskId,
amountCent
});
toast({
title: t('account_team:enterprise_auth_success_grant_tip'),
status: 'success'
});
onSuccess();
onClose();
} catch (error) {
const errorCode = getErrorCode(error);
if (errorCode === EnterpriseAuthErrEnum.amountError) {
amountForm.reset({ amountYuan: '' });
setShowAmountError(true);
try {
await loadTaskDetail();
} catch {
onSuccess();
onClose();
}
return;
}
toast({
title: t(getErrText(error, t('account_team:enterprise_auth_verify_failed')) as any),
status: 'error'
});
onSuccess();
onClose();
}
},
[amountForm, loadTaskDetail, onClose, onSuccess, onVerify, t, taskDetail, toast]
);
const handleReset = useCallback(async () => {
await onReset();
if (taskDetail) {
startForm.reset({
enterpriseName: taskDetail.enterpriseName,
unifiedCreditCode: taskDetail.unifiedCreditCode,
legalPersonName: taskDetail.legalPersonName,
bankAccount: taskDetail.bankAccount,
bankName: taskDetail.bankName,
contactName: taskDetail.contactName,
contactTitle: taskDetail.contactTitle,
contactPhone: taskDetail.contactPhone,
demand: taskDetail.demand
});
}
amountForm.reset({ amountYuan: '' });
setShowAmountError(false);
setHasSubmittedStartForm(false);
onSuccess();
setStep('form');
}, [amountForm, onReset, onSuccess, startForm, taskDetail]);
return {
t,
step,
startForm,
amountForm,
bankOptions,
hasBankLoadError,
isBankLoading,
reloadBanks,
hasSubmittedStartForm,
taskDetail,
loadingTaskDetail,
starting,
verifying,
resetting,
hasLoadedTaskDetail,
canSubmitAmount,
shouldShowAmountError,
shouldBlockEnterpriseAuthForm,
setShowAmountError,
handleStart,
handleStartClick,
handleVerify,
handleReset
};
};
import {
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
export const enterpriseAuthContactBusinessUrl =
'https://fael3z0zfze.feishu.cn/share/base/form/shrcnjJWtKqjOI9NbQTzhNyzljc?prefill_S=C2&hide_S=1&from=navigation';
/**
* 判断金额输入框下方是否展示“金额错误”。
*
* amount_failed 是持久化任务状态,表示这次认证任务历史上曾填错金额;
* 只有本次弹窗内再次提交失败后,才把它转成输入框错误提示,避免退出重进仍显示旧错误。
*/
export const shouldShowEnterpriseAuthAmountError = ({
taskStatus,
showCurrentSubmitError
}: {
taskStatus?: `${TeamEnterpriseAuthTaskStatusEnum}`;
showCurrentSubmitError: boolean;
}) => taskStatus === TeamEnterpriseAuthTaskStatusEnum.amount_failed && showCurrentSubmitError;
/**
* 只有金额验证阶段能打开金额确认弹窗。
* starting/granting 仍属于未完成任务,但任务详情接口不会返回完整金额页数据。
*/
export const canOpenEnterpriseAuthAmountStep = (
taskStatus?: `${TeamEnterpriseAuthTaskStatusEnum}`
) =>
taskStatus === TeamEnterpriseAuthTaskStatusEnum.pending_amount ||
taskStatus === TeamEnterpriseAuthTaskStatusEnum.amount_failed;
/**
* 判断当前成员是否可以发起或继续企业认证。
*
* 团队 owner 和团队管理员才有企业认证操作入口;statusCanManage 来自服务端状态接口,
* 用于在服务端明确拒绝时兜底收紧权限,同时兼容旧接口未返回该字段的场景。
*/
export const canManageEnterpriseAuth = ({
statusCanManage,
isTeamOwner,
hasTeamManagePer
}: {
statusCanManage?: boolean;
isTeamOwner?: boolean;
hasTeamManagePer?: boolean;
}) => (!!isTeamOwner || !!hasTeamManagePer) && statusCanManage !== false;
/**
* 判断企业认证入口是否应该转为商务咨询弹窗。
*
* 第 3 次认证发起成功后 usedTimes 会达到上限,但此时会返回 currentTask,
* 用户仍需要继续填写打款金额;只有次数耗尽且没有可恢复任务时,才阻断认证表单。
*/
export const shouldShowEnterpriseAuthContactBusinessModal = ({
usedTimes,
hasCurrentTask
}: {
usedTimes?: number;
hasCurrentTask: boolean;
}) => usedTimes !== undefined && usedTimes >= EnterpriseAuthMaxTimes && !hasCurrentTask;
'use client';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Flex,
......@@ -63,6 +63,10 @@ const ConversionModal = dynamic(() => import('@/pageComponents/account/info/Conv
const UpdatePswModal = dynamic(() => import('@/pageComponents/account/info/UpdatePswModal'));
const UpdateContact = dynamic(() => import('@/components/support/user/inform/UpdateContactModal'));
const CommunityModal = dynamic(() => import('@/components/CommunityModal'));
const EnterpriseAuthStatusRow = dynamic(
() => import('@/pageComponents/account/team/EnterpriseAuth'),
{ ssr: false }
);
const ModelPriceModal = dynamic(() =>
import('@/components/core/ai/ModelTable').then((mod) => mod.ModelPriceModal)
......@@ -111,7 +115,7 @@ const Info = () => {
export async function getServerSideProps(content: any) {
return {
props: {
...(await serviceSideProps(content, ['account', 'account_info', 'user']))
...(await serviceSideProps(content, ['account', 'account_info', 'account_team', 'user']))
}
};
}
......@@ -120,7 +124,7 @@ export default React.memo(Info);
const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
const theme = useTheme();
const { feConfigs } = useSystemStore();
const { feConfigs, initd } = useSystemStore();
const { t } = useTranslation();
const { userInfo, updateUserInfo, teamPlanStatus, initUserInfo } = useUserStore();
const { reset } = useForm<UserUpdateParams>({
......@@ -129,6 +133,8 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
const standardPlan = teamPlanStatus?.standard;
const { isPc } = useSystem();
const { toast } = useToast();
const [autoOpenEnterpriseAuth, setAutoOpenEnterpriseAuth] = useState(false);
const showEnterpriseAuth = feConfigs?.show_enterprise_auth;
const {
isOpen: isOpenConversionModal,
......@@ -168,6 +174,46 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
},
[onClickSave, userInfo]
);
/**
* 清除 URL 中的 #certification hash,避免刷新页面或重新进入时重复触发企业认证弹窗
*/
const clearCertificationHash = useCallback(() => {
if (typeof window === 'undefined') return;
window.history.replaceState(null, '', window.location.pathname + window.location.search);
}, []);
/**
* 监听 URL hash 变化,当 hash 为 #certification 且系统初始化完成、开启企业认证功能时,
* 延迟设置 autoOpenEnterpriseAuth 为 true,以触发企业认证弹窗。
* 若未开启企业认证功能,则直接清除 hash。
*/
const triggerEnterpriseAuthFromHash = useCallback(() => {
if (typeof window === 'undefined') return;
if (window.location.hash !== '#certification') return;
if (!initd) return;
if (!showEnterpriseAuth) {
clearCertificationHash();
return;
}
// 使用 setTimeout 确保在 React 渲染周期后执行,避免状态更新冲突
window.setTimeout(() => {
setAutoOpenEnterpriseAuth(true);
}, 0);
}, [clearCertificationHash, initd, showEnterpriseAuth]);
useEffect(() => {
// 组件挂载时检查一次 hash
triggerEnterpriseAuthFromHash();
// 监听 hash 变化事件
window.addEventListener('hashchange', triggerEnterpriseAuthFromHash);
return () => {
window.removeEventListener('hashchange', triggerEnterpriseAuthFromHash);
};
}, [triggerEnterpriseAuthFromHash]);
const { Component: AvatarUploader, handleFileSelectorOpen } = useUploadAvatar(
getUploadAvatarPresignedUrl,
{
......@@ -196,6 +242,11 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
letterSpacing: '0.15px'
};
const actionButtonStyles = {
size: 'sm',
minW: '52px'
} as const;
const isSyncMember = feConfigs.register_method?.includes('sync');
return (
<Box>
......@@ -216,7 +267,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
<Flex mt={4} alignItems={'center'}>
<Box {...labelStyles}>{t('account_info:password')}&nbsp;</Box>
<Box flex={1}>*****</Box>
<Button size={'sm'} variant={'whitePrimary'} onClick={onOpenUpdatePsw}>
<Button {...actionButtonStyles} variant={'whitePrimary'} onClick={onOpenUpdatePsw}>
{t('account_info:change')}
</Button>
</Flex>
......@@ -228,7 +279,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
{userInfo?.contact ? userInfo?.contact : t('account_info:please_bind_contact')}
</Box>
<Button size={'sm'} variant={'whitePrimary'} onClick={onOpenUpdateContact}>
<Button {...actionButtonStyles} variant={'whitePrimary'} onClick={onOpenUpdateContact}>
{t('account_info:change')}
</Button>
</Flex>
......@@ -337,7 +388,12 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
</Box>
{userInfo?.permission.hasManagePer && !!standardPlan && (
<Button variant={'primary'} size={'sm'} ml={5} onClick={onOpenConversionModal}>
<Button
{...actionButtonStyles}
variant={'primary'}
ml={5}
onClick={onOpenConversionModal}
>
{t('account_info:exchange')}
</Button>
)}
......@@ -345,6 +401,16 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => {
</Box>
)}
<EnterpriseAuthStatusRow
labelStyles={labelStyles}
buttonProps={actionButtonStyles}
autoOpen={autoOpenEnterpriseAuth}
onAutoOpenFinish={() => {
clearCertificationHash();
setAutoOpenEnterpriseAuth(false);
}}
/>
<MyDivider my={6} />
</Box>
{isOpenConversionModal && (
......
......@@ -97,7 +97,7 @@ const Team = () => {
}}
/>
),
[planContent, router, t, teamTab, toast]
[planContent, router, t, teamTab, toast, userInfo?.team.permission.hasManagePer]
);
return (
......
import type { ApiRequestProps, ApiResponseType } from '@fastgpt/service/type/next';
import type { ApiRequestProps } from '@fastgpt/service/type/next';
import { NextAPI } from '@/service/middleware/entry';
import { TrackEnum } from '@fastgpt/global/common/middle/tracks/constants';
import { TrackModel } from '@fastgpt/service/common/middle/tracks/schema';
import { authCert } from '@fastgpt/service/support/permission/auth/common';
import { useIPFrequencyLimit } from '@fastgpt/service/common/middle/reqFrequencyLimit';
import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError';
import z from 'zod';
export type pushQuery = {};
const pushBodySchema = z.object({
event: z.enum(TrackEnum),
data: z.any()
});
export type pushResponse = {};
async function handler(req: ApiRequestProps, res: ApiResponseType<any>): Promise<pushResponse> {
if (!global.feConfigs?.isPlus) return {};
async function handler(req: ApiRequestProps): Promise<undefined> {
if (!global.feConfigs?.isPlus) return;
const body = pushBodySchema.parse(req.body);
const body = parseApiInput({ req, bodySchema: pushBodySchema }).body;
const { teamId, tmbId, userId } = await authCert({
req,
authToken: true
......@@ -32,7 +29,7 @@ async function handler(req: ApiRequestProps, res: ApiResponseType<any>): Promise
data: body.data
};
return TrackModel.create(data);
await TrackModel.create(data);
}
export default NextAPI(useIPFrequencyLimit({ id: 'push-tracks', seconds: 1, limit: 5 }), handler);
......@@ -73,6 +73,14 @@ export const useInitApp = () => {
const getPathWithoutMarketingParams = () => {
const filteredQuery = { ...router.query };
const hasMarketingParams = MARKETING_PARAMS.some((param) =>
Object.prototype.hasOwnProperty.call(filteredQuery, param)
);
if (!hasMarketingParams) {
return;
}
MARKETING_PARAMS.forEach((param) => {
delete filteredQuery[param];
});
......@@ -88,7 +96,9 @@ export const useInitApp = () => {
}
});
return `${router.pathname}${newQuery.toString() ? `?${newQuery.toString()}` : ''}`;
return `${router.pathname}${newQuery.toString() ? `?${newQuery.toString()}` : ''}${
window.location.hash
}`;
};
const initFetch = useMemoizedFn(async () => {
......@@ -167,7 +177,9 @@ export const useInitApp = () => {
}
const newPath = getPathWithoutMarketingParams();
router.replace(newPath);
if (newPath) {
router.replace(newPath);
}
});
return {
......
import { GET, POST } from '@/web/common/api/request';
import type {
GetEnterpriseAuthBanksResponseType,
GetEnterpriseAuthCurrentTaskDetailResponseType,
GetEnterpriseAuthStatusResponseType,
StartEnterpriseAuthBodyType,
StartEnterpriseAuthResponseType,
VerifyEnterpriseAuthAmountBodyType,
VerifyEnterpriseAuthAmountResponseType
} from '@fastgpt/global/openapi/support/user/team/enterpriseAuth/api';
const baseUrl = '/proApi/support/user/team/enterpriseAuth';
export const getEnterpriseAuthStatus = () =>
GET<GetEnterpriseAuthStatusResponseType>(`${baseUrl}/status`);
export const getEnterpriseAuthCurrentTaskDetail = () =>
GET<GetEnterpriseAuthCurrentTaskDetailResponseType>(`${baseUrl}/currentTaskDetail`);
export const getEnterpriseAuthBanks = () =>
GET<GetEnterpriseAuthBanksResponseType>(`${baseUrl}/banks`);
export const startEnterpriseAuth = (data: StartEnterpriseAuthBodyType) =>
POST<StartEnterpriseAuthResponseType>(`${baseUrl}/start`, data);
export const verifyEnterpriseAuthAmount = (data: VerifyEnterpriseAuthAmountBodyType) =>
POST<VerifyEnterpriseAuthAmountResponseType>(`${baseUrl}/verifyAmount`, data);
export const resetEnterpriseAuthTask = () => POST(`${baseUrl}/reset`);
......@@ -11,6 +11,8 @@ import { setCurrentAuthTmbId } from './currentAuthTmbId';
type State = {
systemMsgReadId: string;
setSysMsgReadId: (id: string) => void;
enterpriseAuthNoticeReadTeamIds: string[];
setEnterpriseAuthNoticeRead: (teamId: string) => void;
isUpdateNotification: boolean;
setIsUpdateNotification: (val: boolean) => void;
......@@ -37,6 +39,15 @@ export const useUserStore = create<State>()(
state.systemMsgReadId = id;
});
},
enterpriseAuthNoticeReadTeamIds: [],
setEnterpriseAuthNoticeRead(teamId: string) {
if (!teamId) return;
set((state) => {
if (state.enterpriseAuthNoticeReadTeamIds.includes(teamId)) return;
state.enterpriseAuthNoticeReadTeamIds.push(teamId);
});
},
isUpdateNotification: true,
setIsUpdateNotification(val: boolean) {
......@@ -107,6 +118,7 @@ export const useUserStore = create<State>()(
name: 'userStore',
partialize: (state) => ({
systemMsgReadId: state.systemMsgReadId,
enterpriseAuthNoticeReadTeamIds: state.enterpriseAuthNoticeReadTeamIds,
isUpdateNotification: state.isUpdateNotification
})
}
......
import { describe, expect, it } from 'vitest';
import {
fieldRules,
formatEnterpriseAuthBankOptions,
formatBankAccountForDisplay,
normalizeBankAccount,
parseEnterpriseAuthAmountCent
} from '../../../../../src/pageComponents/account/team/EnterpriseAuth/shared';
describe('enterprise auth bank account validation', () => {
it('去除空格后校验 15-19 位数字银行账号', () => {
expect(normalizeBankAccount(' 4111 1111 1111 1111 ')).toBe('4111111111111111');
expect(fieldRules.bankAccount.validate('4111 1111 1111 1111')).toBe(true);
expect(fieldRules.bankAccount.validate('571919104910201')).toBe(true);
});
it('银行账号包含非数字或长度不符时校验失败', () => {
expect(fieldRules.bankAccount.validate('4111-1111-1111-1111')).toBe(false);
expect(fieldRules.bankAccount.validate('1'.repeat(14))).toBe(false);
expect(fieldRules.bankAccount.validate('1'.repeat(20))).toBe(false);
});
it('统一社会信用代码按 GB 32100-2015 校验第 18 位', () => {
expect(fieldRules.unifiedCreditCode.validate('91310000ma1k000006')).toBe(true);
expect(fieldRules.unifiedCreditCode.validate('91310000MA1K000000')).toBe(false);
expect(fieldRules.unifiedCreditCode.validate('91310000MA1K00000I')).toBe(false);
});
it('展示银行账号时按 4 位分组', () => {
expect(formatBankAccountForDisplay('4111111111111111')).toBe('4111 1111 1111 1111');
});
});
describe('formatEnterpriseAuthBankOptions', () => {
it('下拉只展示和提交银行简称', () => {
expect(
formatEnterpriseAuthBankOptions({
中国工商银行: '中国工商银行股份有限公司',
北京银行: '北京银行股份有限公司'
})
).toEqual([
{
label: '中国工商银行',
value: '中国工商银行'
},
{
label: '北京银行',
value: '北京银行'
}
]);
});
});
describe('parseEnterpriseAuthAmountCent', () => {
it('将元金额转换为服务端需要的分', () => {
expect(parseEnterpriseAuthAmountCent('0.28')).toBe(28);
expect(parseEnterpriseAuthAmountCent('1')).toBe(100);
expect(parseEnterpriseAuthAmountCent('1.2')).toBe(120);
expect(parseEnterpriseAuthAmountCent('12.34')).toBe(1234);
expect(parseEnterpriseAuthAmountCent('10000')).toBe(1000000);
});
it('拒绝非正数或超过两位小数的金额', () => {
expect(parseEnterpriseAuthAmountCent('0')).toBeUndefined();
expect(parseEnterpriseAuthAmountCent('0.00')).toBeUndefined();
expect(parseEnterpriseAuthAmountCent('-0.28')).toBeUndefined();
expect(parseEnterpriseAuthAmountCent('0.281')).toBeUndefined();
expect(parseEnterpriseAuthAmountCent('abc')).toBeUndefined();
});
it('拒绝超过上限或无法安全转换为分的金额', () => {
expect(parseEnterpriseAuthAmountCent('10000.01')).toBeUndefined();
expect(parseEnterpriseAuthAmountCent('9'.repeat(30))).toBeUndefined();
});
});
import { describe, expect, it } from 'vitest';
import {
canManageEnterpriseAuth,
shouldShowEnterpriseAuthAmountError,
shouldShowEnterpriseAuthContactBusinessModal
} from '../../../../../src/pageComponents/account/team/EnterpriseAuth/utils';
import {
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
describe('shouldShowEnterpriseAuthAmountError', () => {
it('历史 amount_failed 任务重新进入时不展示金额错误', () => {
expect(
shouldShowEnterpriseAuthAmountError({
taskStatus: TeamEnterpriseAuthTaskStatusEnum.amount_failed,
showCurrentSubmitError: false
})
).toBe(false);
});
it('本次提交失败后才展示金额错误', () => {
expect(
shouldShowEnterpriseAuthAmountError({
taskStatus: TeamEnterpriseAuthTaskStatusEnum.amount_failed,
showCurrentSubmitError: true
})
).toBe(true);
});
it('非金额错误状态即使存在本次失败标记也不展示金额错误', () => {
expect(
shouldShowEnterpriseAuthAmountError({
taskStatus: TeamEnterpriseAuthTaskStatusEnum.pending_amount,
showCurrentSubmitError: true
})
).toBe(false);
});
});
describe('canManageEnterpriseAuth', () => {
it('团队 owner 可以操作企业认证', () => {
expect(
canManageEnterpriseAuth({
isTeamOwner: true,
hasTeamManagePer: false
})
).toBe(true);
});
it('团队管理员可以操作企业认证', () => {
expect(
canManageEnterpriseAuth({
isTeamOwner: false,
hasTeamManagePer: true
})
).toBe(true);
});
it('普通成员不能操作企业认证', () => {
expect(
canManageEnterpriseAuth({
isTeamOwner: false,
hasTeamManagePer: false
})
).toBe(false);
});
it('服务端明确返回不可管理时阻断操作', () => {
expect(
canManageEnterpriseAuth({
statusCanManage: false,
isTeamOwner: true,
hasTeamManagePer: true
})
).toBe(false);
});
});
describe('shouldShowEnterpriseAuthContactBusinessModal', () => {
it('认证次数耗尽且没有当前任务时展示商务咨询弹窗', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: EnterpriseAuthMaxTimes,
hasCurrentTask: false
})
).toBe(true);
});
it('认证次数耗尽但存在待确认打款任务时继续允许恢复认证', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: EnterpriseAuthMaxTimes,
hasCurrentTask: true
})
).toBe(false);
});
it('认证状态未加载完成时不提前展示商务咨询弹窗', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: undefined,
hasCurrentTask: false
})
).toBe(false);
});
});
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
getSystemInitData: vi.fn(),
initStaticData: vi.fn()
}));
vi.mock('@/web/common/system/api', () => ({
getSystemInitData: mocks.getSystemInitData
}));
vi.mock('@/web/common/system/useSystemStore', () => ({
useSystemStore: {
getState: () => ({
initDataBufferId: 'buffer_1',
feConfigs: {
show_enterprise_auth: true,
systemTitle: 'cached'
},
initStaticData: mocks.initStaticData
})
}
}));
const { clientInitData } = await import('@/web/common/system/staticData');
describe('clientInitData runtime feConfigs', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('使用 FastGPT 主配置返回的企业认证开关', async () => {
mocks.getSystemInitData.mockResolvedValueOnce({
feConfigs: {
show_enterprise_auth: true,
systemTitle: 'FastGPT'
}
});
const result = await clientInitData();
expect(result.feConfigs.show_enterprise_auth).toBe(true);
expect(mocks.initStaticData).toHaveBeenCalledWith(
expect.objectContaining({
feConfigs: expect.objectContaining({
show_enterprise_auth: true,
systemTitle: 'FastGPT'
})
})
);
});
it('主配置没有返回 feConfigs 时沿用缓存配置', async () => {
mocks.getSystemInitData.mockResolvedValueOnce({
bufferId: 'buffer_1'
});
const result = await clientInitData();
expect(result.feConfigs.show_enterprise_auth).toBe(true);
expect(result.feConfigs.systemTitle).toBe('cached');
});
});
......@@ -21,10 +21,28 @@ const createRedisStorage = () => {
if (isExpired(key)) return null;
return storage.get(key) ?? null;
},
set: (key: string, value: any, exMode?: string, exValue?: number, condition?: string) => {
if (condition === 'NX' && !isExpired(key) && storage.has(key)) {
set: (key: string, value: any, ...args: any[]) => {
let exMode: string | undefined;
let exValue: number | undefined;
let nx = false;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i]).toUpperCase();
if (arg === 'NX') {
nx = true;
continue;
}
if ((arg === 'EX' || arg === 'PX') && args[i + 1] !== undefined) {
exMode = arg;
exValue = Number(args[i + 1]);
i++;
}
}
if (nx && !isExpired(key) && storage.has(key)) {
return null;
}
storage.set(key, value);
// Handle EX (seconds) and PX (milliseconds) options
if (exMode === 'EX' && typeof exValue === 'number') {
......@@ -60,6 +78,27 @@ const createRedisStorage = () => {
clear: () => {
storage.clear();
expiryMap.clear();
},
eval: (_script: string, numberOfKeys: number, ...args: any[]) => {
const keys = args.slice(0, numberOfKeys);
const argv = args.slice(numberOfKeys);
const key = keys[0];
const expectedValue = argv[0];
if (isExpired(key) || storage.get(key) !== expectedValue) {
return 0;
}
const ttl = argv[1];
const ttlMilliseconds = Number(ttl);
if (ttl !== undefined && Number.isFinite(ttlMilliseconds)) {
expiryMap.set(key, Date.now() + ttlMilliseconds);
return 1;
}
storage.delete(key);
expiryMap.delete(key);
return 1;
}
};
};
......@@ -83,9 +122,8 @@ const createSharedMockRedisClient = () => {
get: vi.fn().mockImplementation((key: string) => Promise.resolve(globalRedisStorage.get(key))),
set: vi
.fn()
.mockImplementation(
(key: string, value: any, exMode?: string, exValue?: number, condition?: string) =>
Promise.resolve(globalRedisStorage.set(key, value, exMode, exValue, condition))
.mockImplementation((key: string, value: any, ...args: any[]) =>
Promise.resolve(globalRedisStorage.set(key, value, ...args))
),
del: vi
.fn()
......@@ -100,15 +138,6 @@ const createSharedMockRedisClient = () => {
.mockImplementation((key: string, milliseconds: number) =>
Promise.resolve(globalRedisStorage.pexpire(key, milliseconds))
),
eval: vi
.fn()
.mockImplementation(
(_script: string, _keyCount: number, key: string, token: string, ttl?: number) => {
if (globalRedisStorage.get(key) !== token) return Promise.resolve(0);
if (typeof ttl === 'number') return Promise.resolve(globalRedisStorage.pexpire(key, ttl));
return Promise.resolve(globalRedisStorage.del(key));
}
),
keys: vi.fn().mockResolvedValue([]),
scan: vi.fn().mockImplementation((cursor) => {
if (cursor === '0') return ['100', ['key1', 'key2']];
......@@ -142,6 +171,11 @@ const createSharedMockRedisClient = () => {
globalRedisStorage.clear();
return Promise.resolve('OK');
}),
eval: vi
.fn()
.mockImplementation((script: string, numberOfKeys: number, ...args: any[]) =>
Promise.resolve(globalRedisStorage.eval(script, numberOfKeys, ...args))
),
// List operations
lpush: vi.fn().mockResolvedValue(1),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment