Commit e11aa7a8 by light5980 Committed by GitHub

fix certification (#7282)

* fix certification

* fix:certification limit

* fix:certification key gen
parent e5ced8a4
import z from 'zod';
import {
EnterpriseAuthAmountMaxErrorTimes,
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthStatusSchema,
TeamEnterpriseAuthTaskStatusSchema
} from '../../../../../support/user/team/enterpriseAuth/constant';
......@@ -21,7 +20,6 @@ import {
* ============================================================================ */
export const EnterpriseAuthLightTaskSchema = z.object({
taskId: z.string().meta({ description: '认证任务 ID' }),
status: TeamEnterpriseAuthTaskStatusSchema.meta({ description: '当前任务状态' }),
amountErrorTimes: z.number().int().meta({
description: '当前任务金额填写错误次数',
......@@ -32,11 +30,9 @@ export const EnterpriseAuthLightTaskSchema = z.object({
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 }),
hasRemainingAuthTimes: z.boolean().optional().meta({
description: '是否还有可发起新企业认证的次数;已有金额验证任务仍可继续完成'
}),
canManage: z.boolean().optional().meta({ description: '当前成员是否可管理企业认证' }),
verifiedEnterpriseName: z.string().optional().meta({ description: '认证通过企业名称' }),
currentTask: EnterpriseAuthLightTaskSchema.optional().meta({
......@@ -58,7 +54,6 @@ export type GetEnterpriseAuthStatusResponseType = z.infer<
* ============================================================================ */
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: '统一社会信用代码' }),
......@@ -157,16 +152,11 @@ export const StartEnterpriseAuthResponseSchema = z.object({
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
......
import { describe, expect, it } from 'vitest';
import {
GetEnterpriseAuthStatusResponseSchema,
StartEnterpriseAuthBodySchema,
StartEnterpriseAuthResponseSchema,
VerifyEnterpriseAuthAmountBodySchema
} from '../../../../../../openapi/support/user/team/enterpriseAuth/api';
import {
TeamEnterpriseAuthStatusEnum,
TeamEnterpriseAuthTaskStatusEnum
} from '../../../../../../support/user/team/enterpriseAuth/constant';
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);
......@@ -75,3 +78,43 @@ describe('StartEnterpriseAuthBodySchema', () => {
});
});
});
describe('EnterpriseAuth response schemas', () => {
it('状态接口响应不再暴露 usedTimes', () => {
const result = GetEnterpriseAuthStatusResponseSchema.parse({
enabled: true,
status: TeamEnterpriseAuthStatusEnum.unverified,
usedTimes: 3,
hasRemainingAuthTimes: false,
canManage: true
});
expect(result).toEqual({
enabled: true,
status: TeamEnterpriseAuthStatusEnum.unverified,
hasRemainingAuthTimes: false,
canManage: true
});
});
it('发起认证响应不再暴露 usedTimes', () => {
const result = StartEnterpriseAuthResponseSchema.parse({
status: TeamEnterpriseAuthStatusEnum.verifying,
currentTask: {
status: TeamEnterpriseAuthTaskStatusEnum.pending_amount,
amountErrorTimes: 0
},
usedTimes: 1,
message: '已成功打款,请确认打款金额'
});
expect(result).toEqual({
status: TeamEnterpriseAuthStatusEnum.verifying,
currentTask: {
status: TeamEnterpriseAuthTaskStatusEnum.pending_amount,
amountErrorTimes: 0
},
message: '已成功打款,请确认打款金额'
});
});
});
......@@ -171,7 +171,6 @@ export const pushTrack = {
status?: `${TeamEnterpriseAuthStatusEnum}`;
taskStatus?: `${TeamEnterpriseAuthTaskStatusEnum}`;
errorCode?: string;
usedTimes?: number;
hasCurrentTask?: boolean;
}
) => {
......
import { getGlobalRedisConnection } from '../../redis';
/**
* 基于 Redis 固定窗口的轻量 QPM 限流。
*
* 该 helper 只负责窗口内计数并返回是否允许继续执行,不绑定任何业务错误码。
* 调用方需要根据自身语义决定超限后的错误响应。
*/
export const checkFixedWindowQpmLimit = async ({
key,
limit,
seconds = 60
}: {
key: string;
limit: number;
seconds?: number;
}) => {
const redis = getGlobalRedisConnection();
const result = await redis.multi().incr(key).expire(key, seconds, 'NX').exec();
const currentCount = Number(result?.[0]?.[1] ?? 0);
return currentCount <= limit;
};
import { beforeEach, describe, expect, it } from 'vitest';
import { checkFixedWindowQpmLimit } from '@fastgpt/service/common/system/frequencyLimit/redisFixedWindow';
import { getGlobalRedisConnection } from '@fastgpt/service/common/redis';
describe('checkFixedWindowQpmLimit', () => {
beforeEach(async () => {
await getGlobalRedisConnection().flushdb();
});
it('同一个 key 在固定窗口内超过限制后返回 false', async () => {
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 3 })
).resolves.toBe(false);
});
it('不同 key 独立计数', async () => {
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 1 })
).resolves.toBe(true);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t1', limit: 1 })
).resolves.toBe(false);
await expect(
checkFixedWindowQpmLimit({ key: 'enterprise-auth:start:team:t2', limit: 1 })
).resolves.toBe(true);
});
});
Subproject commit c37a34e9ac142a168af29b5dfcece1b12cccdcca
Subproject commit 63abbb63af6502569932de2a33ab6ed0eac2cd45
......@@ -11,18 +11,23 @@ import { enterpriseAuthFooterButtonStyles } from './shared';
type EnterpriseAuthModalProps = {
defaultStatus: GetEnterpriseAuthStatusResponseType;
onClose: () => void;
onNoRemainingTimes: () => void;
onSuccess: () => void;
};
const EnterpriseAuthModal = ({ defaultStatus, onClose, onSuccess }: EnterpriseAuthModalProps) => {
const EnterpriseAuthModal = ({
defaultStatus,
onClose,
onNoRemainingTimes,
onSuccess
}: EnterpriseAuthModalProps) => {
const flow = useEnterpriseAuthFormFlow({
defaultStatus,
onClose,
onNoRemainingTimes,
onSuccess
});
if (flow.shouldBlockEnterpriseAuthForm) return null;
const remainingAmountVerifyTimes = Math.max(
EnterpriseAuthAmountMaxErrorTimes -
(flow.taskDetail?.amountErrorTimes ?? defaultStatus.currentTask?.amountErrorTimes ?? 0),
......
......@@ -120,11 +120,11 @@ const EnterpriseAuthStatusRow = ({
}),
[data?.currentTask, data?.status, data?.verifiedEnterpriseName, t]
);
const canOpenCurrentTask = canOpenEnterpriseAuthAmountStep(data?.currentTask?.status);
const needContactBusiness = shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: data?.usedTimes,
hasRemainingAuthTimes: data?.hasRemainingAuthTimes,
hasCurrentTask: !!data?.currentTask
});
const canOpenCurrentTask = canOpenEnterpriseAuthAmountStep(data?.currentTask?.status);
const hasOtherMemberProcessingTask =
data?.status === TeamEnterpriseAuthStatusEnum.verifying && !data?.currentTask;
const canManageCurrentEnterpriseAuth = canManageEnterpriseAuth({
......@@ -287,6 +287,11 @@ const EnterpriseAuthStatusRow = ({
<EnterpriseAuthModal
defaultStatus={data}
onClose={onClose}
onNoRemainingTimes={() => {
onClose();
refreshStatus();
onOpenContactBusiness();
}}
onSuccess={() => {
refreshStatus();
initUserInfo();
......
......@@ -16,11 +16,7 @@ import {
startEnterpriseAuth,
verifyEnterpriseAuthAmount
} from '@/web/support/user/team/enterpriseAuth/api';
import {
canOpenEnterpriseAuthAmountStep,
shouldShowEnterpriseAuthAmountError,
shouldShowEnterpriseAuthContactBusinessModal
} from './utils';
import { canOpenEnterpriseAuthAmountStep, shouldShowEnterpriseAuthAmountError } from './utils';
import {
formatEnterpriseAuthBankOptions,
getErrorCode,
......@@ -31,20 +27,18 @@ import {
type UseEnterpriseAuthFormFlowProps = {
defaultStatus: GetEnterpriseAuthStatusResponseType;
onClose: () => void;
onNoRemainingTimes: () => void;
onSuccess: () => void;
};
export const useEnterpriseAuthFormFlow = ({
defaultStatus,
onClose,
onNoRemainingTimes,
onSuccess
}: UseEnterpriseAuthFormFlowProps) => {
const { t } = useTranslation();
const { toast } = useToast();
const shouldBlockEnterpriseAuthForm = shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: defaultStatus.usedTimes,
hasCurrentTask: !!defaultStatus.currentTask
});
const canOpenInitialAmountStep = canOpenEnterpriseAuthAmountStep(
defaultStatus.currentTask?.status
);
......@@ -82,7 +76,7 @@ export const useEnterpriseAuthFormFlow = ({
run: reloadBanks
} = useRequest(getEnterpriseAuthBanks, {
manual: false,
ready: step === 'form' && !shouldBlockEnterpriseAuthForm,
ready: step === 'form',
errorToast: t('account_team:enterprise_auth_bank_load_failed')
});
......@@ -98,7 +92,7 @@ export const useEnterpriseAuthFormFlow = ({
});
const { runAsync: onStart, loading: starting } = useRequest(startEnterpriseAuth, {
errorToast: t('account_team:enterprise_auth_submit_failed')
errorToast: ''
});
const { runAsync: onVerify, loading: verifying } = useRequest(verifyEnterpriseAuthAmount, {
errorToast: ''
......@@ -111,7 +105,7 @@ export const useEnterpriseAuthFormFlow = ({
const hasBankLoadError = !!bankLoadError && !bankOptions.length;
const isBankLoading = loadingBanks;
const amountYuanValue = useWatch({ control: amountForm.control, name: 'amountYuan' });
const hasLoadedTaskDetail = !!taskDetail?.taskId;
const hasLoadedTaskDetail = !!taskDetail;
const canSubmitAmount =
hasLoadedTaskDetail &&
parseEnterpriseAuthAmountCent(String(amountYuanValue ?? '')) !== undefined;
......@@ -120,15 +114,6 @@ export const useEnterpriseAuthFormFlow = ({
showCurrentSubmitError: showAmountError
});
/**
* 认证次数耗尽且没有待验证任务时,认证表单本身也不应展示。
* 外层入口已做拦截,这里作为弹窗边界的兜底保护,避免旧状态或自动打开误触发。
*/
useEffect(() => {
if (!shouldBlockEnterpriseAuthForm) return;
onClose();
}, [onClose, shouldBlockEnterpriseAuthForm]);
useEffect(() => {
if (!defaultStatus.currentTask || canOpenInitialAmountStep) return;
onClose();
......@@ -136,7 +121,19 @@ export const useEnterpriseAuthFormFlow = ({
const handleStart = useCallback(
async (data: StartEnterpriseAuthBodyType) => {
const result = await onStart(data);
const result = await onStart(data).catch((error) => {
if (getErrorCode(error) === EnterpriseAuthErrEnum.noRemainingTimes) {
onNoRemainingTimes();
return;
}
toast({
title: t(getErrText(error, t('account_team:enterprise_auth_submit_failed')) as any),
status: 'error'
});
});
if (!result) return;
onSuccess();
if (canOpenEnterpriseAuthAmountStep(result.currentTask?.status)) {
await loadTaskDetail();
......@@ -151,7 +148,7 @@ export const useEnterpriseAuthFormFlow = ({
});
onClose();
},
[loadTaskDetail, onClose, onStart, onSuccess, t, toast]
[loadTaskDetail, onClose, onNoRemainingTimes, onStart, onSuccess, t, toast]
);
/**
......@@ -169,7 +166,7 @@ export const useEnterpriseAuthFormFlow = ({
const handleVerify = useCallback(
async ({ amountYuan }: AmountFormType) => {
if (!taskDetail?.taskId) {
if (!taskDetail) {
toast({
title: t('account_team:enterprise_auth_task_load_failed'),
status: 'warning'
......@@ -188,7 +185,6 @@ export const useEnterpriseAuthFormFlow = ({
try {
await onVerify({
taskId: taskDetail.taskId,
amountCent
});
toast({
......@@ -263,7 +259,6 @@ export const useEnterpriseAuthFormFlow = ({
hasLoadedTaskDetail,
canSubmitAmount,
shouldShowAmountError,
shouldBlockEnterpriseAuthForm,
setShowAmountError,
handleStart,
handleStartClick,
......
import {
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import { 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';
......@@ -31,6 +28,20 @@ export const canOpenEnterpriseAuthAmountStep = (
taskStatus === TeamEnterpriseAuthTaskStatusEnum.amount_failed;
/**
* 判断企业认证入口是否应该转为商务咨询弹窗。
*
* 次数耗尽但仍有金额验证任务时,用户需要继续完成当前任务;只有没有可继续任务时,
* 才在入口按钮处直接提示联系商务。
*/
export const shouldShowEnterpriseAuthContactBusinessModal = ({
hasRemainingAuthTimes,
hasCurrentTask
}: {
hasRemainingAuthTimes?: boolean;
hasCurrentTask: boolean;
}) => hasRemainingAuthTimes === false && !hasCurrentTask;
/**
* 判断当前成员是否可以发起或继续企业认证。
*
* 团队 owner 和团队管理员才有企业认证操作入口;statusCanManage 来自服务端状态接口,
......@@ -45,17 +56,3 @@ export const canManageEnterpriseAuth = ({
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;
import { describe, expect, it } from 'vitest';
import {
canManageEnterpriseAuth,
shouldShowEnterpriseAuthAmountError,
shouldShowEnterpriseAuthContactBusinessModal
shouldShowEnterpriseAuthContactBusinessModal,
shouldShowEnterpriseAuthAmountError
} from '../../../../../src/pageComponents/account/team/EnterpriseAuth/utils';
import {
EnterpriseAuthMaxTimes,
TeamEnterpriseAuthTaskStatusEnum
} from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
import { TeamEnterpriseAuthTaskStatusEnum } from '@fastgpt/global/support/user/team/enterpriseAuth/constant';
describe('shouldShowEnterpriseAuthAmountError', () => {
it('历史 amount_failed 任务重新进入时不展示金额错误', () => {
......@@ -78,30 +75,21 @@ describe('canManageEnterpriseAuth', () => {
});
describe('shouldShowEnterpriseAuthContactBusinessModal', () => {
it('认证次数耗尽且没有当前任务时展示商务咨询弹窗', () => {
it('认证次数耗尽且没有当前任务时提示联系商务', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: EnterpriseAuthMaxTimes,
hasRemainingAuthTimes: false,
hasCurrentTask: false
})
).toBe(true);
});
it('认证次数耗尽但存在待确认打款任务时继续允许恢复认证', () => {
it('认证次数耗尽但仍有当前金额验证任务时允许继续任务', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: EnterpriseAuthMaxTimes,
hasRemainingAuthTimes: false,
hasCurrentTask: true
})
).toBe(false);
});
it('认证状态未加载完成时不提前展示商务咨询弹窗', () => {
expect(
shouldShowEnterpriseAuthContactBusinessModal({
usedTimes: undefined,
hasCurrentTask: false
})
).toBe(false);
});
});
......@@ -70,6 +70,25 @@ const createRedisStorage = () => {
});
return count;
},
expire: (key: string, seconds: number, mode?: string) => {
if (isExpired(key) || !storage.has(key)) return 0;
if (String(mode ?? '').toUpperCase() === 'NX' && expiryMap.has(key)) return 0;
expiryMap.set(key, Date.now() + seconds * 1000);
return 1;
},
ttl: (key: string) => {
if (isExpired(key) || !storage.has(key)) return -2;
const expiry = expiryMap.get(key);
if (!expiry) return -1;
return Math.max(0, Math.ceil((expiry - Date.now()) / 1000));
},
incr: (key: string) => {
if (isExpired(key)) storage.delete(key);
const current = Number(storage.get(key) ?? 0);
const next = current + 1;
storage.set(key, next);
return next;
},
pexpire: (key: string, milliseconds: number) => {
if (isExpired(key) || !storage.has(key)) return 0;
expiryMap.set(key, Date.now() + milliseconds);
......@@ -153,12 +172,18 @@ const createSharedMockRedisClient = () => {
hmset: vi.fn().mockResolvedValue('OK'),
// Expiry operations
expire: vi.fn().mockResolvedValue(1),
ttl: vi.fn().mockResolvedValue(-1),
expire: vi
.fn()
.mockImplementation((key: string, seconds: number, mode?: string) =>
Promise.resolve(globalRedisStorage.expire(key, seconds, mode))
),
ttl: vi.fn().mockImplementation((key: string) => Promise.resolve(globalRedisStorage.ttl(key))),
expireat: vi.fn().mockResolvedValue(1),
// Increment operations
incr: vi.fn().mockResolvedValue(1),
incr: vi
.fn()
.mockImplementation((key: string) => Promise.resolve(globalRedisStorage.incr(key))),
decr: vi.fn().mockResolvedValue(1),
incrby: vi.fn().mockResolvedValue(1),
decrby: vi.fn().mockResolvedValue(1),
......@@ -196,11 +221,23 @@ const createSharedMockRedisClient = () => {
unlink: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([])
})),
multi: vi.fn(() => ({
incr: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([[null, 1]])
})),
multi: vi.fn(() => {
const commands: Array<() => [null, unknown]> = [];
const pipeline = {
incr: vi.fn((key: string) => {
commands.push(() => [null, globalRedisStorage.incr(key)]);
return pipeline;
}),
expire: vi.fn((key: string, seconds: number, mode?: string) => {
commands.push(() => [null, globalRedisStorage.expire(key, seconds, mode)]);
return pipeline;
}),
exec: vi
.fn()
.mockImplementation(() => Promise.resolve(commands.map((command) => command())))
};
return pipeline;
}),
// Internal storage for testing purposes
_storage: globalRedisStorage
......
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