From 6be6bec72ba93c343744802b5459a5a222f8091c Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 11 Aug 2026 21:34:41 +0800 Subject: [PATCH 01/15] fix: align zod contracts with runtime data --- packages/global/core/dataset/type.ts | 2 +- packages/global/openapi/common/system/api.ts | 2 +- packages/global/support/wallet/sub/type.ts | 25 ++++-- .../global/test/openapi/common/team.test.ts | 70 ++++++++++++++++ packages/service/common/api/frequencyLimit.ts | 7 +- packages/service/core/ai/config/utils.ts | 1 - .../service/support/permission/teamLimit.ts | 6 +- packages/service/support/wallet/sub/utils.ts | 68 +++++++++++---- .../test/support/permission/teamLimit.test.ts | 40 +++++++++ .../test/support/wallet/sub/utils.test.ts | 84 ++++++++++++++++++- .../wallet/NotSufficientModal/index.tsx | 17 ++-- .../dashboard/TeamPlanStatusCard.tsx | 33 ++++++-- projects/app/src/pages/account/info/index.tsx | 18 ++-- .../training/getDatasetTrainingError.ts | 5 +- .../support/user/account/resetExpiredPsw.ts | 6 +- .../user/account/updatePasswordByOld.ts | 6 +- .../training/getDatasetTrainingError.test.ts | 5 +- .../dataset/training/getTrainingError.test.ts | 5 +- .../user/account/resetExpiredPsw.test.ts | 38 ++++++--- .../user/account/updatePasswordByOld.test.ts | 34 ++++++-- 20 files changed, 390 insertions(+), 82 deletions(-) diff --git a/packages/global/core/dataset/type.ts b/packages/global/core/dataset/type.ts index 1f3d662bcc0c..31b22d10cccf 100644 --- a/packages/global/core/dataset/type.ts +++ b/packages/global/core/dataset/type.ts @@ -241,7 +241,7 @@ export const DatasetTrainingSchema = z.object({ expireAt: z.coerce.date().meta({ description: '过期时间' }), lockTime: z.coerce.date().meta({ description: '锁定时间' }), mode: z.enum(TrainingModeEnum).meta({ description: '训练模式' }), - dataId: z.string().optional().meta({ description: '数据 ID' }), + dataId: ObjectIdSchema.optional().meta({ description: '数据 ID' }), q: z.string().meta({ description: '问题/主文本' }), a: z.string().meta({ description: '回答/补充文本' }), imageId: z.string().optional().meta({ description: '图片 ID' }), diff --git a/packages/global/openapi/common/system/api.ts b/packages/global/openapi/common/system/api.ts index e188af6868b4..f881aa276ff2 100644 --- a/packages/global/openapi/common/system/api.ts +++ b/packages/global/openapi/common/system/api.ts @@ -32,7 +32,7 @@ const FastGPTFeConfigsSchema = z.looseObject({ }) as z.ZodType; const SystemModelSchema = z - .union([ + .discriminatedUnion('type', [ LLMModelItemSchema, EmbeddingModelItemSchema, TTSModelItemSchema, diff --git a/packages/global/support/wallet/sub/type.ts b/packages/global/support/wallet/sub/type.ts index 40e8ecadbaaf..4c997c160689 100644 --- a/packages/global/support/wallet/sub/type.ts +++ b/packages/global/support/wallet/sub/type.ts @@ -86,18 +86,18 @@ export const TeamSubSchema = z.object({ startTime: z.date(), expiredTime: z.date(), - currentMode: z.enum(SubModeEnum), - nextMode: z.enum(SubModeEnum), + currentMode: z.enum(SubModeEnum).optional(), + nextMode: z.enum(SubModeEnum).optional(), currentSubLevel: z.enum(StandardSubLevelEnum), - nextSubLevel: z.enum(StandardSubLevelEnum), + nextSubLevel: z.enum(StandardSubLevelEnum).optional(), maxTeamMember: z.int().optional(), maxApp: z.int().optional(), maxDataset: z.int().optional(), totalPoints: z.int(), annualBonusPoints: z.int().optional(), - surplusPoints: z.int(), - currentExtraDatasetSize: z.int(), + surplusPoints: z.number(), + currentExtraDatasetSize: z.int().optional(), // 定制版特有属性 requestsPerMinute: z.int().optional(), @@ -122,7 +122,8 @@ export type TeamSubSchemaType = z.infer; * - pointPrice from TeamStandardSubPlanItemSchema: 避免与 price 字段冲突 * * Field priority: TeamStandardSubPlanItemSchema fields override TeamSubSchema fields when both exist - */ export const TeamPlanStandardSchema = z.object({ + */ +export const TeamPlanStandardSchema = z.object({ ...TeamSubSchema.omit({ maxApp: true, maxDataset: true @@ -131,6 +132,12 @@ export type TeamSubSchemaType = z.infer; pointPrice: true, price: true }).shape, + currentMode: z.enum(SubModeEnum), + nextMode: z.enum(SubModeEnum), + nextSubLevel: z.enum(StandardSubLevelEnum), + totalPoints: z.number().nullable(), + surplusPoints: z.number().nullable(), + currentExtraDatasetSize: z.int(), price: z.number().optional() }); @@ -138,9 +145,9 @@ export type TeamPlanStandardType = z.infer; export const TeamPlanStatusSchema = z.object({ [SubTypeEnum.standard]: TeamPlanStandardSchema.optional(), - totalPoints: z.int(), - usedPoints: z.int(), - datasetMaxSize: z.int() + totalPoints: z.number().nullable(), + usedPoints: z.number().nullable(), + datasetMaxSize: z.number().nullable() }); export type TeamPlanStatusType = z.infer; diff --git a/packages/global/test/openapi/common/team.test.ts b/packages/global/test/openapi/common/team.test.ts index 1966c29bfd9b..ac5d90de108c 100644 --- a/packages/global/test/openapi/common/team.test.ts +++ b/packages/global/test/openapi/common/team.test.ts @@ -10,6 +10,32 @@ import { DatasetSizeLimitQuerySchema, type DatasetSizeLimitQuery } from '../../../openapi/support/user/team/limit/api'; +import { GetTeamPlanStatusResponseSchema } from '../../../openapi/support/user/team/api'; +import { + StandardSubLevelEnum, + SubModeEnum, + SubTypeEnum +} from '../../../support/wallet/sub/constants'; + +const normalizedStandardPlan = { + _id: '68ad85a7463006c963799a05', + teamId: '68ad85a7463006c963799a06', + type: SubTypeEnum.standard, + startTime: new Date('2026-01-01T00:00:00.000Z'), + expiredTime: new Date('2027-01-01T00:00:00.000Z'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + currentSubLevel: StandardSubLevelEnum.basic, + nextSubLevel: StandardSubLevelEnum.basic, + totalPoints: 1000, + surplusPoints: 99.5, + currentExtraDatasetSize: 0, + maxTeamMember: 10, + maxAppAmount: 20, + maxDatasetAmount: 30, + maxDatasetSize: 10000, + chatHistoryStoreDuration: 30 +}; describe('common and team OpenAPI contracts', () => { it('registers third-party usage and team plan status in their requested groups', () => { @@ -49,4 +75,48 @@ describe('common and team OpenAPI contracts', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); }); + + it('accepts a normalized plan response with fractional point usage', () => { + expect( + GetTeamPlanStatusResponseSchema.parse({ + standard: normalizedStandardPlan, + totalPoints: 1000, + usedPoints: 900.5, + datasetMaxSize: 10000, + usedMember: 1, + usedAppAmount: 2, + usedDatasetSize: 3, + usedDatasetIndexSize: 4, + usedRegistrationCount: 5 + }) + ).toMatchObject({ standard: normalizedStandardPlan, usedPoints: 900.5 }); + }); + + it('uses null for unlimited plan values in the client response', () => { + expect( + GetTeamPlanStatusResponseSchema.parse({ + standard: { + ...normalizedStandardPlan, + totalPoints: null, + surplusPoints: null + }, + totalPoints: null, + usedPoints: null, + datasetMaxSize: null, + usedMember: 1, + usedAppAmount: 2, + usedDatasetSize: 3, + usedDatasetIndexSize: 4, + usedRegistrationCount: 5 + }) + ).toMatchObject({ + totalPoints: null, + usedPoints: null, + datasetMaxSize: null, + standard: { + totalPoints: null, + surplusPoints: null + } + }); + }); }); diff --git a/packages/service/common/api/frequencyLimit.ts b/packages/service/common/api/frequencyLimit.ts index ff6cfcb46028..d6267165a613 100644 --- a/packages/service/common/api/frequencyLimit.ts +++ b/packages/service/common/api/frequencyLimit.ts @@ -6,6 +6,7 @@ import { teamQPM } from '../../support/wallet/sub/utils'; import z from 'zod'; import { getLogger, LogCategories } from '../logger'; import { consumeTeamChatRateLimit } from '../rateLimit/interface/team'; +import { UserError } from '@fastgpt/global/common/error/utils'; const logger = getLogger(LogCategories.HTTP.RESPONSE); @@ -75,7 +76,7 @@ export const teamFrequencyLimit = async ({ logger.error('Team QPM rate limit failed closed', { teamId, type, error }); jsonRes(res, { code: 429, - error: 'Rate limit service unavailable. Please try again later.' + error: new UserError('Rate limit service unavailable. Please try again later.') }); return false; } @@ -89,7 +90,9 @@ export const teamFrequencyLimit = async ({ }); jsonRes(res, { code: 429, - error: `Rate limit exceeded. Maximum ${limit} requests per ${seconds} seconds for this team. Please try again in ${result.ttlSeconds} seconds.` + error: new UserError( + `Rate limit exceeded. Maximum ${limit} requests per ${seconds} seconds for this team. Please try again in ${result.ttlSeconds} seconds.` + ) }); return false; } diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index 21361283f8a6..72a8f9a9a09a 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -216,7 +216,6 @@ export const loadSystemModels = async (init = false, language = 'en') => { defaultSystemChatPrompt: undefined, fieldMap: undefined, defaultConfig: undefined, - weight: undefined, dbConfig: undefined, queryConfig: undefined, requestUrl: undefined, diff --git a/packages/service/support/permission/teamLimit.ts b/packages/service/support/permission/teamLimit.ts index 54b49fb747d0..358b8ab993a1 100644 --- a/packages/service/support/permission/teamLimit.ts +++ b/packages/service/support/permission/teamLimit.ts @@ -15,7 +15,7 @@ export const checkTeamAIPoints = async (teamId: string) => { const { totalPoints, usedPoints } = await teamPoint.getTeamPoints({ teamId }); - if (usedPoints >= totalPoints) { + if (usedPoints !== null && totalPoints !== null && usedPoints >= totalPoints) { return Promise.reject(TeamErrEnum.aiPointsNotEnough); } @@ -129,11 +129,11 @@ export const checkDatasetIndexLimit = async ({ if (!standard) return; - if (usedDatasetIndexSize + insertLen >= datasetMaxSize) { + if (datasetMaxSize !== null && usedDatasetIndexSize + insertLen >= datasetMaxSize) { return Promise.reject(TeamErrEnum.datasetSizeNotEnough); } - if (usedPoints >= totalPoints) { + if (usedPoints !== null && totalPoints !== null && usedPoints >= totalPoints) { return Promise.reject(TeamErrEnum.aiPointsNotEnough); } return; diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index ce8742c5f63f..88d00bb8b81c 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -22,6 +22,10 @@ import { serviceEnv } from '../../../env'; const logger = getLogger(LogCategories.MODULE.WALLET.SUB); const teamPointCache = new TeamPointCache({ logger }); +/** 将非有限套餐数值归一化为 null,统一表示无限或不限制。 */ +const normalizeUnlimitedValue = (value: number): number | null => + Number.isFinite(value) ? value : null; + export const getStandardPlansConfig = () => { return global?.subPlans?.standard; }; @@ -35,11 +39,22 @@ export const sortStandPlans = (plans: TeamSubSchemaType[]) => { standardSubLevelMap[b.currentSubLevel].weight - standardSubLevelMap[a.currentSubLevel].weight ); }; + +/** + * 将标准套餐的历史数据库记录与当前静态配置合并为完整的客户端格式。 + * 缺失的续订字段仅在读取结果中按当前套餐补齐,不回写原始订阅记录。 + */ export const buildStandardPlan = ( standard: TeamSubSchemaType, standardConstants: TeamStandardSubPlanItemType ): TeamPlanStandardType => ({ ...standard, + currentMode: standard.currentMode ?? SubModeEnum.month, + nextMode: standard.nextMode ?? standard.currentMode ?? SubModeEnum.month, + nextSubLevel: standard.nextSubLevel ?? standard.currentSubLevel, + totalPoints: normalizeUnlimitedValue(standard.totalPoints), + surplusPoints: normalizeUnlimitedValue(standard.surplusPoints), + currentExtraDatasetSize: standard.currentExtraDatasetSize ?? 0, name: standardConstants.name, desc: standardConstants.desc, price: standardConstants.price, @@ -220,26 +235,38 @@ export const getTeamPlanStatus = async ({ } const totalPoints = standardPlans - ? (standardPlan?.totalPoints || 0) + - extraPoints.reduce((acc, cur) => acc + (cur.totalPoints || 0), 0) - : Infinity; - const surplusPoints = - (standardPlan?.surplusPoints || 0) + - extraPoints.reduce((acc, cur) => acc + (cur.surplusPoints || 0), 0); - - const standardMaxDatasetSize = + ? normalizeUnlimitedValue( + (standardPlan?.totalPoints || 0) + + extraPoints.reduce((acc, cur) => acc + (cur.totalPoints || 0), 0) + ) + : null; + const surplusPoints = standardPlans + ? normalizeUnlimitedValue( + (standardPlan?.surplusPoints || 0) + + extraPoints.reduce((acc, cur) => acc + (cur.surplusPoints || 0), 0) + ) + : null; + + const configuredStandardMaxDatasetSize = standardPlan?.currentSubLevel && standardPlans - ? standardPlan?.maxDatasetSize || + ? (standardPlan?.maxDatasetSize ?? standardPlans[ standardPlan.currentSubLevel === StandardSubLevelEnum.custom ? StandardSubLevelEnum.advanced : standardPlan.currentSubLevel - ]?.maxDatasetSize || - Infinity - : Infinity; + ]?.maxDatasetSize) + : undefined; + const standardMaxDatasetSize = + configuredStandardMaxDatasetSize === undefined + ? null + : normalizeUnlimitedValue(configuredStandardMaxDatasetSize); const totalDatasetSize = - standardMaxDatasetSize + - extraDatasetSize.reduce((acc, cur) => acc + (cur.currentExtraDatasetSize || 0), 0); + standardMaxDatasetSize === null + ? null + : normalizeUnlimitedValue( + standardMaxDatasetSize + + extraDatasetSize.reduce((acc, cur) => acc + (cur.currentExtraDatasetSize || 0), 0) + ); /** 静态的套餐配置,如果是 custom 则返回 advanced */ const standardConstants = @@ -251,7 +278,11 @@ export const getTeamPlanStatus = async ({ ] : undefined; - teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints }); + if (totalPoints === null || surplusPoints === null) { + await teamPointCache.clear(teamId); + } else { + await teamPointCache.set({ teamId, totalPoints, surplusPoints }); + } return { [SubTypeEnum.standard]: standardConstants @@ -259,7 +290,7 @@ export const getTeamPlanStatus = async ({ : undefined, totalPoints, - usedPoints: totalPoints - surplusPoints, + usedPoints: totalPoints === null || surplusPoints === null ? null : totalPoints - surplusPoints, datasetMaxSize: totalDatasetSize }; @@ -282,7 +313,10 @@ export const teamPoint = { const planStatus = await getTeamPlanStatus({ teamId }); return { totalPoints: planStatus.totalPoints, - surplusPoints: planStatus.totalPoints - planStatus.usedPoints, + surplusPoints: + planStatus.totalPoints === null || planStatus.usedPoints === null + ? null + : planStatus.totalPoints - planStatus.usedPoints, usedPoints: planStatus.usedPoints }; }, diff --git a/packages/service/test/support/permission/teamLimit.test.ts b/packages/service/test/support/permission/teamLimit.test.ts index df76111fc6fa..e5e119ab1677 100644 --- a/packages/service/test/support/permission/teamLimit.test.ts +++ b/packages/service/test/support/permission/teamLimit.test.ts @@ -58,6 +58,27 @@ describe('checkTeamAIPoints', () => { }); }); + it('积分额度为 null 时不限制', async () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + totalPoints: 2000 + } + } + }; + + vi.spyOn(walletUtils.teamPoint, 'getTeamPoints').mockResolvedValue({ + totalPoints: null, + surplusPoints: null, + usedPoints: null + }); + + await expect(checkTeamAIPoints(mockTeamId)).resolves.toEqual({ + totalPoints: null, + usedPoints: null + }); + }); + it('当积分不足时抛出错误', async () => { (global as any).subPlans = { standard: { @@ -509,6 +530,25 @@ describe('checkDatasetIndexLimit', () => { ).resolves.toBeUndefined(); }); + it('积分和知识库额度为 null 时不限制', async () => { + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue({ + standard: { + maxDatasetSize: 10000 + }, + totalPoints: null, + usedPoints: null, + datasetMaxSize: null + } as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(50000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 1000 + }) + ).resolves.toBeUndefined(); + }); + it('当数据集大小超限时抛出错误', async () => { const mockPlanStatus = { standard: { diff --git a/packages/service/test/support/wallet/sub/utils.test.ts b/packages/service/test/support/wallet/sub/utils.test.ts index 5dde939199dc..24c54bdd4af6 100644 --- a/packages/service/test/support/wallet/sub/utils.test.ts +++ b/packages/service/test/support/wallet/sub/utils.test.ts @@ -18,8 +18,7 @@ import { getTeamStandPlan, getTeamPlanStatus, teamPoint, - teamQPM, - clearTeamPlanCache + teamQPM } from '@fastgpt/service/support/wallet/sub/utils'; import { MongoTeamSub } from '@fastgpt/service/support/wallet/sub/schema'; @@ -465,6 +464,44 @@ describe('buildStandardPlan', () => { expect(result.currentExtraDatasetSize).toBe(0); }); + it('将旧套餐缺失的续订字段归一化为完整的新格式', () => { + const result = buildStandardPlan( + { + ...baseStandard, + currentMode: undefined, + nextMode: undefined, + nextSubLevel: undefined, + currentExtraDatasetSize: undefined + }, + baseConstants + ); + + expect(result.currentMode).toBe(SubModeEnum.month); + expect(result.nextMode).toBe(SubModeEnum.month); + expect(result.nextSubLevel).toBe(StandardSubLevelEnum.basic); + expect(result.currentExtraDatasetSize).toBe(0); + }); + + it('保留实际计费产生的小数剩余积分', () => { + const result = buildStandardPlan({ ...baseStandard, surplusPoints: 499.5 }, baseConstants); + + expect(result.surplusPoints).toBe(499.5); + }); + + it('将历史套餐中的无限积分归一化为 null', () => { + const result = buildStandardPlan( + { + ...baseStandard, + totalPoints: Infinity, + surplusPoints: Infinity + }, + baseConstants + ); + + expect(result.totalPoints).toBeNull(); + expect(result.surplusPoints).toBeNull(); + }); + it('annualBonusPoints 来自 standard', () => { const standard: TeamSubSchemaType = { ...baseStandard, annualBonusPoints: 200 }; const result = buildStandardPlan(standard, baseConstants); @@ -766,6 +803,49 @@ describe('getTeamPlanStatus', () => { delete (global as any).subPlans; }); + it('未启用套餐限制时统一返回 null 而不是 Infinity', async () => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + ...baseStandard, + currentSubLevel: StandardSubLevelEnum.basic + } + ]) + } as any); + + const result = await getTeamPlanStatus({ teamId: mockTeamId }); + + expect(result.totalPoints).toBeNull(); + expect(result.usedPoints).toBeNull(); + expect(result.datasetMaxSize).toBeNull(); + }); + + it('将历史套餐中的无限计算结果统一归一化为 null', async () => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + ...baseStandard, + totalPoints: Infinity, + surplusPoints: Infinity, + maxDatasetSize: Infinity + } + ]) + } as any); + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: baseConstants + } + }; + + const result = await getTeamPlanStatus({ teamId: mockTeamId }); + + expect(result.totalPoints).toBeNull(); + expect(result.usedPoints).toBeNull(); + expect(result.datasetMaxSize).toBeNull(); + expect(result.standard?.totalPoints).toBeNull(); + expect(result.standard?.surplusPoints).toBeNull(); + }); + it('返回团队套餐状态(包含标准套餐)', async () => { const teamId = mockTeamId; const mockStandardPlan = { diff --git a/projects/app/src/components/support/wallet/NotSufficientModal/index.tsx b/projects/app/src/components/support/wallet/NotSufficientModal/index.tsx index f30f4e9bee2f..5ce513b09835 100644 --- a/projects/app/src/components/support/wallet/NotSufficientModal/index.tsx +++ b/projects/app/src/components/support/wallet/NotSufficientModal/index.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import MyModal from '@fastgpt/web/components/common/MyModal'; import { useTranslation } from 'next-i18next'; import { Box, Button, Flex, ModalBody, ModalFooter, useDisclosure } from '@chakra-ui/react'; -import { type NotSufficientModalType, useSystemStore } from '@/web/common/system/useSystemStore'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; import ExtraPlan from '@/pageComponents/price/ExtraPlan'; import StandardPlan from '@/pageComponents/price/Standard'; import FillRowTabs from '@fastgpt/web/components/common/Tabs/FillRowTabs'; @@ -95,13 +95,14 @@ export const RechargeModal = ({ initTeamPlanStatus(); }); + const currentSubLevel = teamPlanStatus?.standard?.currentSubLevel; const planName = useMemo(() => { - if (!teamPlanStatus?.standard?.currentSubLevel) return ''; + if (!currentSubLevel) return ''; return ( - subPlans?.standard?.[teamPlanStatus.standard.currentSubLevel]?.name || - t(standardSubLevelMap[teamPlanStatus.standard.currentSubLevel]?.label as any) + subPlans?.standard?.[currentSubLevel]?.name || + t(standardSubLevelMap[currentSubLevel]?.label as any) ); - }, [teamPlanStatus?.standard?.currentSubLevel, subPlans?.standard, t]); + }, [currentSubLevel, subPlans?.standard, t]); const [tab, setTab] = useState<'standard' | 'extra'>('standard'); @@ -150,15 +151,15 @@ export const RechargeModal = ({ {`${Math.round(teamPlanStatus?.usedPoints || 0)} / ${teamPlanStatus?.totalPoints ?? t('common:Unlimited')}`} + >{`${teamPlanStatus?.usedPoints === null ? t('common:Unlimited') : Math.round(teamPlanStatus?.usedPoints ?? 0)} / ${teamPlanStatus?.totalPoints ?? t('common:Unlimited')}`} { } }); } - }, [operationalAd, loadOperationalAd]); + }, [operationalAd, loadOperationalAd, feConfigs?.isPlus]); const [hiddenUntil, setHiddenUntil] = useLocalStorageState( `logout-operational-${operationalAd?.id}`, @@ -53,9 +53,13 @@ const TeamPlanStatusCard = () => { }, [teamPlanStatus?.standard?.currentSubLevel, isWecomTeam, t, subPlans?.standard]); const aiPointsUsageMap = useMemo(() => { - if (!teamPlanStatus) { + if ( + !teamPlanStatus || + teamPlanStatus.usedPoints === null || + teamPlanStatus.totalPoints === null + ) { return { - value: 0, + value: t('account_info:unlimited'), max: t('account_info:unlimited'), rate: 0 }; @@ -74,10 +78,25 @@ const TeamPlanStatusCard = () => { return 'red'; }, []); - const shouldHide = useMemo(() => { - if (!hiddenUntil) return false; - return Date.now() < hiddenUntil; + const [currentTime, setCurrentTime] = useState(); + useEffect(() => { + if (!hiddenUntil) return; + + const updateCurrentTime = () => setCurrentTime(Date.now()); + const initialTimer = window.setTimeout(updateCurrentTime, 0); + const expirationTimer = window.setTimeout( + updateCurrentTime, + Math.max(0, hiddenUntil - Date.now()) + ); + + return () => { + window.clearTimeout(initialTimer); + window.clearTimeout(expirationTimer); + }; }, [hiddenUntil]); + const shouldHide = Boolean( + hiddenUntil && (currentTime === undefined || currentTime < hiddenUntil) + ); const handleClose = useCallback(() => { if (operationalAd?.id) { diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 248860d2a201..e79d86007c72 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -470,8 +470,12 @@ const PlanUsage = () => { const isFreeTeam = useMemo(() => { if (!teamPlanStatus || !teamPlanStatus?.standard) return false; const hasExtraDatasetSize = + teamPlanStatus.datasetMaxSize !== null && teamPlanStatus.datasetMaxSize > teamPlanStatus.standard.maxDatasetSize; - const hasExtraPoints = teamPlanStatus.totalPoints > teamPlanStatus.standard.totalPoints; + const hasExtraPoints = + teamPlanStatus.totalPoints !== null && + teamPlanStatus.standard.totalPoints !== null && + teamPlanStatus.totalPoints > teamPlanStatus.standard.totalPoints; if ( teamPlanStatus?.standard?.currentSubLevel === StandardSubLevelEnum.free && !hasExtraDatasetSize && @@ -508,9 +512,10 @@ const PlanUsage = () => { }; } - const rate = teamPlanStatus.totalPoints - ? (teamPlanStatus.usedPoints / teamPlanStatus.totalPoints) * 100 - : 0; + const rate = + teamPlanStatus.totalPoints && teamPlanStatus.usedPoints !== null + ? (teamPlanStatus.usedPoints / teamPlanStatus.totalPoints) * 100 + : 0; return { total: teamPlanStatus.totalPoints ?? t('account_info:unlimited'), @@ -688,7 +693,10 @@ const PlanUsage = () => { - {Math.round(teamPlanStatus?.usedPoints || 0)} / {aiPointsUsageMap.total} + {teamPlanStatus?.usedPoints === null + ? t('account_info:unlimited') + : Math.round(teamPlanStatus?.usedPoints ?? 0)}{' '} + / {aiPointsUsageMap.total} diff --git a/projects/app/src/pages/api/core/dataset/training/getDatasetTrainingError.ts b/projects/app/src/pages/api/core/dataset/training/getDatasetTrainingError.ts index b1347861ad20..3cd97e6da5e9 100644 --- a/projects/app/src/pages/api/core/dataset/training/getDatasetTrainingError.ts +++ b/projects/app/src/pages/api/core/dataset/training/getDatasetTrainingError.ts @@ -14,8 +14,7 @@ import { GetDatasetTrainingErrorResponseSchema, type GetDatasetTrainingErrorBody, type GetDatasetTrainingErrorResponse, - type TrainingErrorGroupType, - type TrainingErrorItemType + type TrainingErrorGroupType } from '@fastgpt/global/openapi/core/dataset/training/api'; import { finalErrorTrainingMatch, @@ -218,7 +217,7 @@ async function handler(req: ApiRequestProps): Promise((groups, id: any, index: number) => { const collection = collectionMap.get(String(id)); const errorCount = statsMap.get(String(id)) ?? 0; - const items = (itemsList[index] ?? []) as TrainingErrorItemType[]; + const items = itemsList[index] ?? []; if (!collection || errorCount === 0 || items.length === 0) return groups; const { sourceName, sourceId } = getCollectionSourceData(collection); diff --git a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts index 5a0905a7c7b5..d13dff5b8107 100644 --- a/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts +++ b/projects/app/src/pages/api/support/user/account/resetExpiredPsw.ts @@ -5,6 +5,7 @@ import { NextAPI } from '@/service/middleware/entry'; import { i18nT } from '@fastgpt/global/common/i18n/utils'; import { checkPswExpired } from '@/service/support/user/account/password'; import { delUserAllSession } from '@fastgpt/service/support/user/session'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; import { ResetExpiredPswBodySchema, ResetExpiredPswResponseSchema, @@ -12,7 +13,10 @@ import { } from '@fastgpt/global/openapi/support/user/account/password/api'; async function resetExpiredPswHandler(req: ApiRequestProps): Promise { - const { newPsw } = ResetExpiredPswBodySchema.parse(req.body); + const { newPsw } = parseApiInput({ + req, + bodySchema: ResetExpiredPswBodySchema + }).body; const { userId, sessionId } = await authCert({ req, authToken: true }); const user = await MongoUser.findById(userId, 'passwordUpdateTime').lean(); diff --git a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts index 22a9cf5eeab8..9090c92583eb 100644 --- a/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts +++ b/projects/app/src/pages/api/support/user/account/updatePasswordByOld.ts @@ -13,12 +13,16 @@ import { type UpdatePasswordByOldResponseType } from '@fastgpt/global/openapi/support/user/account/password/api'; import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; async function handler( req: ApiRequestProps, _res: ApiResponseType ): Promise { - const { oldPsw, newPsw } = UpdatePasswordByOldBodySchema.parse(req.body); + const { oldPsw, newPsw } = parseApiInput({ + req, + bodySchema: UpdatePasswordByOldBodySchema + }).body; const { tmbId, teamId, sessionId } = await authCert({ req, authToken: true }); const tmb = await MongoTeamMember.findById(tmbId); diff --git a/projects/app/test/api/core/dataset/training/getDatasetTrainingError.test.ts b/projects/app/test/api/core/dataset/training/getDatasetTrainingError.test.ts index d87e28197bdb..ba3e5d1f906e 100644 --- a/projects/app/test/api/core/dataset/training/getDatasetTrainingError.test.ts +++ b/projects/app/test/api/core/dataset/training/getDatasetTrainingError.test.ts @@ -10,6 +10,7 @@ import { DatasetTrainingErrorPaginationLimits } from '@fastgpt/global/openapi/co import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; +import { Types } from '@fastgpt/service/common/mongo'; import { ApiRequestInputParseError } from '@fastgpt/service/common/zod/requestParseError'; import { getRootUser } from '@test/datas/users'; import { Call } from '@test/utils/request'; @@ -79,7 +80,8 @@ describe('dataset training error list test', () => { mode: TrainingModeEnum.parse, retryCount: 0, errorMsg: 'file parse error', - chunkIndex: 1 + chunkIndex: 1, + dataId: new Types.ObjectId() }, { teamId: root.teamId, @@ -138,6 +140,7 @@ describe('dataset training error list test', () => { expect(firstPageRes.data.list[0].hasMoreItems).toBe(true); expect(firstPageRes.data.list[0].items).toHaveLength(1); expect(firstPageRes.data.list[0].items[0].mode).toBe(TrainingModeEnum.parse); + expect(typeof firstPageRes.data.list[0].items[0].dataId).toBe('string'); const secondPageRes = await Call< getDatasetTrainingErrorBody, diff --git a/projects/app/test/api/core/dataset/training/getTrainingError.test.ts b/projects/app/test/api/core/dataset/training/getTrainingError.test.ts index ef7342f39fb7..1752ba54b4ea 100644 --- a/projects/app/test/api/core/dataset/training/getTrainingError.test.ts +++ b/projects/app/test/api/core/dataset/training/getTrainingError.test.ts @@ -9,6 +9,7 @@ import { import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; import { MongoDatasetTraining } from '@fastgpt/service/core/dataset/training/schema'; +import { Types } from '@fastgpt/service/common/mongo'; import { getRootUser } from '@test/datas/users'; import { Call } from '@test/utils/request'; import { describe, expect, it } from 'vitest'; @@ -53,7 +54,8 @@ describe('training error list test', () => { mode: TrainingModeEnum.parse, retryCount: 0, errorMsg: 'parse should sort before chunk', - chunkIndex: 9 + chunkIndex: 9, + dataId: new Types.ObjectId() }, ...[...Array(10).keys()].map((i) => ({ teamId: root.teamId, @@ -104,6 +106,7 @@ describe('training error list test', () => { res.data.list.map((item) => ('mode' in item ? item.mode : undefined)).slice(0, 2) ).toEqual([TrainingModeEnum.parse, TrainingModeEnum.chunk]); expect(res.data.list.every((item) => 'mode' in item)).toBe(true); + expect(typeof res.data.list[0].dataId).toBe('string'); }); it('should reject dataset scope request', async () => { diff --git a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts b/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts index 2880954fb6d3..b400ed15410f 100644 --- a/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts +++ b/projects/app/test/api/support/user/account/resetExpiredPsw.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import resetExpiredPswHandler from '@/pages/api/support/user/account/resetExpiredPsw'; import { MongoUser } from '@fastgpt/service/support/user/schema'; import { MongoTeam } from '@fastgpt/service/support/user/team/teamSchema'; import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; @@ -53,7 +54,7 @@ describe('resetExpiredPsw API', () => { passwordUpdateTime: twoMonthsAgo }); - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswApi.default, { body: { newPsw: 'newhashedpassword' }, auth: { userId: String(testUser._id), @@ -84,7 +85,7 @@ describe('resetExpiredPsw API', () => { passwordUpdateTime: new Date() }); - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswApi.default, { body: { newPsw: 'newhashedpassword' }, auth: { userId: String(testUser._id), @@ -108,7 +109,7 @@ describe('resetExpiredPsw API', () => { passwordUpdateTime: new Date() }); - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswApi.default, { body: { newPsw: 'newhashedpassword' }, auth: { userId: String(testUser._id), @@ -124,9 +125,7 @@ describe('resetExpiredPsw API', () => { it('should reject when newPsw is missing', async () => { vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); - - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswHandler, { body: {}, auth: { userId: String(testUser._id), @@ -138,6 +137,7 @@ describe('resetExpiredPsw API', () => { }); expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); }); it('should reject when user is not found', async () => { @@ -146,7 +146,7 @@ describe('resetExpiredPsw API', () => { const nonExistentId = '000000000000000000000001'; - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswApi.default, { body: { newPsw: 'newhashedpassword' }, auth: { userId: nonExistentId, @@ -163,7 +163,7 @@ describe('resetExpiredPsw API', () => { it('should reject request without authentication', async () => { const resetExpiredPswApi = await loadResetExpiredPswApi(); - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswApi.default, { body: { newPsw: 'newhashedpassword' } }); @@ -172,7 +172,6 @@ describe('resetExpiredPsw API', () => { it('should reject newPsw as non-string (injection guard)', async () => { vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); - const resetExpiredPswApi = await loadResetExpiredPswApi(); const twoMonthsAgo = new Date(); twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); @@ -180,7 +179,7 @@ describe('resetExpiredPsw API', () => { passwordUpdateTime: twoMonthsAgo }); - const res = await Call(resetExpiredPswApi.default, { + const res = await Call(resetExpiredPswHandler, { body: { newPsw: { $ne: '' } }, auth: { userId: String(testUser._id), @@ -192,5 +191,24 @@ describe('resetExpiredPsw API', () => { }); expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); + }); + + it('should reject string body as input parse error', async () => { + vi.stubEnv('PASSWORD_EXPIRED_MONTH', '1'); + + const res = await Call(resetExpiredPswHandler, { + body: 'newhashedpassword', + auth: { + userId: String(testUser._id), + teamId: String(testTeam._id), + tmbId: String(testTmb._id), + isRoot: false, + sessionId: 'session123' + } as any + }); + + expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); }); }); diff --git a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts b/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts index 07e5f289af53..0abf78f8f2a4 100644 --- a/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts +++ b/projects/app/test/api/support/user/account/updatePasswordByOld.test.ts @@ -7,6 +7,7 @@ import { UserStatusEnum } from '@fastgpt/global/support/user/constant'; import { initTeamFreePlan } from '@fastgpt/service/support/wallet/sub/utils'; import type { UpdatePasswordByOldBodyType } from '@fastgpt/global/openapi/support/user/account/password/api'; import { Call } from '@test/utils/request'; +import { ApiRequestInputParseError } from '@fastgpt/service/common/zod/requestParseError'; describe('updatePasswordByOld API', () => { let testUser: any; @@ -42,7 +43,7 @@ describe('updatePasswordByOld API', () => { }); it('should update password successfully with correct old password', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); @@ -55,7 +56,7 @@ describe('updatePasswordByOld API', () => { }); it('should reject when old password is incorrect', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'wrongpassword', newPsw: 'newhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); @@ -68,7 +69,7 @@ describe('updatePasswordByOld API', () => { }); it('should reject when old and new passwords are the same', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'oldhashpassword', newPsw: 'oldhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); @@ -77,25 +78,37 @@ describe('updatePasswordByOld API', () => { }); it('should reject when oldPsw is missing', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { newPsw: 'newhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); }); it('should reject when newPsw is missing', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'oldhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); + }); + + it('should reject string body as input parse error', async () => { + const res = await Call(updatePasswordApi.default, { + body: 'invalid body', + auth: makeAuth(testUser, testTeam, testTmb) as any + }); + + expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); }); it('should reject request without authentication', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'oldhashpassword', newPsw: 'newhashpassword' } }); @@ -106,13 +119,14 @@ describe('updatePasswordByOld API', () => { it('should reject oldPsw as MongoDB operator object ($ne injection)', async () => { // GHSA-jxvr-h2vx-p73r Step 3: oldPsw: {"$ne": ""} bypasses old password check - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: { $ne: '' }, newPsw: 'newhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); // Zod z.string() must reject object-type oldPsw expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); // Password must NOT be changed const user = await MongoUser.findById(testUser._id).select('+passwordUpdateTime'); @@ -120,20 +134,22 @@ describe('updatePasswordByOld API', () => { }); it('should reject oldPsw with $regex injection', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: { $regex: '.*' }, newPsw: 'newhashpassword' }, auth: makeAuth(testUser, testTeam, testTmb) as any }); expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); }); it('should reject newPsw as non-string type', async () => { - const res = await Call(updatePasswordApi.default, { + const res = await Call(updatePasswordApi.default, { body: { oldPsw: 'oldhashpassword', newPsw: { $ne: '' } }, auth: makeAuth(testUser, testTeam, testTmb) as any }); expect(res.code).toBe(500); + expect(res.error).toBeInstanceOf(ApiRequestInputParseError); }); }); From 520af43a007c33eb9513def56a738d89f9d408d0 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 11 Aug 2026 22:25:10 +0800 Subject: [PATCH 02/15] fix: validate and clean system model configs --- .../ai/model-config-validation-and-cleanup.md | 51 ++++ .../skills/system/api-development/SKILL.md | 23 +- .../self-host/upgrading/4-16/41602.en.mdx | 26 ++ .../self-host/upgrading/4-16/41602.mdx | 26 ++ document/data/doc-last-modified.json | 8 +- packages/global/core/ai/model.schema.ts | 43 ++- .../global/openapi/admin/core/ai/model/api.ts | 84 ++++++ .../openapi/admin/core/ai/model/index.ts | 59 +++++ packages/global/openapi/admin/core/index.ts | 4 +- packages/global/openapi/common/system/api.ts | 17 +- .../global/test/openapi/common/system.test.ts | 17 +- packages/service/core/ai/config/utils.ts | 24 +- .../account/model/AddModelBox.tsx | 2 +- .../dataClean/cleanSystemModelConfigs.ts | 246 ++++++++++++++++++ .../app/src/pages/api/core/ai/model/update.ts | 78 ++---- .../pages/api/core/ai/model/updateWithJson.ts | 62 ++--- .../dataClean/cleanSystemModelConfigs.test.ts | 201 ++++++++++++++ .../pages/api/core/ai/model/update.test.ts | 105 ++++++++ .../api/core/ai/model/updateWithJson.test.ts | 192 ++++++-------- 19 files changed, 1038 insertions(+), 230 deletions(-) create mode 100644 .agents/design/ai/model-config-validation-and-cleanup.md create mode 100644 packages/global/openapi/admin/core/ai/model/api.ts create mode 100644 packages/global/openapi/admin/core/ai/model/index.ts create mode 100644 projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts create mode 100644 projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts create mode 100644 projects/app/test/pages/api/core/ai/model/update.test.ts diff --git a/.agents/design/ai/model-config-validation-and-cleanup.md b/.agents/design/ai/model-config-validation-and-cleanup.md new file mode 100644 index 000000000000..d5a3cba61d89 --- /dev/null +++ b/.agents/design/ai/model-config-validation-and-cleanup.md @@ -0,0 +1,51 @@ +# 模型配置严格校验与历史数据清洗 + +## 背景 + +模型配置写入接口当前只做 TypeScript 类型声明或少量手工检查,MongoDB 的 `metadata` 也是普通对象。前端还会把部分空值转换为 `''`,导致数字、布尔值和 `priceTiers` 以字符串形式进入数据库,并在系统初始化响应严格校验时失败。 + +## 目标 + +- 所有常规模型配置写入在落库前通过统一的判别联合 Schema。 +- Schema 只填充有明确语义的默认值:Embedding `weight=0`;新版协议中的 `functionCall` 为可选字段。 +- 前端不再用空字符串表示缺失的可选字段。 +- 提供管理员历史数据清洗接口,默认 dry-run,无法确定修复方式的数据跳过并报告。 +- 运行时模型加载不承担通用脏数据转换,仅适配插件协议中明确存在的字段差异。 + +## 设计 + +### 统一模型 Schema + +在 `packages/global/core/ai/model.schema.ts` 导出 `SystemModelItemSchema`,以 `type` 为判别字段组合 LLM、Embedding、TTS、STT 和 Rerank Schema。模型保存、JSON 导入和初始化响应复用该 Schema。 + +严格 Schema 不接受数字字符串、布尔字符串或字符串形式的数组。缺失的 `weight` 通过 Schema 默认值补齐,`functionCall` 保持可选,解析后的对象才允许写入数据库。 + +### 写入接口 + +- `update`:使用 `parseApiInput` 校验请求外层结构;合并系统模型、历史配置和本次修改后,再用 `SystemModelItemSchema.parse` 校验完整结果。 +- `updateWithJson`:校验 `config` 是 JSON 字符串,对解析后的每条记录统一覆盖可信的 `metadata.model`,补齐空名称,再严格解析完整模型后进入事务。 +- 空成功响应统一为 `z.undefined()`。 + +### 前端 + +提交前删除 `null`、`undefined` 和 `NaN` 字段,不再转换成 `''`。用户实际输入的空字符串仍由严格接口拒绝。 + +### 清洗接口 + +管理员 `dataClean` 接口扫描 `system_models`: + +- 合法数字字符串转换为 number;非法的可选数字字段删除,非法或缺失的必填数字字段使用系统默认值。 +- JSON 字符串形式的 `priceTiers` 转为数组,并校验每个梯度。 +- 缺失的 `weight` 使用统一 Schema 默认值,不补写可选的 `functionCall`。 +- 最终必须通过 `SystemModelItemSchema`;无法修复的记录不更新,只输出字段路径和原因。 +- 默认 dry-run;显式传入 `dryRun=false` 才通过一次 `bulkWrite` 写库,可重复执行。 +- 实际写入完成且存在更新时,立即触发统一的系统模型缓存重载。 + +## TODO + +- [x] 导出统一模型判别联合与默认值。 +- [x] 改造模型更新和 JSON 导入接口。 +- [x] 修正前端空值提交行为。 +- [x] 实现管理员历史模型配置清洗接口。 +- [x] 补充 Schema、接口和清洗测试。 +- [x] 运行定向测试、类型检查、lint 和差异检查。 diff --git a/.agents/skills/system/api-development/SKILL.md b/.agents/skills/system/api-development/SKILL.md index 9119ae5c1eb2..1fcdd20c12a1 100644 --- a/.agents/skills/system/api-development/SKILL.md +++ b/.agents/skills/system/api-development/SKILL.md @@ -20,10 +20,28 @@ description: FastGPT API 开发规范。重点强调使用 zod schema 定义入 1. **所有 API 必须使用 zod schema 定义入参和出参** 2. **必须导出 schema 的 TypeScript 类型** -3. **必须在 schema 文件头部声明 API 信息(路由、方法、描述、标签)** +3. **必须在 schema 文件头部声明 API 信息(路由、方法、描述、标签),一次性管理员升级/清洗能力除外** 4. **入参必须使用 schema.parse() 验证** 5. **函数返回值必须使用 schema.parse() 验证** -6. **必须编写完整的 OpenAPI 文档** +6. **必须编写完整的 OpenAPI 文档,一次性管理员升级/清洗能力除外** + +### 管理员升级与清洗能力的文档豁免 + +仅供系统管理员执行、用于一次性升级、迁移、修复或数据清洗的内部接口和脚本,不属于产品 API,不要求: + +- 在 `packages/global/openapi/` 声明接口文档; +- 注册 OpenAPI Path; +- 编写 API 头部路由、方法、描述和标签信息。 + +豁免只针对文档,不豁免安全和校验要求: + +- 管理员接口必须使用 `authSystemAdmin` 鉴权; +- API 入参必须使用 Zod Schema 和 `parseApiInput`; +- 返回值必须使用 Zod Schema 校验,空成功响应使用 `z.undefined()`; +- 数据清洗默认使用 dry-run,显式确认后才能写入,并输出成功、跳过和失败统计; +- 清洗逻辑应可重复执行,无法安全修复的数据必须跳过并报告,不得静默填入猜测值。 + +常规管理员产品接口(例如模型配置 CRUD、用户管理和系统配置)不因仅管理员可用而获得豁免,仍需按标准 API 流程维护文档。 ## 开发流程 @@ -464,6 +482,7 @@ export default NextAPI(handler); ### 🔴 必须检查项 (阻塞性) **Schema 文件** (`packages/global/openapi/.../api.ts`): +- [ ] **文档豁免判断**: 仅一次性管理员升级/清洗能力可跳过 OpenAPI 文档 - [ ] **API 声明**: 文件头部有 API 信息(路由、方法、描述、标签) - [ ] **Schema 定义**: 入参和出参都使用 zod 定义 - [ ] **类型导出**: 导出 `z.infer` 类型 diff --git a/document/content/self-host/upgrading/4-16/41602.en.mdx b/document/content/self-host/upgrading/4-16/41602.en.mdx index acb77a00228a..84c42794d4cd 100644 --- a/document/content/self-host/upgrading/4-16/41602.en.mdx +++ b/document/content/self-host/upgrading/4-16/41602.en.mdx @@ -3,6 +3,32 @@ title: 'V4.16.0-beta2 (In Progress)' description: 'FastGPT V4.16.0-beta2 release notes' --- +## ⚠️ Upgrade notes + +### Clean up legacy system model configurations + +This release applies strict schemas when system models are initialized or saved. Numeric strings, serialized price tiers, and missing fields saved by earlier releases may fail initialization validation. After upgrading, run a dry run first to inspect the model configurations that require cleanup. A dry run does not modify data or reload the model cache: + +```bash +curl -X POST 'https://your-domain/api/admin/dataClean/cleanSystemModelConfigs' \ + -H 'Content-Type: application/json' \ + -H 'rootkey: YOUR_ROOT_KEY' \ + -d '{"dryRun":true,"sampleLimit":20}' +``` + +After confirming that `invalidSamples` contains no records that require manual correction, run the cleanup: + +```bash +curl -X POST 'https://your-domain/api/admin/dataClean/cleanSystemModelConfigs' \ + -H 'Content-Type: application/json' \ + -H 'rootkey: YOUR_ROOT_KEY' \ + -d '{"dryRun":false,"sampleLimit":20}' +``` + +The cleanup converts valid numeric strings to numbers, parses serialized `priceTiers` arrays, and removes invalid optional numeric fields. Invalid or missing required numeric fields use system defaults: LLM `maxContext/maxResponse/quoteMaxToken` default to `16000/16000/13000`, Embedding `defaultToken/maxToken` default to `500/3000`, and price fields default to `0`. `functionCall` remains optional, and a missing Embedding `weight` defaults to `0`. + +The write operation updates all matching records in one operation and immediately reloads the system model cache when at least one record changes. The endpoint is safe to run repeatedly; a follow-up dry run should report `wouldUpdate` as `0`. Records that still fail the complete current model schema are not written and are listed in `invalidSamples`. `sampleLimit` defaults to `20` and has a maximum of `100`. + ## 🚀 New 1. Moved Workflow app system settings to a dedicated panel in the canvas toolbar. The panel opens automatically when a new Workflow app is created. diff --git a/document/content/self-host/upgrading/4-16/41602.mdx b/document/content/self-host/upgrading/4-16/41602.mdx index 80d1a713a4c0..d92ea13fe2f8 100644 --- a/document/content/self-host/upgrading/4-16/41602.mdx +++ b/document/content/self-host/upgrading/4-16/41602.mdx @@ -3,6 +3,32 @@ title: 'V4.16.0-beta2(进行中)' description: 'FastGPT V4.16.0-beta2 更新说明' --- +## ⚠️ 升级说明 + +### 清洗历史系统模型配置 + +本版本开始在系统模型初始化及保存时使用严格 Schema。此前版本保存的数字字符串、字符串形式的价格梯度或缺失字段可能导致初始化校验失败。升级后请先执行 dry-run,查看需要处理的模型配置;dry-run 不会修改数据或刷新缓存: + +```bash +curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' \ + -H 'Content-Type: application/json' \ + -H 'rootkey: 你的ROOT_KEY' \ + -d '{"dryRun":true,"sampleLimit":20}' +``` + +确认 `invalidSamples` 中没有需要人工处理的数据后,执行正式清洗: + +```bash +curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' \ + -H 'Content-Type: application/json' \ + -H 'rootkey: 你的ROOT_KEY' \ + -d '{"dryRun":false,"sampleLimit":20}' +``` + +清洗会将合法数字字符串转换为 number、将字符串形式的 `priceTiers` 转换为数组,并删除非法的可选数字。非法或缺失的必填数字使用系统默认值:LLM 的 `maxContext/maxResponse/quoteMaxToken` 分别为 `16000/16000/13000`,Embedding 的 `defaultToken/maxToken` 分别为 `500/3000`,价格为 `0`。`functionCall` 保持可选,Embedding 缺失的 `weight` 补为 `0`。 + +正式执行会统一写入数据,并在存在实际更新时立即刷新系统模型缓存。接口可安全重复执行;再次 dry-run 时,`wouldUpdate` 应为 `0`。无法通过当前完整模型 Schema 的记录不会写入,详情会返回在 `invalidSamples` 中;`sampleLimit` 默认为 `20`,最大为 `100`。 + ## 🚀 新增内容 1. 工作流应用的系统配置移至画布左侧工具栏中的独立配置面板,新建工作流应用时会自动打开。 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index d3bc37cc6225..ff03534e2d42 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -155,8 +155,8 @@ "content/openapi/app.mdx": "2026-05-29T19:31:16+08:00", "content/openapi/chat.en.mdx": "2026-07-08T22:37:19+08:00", "content/openapi/chat.mdx": "2026-07-08T22:37:19+08:00", - "content/openapi/dataset.en.mdx": "2026-08-11T16:50:38+08:00", - "content/openapi/dataset.mdx": "2026-08-11T16:50:38+08:00", + "content/openapi/dataset.en.mdx": "2026-08-11T20:41:16+08:00", + "content/openapi/dataset.mdx": "2026-08-11T20:41:16+08:00", "content/openapi/index.en.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/index.mdx": "2026-04-26T21:08:47+08:00", "content/openapi/intro.en.mdx": "2026-07-02T11:56:02+08:00", @@ -337,8 +337,8 @@ "content/self-host/upgrading/4-15/4157.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", - "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T16:50:38+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T16:50:38+08:00", + "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T20:41:16+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T20:41:16+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", diff --git a/packages/global/core/ai/model.schema.ts b/packages/global/core/ai/model.schema.ts index cbb9dd6dd444..8a12eeb6e7fa 100644 --- a/packages/global/core/ai/model.schema.ts +++ b/packages/global/core/ai/model.schema.ts @@ -34,9 +34,9 @@ const PriceTypeSchema = z.object({ export type PriceType = z.infer; const BaseModelItemSchema = z.object({ - provider: z.string(), - model: z.string(), - name: z.string(), + provider: z.string().trim().min(1), + model: z.string().trim().min(1), + name: z.string().trim().min(1), avatar: z.string().optional(), // model icon, from provider isActive: z.boolean().optional(), @@ -70,8 +70,8 @@ export const LLMModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.sha reasoning: z.boolean().optional(), reasoningEffort: z.boolean().optional(), - functionCall: z.boolean(), - toolChoice: z.boolean(), + functionCall: z.boolean().optional(), + toolChoice: z.boolean().optional(), defaultSystemChatPrompt: z.string().optional(), defaultConfig: z.record(z.string(), z.any()).optional(), @@ -127,3 +127,36 @@ export const STTModelItemSchema = PriceTypeSchema.extend(BaseModelItemSchema.sha type: z.literal(ModelTypeEnum.stt) }); export type STTModelType = z.infer; + +export const SystemModelItemSchema = z.discriminatedUnion('type', [ + LLMModelItemSchema, + EmbeddingModelItemSchema, + TTSModelItemSchema, + STTModelItemSchema, + RerankModelItemSchema +]); +export type SystemModelItemType = z.infer; + +export const PersistedSystemModelItemSchema = SystemModelItemSchema.transform((metadata) => { + const persistedMetadata = { ...metadata } as Record; + + delete persistedMetadata.avatar; + delete persistedMetadata.isCustom; + delete persistedMetadata.datasetProcess; + delete persistedMetadata.usedInClassify; + delete persistedMetadata.usedInExtractFields; + delete persistedMetadata.usedInToolCall; + delete persistedMetadata.useInEvaluation; + + for (const [key, value] of Object.entries(persistedMetadata)) { + if (value === undefined) delete persistedMetadata[key]; + } + + if (Array.isArray(persistedMetadata.priceTiers)) { + delete persistedMetadata.charsPointsPrice; + delete persistedMetadata.inputPrice; + delete persistedMetadata.outputPrice; + } + + return persistedMetadata as SystemModelItemType; +}); diff --git a/packages/global/openapi/admin/core/ai/model/api.ts b/packages/global/openapi/admin/core/ai/model/api.ts new file mode 100644 index 000000000000..c6c04b6d4aca --- /dev/null +++ b/packages/global/openapi/admin/core/ai/model/api.ts @@ -0,0 +1,84 @@ +import { SystemModelItemSchema } from '../../../../../core/ai/model.schema'; +import z from 'zod'; + +/* ============================================================================ + * API: 更新系统模型配置 + * Route: PUT /api/core/ai/model/update + * Method: PUT + * Description: 合并并严格校验指定系统模型的配置 + * Tags: ['管理员系统配置', 'Write'] + * ============================================================================ */ + +export const UpdateSystemModelBodySchema = z.object({ + model: z.string().trim().min(1).meta({ + example: 'gpt-5', + description: '待更新的模型标识' + }), + metadata: z.record(z.string(), z.unknown()).optional().meta({ + description: '本次更新的模型配置字段;与已有配置合并后执行完整模型校验' + }) +}); +export type UpdateSystemModelBody = z.infer; + +export const UpdateSystemModelResponseSchema = z.undefined().meta({ + description: '模型配置更新成功' +}); +export type UpdateSystemModelResponse = z.infer; + +const ImportedSystemModelRecordSchema = z + .object({ + model: z.string().trim().min(1), + metadata: z.record(z.string(), z.unknown()) + }) + .transform(({ model, metadata }) => ({ + model, + metadata: { + ...metadata, + model, + name: typeof metadata.name === 'string' && metadata.name.trim() ? metadata.name.trim() : model + } + })) + .pipe( + z.object({ + model: z.string(), + metadata: SystemModelItemSchema + }) + ); + +const ImportedSystemModelListSchema = z.array(ImportedSystemModelRecordSchema); + +const JsonSystemModelListSchema = z.string().transform((value, ctx) => { + try { + return JSON.parse(value) as unknown; + } catch { + ctx.addIssue({ + code: 'custom', + message: 'config must be valid JSON' + }); + return z.NEVER; + } +}); + +/* ============================================================================ + * API: 导入系统模型配置 + * Route: PUT /api/core/ai/model/updateWithJson + * Method: PUT + * Description: 使用 JSON 配置严格校验并覆盖系统模型记录 + * Tags: ['管理员系统配置', 'Write'] + * ============================================================================ */ + +export const UpdateSystemModelsWithJsonBodySchema = z.object({ + config: JsonSystemModelListSchema.pipe(ImportedSystemModelListSchema).meta({ + example: '[{"model":"gpt-5","metadata":{"type":"llm"}}]', + description: '系统模型配置 JSON;解析后每条 metadata 必须符合完整模型 Schema' + }) +}); +export type UpdateSystemModelsWithJsonBody = z.input; +export type ParsedSystemModelsWithJsonBody = z.output; + +export const UpdateSystemModelsWithJsonResponseSchema = z.undefined().meta({ + description: '模型配置导入成功' +}); +export type UpdateSystemModelsWithJsonResponse = z.infer< + typeof UpdateSystemModelsWithJsonResponseSchema +>; diff --git a/packages/global/openapi/admin/core/ai/model/index.ts b/packages/global/openapi/admin/core/ai/model/index.ts new file mode 100644 index 000000000000..13ddaaa72f99 --- /dev/null +++ b/packages/global/openapi/admin/core/ai/model/index.ts @@ -0,0 +1,59 @@ +import type { OpenAPIPath } from '../../../../type'; +import { DevApiTagsMap } from '../../../../tag'; +import { + UpdateSystemModelBodySchema, + UpdateSystemModelResponseSchema, + UpdateSystemModelsWithJsonBodySchema, + UpdateSystemModelsWithJsonResponseSchema +} from './api'; + +export const AdminSystemModelPath: OpenAPIPath = { + '/core/ai/model/update': { + put: { + summary: '更新系统模型配置', + description: '合并并严格校验指定系统模型的配置', + tags: [DevApiTagsMap.adminSettings], + requestBody: { + content: { + 'application/json': { + schema: UpdateSystemModelBodySchema + } + } + }, + responses: { + 200: { + description: '更新成功', + content: { + 'application/json': { + schema: UpdateSystemModelResponseSchema + } + } + } + } + } + }, + '/core/ai/model/updateWithJson': { + put: { + summary: '导入系统模型配置', + description: '严格校验 JSON 内容后覆盖系统模型配置', + tags: [DevApiTagsMap.adminSettings], + requestBody: { + content: { + 'application/json': { + schema: UpdateSystemModelsWithJsonBodySchema + } + } + }, + responses: { + 200: { + description: '导入成功', + content: { + 'application/json': { + schema: UpdateSystemModelsWithJsonResponseSchema + } + } + } + } + } + } +}; diff --git a/packages/global/openapi/admin/core/index.ts b/packages/global/openapi/admin/core/index.ts index 419da11c7df7..6e32d7843b64 100644 --- a/packages/global/openapi/admin/core/index.ts +++ b/packages/global/openapi/admin/core/index.ts @@ -3,10 +3,12 @@ import { AdminAppPath } from './app'; import { AdminRoutesPath } from '../routes'; import { AdminCommonPath } from '../common'; import type { OpenAPIPath } from '../../type'; +import { AdminSystemModelPath } from './ai/model'; export const AdminCorePath: OpenAPIPath = { ...DashboardPath, ...AdminAppPath, ...AdminRoutesPath, - ...AdminCommonPath + ...AdminCommonPath, + ...AdminSystemModelPath }; diff --git a/packages/global/openapi/common/system/api.ts b/packages/global/openapi/common/system/api.ts index f881aa276ff2..20724a5d2288 100644 --- a/packages/global/openapi/common/system/api.ts +++ b/packages/global/openapi/common/system/api.ts @@ -6,7 +6,8 @@ import { LLMModelItemSchema, RerankModelItemSchema, STTModelItemSchema, - TTSModelItemSchema + TTSModelItemSchema, + SystemModelItemSchema } from '../../../core/ai/model.schema'; import { ModelTypeEnum } from '../../../core/ai/constants'; @@ -31,17 +32,9 @@ const FastGPTFeConfigsSchema = z.looseObject({ uploadFileMaxSize: z.number() }) as z.ZodType; -const SystemModelSchema = z - .discriminatedUnion('type', [ - LLMModelItemSchema, - EmbeddingModelItemSchema, - TTSModelItemSchema, - STTModelItemSchema, - RerankModelItemSchema - ]) - .meta({ - description: '脱敏后的系统模型配置' - }); +const SystemModelSchema = SystemModelItemSchema.meta({ + description: '脱敏后的系统模型配置' +}); const I18nStringStrictSchema = z.object({ en: z.string(), diff --git a/packages/global/test/openapi/common/system.test.ts b/packages/global/test/openapi/common/system.test.ts index 120d3c37e6a3..a22b1a16345a 100644 --- a/packages/global/test/openapi/common/system.test.ts +++ b/packages/global/test/openapi/common/system.test.ts @@ -2,7 +2,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { ModelTypeEnum } from '../../../core/ai/constants'; import { EmbeddingModelItemSchema, - type EmbeddingModelItemType + type EmbeddingModelItemType, + LLMModelItemSchema } from '../../../core/ai/model.schema'; import { GetSystemInitDataResponseSchema } from '../../../openapi/common/system/api'; @@ -40,4 +41,18 @@ describe('system initialization OpenAPI contract', () => { weight: 2 }); }); + + it('accepts an LLM model without functionCall', () => { + expect( + LLMModelItemSchema.parse({ + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'gpt-5', + name: 'GPT-5', + maxContext: 128000, + maxResponse: 16000, + quoteMaxToken: 12000 + }) + ).not.toHaveProperty('functionCall'); + }); }); diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index 72a8f9a9a09a..ae8a10fb3d2d 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -6,7 +6,8 @@ import { type EmbeddingModelItemType, type TTSModelType, type STTModelType, - type RerankModelItemType + type RerankModelItemType, + PersistedSystemModelItemSchema } from '@fastgpt/global/core/ai/model.schema'; import { debounce } from 'lodash-es'; import { getModelProvider } from '../../../core/app/provider/controller'; @@ -24,6 +25,26 @@ import { SystemCacheKeyEnum } from '../../../common/cache/type'; import { getLogger, LogCategories } from '../../../common/logger'; import { getRuntimeResolvedPriceTiers } from '@fastgpt/global/core/ai/pricing'; +/** + * 生成允许持久化的严格模型配置。运行时字段和废弃字段会被移除,明确默认值由统一 Schema 填充。 + */ +export const parsePersistedSystemModelConfig = ({ + model, + metadata +}: { + model: string; + metadata: Record; +}): SystemModelItemType => { + const normalizedModel = model.trim(); + const persistedMetadata = { + ...metadata, + model: normalizedModel, + name: typeof metadata.name === 'string' ? metadata.name.trim() : metadata.name + }; + + return PersistedSystemModelItemSchema.parse(persistedMetadata); +}; + export const loadSystemModels = async (init = false, language = 'en') => { if (!init && global.systemModelList) return; @@ -134,6 +155,7 @@ export const loadSystemModels = async (init = false, language = 'en') => { ...(model.type === ModelTypeEnum.llm && { maxResponse: model.maxTokens ?? 16000, + maxTemperature: dbLlmMetadata?.maxTemperature ?? model.maxTemperature ?? undefined, reasoning: dbLlmMetadata?.reasoning ?? model.reasoning ?? false, reasoningEffort: dbLlmMetadata?.reasoningEffort ?? model.reasoningEffort ?? false }), diff --git a/projects/app/src/pageComponents/account/model/AddModelBox.tsx b/projects/app/src/pageComponents/account/model/AddModelBox.tsx index 8e089109f530..8465bf62b4d3 100644 --- a/projects/app/src/pageComponents/account/model/AddModelBox.tsx +++ b/projects/app/src/pageComponents/account/model/AddModelBox.tsx @@ -844,7 +844,7 @@ export const ModelEditModal = ({ for (const key of Object.keys(modelData)) { const val = modelData[key]; if (val === null || val === undefined || Number.isNaN(val)) { - modelData[key] = ''; + delete modelData[key]; } } diff --git a/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts new file mode 100644 index 000000000000..ae04745382f3 --- /dev/null +++ b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts @@ -0,0 +1,246 @@ +import { NextAPI } from '@/service/middleware/entry'; +import type { ApiRequestProps } from '@fastgpt/next/type'; +import { BoolSchema, IntSchema } from '@fastgpt/global/common/zod'; +import { defaultQAModels, defaultVectorModels } from '@fastgpt/global/core/ai/constants'; +import { + EmbeddingModelItemSchema, + LLMModelItemSchema, + ModelPriceTierSchema, + PersistedSystemModelItemSchema, + RerankModelItemSchema, + STTModelItemSchema, + TTSModelItemSchema, + type SystemModelItemType +} from '@fastgpt/global/core/ai/model.schema'; +import { MongoSystemModel } from '@fastgpt/service/core/ai/config/schema'; +import { updatedReloadSystemModel } from '@fastgpt/service/core/ai/config/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { authCert } from '@fastgpt/service/support/permission/auth/common'; +import { isDeepStrictEqual } from 'node:util'; +import z from 'zod'; + +const DEFAULT_SAMPLE_LIMIT = 20; + +const CleanSystemModelConfigsBodySchema = z.object({ + dryRun: BoolSchema.optional().default(true), + sampleLimit: IntSchema.max(100).optional().default(DEFAULT_SAMPLE_LIMIT) +}); +export type CleanSystemModelConfigsBody = z.infer; + +const CleanupIssueSchema = z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string() +}); + +const CleanSystemModelConfigsResponseSchema = z.object({ + dryRun: z.boolean(), + scanned: z.number().int().nonnegative(), + unchanged: z.number().int().nonnegative(), + invalid: z.number().int().nonnegative(), + wouldUpdate: z.number().int().nonnegative(), + updated: z.number().int().nonnegative(), + invalidSamples: z.array( + z.object({ + model: z.string(), + issues: z.array(CleanupIssueSchema) + }) + ) +}); +export type CleanSystemModelConfigsResponse = z.infer; + +const parseLegacyNumber = (value: unknown) => { + if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; + if (typeof value !== 'string' || value.trim() === '') return undefined; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const LegacyOptionalNumberSchema = z.preprocess(parseLegacyNumber, z.number().optional()); +const legacyNumberWithDefaultSchema = (defaultValue: number) => + z.preprocess((value) => parseLegacyNumber(value) ?? defaultValue, z.number()); + +const defaultLlmConfig = defaultQAModels[0]; +const defaultEmbeddingConfig = defaultVectorModels[0]; + +const LegacyModelPriceTierSchema = ModelPriceTierSchema.extend({ + minInputTokens: LegacyOptionalNumberSchema, + maxInputTokens: LegacyOptionalNumberSchema, + inputPrice: legacyNumberWithDefaultSchema(0), + outputPrice: legacyNumberWithDefaultSchema(0) +}); + +const LegacyPriceTiersSchema = z.preprocess((value) => { + if (value === '' || value === null) return undefined; + if (typeof value !== 'string') return value; + + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +}, z.array(LegacyModelPriceTierSchema).optional()); + +const legacyPriceFields = { + charsPointsPrice: LegacyOptionalNumberSchema, + priceTiers: LegacyPriceTiersSchema, + inputPrice: LegacyOptionalNumberSchema, + outputPrice: LegacyOptionalNumberSchema +}; + +const LegacySystemModelItemSchema = z + .discriminatedUnion('type', [ + LLMModelItemSchema.extend({ + ...legacyPriceFields, + maxContext: legacyNumberWithDefaultSchema(defaultLlmConfig.maxContext), + maxResponse: legacyNumberWithDefaultSchema(defaultLlmConfig.maxResponse), + quoteMaxToken: legacyNumberWithDefaultSchema(defaultLlmConfig.quoteMaxToken), + maxTemperature: LegacyOptionalNumberSchema + }), + EmbeddingModelItemSchema.extend({ + ...legacyPriceFields, + defaultToken: legacyNumberWithDefaultSchema(defaultEmbeddingConfig.defaultToken), + maxToken: legacyNumberWithDefaultSchema(defaultEmbeddingConfig.maxToken), + weight: LegacyOptionalNumberSchema + }), + TTSModelItemSchema.extend(legacyPriceFields), + STTModelItemSchema.extend(legacyPriceFields), + RerankModelItemSchema.extend({ + ...legacyPriceFields, + maxToken: LegacyOptionalNumberSchema + }) + ]) + .transform((metadata) => PersistedSystemModelItemSchema.parse(metadata)); + +export type SystemModelCleanupResult = + | { + status: 'valid'; + changed: boolean; + metadata: SystemModelItemType; + } + | { + status: 'invalid'; + issues: Array<{ path: Array; message: string }>; + }; + +/** 只转换已知历史格式,无法通过当前完整 Schema 的记录留给管理员人工处理。 */ +export const cleanSystemModelConfig = ({ + model, + metadata +}: { + model: unknown; + metadata: unknown; +}): SystemModelCleanupResult => { + if (typeof model !== 'string' || !metadata || typeof metadata !== 'object') { + return { + status: 'invalid', + issues: [{ path: [], message: 'model and metadata are required' }] + }; + } + + const normalizedModel = model.trim(); + const rawMetadata = metadata as Record; + const result = LegacySystemModelItemSchema.safeParse({ + ...rawMetadata, + model: normalizedModel, + name: + typeof rawMetadata.name === 'string' && rawMetadata.name.trim() + ? rawMetadata.name.trim() + : normalizedModel + }); + + if (!result.success) { + return { + status: 'invalid', + issues: result.error.issues.map((issue) => ({ + path: issue.path.map((item) => + typeof item === 'symbol' ? (item.description ?? '') : item + ), + message: issue.message + })) + }; + } + + return { + status: 'valid', + changed: !isDeepStrictEqual(rawMetadata, result.data), + metadata: result.data + }; +}; + +/** 扫描并按批次清洗历史系统模型配置,默认仅返回预览统计。 */ +export const runCleanSystemModelConfigs = async ({ + dryRun, + sampleLimit +}: CleanSystemModelConfigsBody): Promise => { + const stats: CleanSystemModelConfigsResponse = { + dryRun, + scanned: 0, + unchanged: 0, + invalid: 0, + wouldUpdate: 0, + updated: 0, + invalidSamples: [] + }; + type BulkOperation = Parameters[0][number]; + const operations: BulkOperation[] = []; + + const cursor = MongoSystemModel.find({}, { model: 1, metadata: 1 }).lean().cursor(); + for await (const record of cursor) { + stats.scanned += 1; + const cleaned = cleanSystemModelConfig({ + model: record.model, + metadata: record.metadata + }); + + if (cleaned.status === 'invalid') { + stats.invalid += 1; + if (stats.invalidSamples.length < sampleLimit) { + stats.invalidSamples.push({ + model: typeof record.model === 'string' ? record.model : String(record._id), + issues: cleaned.issues + }); + } + continue; + } + if (!cleaned.changed) { + stats.unchanged += 1; + continue; + } + if (dryRun) { + stats.wouldUpdate += 1; + continue; + } + + operations.push({ + updateOne: { + filter: { _id: record._id }, + update: { $set: { metadata: cleaned.metadata } } + } + }); + } + + if (operations.length > 0) { + const result = await MongoSystemModel.bulkWrite(operations, { ordered: false }); + stats.updated = result.modifiedCount; + } + + if (!dryRun && stats.updated > 0) { + await updatedReloadSystemModel(); + } + + return CleanSystemModelConfigsResponseSchema.parse(stats); +}; + +async function handler(req: ApiRequestProps): Promise { + await authCert({ req, authRoot: true }); + + const { body } = parseApiInput({ + req, + bodySchema: CleanSystemModelConfigsBodySchema + }); + + return runCleanSystemModelConfigs(body); +} + +export default NextAPI(handler); diff --git a/projects/app/src/pages/api/core/ai/model/update.ts b/projects/app/src/pages/api/core/ai/model/update.ts index e04e0f2c30e3..34b12612079b 100644 --- a/projects/app/src/pages/api/core/ai/model/update.ts +++ b/projects/app/src/pages/api/core/ai/model/update.ts @@ -1,69 +1,47 @@ -import type { ApiRequestProps, ApiResponseType } from '@fastgpt/next/type'; +import type { ApiRequestProps } from '@fastgpt/next/type'; import { NextAPI } from '@/service/middleware/entry'; import { authSystemAdmin } from '@fastgpt/service/support/permission/user/auth'; import { MongoSystemModel } from '@fastgpt/service/core/ai/config/schema'; import { findModelFromAlldata } from '@fastgpt/service/core/ai/model'; -import { updatedReloadSystemModel } from '@fastgpt/service/core/ai/config/utils'; -import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; - -export type updateBody = { - model: string; - metadata?: Record; -}; - -async function handler(req: ApiRequestProps, _res: ApiResponseType) { +import { + parsePersistedSystemModelConfig, + updatedReloadSystemModel +} from '@fastgpt/service/core/ai/config/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { + UpdateSystemModelBodySchema, + UpdateSystemModelResponseSchema, + type UpdateSystemModelBody, + type UpdateSystemModelResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +export type updateBody = UpdateSystemModelBody; + +async function handler(req: ApiRequestProps): Promise { await authSystemAdmin({ req }); - const metadata = req.body.metadata; - let { model } = req.body; - if (!model) return Promise.reject(new Error('model is required')); - model = model.trim(); + const { model, metadata = {} } = parseApiInput({ + req, + bodySchema: UpdateSystemModelBodySchema + }).body; const dbModel = await MongoSystemModel.findOne({ model }).lean(); const modelData = findModelFromAlldata(model); - const metadataConcat: Record = { - ...modelData, // system config - ...dbModel?.metadata, // db config - ...metadata // user config - }; - delete metadataConcat.avatar; - delete metadataConcat.isCustom; - - // delete deprecated fields - delete metadataConcat.datasetProcess; - delete metadataConcat.usedInClassify; - delete metadataConcat.usedInExtractFields; - delete metadataConcat.usedInToolCall; - delete metadataConcat.useInEvaluation; - // TODO: 这里应该是所有模型,而不是仅LLM,我再看看 - if (metadataConcat.type === ModelTypeEnum.llm && Array.isArray(metadataConcat.priceTiers)) { - delete metadataConcat.charsPointsPrice; - delete metadataConcat.inputPrice; - delete metadataConcat.outputPrice; - } - - // 强制赋值 model,避免脏的 metadata 覆盖真实 model - metadataConcat.model = model; - metadataConcat.name = metadataConcat?.name?.trim(); - - // Delete null value - Object.keys(metadataConcat).forEach((key) => { - if (metadataConcat[key] === null || metadataConcat[key] === undefined) { - delete metadataConcat[key]; + const persistedMetadata = parsePersistedSystemModelConfig({ + model, + metadata: { + ...modelData, // system config + ...dbModel?.metadata, // db config + ...metadata // user config } }); - // 强制更新 defaultConfig 数据类型 - if ('defaultConfig' in metadataConcat && typeof metadataConcat.defaultConfig !== 'object') { - metadataConcat.defaultConfig = {}; - } - await MongoSystemModel.updateOne( { model }, { model, - metadata: metadataConcat + metadata: persistedMetadata }, { upsert: true @@ -72,7 +50,7 @@ async function handler(req: ApiRequestProps, _res: ApiResponseType) { +import { + parsePersistedSystemModelConfig, + updatedReloadSystemModel +} from '@fastgpt/service/core/ai/config/utils'; +import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { + UpdateSystemModelsWithJsonBodySchema, + UpdateSystemModelsWithJsonResponseSchema, + type UpdateSystemModelsWithJsonBody, + type UpdateSystemModelsWithJsonResponse +} from '@fastgpt/global/openapi/admin/core/ai/model/api'; + +export type updateWithJsonBody = UpdateSystemModelsWithJsonBody; + +async function handler( + req: ApiRequestProps +): Promise { await authSystemAdmin({ req }); - const { config } = req.body; - const data = JSON.parse(config) as SystemModelSchemaType[]; - - // Check - for (const item of data) { - if (!item.model || !item.metadata || typeof item.metadata !== 'object') { - return Promise.reject('Invalid model or metadata'); - } - if (!item.metadata.type) { - return Promise.reject(`${item.model} metadata.type is required`); - } - if (!item.metadata.model) { - return Promise.reject(`${item.model} metadata.model is required`); - } - if (!item.metadata.provider) { - return Promise.reject(`${item.model} metadata.provider is required`); - } - item.metadata.model = item.model.trim(); - if (!item.metadata.name) { - item.metadata.name = item.model; - } - if ('defaultConfig' in item.metadata && typeof item.metadata.defaultConfig !== 'object') { - { - item.metadata.defaultConfig = {}; - } - } - } + const { config } = parseApiInput({ + req, + bodySchema: UpdateSystemModelsWithJsonBodySchema + }).body; + const data = config.map(({ model, metadata }) => ({ + model, + metadata: parsePersistedSystemModelConfig({ model, metadata }) + })); await mongoSessionRun(async (session) => { await MongoSystemModel.deleteMany({}, { session }); @@ -54,7 +44,7 @@ async function handler(req: ApiRequestProps) { await updatedReloadSystemModel(); - return {}; + return UpdateSystemModelsWithJsonResponseSchema.parse(undefined); } export default NextAPI(handler); diff --git a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts new file mode 100644 index 000000000000..743b2ad03d65 --- /dev/null +++ b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts @@ -0,0 +1,201 @@ +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { MongoSystemModel } from '@fastgpt/service/core/ai/config/schema'; +import { + default as cleanSystemModelConfigsApi, + cleanSystemModelConfig, + runCleanSystemModelConfigs +} from '@/pages/api/admin/dataClean/cleanSystemModelConfigs'; +import { getRootUser } from '@test/datas/users'; +import { Call } from '@test/utils/request'; +import { updatedReloadSystemModel } from '@fastgpt/service/core/ai/config/utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + updatedReloadSystemModel: vi.fn().mockResolvedValue(undefined) + }; +}); + +const baseLlmModel = { + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'dirty-model', + name: 'Dirty Model', + maxContext: 16000, + maxResponse: 8000, + quoteMaxToken: 12000 +}; + +describe('cleanSystemModelConfig', () => { + it('coerces known numeric strings and removes empty optional fields', () => { + const result = cleanSystemModelConfig({ + model: ' clean-model ', + metadata: { + ...baseLlmModel, + charsPointsPrice: '', + maxContext: '32000', + maxResponse: '16000', + quoteMaxToken: '24000', + maxTemperature: '1.2' + } + }); + + expect(result).toEqual({ + status: 'valid', + changed: true, + metadata: expect.objectContaining({ + model: 'clean-model', + maxContext: 32000, + maxResponse: 16000, + quoteMaxToken: 24000, + maxTemperature: 1.2 + }) + }); + if (result.status === 'valid') { + expect(result.metadata).not.toHaveProperty('charsPointsPrice'); + expect(result.metadata).not.toHaveProperty('functionCall'); + } + }); + + it('parses JSON price tiers and fills the embedding weight default', () => { + const result = cleanSystemModelConfig({ + model: 'embedding-model', + metadata: { + type: ModelTypeEnum.embedding, + provider: 'OpenAI', + model: 'embedding-model', + name: 'Embedding Model', + defaultToken: '500', + maxToken: '3000', + priceTiers: JSON.stringify([ + { + minInputTokens: '0', + maxInputTokens: '100', + inputPrice: '0.1', + outputPrice: '0.2' + } + ]) + } + }); + + expect(result).toEqual({ + status: 'valid', + changed: true, + metadata: expect.objectContaining({ + weight: 0, + defaultToken: 500, + maxToken: 3000, + priceTiers: [ + { + minInputTokens: 0, + maxInputTokens: 100, + inputPrice: 0.1, + outputPrice: 0.2 + } + ] + }) + }); + }); + + it('removes invalid optional numbers and defaults invalid required numbers', () => { + const result = cleanSystemModelConfig({ + model: 'invalid-number-model', + metadata: { + ...baseLlmModel, + maxContext: 'invalid', + maxTemperature: 'invalid' + } + }); + + expect(result).toMatchObject({ + status: 'valid', + metadata: { maxContext: 16000 } + }); + if (result.status === 'valid') { + expect(result.metadata).not.toHaveProperty('maxTemperature'); + } + }); + + it('rejects records without a usable model and metadata object', () => { + expect(cleanSystemModelConfig({ model: null, metadata: null })).toEqual({ + status: 'invalid', + issues: [{ path: [], message: 'model and metadata are required' }] + }); + }); +}); + +describe('runCleanSystemModelConfigs', () => { + beforeEach(async () => { + vi.clearAllMocks(); + await MongoSystemModel.deleteMany({}); + }); + + it('defaults to a non-destructive preview and updates only valid records when executed', async () => { + await MongoSystemModel.collection.insertMany([ + { + model: 'embedding-model', + metadata: { + type: ModelTypeEnum.embedding, + provider: 'OpenAI', + model: 'embedding-model', + name: 'Embedding Model', + defaultToken: '500', + maxToken: '3000' + } + }, + { + model: 'invalid-model', + metadata: { ...baseLlmModel, model: 'invalid-model', type: 'unknown' } + } + ]); + + await expect( + runCleanSystemModelConfigs({ dryRun: true, sampleLimit: 10 }) + ).resolves.toMatchObject({ + dryRun: true, + scanned: 2, + invalid: 1, + wouldUpdate: 1, + updated: 0, + invalidSamples: [{ model: 'invalid-model' }] + }); + expect(updatedReloadSystemModel).not.toHaveBeenCalled(); + await expect( + MongoSystemModel.collection.findOne({ model: 'embedding-model' }) + ).resolves.toMatchObject({ metadata: { defaultToken: '500' } }); + + await expect( + runCleanSystemModelConfigs({ dryRun: false, sampleLimit: 10 }) + ).resolves.toMatchObject({ + dryRun: false, + invalid: 1, + updated: 1 + }); + expect(updatedReloadSystemModel).toHaveBeenCalledTimes(1); + await expect( + MongoSystemModel.findOne({ model: 'embedding-model' }).lean() + ).resolves.toMatchObject({ + metadata: { defaultToken: 500, maxToken: 3000, weight: 0 } + }); + }); + + it('uses dry-run defaults at the authenticated API boundary', async () => { + const root = await getRootUser(); + const res = await Call(cleanSystemModelConfigsApi, { + auth: root, + body: {} + }); + + expect(res).toMatchObject({ + code: 200, + data: { + dryRun: true, + scanned: 0, + updated: 0 + } + }); + }); +}); diff --git a/projects/app/test/pages/api/core/ai/model/update.test.ts b/projects/app/test/pages/api/core/ai/model/update.test.ts new file mode 100644 index 000000000000..a3977a0f7b0b --- /dev/null +++ b/projects/app/test/pages/api/core/ai/model/update.test.ts @@ -0,0 +1,105 @@ +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { MongoSystemModel } from '@fastgpt/service/core/ai/config/schema'; +import { Call } from '@test/utils/request'; +import { getRootUser } from '@test/datas/users'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + updatedReloadSystemModel: vi.fn().mockResolvedValue(undefined) + }; +}); + +import updateModelApi from '@/pages/api/core/ai/model/update'; + +const buildLlmMetadata = () => ({ + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'test-llm', + name: 'Test LLM', + maxContext: 16000, + maxResponse: 8000, + quoteMaxToken: 12000, + toolChoice: true, + isActive: true +}); + +const callUpdate = async (body: unknown) => { + const root = await getRootUser(); + + return Call(updateModelApi, { + auth: root, + body + }); +}; + +describe('update model api', () => { + it('validates the complete merged model, fills defaults and removes runtime fields', async () => { + const res = await callUpdate({ + model: ' test-llm ', + metadata: { + ...buildLlmMetadata(), + avatar: '/model.svg', + isCustom: true, + datasetProcess: true + } + }); + + expect(res.code).toBe(200); + expect(res.data).toBeUndefined(); + const saved = await MongoSystemModel.findOne({ model: 'test-llm' }).lean(); + expect(saved?.metadata).toMatchObject({ + model: 'test-llm' + }); + expect(saved?.metadata).not.toHaveProperty('functionCall'); + expect(saved?.metadata).not.toHaveProperty('avatar'); + expect(saved?.metadata).not.toHaveProperty('isCustom'); + expect(saved?.metadata).not.toHaveProperty('datasetProcess'); + }); + + it('supports strict partial updates by validating after merging persisted metadata', async () => { + await MongoSystemModel.create({ + model: 'test-llm', + metadata: { + ...buildLlmMetadata(), + functionCall: false + } + }); + + const res = await callUpdate({ + model: 'test-llm', + metadata: { maxTemperature: 1.2 } + }); + + expect(res.code).toBe(200); + await expect(MongoSystemModel.findOne({ model: 'test-llm' }).lean()).resolves.toMatchObject({ + metadata: { + maxContext: 16000, + maxTemperature: 1.2 + } + }); + }); + + it('rejects numeric strings without modifying the database', async () => { + const res = await callUpdate({ + model: 'test-llm', + metadata: { + ...buildLlmMetadata(), + maxTemperature: '1.2' + } + }); + + expect(res.code).toBe(500); + await expect(MongoSystemModel.countDocuments()).resolves.toBe(0); + }); + + it('rejects malformed request bodies through parseApiInput', async () => { + const res = await callUpdate({ metadata: buildLlmMetadata() }); + + expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); + }); +}); diff --git a/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts b/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts index 5c6d536e60a5..2909a4b86e86 100644 --- a/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts +++ b/projects/app/test/pages/api/core/ai/model/updateWithJson.test.ts @@ -1,5 +1,4 @@ import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; -import type { SystemModelSchemaType } from '@fastgpt/service/core/ai/type'; import { MongoSystemModel } from '@fastgpt/service/core/ai/config/schema'; import { Call } from '@test/utils/request'; import { getRootUser } from '@test/datas/users'; @@ -16,146 +15,105 @@ vi.mock('@fastgpt/service/core/ai/config/utils', async (importOriginal) => { import updateWithJsonApi from '@/pages/api/core/ai/model/updateWithJson'; -const buildModelConfig = ( - metadata: Partial & { - type: SystemModelSchemaType['metadata']['type']; - }, - model = ' test-model ' -): SystemModelSchemaType => - ({ - _id: 'model-config-id', +const buildLlmConfig = (model = 'test-llm') => ({ + model, + metadata: { + type: ModelTypeEnum.llm, + provider: 'OpenAI', + model: 'dirty-model', + name: 'Test LLM', + maxContext: 16000, + maxResponse: 8000, + quoteMaxToken: 12000, + toolChoice: true, + isActive: true + } +}); + +const buildEmbeddingConfig = (model = 'test-embedding') => ({ + model, + metadata: { + type: ModelTypeEnum.embedding, + provider: 'OpenAI', model, - metadata: { - type: metadata.type, - model: 'dirty-model', - name: '', - provider: 'OpenAI', - ...metadata - } - }) as SystemModelSchemaType; - -const callUpdateWithJson = async (data: SystemModelSchemaType[]) => { + name: 'Test Embedding', + defaultToken: 500, + maxToken: 3000, + isActive: true + } +}); + +const callUpdateWithJson = async (config: string) => { const root = await getRootUser(); return Call(updateWithJsonApi, { auth: root, - body: { - config: JSON.stringify(data) - } + body: { config } }); }; -const findSavedModel = (model: string) => MongoSystemModel.findOne({ model }).lean(); - describe('updateWithJson api', () => { - it('imports configs, clears old records and normalizes model metadata', async () => { + it('strictly imports valid models, fills defaults and clears old records', async () => { await MongoSystemModel.create({ model: 'old-model', - metadata: buildModelConfig({ type: ModelTypeEnum.llm }, 'old-model').metadata + metadata: buildLlmConfig('old-model').metadata }); - const res = await callUpdateWithJson([buildModelConfig({ type: ModelTypeEnum.llm })]); + const res = await callUpdateWithJson( + JSON.stringify([buildLlmConfig(' test-llm '), buildEmbeddingConfig()]) + ); expect(res.code).toBe(200); - expect(res.data).toEqual({}); - await expect(findSavedModel('old-model')).resolves.toBeNull(); + expect(res.data).toBeUndefined(); + await expect(MongoSystemModel.findOne({ model: 'old-model' })).resolves.toBeNull(); + await expect(MongoSystemModel.findOne({ model: 'test-llm' }).lean()).resolves.toMatchObject({ + metadata: { + model: 'test-llm' + } + }); + const savedLlm = await MongoSystemModel.findOne({ model: 'test-llm' }).lean(); + expect(savedLlm?.metadata).not.toHaveProperty('functionCall'); + await expect( + MongoSystemModel.findOne({ model: 'test-embedding' }).lean() + ).resolves.toMatchObject({ + metadata: { + weight: 0 + } + }); + }); - const saved = await findSavedModel(' test-model '); + it('rejects numeric strings and preserves existing records', async () => { + await MongoSystemModel.create({ + model: 'existing-model', + metadata: buildLlmConfig('existing-model').metadata + }); + const invalidConfig = buildLlmConfig(); + invalidConfig.metadata.maxContext = '16000' as unknown as number; - expect(saved?.metadata.model).toBe('test-model'); - expect(saved?.metadata.name).toBe(' test-model '); - expect(saved?.metadata.provider).toBe('OpenAI'); - }); + const res = await callUpdateWithJson(JSON.stringify([invalidConfig])); - it('does not add missing defaultConfig and only sanitizes existing non-object values', async () => { - const objectDefaultConfig = { extra_body: { enable_thinking: false } }; - const data = [ - buildModelConfig({ type: ModelTypeEnum.llm }, 'missing-default-config'), - buildModelConfig( - { type: ModelTypeEnum.embedding, defaultConfig: '' as any }, - 'empty-string-default-config' - ), - buildModelConfig( - { type: ModelTypeEnum.rerank, defaultConfig: 1 as any }, - 'number-default-config' - ), - buildModelConfig( - { type: ModelTypeEnum.llm, defaultConfig: null as any }, - 'null-default-config' - ), - buildModelConfig( - { type: ModelTypeEnum.llm, defaultConfig: objectDefaultConfig }, - 'object-default-config' - ) - ]; - - const res = await callUpdateWithJson(data); + expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); + await expect(MongoSystemModel.findOne({ model: 'existing-model' })).resolves.not.toBeNull(); + await expect(MongoSystemModel.findOne({ model: 'test-llm' })).resolves.toBeNull(); + }); - expect(res.code).toBe(200); + it('rejects malformed JSON as an input parse error', async () => { + const res = await callUpdateWithJson('{invalid-json'); - await expect(findSavedModel('missing-default-config')).resolves.toMatchObject({ - metadata: expect.not.objectContaining({ defaultConfig: expect.anything() }) - }); - await expect(findSavedModel('empty-string-default-config')).resolves.toMatchObject({ - metadata: { defaultConfig: {} } - }); - await expect(findSavedModel('number-default-config')).resolves.toMatchObject({ - metadata: { defaultConfig: {} } - }); - await expect(findSavedModel('null-default-config')).resolves.toMatchObject({ - metadata: { defaultConfig: null } - }); - await expect(findSavedModel('object-default-config')).resolves.toMatchObject({ - metadata: { defaultConfig: objectDefaultConfig } - }); + expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); + await expect(MongoSystemModel.countDocuments()).resolves.toBe(0); }); - it.each([ - ['empty item', [{} as SystemModelSchemaType], 'Invalid model or metadata'], - [ - 'missing type', - [ - { - model: 'missing-type', - metadata: { - model: 'missing-type', - provider: 'OpenAI' - } - } as SystemModelSchemaType - ], - 'missing-type metadata.type is required' - ], - [ - 'missing metadata model', - [ - { - model: 'missing-model', - metadata: { - type: ModelTypeEnum.llm, - provider: 'OpenAI' - } - } as SystemModelSchemaType - ], - 'missing-model metadata.model is required' - ], - [ - 'missing provider', - [ - { - model: 'missing-provider', - metadata: { - type: ModelTypeEnum.llm, - model: 'missing-provider' - } - } as SystemModelSchemaType - ], - 'missing-provider metadata.provider is required' - ] - ])('rejects invalid model config: %s', async (_name, data, error) => { - const res = await callUpdateWithJson(data); + it('rejects invalid nested model configuration', async () => { + const config = buildLlmConfig(); + config.metadata.defaultConfig = '' as unknown as Record; + + const res = await callUpdateWithJson(JSON.stringify([config])); expect(res.code).toBe(500); - expect(res.error).toBe(error); + expect(res.error?.name).toBe('ApiRequestInputParseError'); await expect(MongoSystemModel.countDocuments()).resolves.toBe(0); }); }); From 634afda1b28c248be12d6aa13bcb293d56e4bbf1 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 11 Aug 2026 22:43:06 +0800 Subject: [PATCH 03/15] fix: refresh cleaned model cache --- .../ai/model-config-validation-and-cleanup.md | 4 +-- .../self-host/upgrading/4-16/41602.en.mdx | 2 +- .../self-host/upgrading/4-16/41602.mdx | 2 +- document/data/doc-last-modified.json | 4 +-- packages/service/core/ai/config/utils.ts | 21 +++++++++++- .../service/test/core/ai/config/utils.test.ts | 33 +++++++++++++++++++ .../dataClean/cleanSystemModelConfigs.ts | 4 +-- .../dataClean/cleanSystemModelConfigs.test.ts | 8 +++++ 8 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 packages/service/test/core/ai/config/utils.test.ts diff --git a/.agents/design/ai/model-config-validation-and-cleanup.md b/.agents/design/ai/model-config-validation-and-cleanup.md index d5a3cba61d89..bfcbcb9935ec 100644 --- a/.agents/design/ai/model-config-validation-and-cleanup.md +++ b/.agents/design/ai/model-config-validation-and-cleanup.md @@ -10,7 +10,7 @@ - Schema 只填充有明确语义的默认值:Embedding `weight=0`;新版协议中的 `functionCall` 为可选字段。 - 前端不再用空字符串表示缺失的可选字段。 - 提供管理员历史数据清洗接口,默认 dry-run,无法确定修复方式的数据跳过并报告。 -- 运行时模型加载不承担通用脏数据转换,仅适配插件协议中明确存在的字段差异。 +- 运行时模型加载不承担通用脏数据转换,仅适配插件协议中明确存在的字段差异,例如将 LLM 的 `maxTemperature=null` 统一为字段缺失。 ## 设计 @@ -39,7 +39,7 @@ - 缺失的 `weight` 使用统一 Schema 默认值,不补写可选的 `functionCall`。 - 最终必须通过 `SystemModelItemSchema`;无法修复的记录不更新,只输出字段路径和原因。 - 默认 dry-run;显式传入 `dryRun=false` 才通过一次 `bulkWrite` 写库,可重复执行。 -- 实际写入完成且存在更新时,立即触发统一的系统模型缓存重载。 +- 每次正式执行都立即触发统一的系统模型缓存重载;即使数据已清洗完成,也可通过重复执行重新构建运行时缓存。 ## TODO diff --git a/document/content/self-host/upgrading/4-16/41602.en.mdx b/document/content/self-host/upgrading/4-16/41602.en.mdx index 84c42794d4cd..0d4c2d2fffce 100644 --- a/document/content/self-host/upgrading/4-16/41602.en.mdx +++ b/document/content/self-host/upgrading/4-16/41602.en.mdx @@ -27,7 +27,7 @@ curl -X POST 'https://your-domain/api/admin/dataClean/cleanSystemModelConfigs' \ The cleanup converts valid numeric strings to numbers, parses serialized `priceTiers` arrays, and removes invalid optional numeric fields. Invalid or missing required numeric fields use system defaults: LLM `maxContext/maxResponse/quoteMaxToken` default to `16000/16000/13000`, Embedding `defaultToken/maxToken` default to `500/3000`, and price fields default to `0`. `functionCall` remains optional, and a missing Embedding `weight` defaults to `0`. -The write operation updates all matching records in one operation and immediately reloads the system model cache when at least one record changes. The endpoint is safe to run repeatedly; a follow-up dry run should report `wouldUpdate` as `0`. Records that still fail the complete current model schema are not written and are listed in `invalidSamples`. `sampleLimit` defaults to `20` and has a maximum of `100`. +The write operation updates all matching records in one operation and immediately reloads the system model cache. The runtime cache is rebuilt even when no database record needs an update. The endpoint is safe to run repeatedly; a follow-up dry run should report `wouldUpdate` as `0`. Records that still fail the complete current model schema are not written and are listed in `invalidSamples`. `sampleLimit` defaults to `20` and has a maximum of `100`. ## 🚀 New diff --git a/document/content/self-host/upgrading/4-16/41602.mdx b/document/content/self-host/upgrading/4-16/41602.mdx index d92ea13fe2f8..b2f8f069c603 100644 --- a/document/content/self-host/upgrading/4-16/41602.mdx +++ b/document/content/self-host/upgrading/4-16/41602.mdx @@ -27,7 +27,7 @@ curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' 清洗会将合法数字字符串转换为 number、将字符串形式的 `priceTiers` 转换为数组,并删除非法的可选数字。非法或缺失的必填数字使用系统默认值:LLM 的 `maxContext/maxResponse/quoteMaxToken` 分别为 `16000/16000/13000`,Embedding 的 `defaultToken/maxToken` 分别为 `500/3000`,价格为 `0`。`functionCall` 保持可选,Embedding 缺失的 `weight` 补为 `0`。 -正式执行会统一写入数据,并在存在实际更新时立即刷新系统模型缓存。接口可安全重复执行;再次 dry-run 时,`wouldUpdate` 应为 `0`。无法通过当前完整模型 Schema 的记录不会写入,详情会返回在 `invalidSamples` 中;`sampleLimit` 默认为 `20`,最大为 `100`。 +正式执行会统一写入数据并立即刷新系统模型缓存;即使没有记录需要更新,也会重新构建运行时缓存。接口可安全重复执行;再次 dry-run 时,`wouldUpdate` 应为 `0`。无法通过当前完整模型 Schema 的记录不会写入,详情会返回在 `invalidSamples` 中;`sampleLimit` 默认为 `20`,最大为 `100`。 ## 🚀 新增内容 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index ff03534e2d42..9155b24b45e0 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -337,8 +337,8 @@ "content/self-host/upgrading/4-15/4157.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", - "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T20:41:16+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T20:41:16+08:00", + "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T22:25:10+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T22:25:10+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index ae8a10fb3d2d..8441a364bdec 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -45,6 +45,24 @@ export const parsePersistedSystemModelConfig = ({ return PersistedSystemModelItemSchema.parse(persistedMetadata); }; +/** + * 规范化插件与数据库配置合并后的运行时模型。 + * 插件协议可能使用 null 表示未配置,最终对外模型统一使用字段缺失表示可选值不存在。 + */ +export const normalizeRuntimeSystemModelConfig = < + T extends { type?: unknown; maxTemperature?: unknown } +>( + model: T +): T => { + if (model.type !== ModelTypeEnum.llm || model.maxTemperature !== null) { + return model; + } + + const normalizedModel = { ...model }; + delete normalizedModel.maxTemperature; + return normalizedModel; +}; + export const loadSystemModels = async (init = false, language = 'en') => { if (!init && global.systemModelList) return; @@ -176,7 +194,8 @@ export const loadSystemModels = async (init = false, language = 'en') => { } : {}) }; - pushModel(modelData); + // 按合并后的最终类型处理插件协议空值,避免数据库覆盖类型时遗漏 LLM 规范化。 + pushModel(normalizeRuntimeSystemModelConfig(modelData)); }); // Custom model(Not in system config) diff --git a/packages/service/test/core/ai/config/utils.test.ts b/packages/service/test/core/ai/config/utils.test.ts new file mode 100644 index 000000000000..4a07f11daaae --- /dev/null +++ b/packages/service/test/core/ai/config/utils.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { ModelTypeEnum } from '@fastgpt/global/core/ai/constants'; +import { normalizeRuntimeSystemModelConfig } from '../../../../core/ai/config/utils'; + +describe('normalizeRuntimeSystemModelConfig', () => { + it('removes a null maxTemperature from the final LLM model', () => { + const result = normalizeRuntimeSystemModelConfig({ + type: ModelTypeEnum.llm, + model: 'test-llm', + maxTemperature: null + }); + + expect(result).not.toHaveProperty('maxTemperature'); + }); + + it('preserves a valid LLM maxTemperature', () => { + const result = normalizeRuntimeSystemModelConfig({ + type: ModelTypeEnum.llm, + maxTemperature: 1.2 + }); + + expect(result.maxTemperature).toBe(1.2); + }); + + it('does not normalize fields on non-LLM models', () => { + const result = normalizeRuntimeSystemModelConfig({ + type: ModelTypeEnum.embedding, + maxTemperature: null + }); + + expect(result.maxTemperature).toBeNull(); + }); +}); diff --git a/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts index ae04745382f3..83ae08a4c15e 100644 --- a/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts +++ b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts @@ -168,7 +168,7 @@ export const cleanSystemModelConfig = ({ }; }; -/** 扫描并按批次清洗历史系统模型配置,默认仅返回预览统计。 */ +/** 扫描并一次性清洗历史系统模型配置;正式执行后统一重载系统模型缓存。 */ export const runCleanSystemModelConfigs = async ({ dryRun, sampleLimit @@ -225,7 +225,7 @@ export const runCleanSystemModelConfigs = async ({ stats.updated = result.modifiedCount; } - if (!dryRun && stats.updated > 0) { + if (!dryRun) { await updatedReloadSystemModel(); } diff --git a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts index 743b2ad03d65..92a748c2384b 100644 --- a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts +++ b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts @@ -180,6 +180,14 @@ describe('runCleanSystemModelConfigs', () => { ).resolves.toMatchObject({ metadata: { defaultToken: 500, maxToken: 3000, weight: 0 } }); + + await expect( + runCleanSystemModelConfigs({ dryRun: false, sampleLimit: 10 }) + ).resolves.toMatchObject({ + dryRun: false, + updated: 0 + }); + expect(updatedReloadSystemModel).toHaveBeenCalledTimes(2); }); it('uses dry-run defaults at the authenticated API boundary', async () => { From f9e7032e2bfe8f63e07a715a9beddd04d15353fe Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 11 Aug 2026 22:57:03 +0800 Subject: [PATCH 04/15] fix: update pro for team plan contract --- pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pro b/pro index b77ab4229ba8..9aacdfa81953 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit b77ab4229ba80e47132a8fef6478609c5b783b61 +Subproject commit 9aacdfa81953ddf035c09006f996c4f383b7ad3c From 16f37b7d13716dfa825dacbe9bb6ee4c8e6790bc Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 00:24:25 +0800 Subject: [PATCH 05/15] fix: classify merged model validation errors --- .../global/openapi/admin/core/ai/model/api.ts | 3 +- .../app/src/pages/api/core/ai/model/update.ts | 30 ++++++++++++++----- .../pages/api/core/ai/model/update.test.ts | 3 +- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/global/openapi/admin/core/ai/model/api.ts b/packages/global/openapi/admin/core/ai/model/api.ts index c6c04b6d4aca..e7b46dede102 100644 --- a/packages/global/openapi/admin/core/ai/model/api.ts +++ b/packages/global/openapi/admin/core/ai/model/api.ts @@ -69,7 +69,8 @@ const JsonSystemModelListSchema = z.string().transform((value, ctx) => { export const UpdateSystemModelsWithJsonBodySchema = z.object({ config: JsonSystemModelListSchema.pipe(ImportedSystemModelListSchema).meta({ - example: '[{"model":"gpt-5","metadata":{"type":"llm"}}]', + example: + '[{"model":"gpt-5","metadata":{"type":"llm","provider":"OpenAI","model":"gpt-5","name":"GPT-5","maxContext":400000,"maxResponse":128000,"quoteMaxToken":300000,"toolChoice":true,"isActive":true}}]', description: '系统模型配置 JSON;解析后每条 metadata 必须符合完整模型 Schema' }) }); diff --git a/projects/app/src/pages/api/core/ai/model/update.ts b/projects/app/src/pages/api/core/ai/model/update.ts index 34b12612079b..3cd7b6e123ce 100644 --- a/projects/app/src/pages/api/core/ai/model/update.ts +++ b/projects/app/src/pages/api/core/ai/model/update.ts @@ -7,13 +7,17 @@ import { parsePersistedSystemModelConfig, updatedReloadSystemModel } from '@fastgpt/service/core/ai/config/utils'; -import { parseApiInput } from '@fastgpt/service/common/zod/requestParseError'; +import { + ApiRequestInputParseError, + parseApiInput +} from '@fastgpt/service/common/zod/requestParseError'; import { UpdateSystemModelBodySchema, UpdateSystemModelResponseSchema, type UpdateSystemModelBody, type UpdateSystemModelResponse } from '@fastgpt/global/openapi/admin/core/ai/model/api'; +import { ZodError } from 'zod'; export type updateBody = UpdateSystemModelBody; @@ -28,14 +32,24 @@ async function handler(req: ApiRequestProps): Promise { + try { + return parsePersistedSystemModelConfig({ + model, + metadata: { + ...modelData, // system config + ...dbModel?.metadata, // db config + ...metadata // user config + } + }); + } catch (error) { + if (error instanceof ZodError) { + // metadata 是宽松的增量配置,必须合并后才能校验;失败仍属于请求体错误。 + throw new ApiRequestInputParseError(error, { inputSource: 'body' }); + } + throw error; } - }); + })(); await MongoSystemModel.updateOne( { model }, diff --git a/projects/app/test/pages/api/core/ai/model/update.test.ts b/projects/app/test/pages/api/core/ai/model/update.test.ts index a3977a0f7b0b..7976f70b21b6 100644 --- a/projects/app/test/pages/api/core/ai/model/update.test.ts +++ b/projects/app/test/pages/api/core/ai/model/update.test.ts @@ -92,7 +92,8 @@ describe('update model api', () => { } }); - expect(res.code).toBe(500); + expect(res.error?.name).toBe('ApiRequestInputParseError'); + expect(res.error?.context).toEqual({ inputSource: 'body' }); await expect(MongoSystemModel.countDocuments()).resolves.toBe(0); }); From bb053f0319009a09c3dc81d364891d847a8a89df Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 10:35:10 +0800 Subject: [PATCH 06/15] fix: support legacy subscription plan configs --- packages/global/support/wallet/sub/type.ts | 9 +++- .../global/test/openapi/common/system.test.ts | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/global/support/wallet/sub/type.ts b/packages/global/support/wallet/sub/type.ts index 4c997c160689..8361d1fdd984 100644 --- a/packages/global/support/wallet/sub/type.ts +++ b/packages/global/support/wallet/sub/type.ts @@ -50,7 +50,7 @@ export const TeamStandardSubPlanItemSchema = z.object({ }); export type TeamStandardSubPlanItemType = z.infer; -export const StandSubPlanLevelMapSchema = z.record( +export const StandSubPlanLevelMapSchema = z.partialRecord( z.enum(StandardSubLevelEnum), TeamStandardSubPlanItemSchema ); @@ -64,6 +64,11 @@ export const PointsPackageItemSchema = z.object({ }); export type PointsPackageItem = z.infer; +const OptionalConfigDateSchema = z.preprocess( + (value) => (value === '' || value === null ? undefined : value), + z.coerce.date().optional() +); + export const SubPlanSchema = z.object({ [SubTypeEnum.standard]: StandSubPlanLevelMapSchema.optional(), [SubTypeEnum.extraDatasetSize]: z.object({ price: z.number() }).optional(), @@ -71,7 +76,7 @@ export const SubPlanSchema = z.object({ planDescriptionUrl: z.string().optional(), appRegistrationUrl: z.string().optional(), communitySupportTip: z.string().optional(), - activityExpirationTime: z.date().optional() + activityExpirationTime: OptionalConfigDateSchema }); export type SubPlanType = z.infer; diff --git a/packages/global/test/openapi/common/system.test.ts b/packages/global/test/openapi/common/system.test.ts index a22b1a16345a..4a9ea72c9deb 100644 --- a/packages/global/test/openapi/common/system.test.ts +++ b/packages/global/test/openapi/common/system.test.ts @@ -6,6 +6,7 @@ import { LLMModelItemSchema } from '../../../core/ai/model.schema'; import { GetSystemInitDataResponseSchema } from '../../../openapi/common/system/api'; +import { StandardSubLevelEnum } from '../../../support/wallet/sub/constants'; const desensitizedEmbeddingModel = { type: ModelTypeEnum.embedding, @@ -17,6 +18,55 @@ const desensitizedEmbeddingModel = { }; describe('system initialization OpenAPI contract', () => { + it('accepts legacy partial standard plans and coerces a serialized activity expiration date', () => { + const activityExpirationTime = '2026-08-31T16:00:00.000Z'; + const plan = { + price: 0, + totalPoints: 100, + maxTeamMember: 1, + maxAppAmount: 10, + maxDatasetAmount: 3, + maxDatasetSize: 600, + chatHistoryStoreDuration: 30 + }; + + const result = GetSystemInitDataResponseSchema.parse({ + subPlans: { + standard: { + [StandardSubLevelEnum.free]: plan, + [StandardSubLevelEnum.basic]: plan, + [StandardSubLevelEnum.advanced]: plan, + [StandardSubLevelEnum.custom]: plan + }, + activityExpirationTime + } + }); + + expect(result.subPlans?.standard).toEqual({ + [StandardSubLevelEnum.free]: plan, + [StandardSubLevelEnum.basic]: plan, + [StandardSubLevelEnum.advanced]: plan, + [StandardSubLevelEnum.custom]: plan + }); + expect(result.subPlans?.activityExpirationTime).toEqual(new Date(activityExpirationTime)); + }); + + it.each(['', null])('treats an empty activity expiration value as unset', (value) => { + expect( + GetSystemInitDataResponseSchema.parse({ + subPlans: { activityExpirationTime: value } + }) + ).toEqual({ subPlans: {} }); + }); + + it('rejects an invalid activity expiration date', () => { + expect(() => + GetSystemInitDataResponseSchema.parse({ + subPlans: { activityExpirationTime: 'invalid-date' } + }) + ).toThrow(); + }); + it('fills the default weight for an embedding model without weight', () => { expect( GetSystemInitDataResponseSchema.parse({ From 4acdb95ecf1f66a671c01e0f838783dd599ba8f4 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:03:37 +0800 Subject: [PATCH 07/15] fix: clean legacy model numeric values --- packages/service/core/ai/config/utils.ts | 2 +- packages/service/support/wallet/sub/utils.ts | 2 +- .../dataClean/cleanSystemModelConfigs.test.ts | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/service/core/ai/config/utils.ts b/packages/service/core/ai/config/utils.ts index 8441a364bdec..d550ab69b693 100644 --- a/packages/service/core/ai/config/utils.ts +++ b/packages/service/core/ai/config/utils.ts @@ -194,7 +194,7 @@ export const loadSystemModels = async (init = false, language = 'en') => { } : {}) }; - // 按合并后的最终类型处理插件协议空值,避免数据库覆盖类型时遗漏 LLM 规范化。 + // 仅兼容插件协议使用 null 表示不支持温度的历史数据。 pushModel(normalizeRuntimeSystemModelConfig(modelData)); }); diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index 88d00bb8b81c..2199da6aeb5f 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -89,7 +89,7 @@ export const initTeamFreePlan = async ({ session?: ClientSession; }) => { const freePoints = isWecomTeam - ? Math.round((global.subPlans?.standard?.basic.totalPoints ?? 4000) / 2) + ? Math.round((global.subPlans?.standard?.basic?.totalPoints ?? 4000) / 2) : global?.subPlans?.standard?.[StandardSubLevelEnum.free]?.totalPoints || 100; const freePlan = await MongoTeamSub.findOne({ diff --git a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts index 92a748c2384b..691f8c18d1c6 100644 --- a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts +++ b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts @@ -119,6 +119,30 @@ describe('cleanSystemModelConfig', () => { } }); + it('removes empty price tiers and coerces TTS price strings', () => { + const result = cleanSystemModelConfig({ + model: 'speech-model', + metadata: { + type: ModelTypeEnum.tts, + provider: 'MiniMax', + model: 'speech-model', + name: 'Speech Model', + charsPointsPrice: '20.00', + priceTiers: '', + voices: [] + } + }); + + expect(result).toMatchObject({ + status: 'valid', + changed: true, + metadata: { charsPointsPrice: 20 } + }); + if (result.status === 'valid') { + expect(result.metadata).not.toHaveProperty('priceTiers'); + } + }); + it('rejects records without a usable model and metadata object', () => { expect(cleanSystemModelConfig({ model: null, metadata: null })).toEqual({ status: 'invalid', From 42b01f5ee17f1fa1aeb2f9a5bc30825c7f342f8e Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:06:44 +0800 Subject: [PATCH 08/15] feat: add audit log cold archive storage --- .../global/support/wallet/sub/constants.ts | 11 ++++ .../common/s3/sources/audit/global.d.ts | 7 +++ .../service/common/s3/sources/audit/index.ts | 55 +++++++++++++++++++ .../test/common/s3/sources/audit.test.ts | 50 +++++++++++++++++ pro | 2 +- 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/service/common/s3/sources/audit/global.d.ts create mode 100644 packages/service/common/s3/sources/audit/index.ts create mode 100644 packages/service/test/common/s3/sources/audit.test.ts diff --git a/packages/global/support/wallet/sub/constants.ts b/packages/global/support/wallet/sub/constants.ts index 34db966c7ad0..4e293cd3da96 100644 --- a/packages/global/support/wallet/sub/constants.ts +++ b/packages/global/support/wallet/sub/constants.ts @@ -56,6 +56,17 @@ export enum StandardSubLevelEnum { enterprise = 'enterprise' } +/** 各套餐的审计日志默认配置值;清理任务缺少配置时统一按免费版天数兜底。 */ +export const defaultAuditLogRetentionDays: Record = { + [StandardSubLevelEnum.free]: 90, + [StandardSubLevelEnum.basic]: 360, + [StandardSubLevelEnum.advanced]: 1080, + [StandardSubLevelEnum.custom]: 1080, + [StandardSubLevelEnum.experience]: 360, + [StandardSubLevelEnum.team]: 1080, + [StandardSubLevelEnum.enterprise]: 1080 +}; + export const standardSubLevelMap = { [StandardSubLevelEnum.free]: { label: i18nT('common:support.wallet.subscription.standardSubLevel.free'), diff --git a/packages/service/common/s3/sources/audit/global.d.ts b/packages/service/common/s3/sources/audit/global.d.ts new file mode 100644 index 000000000000..6419e44e7bef --- /dev/null +++ b/packages/service/common/s3/sources/audit/global.d.ts @@ -0,0 +1,7 @@ +import type { S3AuditSource } from './index'; + +declare global { + var auditBucket: S3AuditSource; +} + +export {}; diff --git a/packages/service/common/s3/sources/audit/index.ts b/packages/service/common/s3/sources/audit/index.ts new file mode 100644 index 000000000000..5f89043012e4 --- /dev/null +++ b/packages/service/common/s3/sources/audit/index.ts @@ -0,0 +1,55 @@ +import type { Readable } from 'node:stream'; +import { getContentDisposition } from '@fastgpt/global/common/file/tools'; +import { S3PrivateBucket } from '../../buckets/private'; + +const getAuditArchiveKey = ({ teamId, archiveDate }: { teamId: string; archiveDate: string }) => + `audit-archive/${teamId}/${archiveDate}.jsonl.gz`; + +export class S3AuditSource extends S3PrivateBucket { + constructor() { + super(); + } + + /** 上传团队单次清理产生的 gzip JSONL 审计归档。 */ + async uploadAuditArchive({ + teamId, + archiveDate, + body + }: { + teamId: string; + archiveDate: string; + body: Readable; + }) { + const key = getAuditArchiveKey({ teamId, archiveDate }); + const filename = `${archiveDate}.jsonl.gz`; + + await this.client.uploadObject({ + key, + body, + contentType: 'application/gzip', + contentDisposition: getContentDisposition({ filename, type: 'attachment' }), + metadata: { + originFilename: encodeURIComponent(filename), + uploadTime: new Date().toISOString(), + teamId, + archiveDate + } + }); + + return key; + } + + /** 判断团队当天的审计归档是否已生成,防止定时任务重复执行时覆盖已上传对象。 */ + isAuditArchiveExists(params: { teamId: string; archiveDate: string }) { + return this.isObjectExists(getAuditArchiveKey(params)); + } +} + +/** 获取审计日志私有归档存储实例。 */ +export function getS3AuditSource() { + if (global.auditBucket) { + return global.auditBucket; + } + global.auditBucket = new S3AuditSource(); + return global.auditBucket; +} diff --git a/packages/service/test/common/s3/sources/audit.test.ts b/packages/service/test/common/s3/sources/audit.test.ts new file mode 100644 index 000000000000..0166b09b17ee --- /dev/null +++ b/packages/service/test/common/s3/sources/audit.test.ts @@ -0,0 +1,50 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { S3AuditSource } from '@fastgpt/service/common/s3/sources/audit'; + +const createSourceWithoutConstructor = (exists: boolean) => { + const source = Object.create(S3AuditSource.prototype); + Object.defineProperty(source, 'client', { + value: { + uploadObject: vi.fn().mockImplementation(async ({ body }: { body: Readable }) => { + for await (const _chunk of body) { + // Consume stream + } + }) + } + }); + source.isObjectExists = vi.fn().mockResolvedValue(exists); + return source; +}; + +describe('S3AuditSource', () => { + it('按团队和归档日期生成 gzip JSONL 对象路径', async () => { + const source = createSourceWithoutConstructor(false); + + const key = await S3AuditSource.prototype.uploadAuditArchive.call(source, { + teamId: 'team-1', + archiveDate: '2026-08-11', + body: Readable.from(Buffer.from('gzip')) + }); + + expect(key).toBe('audit-archive/team-1/2026-08-11.jsonl.gz'); + expect(source.client.uploadObject).toHaveBeenCalledWith( + expect.objectContaining({ + key, + contentType: 'application/gzip' + }) + ); + }); + + it('检查同一天归档是否已经存在', async () => { + const source = createSourceWithoutConstructor(true); + + await expect( + S3AuditSource.prototype.isAuditArchiveExists.call(source, { + teamId: 'team-2', + archiveDate: '2026-08-11' + }) + ).resolves.toBe(true); + expect(source.isObjectExists).toHaveBeenCalledWith('audit-archive/team-2/2026-08-11.jsonl.gz'); + }); +}); diff --git a/pro b/pro index 9aacdfa81953..58c8f36c64d0 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 9aacdfa81953ddf035c09006f996c4f383b7ad3c +Subproject commit 58c8f36c64d0d77ab5fdafd3a61198fc94f91a07 From 5a092d315b54dab738a5cc3c1daa5c00d3030e63 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:07:30 +0800 Subject: [PATCH 09/15] docs: update audit retention copy --- .../self-host/upgrading/4-16/41602.mdx | 8 +++-- document/data/doc-last-modified.json | 4 +-- packages/web/i18n/en/account.json | 32 +++++++++---------- packages/web/i18n/en/common.json | 14 ++++---- packages/web/i18n/zh-CN/account.json | 2 +- packages/web/i18n/zh-CN/common.json | 2 +- packages/web/i18n/zh-Hant/account.json | 32 +++++++++---------- pro | 2 +- 8 files changed, 50 insertions(+), 46 deletions(-) diff --git a/document/content/self-host/upgrading/4-16/41602.mdx b/document/content/self-host/upgrading/4-16/41602.mdx index b2f8f069c603..32c87c03e7b7 100644 --- a/document/content/self-host/upgrading/4-16/41602.mdx +++ b/document/content/self-host/upgrading/4-16/41602.mdx @@ -3,9 +3,9 @@ title: 'V4.16.0-beta2(进行中)' description: 'FastGPT V4.16.0-beta2 更新说明' --- -## ⚠️ 升级说明 +## 📦 升级指南 -### 清洗历史系统模型配置 +### 1. 清洗历史系统模型配置 本版本开始在系统模型初始化及保存时使用严格 Schema。此前版本保存的数字字符串、字符串形式的价格梯度或缺失字段可能导致初始化校验失败。升级后请先执行 dry-run,查看需要处理的模型配置;dry-run 不会修改数据或刷新缓存: @@ -49,3 +49,7 @@ curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' 4. 修复应用发布后,文件变量无法上传文件的问题。 5. 修复共享工作流工具的文件参数无法上传文件的问题。 6. 修复 S3 对象键和文件名包含空格、`%`、`#`、`?`、斜杠等特殊字符时,可能导致上传、解析、预览或下载异常的问题。 + +## 🛠️ 代码优化 + +1. 审计日志归档,不再删除,改成转存到 S3 冷归档。 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 9155b24b45e0..1bcaa04dbf7a 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -337,8 +337,8 @@ "content/self-host/upgrading/4-15/4157.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", - "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T22:25:10+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T22:25:10+08:00", + "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T22:43:06+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T22:43:06+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", diff --git a/packages/web/i18n/en/account.json b/packages/web/i18n/en/account.json index 01ee1ea3bd91..b7709cd3e24f 100644 --- a/packages/web/i18n/en/account.json +++ b/packages/web/i18n/en/account.json @@ -1,46 +1,46 @@ { "api_key": "API key", - "bills_and_invoices": "Bills", - "custom_domain": "Custom Domain", - "confirm_logout": "Confirm to log out?", - "language": "Language and time zone", - "logout": "Sign out", - "model_provider": "Model Provider", - "notifications": "Notify", - "personal_information": "Personal", - "promotion_records": "Promotions", - "team": "Team", - "third_party": "Third Party", - "usage_records": "Usage", "app_registration_count": "App registration count", - "audit_log_store_duration": "Audit log storage duration", + "audit_log_store_duration": "Audit log duration", + "bill.not_need_invoice": "Balance payment, invoice not available", "bill_detail": "Bill details", + "bills_and_invoices": "Bills", "chat_history_store_duration": "Chat history storage duration", + "confirm_logout": "Confirm to log out?", "custom_config_details": "Custom configuration details", + "custom_domain": "Custom Domain", "day": "day", "extra_ai_points": "AI points calculation standard", "extra_dataset_size": "Additional knowledge base capacity", "generation_time": "Generation time", "has_invoice": "Whether the invoice has been issued", "hour": "hour", + "language": "Language and time zone", + "logout": "Sign out", "max_app_amount": "Max app amount", "max_dataset_amount": "Max dataset amount", "max_dataset_size": "Max dataset size", "max_team_member": "Max team members", + "model_provider": "Model Provider", "month": "month", + "no": "no", + "notifications": "Notify", "order_number": "Order number", "order_type": "Order type", "payment_method": "Payment method", + "personal_information": "Personal", + "promotion_records": "Promotions", "requests_per_minute": "Requests per minute", "status": "state", "subscription_mode_month": "Duration", "subscription_package": "Subscription package", "subscription_period": "Subscription cycle", "support_wallet_amount": "Amount", + "team": "Team", + "third_party": "Third Party", "ticket_response_time": "Ticket response time", + "usage_records": "Usage", "website_sync_per_dataset": "Website sync per dataset", "yes": "yes", - "no": "no", - "yuan": "¥{{amount}}", - "bill.not_need_invoice": "Balance payment, invoice not available" + "yuan": "¥{{amount}}" } diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index c96aadf8cdc0..c9937049cb37 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -132,7 +132,6 @@ "code_error.account_error": "Incorrect account name or password", "code_error.account_exist": "Account has been registered", "code_error.account_not_found": "User is not registered", - "code_error.invalid_account": "Incorrect account", "code_error.app_error.can_not_edit_admin_permission": "Can not edit admin permission", "code_error.app_error.invalid_app_type": "Invalid Application Type", "code_error.app_error.invalid_owner": "Unauthorized Application Owner", @@ -158,6 +157,7 @@ "code_error.error_message.511": "Unauthorized to Operate This Model", "code_error.error_message.513": "Unauthorized to Read This File", "code_error.error_message.514": "Invalid API Key", + "code_error.invalid_account": "Incorrect account", "code_error.openapi_error.api_key_not_exist": "API Key Does Not Exist", "code_error.openapi_error.exceed_limit": "API key limit reached", "code_error.openapi_error.un_auth": "Unauthorized to Operate This API Key", @@ -305,6 +305,7 @@ "core.app.outLink.Select Mode": "Start Using", "core.app.outLink.Select Using Way": "Select Usage Method", "core.app.outLink.Show History": "Show Chat History", + "core.app.placeholder.welcomeText": "Please enter the conversation opening", "core.app.publish.Fei shu bot publish": "Publish to Feishu Bot", "core.app.saving": "Saving", "core.app.schedule.Default prompt": "Default Question", @@ -328,7 +329,6 @@ "core.app.tip.chatNodeSystemPromptTip": "Enter a prompt here", "core.app.tip.systemPromptTip": "Fixed guide words for the model. By adjusting this content, you can guide the model's chat direction. This content will be fixed at the beginning of the context. You can use / to insert variables.\nIf a Dataset is associated, you can also guide the model when to call the Dataset search by appropriate description. For example:\nYou are an assistant for the movie 'Interstellar'. When users ask about content related to 'Interstellar', please search the Dataset and answer based on the search results.", "core.app.tip.variableTip": "Before the conversation begins, users can be asked to fill in some content as specific variables for this round of conversation. \nThis module is located after the opening boot.\n\nIn the input box, you can select variables through / activation, such as: prompt words, qualifiers, etc.", - "core.app.placeholder.welcomeText": "Please enter the conversation opening", "core.app.tip.welcomeTextTip": "Before each conversation starts, send an initial content. Supports standard Markdown syntax.", "core.app.tts.Speech model": "Speech Model", "core.app.tts.Speech speed": "Speech Speed", @@ -671,10 +671,10 @@ "core.workflow.inputType.Manual select": "Manual Select", "core.workflow.inputType.Reference": "Variable Reference", "core.workflow.inputType.agentGenerated": "Agent generated", + "core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.", "core.workflow.inputType.agentGeneratedInputPlaceholder": "Parameter value is generated dynamically by AI", "core.workflow.inputType.agentGeneratedManaged": "AI Generate", "core.workflow.inputType.agentGeneratedSwitchTip": "Controlled by AI", - "core.workflow.inputType.agentGeneratedDesc": "Filled by the model at runtime and exposed as a tool-call parameter.", "core.workflow.inputType.custom": "External Variable", "core.workflow.inputType.dynamicTargetInput": "Dynamic External Data", "core.workflow.inputType.file": "File upload", @@ -762,6 +762,7 @@ "delete_warning": "Deletion Warning", "discount_coupon_used": "Coupon used:", "embedding_model_not_config": "No index model is detected", + "empty_file": "Empty files cannot be uploaded", "enable_sandbox": "Experience Sandbox Tool", "enterprise_auth.error.already_verified": "This team or enterprise has already been verified", "enterprise_auth.error.amount_error": "Incorrect verification amount", @@ -799,12 +800,11 @@ "error.folderMoveDepthLimit": "Cannot move here. The folder would exceed the depth limit.", "error.inheritPermissionError": "Inherit permission Error", "error.invalid_params": "Invalid parameter", - "error.registration_method_not_supported": "Unsupported username", "error.llm_track_expired": "Request details expired", "error.missingParams": "Insufficient parameters", + "error.registration_method_not_supported": "Unsupported username", "error.s3_upload_invalid_file_type": "Unsupported file content or file extension does not match", "error.send_auth_code_too_frequently": "Please do not obtain verification code frequently", - "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", "error.too_many_request": "Too many request", "error.tool_not_exist": "Tool deleted", "error.unAuthFile": "Unauthorized to read this file", @@ -812,6 +812,7 @@ "error.upload_file_error_filename": "{{name}} Upload Failed", "error.upload_file_interval_limit": "Too many uploads in a short time or the current round limit was reached. Please try again later.", "error.username_empty": "Account cannot be empty", + "error.verify_code_too_frequently": "Too many verification attempts. Please try again later.", "error_collection_not_exist": "The collection does not exist", "error_embedding_not_config": "Unconfigured index model", "error_invalid_resource": "Invalid resources", @@ -901,7 +902,7 @@ "n_max_upload_file_count": "Upload up to {{amount}} files", "n_max_upload_file_limit": "Upload up to {{count}} files of {{size}}MB each", "n_max_upload_file_size": "Max {{amount}}MB per file", - "n_team_audit_day": "{{amount}} days team operation log records", + "n_team_audit_day": "{{amount}} days of audit logs", "n_team_members": "{{amount}} Member", "n_team_qpm": "{{amount}} QPM", "n_website_sync_max_pages": "Single knowledge base {{amount}} web pages synchronized", @@ -1190,7 +1191,6 @@ "upgrade": "upgrade", "upload_file": "Upload File", "upload_file_error": "File Upload Failed", - "empty_file": "Empty files cannot be uploaded", "usage_records": "Usage records", "user.Account": "Account", "user.No_right_to_reset_password": "You do not have the right to reset the password", diff --git a/packages/web/i18n/zh-CN/account.json b/packages/web/i18n/zh-CN/account.json index 38daa37a0e35..ff1a034a2368 100644 --- a/packages/web/i18n/zh-CN/account.json +++ b/packages/web/i18n/zh-CN/account.json @@ -13,7 +13,7 @@ "third_party": "第三方账号", "usage_records": "使用记录", "app_registration_count": "应用备案数", - "audit_log_store_duration": "团队操作日志记录时长", + "audit_log_store_duration": "审计日志时长", "bill_detail": "账单详情", "chat_history_store_duration": "对话记录保留时长", "custom_config_details": "定制配置详情", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 896b3df83a34..ba4cefc18f53 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -901,7 +901,7 @@ "n_max_upload_file_count": "单次可上传 {{amount}} 个文件", "n_max_upload_file_limit": "单次可上传 {{count}} 个 {{size}} 的文件", "n_max_upload_file_size": "单个文件最大 {{amount}}MB", - "n_team_audit_day": "{{amount}} 天团队操作日志记录", + "n_team_audit_day": "{{amount}} 天审计日志", "n_team_members": "{{amount}} 个团队成员", "n_team_qpm": "{{amount}} QPM", "n_website_sync_max_pages": "站点同步最大 {{amount}} 页", diff --git a/packages/web/i18n/zh-Hant/account.json b/packages/web/i18n/zh-Hant/account.json index 867890e1aa06..153f8b88c933 100644 --- a/packages/web/i18n/zh-Hant/account.json +++ b/packages/web/i18n/zh-Hant/account.json @@ -1,46 +1,46 @@ { "api_key": "API 金鑰", - "bills_and_invoices": "帳單與發票", - "custom_domain": "自訂域名", - "confirm_logout": "確認登出?", - "language": "語言與時區", - "logout": "登出", - "model_provider": "模型提供者", - "notifications": "通知", - "personal_information": "個人資訊", - "promotion_records": "促銷記錄", - "team": "團隊管理", - "third_party": "第三方帳號", - "usage_records": "使用記錄", "app_registration_count": "應用備案數", - "audit_log_store_duration": "團隊操作日誌記錄時長", + "audit_log_store_duration": "審計日誌時長", + "bill.not_need_invoice": "餘額支付無法開立發票", "bill_detail": "帳單詳細資訊", + "bills_and_invoices": "帳單與發票", "chat_history_store_duration": "對話記錄保留時長", + "confirm_logout": "確認登出?", "custom_config_details": "定制配置詳情", + "custom_domain": "自訂域名", "day": "天", "extra_ai_points": "AI 積分運算標準", "extra_dataset_size": "額外知識庫索引量", "generation_time": "生成時間", "has_invoice": "是否已開票", "hour": "小時", + "language": "語言與時區", + "logout": "登出", "max_app_amount": "Agent 上限", "max_dataset_amount": "知識庫上限", "max_dataset_size": "知識庫索引上限", "max_team_member": "團隊成員上限", + "model_provider": "模型提供者", "month": "月", + "no": "否", + "notifications": "通知", "order_number": "訂單編號", "order_type": "訂單類型", "payment_method": "支付方式", + "personal_information": "個人資訊", + "promotion_records": "促銷記錄", "requests_per_minute": "QPM", "status": "狀態", "subscription_mode_month": "時長", "subscription_package": "訂閱套餐", "subscription_period": "訂閱週期", "support_wallet_amount": "金額", + "team": "團隊管理", + "third_party": "第三方帳號", "ticket_response_time": "工單支援響應時間", + "usage_records": "使用記錄", "website_sync_per_dataset": "站點同步最大頁數", "yes": "是", - "no": "否", - "yuan": "{{amount}}元", - "bill.not_need_invoice": "餘額支付無法開立發票" + "yuan": "{{amount}}元" } diff --git a/pro b/pro index 58c8f36c64d0..364ccd54f11f 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 58c8f36c64d0d77ab5fdafd3a61198fc94f91a07 +Subproject commit 364ccd54f11f3c81dabeeb3588240af5652e2f02 From 5a0d47bc93df0b309075baef397f5acda620afa1 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:15:50 +0800 Subject: [PATCH 10/15] remove doc --- .../ai/model-config-validation-and-cleanup.md | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 .agents/design/ai/model-config-validation-and-cleanup.md diff --git a/.agents/design/ai/model-config-validation-and-cleanup.md b/.agents/design/ai/model-config-validation-and-cleanup.md deleted file mode 100644 index bfcbcb9935ec..000000000000 --- a/.agents/design/ai/model-config-validation-and-cleanup.md +++ /dev/null @@ -1,51 +0,0 @@ -# 模型配置严格校验与历史数据清洗 - -## 背景 - -模型配置写入接口当前只做 TypeScript 类型声明或少量手工检查,MongoDB 的 `metadata` 也是普通对象。前端还会把部分空值转换为 `''`,导致数字、布尔值和 `priceTiers` 以字符串形式进入数据库,并在系统初始化响应严格校验时失败。 - -## 目标 - -- 所有常规模型配置写入在落库前通过统一的判别联合 Schema。 -- Schema 只填充有明确语义的默认值:Embedding `weight=0`;新版协议中的 `functionCall` 为可选字段。 -- 前端不再用空字符串表示缺失的可选字段。 -- 提供管理员历史数据清洗接口,默认 dry-run,无法确定修复方式的数据跳过并报告。 -- 运行时模型加载不承担通用脏数据转换,仅适配插件协议中明确存在的字段差异,例如将 LLM 的 `maxTemperature=null` 统一为字段缺失。 - -## 设计 - -### 统一模型 Schema - -在 `packages/global/core/ai/model.schema.ts` 导出 `SystemModelItemSchema`,以 `type` 为判别字段组合 LLM、Embedding、TTS、STT 和 Rerank Schema。模型保存、JSON 导入和初始化响应复用该 Schema。 - -严格 Schema 不接受数字字符串、布尔字符串或字符串形式的数组。缺失的 `weight` 通过 Schema 默认值补齐,`functionCall` 保持可选,解析后的对象才允许写入数据库。 - -### 写入接口 - -- `update`:使用 `parseApiInput` 校验请求外层结构;合并系统模型、历史配置和本次修改后,再用 `SystemModelItemSchema.parse` 校验完整结果。 -- `updateWithJson`:校验 `config` 是 JSON 字符串,对解析后的每条记录统一覆盖可信的 `metadata.model`,补齐空名称,再严格解析完整模型后进入事务。 -- 空成功响应统一为 `z.undefined()`。 - -### 前端 - -提交前删除 `null`、`undefined` 和 `NaN` 字段,不再转换成 `''`。用户实际输入的空字符串仍由严格接口拒绝。 - -### 清洗接口 - -管理员 `dataClean` 接口扫描 `system_models`: - -- 合法数字字符串转换为 number;非法的可选数字字段删除,非法或缺失的必填数字字段使用系统默认值。 -- JSON 字符串形式的 `priceTiers` 转为数组,并校验每个梯度。 -- 缺失的 `weight` 使用统一 Schema 默认值,不补写可选的 `functionCall`。 -- 最终必须通过 `SystemModelItemSchema`;无法修复的记录不更新,只输出字段路径和原因。 -- 默认 dry-run;显式传入 `dryRun=false` 才通过一次 `bulkWrite` 写库,可重复执行。 -- 每次正式执行都立即触发统一的系统模型缓存重载;即使数据已清洗完成,也可通过重复执行重新构建运行时缓存。 - -## TODO - -- [x] 导出统一模型判别联合与默认值。 -- [x] 改造模型更新和 JSON 导入接口。 -- [x] 修正前端空值提交行为。 -- [x] 实现管理员历史模型配置清洗接口。 -- [x] 补充 Schema、接口和清洗测试。 -- [x] 运行定向测试、类型检查、lint 和差异检查。 From f93e113a84fba095500ad4b026313b47c4b26bdb Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:32:37 +0800 Subject: [PATCH 11/15] test: cover active legacy subscription plans --- .../test/support/wallet/sub/utils.test.ts | 86 +++++++++++++++++++ pro | 2 +- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/service/test/support/wallet/sub/utils.test.ts b/packages/service/test/support/wallet/sub/utils.test.ts index 24c54bdd4af6..44de9827d46f 100644 --- a/packages/service/test/support/wallet/sub/utils.test.ts +++ b/packages/service/test/support/wallet/sub/utils.test.ts @@ -795,6 +795,52 @@ describe('getTeamStandPlan', () => { expect(result[SubTypeEnum.standard]).toBeUndefined(); }); + + it.each([ + [StandardSubLevelEnum.experience, 'Experience Plan', 5], + [StandardSubLevelEnum.team, 'Team Plan', 20], + [StandardSubLevelEnum.enterprise, 'Enterprise Plan', 100] + ])('未过期旧套餐 %s 使用自身配置,不映射套餐等级', async (level, name, maxTeamMember) => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + ...baseStandard, + currentSubLevel: level, + nextSubLevel: level, + expiredTime: new Date('2099-01-01') + } + ]) + } as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + ...baseConstants, + name: 'Basic Plan', + maxTeamMember: 1 + }, + [StandardSubLevelEnum.advanced]: { + ...baseConstants, + name: 'Advanced Plan', + maxTeamMember: 2 + }, + [level]: { + ...baseConstants, + name, + maxTeamMember + } + } + }; + + const result = await getTeamStandPlan({ teamId: mockTeamId }); + + expect(result.standard).toMatchObject({ + currentSubLevel: level, + nextSubLevel: level, + name, + maxTeamMember + }); + }); }); describe('getTeamPlanStatus', () => { @@ -893,6 +939,46 @@ describe('getTeamPlanStatus', () => { expect(result[SubTypeEnum.standard]).toBeDefined(); }); + it.each([ + [StandardSubLevelEnum.experience, 60], + [StandardSubLevelEnum.team, 600], + [StandardSubLevelEnum.enterprise, 6000] + ])('未过期旧套餐 %s 的额度使用自身配置', async (level, maxDatasetSize) => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + ...baseStandard, + currentSubLevel: level, + nextSubLevel: level, + expiredTime: new Date('2099-01-01') + } + ]) + } as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + ...baseConstants, + maxDatasetSize: 1 + }, + [StandardSubLevelEnum.advanced]: { + ...baseConstants, + maxDatasetSize: 2 + }, + [level]: { + ...baseConstants, + maxDatasetSize + } + } + }; + + const result = await getTeamPlanStatus({ teamId: mockTeamId }); + + expect(result.standard?.currentSubLevel).toBe(level); + expect(result.standard?.maxDatasetSize).toBe(maxDatasetSize); + expect(result.datasetMaxSize).toBe(maxDatasetSize); + }); + it('包含额外积分套餐', async () => { const teamId = mockTeamId; const mockStandardPlan = { diff --git a/pro b/pro index 364ccd54f11f..9ed224a69043 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 364ccd54f11f3c81dabeeb3588240af5652e2f02 +Subproject commit 9ed224a6904311cc93c8f720afc11c420ebe38e0 From 7e26eef0bab20b902fec2dbd1a537efa3afae4eb Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 11:35:38 +0800 Subject: [PATCH 12/15] fix: remove model cleanup sample limit --- .../self-host/upgrading/4-16/41602.en.mdx | 6 ++--- .../self-host/upgrading/4-16/41602.mdx | 6 ++--- document/data/doc-last-modified.json | 2 +- .../dataClean/cleanSystemModelConfigs.ts | 20 +++++--------- .../dataClean/cleanSystemModelConfigs.test.ts | 26 ++++++++++++------- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/document/content/self-host/upgrading/4-16/41602.en.mdx b/document/content/self-host/upgrading/4-16/41602.en.mdx index 0d4c2d2fffce..0ececf70ad87 100644 --- a/document/content/self-host/upgrading/4-16/41602.en.mdx +++ b/document/content/self-host/upgrading/4-16/41602.en.mdx @@ -13,7 +13,7 @@ This release applies strict schemas when system models are initialized or saved. curl -X POST 'https://your-domain/api/admin/dataClean/cleanSystemModelConfigs' \ -H 'Content-Type: application/json' \ -H 'rootkey: YOUR_ROOT_KEY' \ - -d '{"dryRun":true,"sampleLimit":20}' + -d '{"dryRun":true}' ``` After confirming that `invalidSamples` contains no records that require manual correction, run the cleanup: @@ -22,12 +22,12 @@ After confirming that `invalidSamples` contains no records that require manual c curl -X POST 'https://your-domain/api/admin/dataClean/cleanSystemModelConfigs' \ -H 'Content-Type: application/json' \ -H 'rootkey: YOUR_ROOT_KEY' \ - -d '{"dryRun":false,"sampleLimit":20}' + -d '{"dryRun":false}' ``` The cleanup converts valid numeric strings to numbers, parses serialized `priceTiers` arrays, and removes invalid optional numeric fields. Invalid or missing required numeric fields use system defaults: LLM `maxContext/maxResponse/quoteMaxToken` default to `16000/16000/13000`, Embedding `defaultToken/maxToken` default to `500/3000`, and price fields default to `0`. `functionCall` remains optional, and a missing Embedding `weight` defaults to `0`. -The write operation updates all matching records in one operation and immediately reloads the system model cache. The runtime cache is rebuilt even when no database record needs an update. The endpoint is safe to run repeatedly; a follow-up dry run should report `wouldUpdate` as `0`. Records that still fail the complete current model schema are not written and are listed in `invalidSamples`. `sampleLimit` defaults to `20` and has a maximum of `100`. +The write operation updates all matching records in one operation and immediately reloads the system model cache. The runtime cache is rebuilt even when no database record needs an update. The endpoint is safe to run repeatedly; a follow-up dry run should report `wouldUpdate` as `0`. Records that still fail the complete current model schema are not written, and all of them are listed in `invalidSamples`. ## 🚀 New diff --git a/document/content/self-host/upgrading/4-16/41602.mdx b/document/content/self-host/upgrading/4-16/41602.mdx index 32c87c03e7b7..8acec97921fa 100644 --- a/document/content/self-host/upgrading/4-16/41602.mdx +++ b/document/content/self-host/upgrading/4-16/41602.mdx @@ -13,7 +13,7 @@ description: 'FastGPT V4.16.0-beta2 更新说明' curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' \ -H 'Content-Type: application/json' \ -H 'rootkey: 你的ROOT_KEY' \ - -d '{"dryRun":true,"sampleLimit":20}' + -d '{"dryRun":true}' ``` 确认 `invalidSamples` 中没有需要人工处理的数据后,执行正式清洗: @@ -22,12 +22,12 @@ curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' \ -H 'Content-Type: application/json' \ -H 'rootkey: 你的ROOT_KEY' \ - -d '{"dryRun":false,"sampleLimit":20}' + -d '{"dryRun":false}' ``` 清洗会将合法数字字符串转换为 number、将字符串形式的 `priceTiers` 转换为数组,并删除非法的可选数字。非法或缺失的必填数字使用系统默认值:LLM 的 `maxContext/maxResponse/quoteMaxToken` 分别为 `16000/16000/13000`,Embedding 的 `defaultToken/maxToken` 分别为 `500/3000`,价格为 `0`。`functionCall` 保持可选,Embedding 缺失的 `weight` 补为 `0`。 -正式执行会统一写入数据并立即刷新系统模型缓存;即使没有记录需要更新,也会重新构建运行时缓存。接口可安全重复执行;再次 dry-run 时,`wouldUpdate` 应为 `0`。无法通过当前完整模型 Schema 的记录不会写入,详情会返回在 `invalidSamples` 中;`sampleLimit` 默认为 `20`,最大为 `100`。 +正式执行会统一写入数据并立即刷新系统模型缓存;即使没有记录需要更新,也会重新构建运行时缓存。接口可安全重复执行;再次 dry-run 时,`wouldUpdate` 应为 `0`。无法通过当前完整模型 Schema 的记录不会写入,详情会全部返回在 `invalidSamples` 中。 ## 🚀 新增内容 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 1bcaa04dbf7a..894eeca9436c 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -338,7 +338,7 @@ "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T22:43:06+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-11T22:43:06+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-12T11:07:30+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", diff --git a/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts index 83ae08a4c15e..f72fcfb6aa72 100644 --- a/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts +++ b/projects/app/src/pages/api/admin/dataClean/cleanSystemModelConfigs.ts @@ -1,6 +1,6 @@ import { NextAPI } from '@/service/middleware/entry'; import type { ApiRequestProps } from '@fastgpt/next/type'; -import { BoolSchema, IntSchema } from '@fastgpt/global/common/zod'; +import { BoolSchema } from '@fastgpt/global/common/zod'; import { defaultQAModels, defaultVectorModels } from '@fastgpt/global/core/ai/constants'; import { EmbeddingModelItemSchema, @@ -19,11 +19,8 @@ import { authCert } from '@fastgpt/service/support/permission/auth/common'; import { isDeepStrictEqual } from 'node:util'; import z from 'zod'; -const DEFAULT_SAMPLE_LIMIT = 20; - const CleanSystemModelConfigsBodySchema = z.object({ - dryRun: BoolSchema.optional().default(true), - sampleLimit: IntSchema.max(100).optional().default(DEFAULT_SAMPLE_LIMIT) + dryRun: BoolSchema.optional().default(true) }); export type CleanSystemModelConfigsBody = z.infer; @@ -170,8 +167,7 @@ export const cleanSystemModelConfig = ({ /** 扫描并一次性清洗历史系统模型配置;正式执行后统一重载系统模型缓存。 */ export const runCleanSystemModelConfigs = async ({ - dryRun, - sampleLimit + dryRun }: CleanSystemModelConfigsBody): Promise => { const stats: CleanSystemModelConfigsResponse = { dryRun, @@ -195,12 +191,10 @@ export const runCleanSystemModelConfigs = async ({ if (cleaned.status === 'invalid') { stats.invalid += 1; - if (stats.invalidSamples.length < sampleLimit) { - stats.invalidSamples.push({ - model: typeof record.model === 'string' ? record.model : String(record._id), - issues: cleaned.issues - }); - } + stats.invalidSamples.push({ + model: typeof record.model === 'string' ? record.model : String(record._id), + issues: cleaned.issues + }); continue; } if (!cleaned.changed) { diff --git a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts index 691f8c18d1c6..11c7668988f8 100644 --- a/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts +++ b/projects/app/test/pages/api/admin/dataClean/cleanSystemModelConfigs.test.ts @@ -176,9 +176,7 @@ describe('runCleanSystemModelConfigs', () => { } ]); - await expect( - runCleanSystemModelConfigs({ dryRun: true, sampleLimit: 10 }) - ).resolves.toMatchObject({ + await expect(runCleanSystemModelConfigs({ dryRun: true })).resolves.toMatchObject({ dryRun: true, scanned: 2, invalid: 1, @@ -191,9 +189,7 @@ describe('runCleanSystemModelConfigs', () => { MongoSystemModel.collection.findOne({ model: 'embedding-model' }) ).resolves.toMatchObject({ metadata: { defaultToken: '500' } }); - await expect( - runCleanSystemModelConfigs({ dryRun: false, sampleLimit: 10 }) - ).resolves.toMatchObject({ + await expect(runCleanSystemModelConfigs({ dryRun: false })).resolves.toMatchObject({ dryRun: false, invalid: 1, updated: 1 @@ -205,15 +201,27 @@ describe('runCleanSystemModelConfigs', () => { metadata: { defaultToken: 500, maxToken: 3000, weight: 0 } }); - await expect( - runCleanSystemModelConfigs({ dryRun: false, sampleLimit: 10 }) - ).resolves.toMatchObject({ + await expect(runCleanSystemModelConfigs({ dryRun: false })).resolves.toMatchObject({ dryRun: false, updated: 0 }); expect(updatedReloadSystemModel).toHaveBeenCalledTimes(2); }); + it('returns all invalid records without a sample limit', async () => { + await MongoSystemModel.collection.insertMany( + Array.from({ length: 25 }, (_, index) => ({ + model: `invalid-model-${index}`, + metadata: { ...baseLlmModel, model: `invalid-model-${index}`, type: 'unknown' } + })) + ); + + const result = await runCleanSystemModelConfigs({ dryRun: true }); + + expect(result.invalid).toBe(25); + expect(result.invalidSamples).toHaveLength(25); + }); + it('uses dry-run defaults at the authenticated API boundary', async () => { const root = await getRootUser(); const res = await Call(cleanSystemModelConfigsApi, { From 867c8834abeef93486192c87dbcb85e33b35fe2c Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 12:26:53 +0800 Subject: [PATCH 13/15] fix: validate and resolve subscription plans --- .../openapi/admin/routes/settings/api.ts | 12 ++- .../global/support/wallet/sub/constants.ts | 2 +- packages/global/support/wallet/sub/type.ts | 101 ++++++++++++++++-- packages/global/support/wallet/sub/utils.ts | 42 ++++++++ .../openapi/admin/routes/settings.test.ts | 65 +++++++++++ .../global/test/openapi/common/system.test.ts | 44 +++++--- .../test/support/wallet/sub/utils.test.ts | 54 ++++++++++ packages/service/support/wallet/sub/utils.ts | 35 +++--- .../test/support/wallet/sub/utils.test.ts | 37 +++++-- pro | 2 +- .../pages/api/common/system/getInitData.ts | 6 +- 11 files changed, 342 insertions(+), 58 deletions(-) create mode 100644 packages/global/support/wallet/sub/utils.ts create mode 100644 packages/global/test/openapi/admin/routes/settings.test.ts create mode 100644 packages/global/test/support/wallet/sub/utils.test.ts diff --git a/packages/global/openapi/admin/routes/settings/api.ts b/packages/global/openapi/admin/routes/settings/api.ts index d8726537ad86..b0c6ecea9fc9 100644 --- a/packages/global/openapi/admin/routes/settings/api.ts +++ b/packages/global/openapi/admin/routes/settings/api.ts @@ -1,4 +1,5 @@ import z from 'zod'; +import { SubPlanInputSchema } from '../../../../support/wallet/sub/type'; export const GetConfigResponseSchema = z.object({ fastgpt: z.any().optional().meta({ description: '系统 FastGPT 配置' }), @@ -9,6 +10,13 @@ export const GetConfigResponseSchema = z.object({ }); export const UpdateConfigBodySchema = z.object({ - fastgpt: z.any().optional().meta({ description: 'FastGPT 系统配置对象' }), - fastgptPro: z.any().optional().meta({ description: 'FastGPT Pro 商业版配置对象' }) + fastgpt: z + .looseObject({ + feConfigs: z.looseObject({}), + systemEnv: z.looseObject({}), + subPlans: SubPlanInputSchema.optional() + }) + .meta({ description: 'FastGPT 系统配置对象' }), + fastgptPro: z.looseObject({}).meta({ description: 'FastGPT Pro 商业版配置对象' }) }); +export type UpdateConfigBody = z.infer; diff --git a/packages/global/support/wallet/sub/constants.ts b/packages/global/support/wallet/sub/constants.ts index 4e293cd3da96..63395a01176a 100644 --- a/packages/global/support/wallet/sub/constants.ts +++ b/packages/global/support/wallet/sub/constants.ts @@ -56,7 +56,7 @@ export enum StandardSubLevelEnum { enterprise = 'enterprise' } -/** 各套餐的审计日志默认配置值;清理任务缺少配置时统一按免费版天数兜底。 */ +/** 各套餐等级的审计日志默认保留天数。 */ export const defaultAuditLogRetentionDays: Record = { [StandardSubLevelEnum.free]: 90, [StandardSubLevelEnum.basic]: 360, diff --git a/packages/global/support/wallet/sub/type.ts b/packages/global/support/wallet/sub/type.ts index 8361d1fdd984..6ab7c83cfbae 100644 --- a/packages/global/support/wallet/sub/type.ts +++ b/packages/global/support/wallet/sub/type.ts @@ -1,6 +1,7 @@ import z from 'zod'; import { StandardSubLevelEnum, SubModeEnum, SubTypeEnum } from './constants'; import { ObjectIdSchema } from '../../../common/type/mongo'; +import { IntSchema, NumSchema } from '../../../common/zod'; /** * Static plan config, stored in global.subPlans @@ -50,11 +51,22 @@ export const TeamStandardSubPlanItemSchema = z.object({ }); export type TeamStandardSubPlanItemType = z.infer; -export const StandSubPlanLevelMapSchema = z.partialRecord( - z.enum(StandardSubLevelEnum), - TeamStandardSubPlanItemSchema -); -export type StandSubPlanLevelMapType = z.infer; +/** 定制套餐允许只保存覆盖字段,未配置的权益在使用时继承高级版。 */ +export const CustomStandardSubPlanItemSchema = TeamStandardSubPlanItemSchema.partial(); +export type CustomStandardSubPlanItemType = Partial; + +export type StandSubPlanLevelMapType = Partial< + Record +>; +export const StandSubPlanLevelMapSchema = z.object({ + [StandardSubLevelEnum.free]: TeamStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.basic]: TeamStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.advanced]: TeamStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.custom]: CustomStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.experience]: TeamStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.team]: TeamStandardSubPlanItemSchema.optional(), + [StandardSubLevelEnum.enterprise]: TeamStandardSubPlanItemSchema.optional() +}) as unknown as z.ZodType; export const PointsPackageItemSchema = z.object({ points: z.int(), @@ -64,11 +76,86 @@ export const PointsPackageItemSchema = z.object({ }); export type PointsPackageItem = z.infer; -const OptionalConfigDateSchema = z.preprocess( +const OptionalConfigDateInputSchema = z.preprocess( (value) => (value === '' || value === null ? undefined : value), z.coerce.date().optional() ); +const emptyValueToUndefined = (value: unknown) => + value === '' || value === null ? undefined : value; +const configOptionalIntegerInputSchema = z.preprocess(emptyValueToUndefined, IntSchema.optional()); +const configOptionalNumberInputSchema = z.preprocess(emptyValueToUndefined, NumSchema.optional()); + +const TeamStandardSubPlanItemInputSchema = TeamStandardSubPlanItemSchema.extend({ + price: NumSchema.nonnegative(), + totalPoints: IntSchema, + maxTeamMember: IntSchema, + maxAppAmount: IntSchema, + maxDatasetAmount: IntSchema, + maxDatasetSize: IntSchema, + requestsPerMinute: configOptionalIntegerInputSchema, + appRegistrationCount: configOptionalIntegerInputSchema, + chatHistoryStoreDuration: IntSchema, + websiteSyncPerDataset: configOptionalIntegerInputSchema, + auditLogStoreDuration: configOptionalIntegerInputSchema, + ticketResponseTime: configOptionalIntegerInputSchema, + customDomain: configOptionalIntegerInputSchema, + maxUploadFileSize: configOptionalIntegerInputSchema, + maxUploadFileCount: configOptionalIntegerInputSchema, + annualBonusPoints: configOptionalIntegerInputSchema, + pointPrice: configOptionalNumberInputSchema, + wecom: z + .object({ + price: NumSchema.nonnegative(), + points: NumSchema.nonnegative() + }) + .nullish() +}); + +const CustomStandardSubPlanItemInputSchema = z.preprocess((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + + return Object.fromEntries( + Object.entries(value).filter( + ([, fieldValue]) => fieldValue !== '' && fieldValue !== null && fieldValue !== undefined + ) + ); +}, TeamStandardSubPlanItemInputSchema.partial()); + +const StandSubPlanLevelMapInputSchema = z.object({ + [StandardSubLevelEnum.free]: TeamStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.basic]: TeamStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.advanced]: TeamStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.custom]: CustomStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.experience]: TeamStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.team]: TeamStandardSubPlanItemInputSchema.optional(), + [StandardSubLevelEnum.enterprise]: TeamStandardSubPlanItemInputSchema.optional() +}); + +/** 保存系统套餐配置时的兼容输入;输出始终满足稳定的持久化结构。 */ +export const SubPlanInputSchema = z + .object({ + [SubTypeEnum.standard]: StandSubPlanLevelMapInputSchema.optional(), + [SubTypeEnum.extraDatasetSize]: z.object({ price: NumSchema.nonnegative() }).optional(), + [SubTypeEnum.extraPoints]: z + .object({ + packages: z.array( + PointsPackageItemSchema.extend({ + points: IntSchema, + month: IntSchema, + price: NumSchema.nonnegative(), + activityBonusPoints: configOptionalIntegerInputSchema + }) + ) + }) + .optional(), + planDescriptionUrl: z.string().optional(), + appRegistrationUrl: z.string().optional(), + communitySupportTip: z.string().optional(), + activityExpirationTime: OptionalConfigDateInputSchema + }) + .transform((subPlans) => SubPlanSchema.parse(subPlans)); + export const SubPlanSchema = z.object({ [SubTypeEnum.standard]: StandSubPlanLevelMapSchema.optional(), [SubTypeEnum.extraDatasetSize]: z.object({ price: z.number() }).optional(), @@ -76,7 +163,7 @@ export const SubPlanSchema = z.object({ planDescriptionUrl: z.string().optional(), appRegistrationUrl: z.string().optional(), communitySupportTip: z.string().optional(), - activityExpirationTime: OptionalConfigDateSchema + activityExpirationTime: z.date().optional() }); export type SubPlanType = z.infer; diff --git a/packages/global/support/wallet/sub/utils.ts b/packages/global/support/wallet/sub/utils.ts new file mode 100644 index 000000000000..31a577cbd945 --- /dev/null +++ b/packages/global/support/wallet/sub/utils.ts @@ -0,0 +1,42 @@ +import { StandardSubLevelEnum } from './constants'; +import type { StandSubPlanLevelMapType, SubPlanType, TeamStandardSubPlanItemType } from './type'; + +/** + * 获取套餐运行时生效配置。定制套餐只存覆盖项,缺失权益统一继承高级版。 + * 该函数仅合并内存数据,不承担配置清洗或持久化转换。 + */ +export const getRuntimeStandardPlanConfig = ({ + plans, + level +}: { + plans?: StandSubPlanLevelMapType; + level: `${StandardSubLevelEnum}`; +}): TeamStandardSubPlanItemType | undefined => { + const plan = plans?.[level]; + if (level !== StandardSubLevelEnum.custom) return plan; + + const advancedPlan = plans?.[StandardSubLevelEnum.advanced]; + if (!plan || !advancedPlan) return; + + const configuredOverrides = Object.fromEntries( + Object.entries(plan).filter(([, value]) => value !== undefined) + ); + return { ...advancedPlan, ...configuredOverrides }; +}; + +/** 返回可直接用于运行时和客户端展示的套餐配置,不修改原始系统配置。 */ +export const getRuntimeSubPlansConfig = (subPlans?: SubPlanType): SubPlanType | undefined => { + const customPlan = getRuntimeStandardPlanConfig({ + plans: subPlans?.standard, + level: StandardSubLevelEnum.custom + }); + if (!subPlans?.standard?.custom || !customPlan) return subPlans; + + return { + ...subPlans, + standard: { + ...subPlans.standard, + [StandardSubLevelEnum.custom]: customPlan + } + }; +}; diff --git a/packages/global/test/openapi/admin/routes/settings.test.ts b/packages/global/test/openapi/admin/routes/settings.test.ts new file mode 100644 index 000000000000..5205340b8666 --- /dev/null +++ b/packages/global/test/openapi/admin/routes/settings.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { UpdateConfigBodySchema } from '../../../../openapi/admin/routes/settings/api'; +import { StandardSubLevelEnum, SubTypeEnum } from '../../../../support/wallet/sub/constants'; + +const createBody = (subPlans: unknown) => ({ + fastgpt: { + feConfigs: {}, + systemEnv: {}, + subPlans + }, + fastgptPro: {} +}); + +describe('UpdateConfigBodySchema', () => { + it('converts numeric strings and strips unsupported legacy custom plan fields', () => { + const result = UpdateConfigBodySchema.parse( + createBody({ + standard: { + [StandardSubLevelEnum.custom]: { + priceDesc: '定制化计费', + customDescriptions: ['专属客户经理'], + customFormUrl: 'https://example.com/contact' + } + }, + [SubTypeEnum.extraDatasetSize]: { price: '4' }, + activityExpirationTime: '2026-08-31T16:00:00.000Z' + }) + ); + + expect(result.fastgpt.subPlans).toEqual({ + standard: { + [StandardSubLevelEnum.custom]: { + customDescriptions: ['专属客户经理'], + customFormUrl: 'https://example.com/contact' + } + }, + [SubTypeEnum.extraDatasetSize]: { price: 4 }, + activityExpirationTime: new Date('2026-08-31T16:00:00.000Z') + }); + }); + + it('removes an empty activity expiration value before storage', () => { + const result = UpdateConfigBodySchema.parse( + createBody({ activityExpirationTime: '', [SubTypeEnum.extraDatasetSize]: { price: '4' } }) + ); + + expect(result.fastgpt.subPlans).toEqual({ + [SubTypeEnum.extraDatasetSize]: { price: 4 } + }); + }); + + it('rejects invalid numeric strings and incomplete non-custom plans', () => { + expect(() => + UpdateConfigBodySchema.parse( + createBody({ [SubTypeEnum.extraDatasetSize]: { price: 'not-a-number' } }) + ) + ).toThrow(); + + expect(() => + UpdateConfigBodySchema.parse( + createBody({ standard: { [StandardSubLevelEnum.basic]: { price: '99' } } }) + ) + ).toThrow(); + }); +}); diff --git a/packages/global/test/openapi/common/system.test.ts b/packages/global/test/openapi/common/system.test.ts index 4a9ea72c9deb..0a40e4fc6cfe 100644 --- a/packages/global/test/openapi/common/system.test.ts +++ b/packages/global/test/openapi/common/system.test.ts @@ -18,8 +18,8 @@ const desensitizedEmbeddingModel = { }; describe('system initialization OpenAPI contract', () => { - it('accepts legacy partial standard plans and coerces a serialized activity expiration date', () => { - const activityExpirationTime = '2026-08-31T16:00:00.000Z'; + it('accepts legacy partial standard plans with a stored activity expiration date', () => { + const activityExpirationTime = new Date('2026-08-31T16:00:00.000Z'); const plan = { price: 0, totalPoints: 100, @@ -36,7 +36,10 @@ describe('system initialization OpenAPI contract', () => { [StandardSubLevelEnum.free]: plan, [StandardSubLevelEnum.basic]: plan, [StandardSubLevelEnum.advanced]: plan, - [StandardSubLevelEnum.custom]: plan + [StandardSubLevelEnum.custom]: { + name: 'Custom Plan', + customFormUrl: 'https://example.com/contact' + } }, activityExpirationTime } @@ -46,23 +49,38 @@ describe('system initialization OpenAPI contract', () => { [StandardSubLevelEnum.free]: plan, [StandardSubLevelEnum.basic]: plan, [StandardSubLevelEnum.advanced]: plan, - [StandardSubLevelEnum.custom]: plan + [StandardSubLevelEnum.custom]: { + name: 'Custom Plan', + customFormUrl: 'https://example.com/contact' + } }); expect(result.subPlans?.activityExpirationTime).toEqual(new Date(activityExpirationTime)); }); - it.each(['', null])('treats an empty activity expiration value as unset', (value) => { - expect( - GetSystemInitDataResponseSchema.parse({ - subPlans: { activityExpirationTime: value } - }) - ).toEqual({ subPlans: {} }); - }); + it.each(['', null, '2026-08-31T16:00:00.000Z'])( + 'rejects a non-Date activity expiration value at read time', + (value) => { + expect(() => + GetSystemInitDataResponseSchema.parse({ + subPlans: { activityExpirationTime: value } + }) + ).toThrow(); + } + ); - it('rejects an invalid activity expiration date', () => { + it('rejects dirty subscription values at read time', () => { expect(() => GetSystemInitDataResponseSchema.parse({ - subPlans: { activityExpirationTime: 'invalid-date' } + subPlans: { + standard: { + [StandardSubLevelEnum.custom]: { + priceDesc: '定制化计费', + customDescriptions: ['专属客户经理'], + customFormUrl: 'https://example.com/contact' + } + }, + extraDatasetSize: { price: '4' } + } }) ).toThrow(); }); diff --git a/packages/global/test/support/wallet/sub/utils.test.ts b/packages/global/test/support/wallet/sub/utils.test.ts new file mode 100644 index 000000000000..bf5e8c702883 --- /dev/null +++ b/packages/global/test/support/wallet/sub/utils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { StandardSubLevelEnum } from '../../../../support/wallet/sub/constants'; +import type { SubPlanType } from '../../../../support/wallet/sub/type'; +import { getRuntimeSubPlansConfig } from '../../../../support/wallet/sub/utils'; + +describe('getRuntimeSubPlansConfig', () => { + it('returns a complete custom plan inherited from advanced without mutating source config', () => { + const source = { + standard: { + [StandardSubLevelEnum.advanced]: { + price: 599, + totalPoints: 25000, + maxTeamMember: 50, + maxAppAmount: 200, + maxDatasetAmount: 100, + maxDatasetSize: 36000, + chatHistoryStoreDuration: 360, + ticketResponseTime: 24 + }, + [StandardSubLevelEnum.custom]: { + name: 'Custom', + customFormUrl: 'https://example.com/contact', + maxTeamMember: 200 + } + } + } as SubPlanType; + + const result = getRuntimeSubPlansConfig(source); + + expect(result?.standard?.custom).toMatchObject({ + name: 'Custom', + customFormUrl: 'https://example.com/contact', + price: 599, + maxTeamMember: 200, + maxAppAmount: 200, + ticketResponseTime: 24 + }); + expect(source.standard?.custom).toEqual({ + name: 'Custom', + customFormUrl: 'https://example.com/contact', + maxTeamMember: 200 + }); + }); + + it('keeps source config unchanged when advanced is missing', () => { + const source = { + standard: { + [StandardSubLevelEnum.custom]: { customFormUrl: 'https://example.com/contact' } + } + } as SubPlanType; + + expect(getRuntimeSubPlansConfig(source)).toBe(source); + }); +}); diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index 2199da6aeb5f..0bedf38948ff 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -18,6 +18,7 @@ import { readFromSecondary } from '../../../common/mongo/utils'; import { TeamPointCache, teamQpmCache } from '@fastgpt/dal/redis/caches'; import { getLogger, LogCategories } from '../../../common/logger'; import { serviceEnv } from '../../../env'; +import { getRuntimeStandardPlanConfig } from '@fastgpt/global/support/wallet/sub/utils'; const logger = getLogger(LogCategories.MODULE.WALLET.SUB); const teamPointCache = new TeamPointCache({ logger }); @@ -30,7 +31,10 @@ export const getStandardPlansConfig = () => { return global?.subPlans?.standard; }; export const getStandardPlanConfig = (level: `${StandardSubLevelEnum}`) => { - return global.subPlans?.standard?.[level]; + return getRuntimeStandardPlanConfig({ + plans: global.subPlans?.standard, + level + }); }; export const sortStandPlans = (plans: TeamSubSchemaType[]) => { @@ -184,14 +188,9 @@ export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { const standard = plans[0]; - const standardConstants = - standard?.currentSubLevel && standardPlans - ? standardPlans[ - standard.currentSubLevel === StandardSubLevelEnum.custom - ? StandardSubLevelEnum.advanced - : standard.currentSubLevel - ] - : undefined; + const standardConstants = standard?.currentSubLevel + ? getStandardPlanConfig(standard.currentSubLevel) + : undefined; return { [SubTypeEnum.standard]: @@ -250,11 +249,7 @@ export const getTeamPlanStatus = async ({ const configuredStandardMaxDatasetSize = standardPlan?.currentSubLevel && standardPlans ? (standardPlan?.maxDatasetSize ?? - standardPlans[ - standardPlan.currentSubLevel === StandardSubLevelEnum.custom - ? StandardSubLevelEnum.advanced - : standardPlan.currentSubLevel - ]?.maxDatasetSize) + getStandardPlanConfig(standardPlan.currentSubLevel)?.maxDatasetSize) : undefined; const standardMaxDatasetSize = configuredStandardMaxDatasetSize === undefined @@ -268,15 +263,9 @@ export const getTeamPlanStatus = async ({ extraDatasetSize.reduce((acc, cur) => acc + (cur.currentExtraDatasetSize || 0), 0) ); - /** 静态的套餐配置,如果是 custom 则返回 advanced */ - const standardConstants = - standardPlan?.currentSubLevel && standardPlans - ? standardPlans[ - standardPlan.currentSubLevel === StandardSubLevelEnum.custom - ? StandardSubLevelEnum.advanced - : standardPlan.currentSubLevel - ] - : undefined; + const standardConstants = standardPlan?.currentSubLevel + ? getStandardPlanConfig(standardPlan.currentSubLevel) + : undefined; if (totalPoints === null || surplusPoints === null) { await teamPointCache.clear(teamId); diff --git a/packages/service/test/support/wallet/sub/utils.test.ts b/packages/service/test/support/wallet/sub/utils.test.ts index 44de9827d46f..164ae658349e 100644 --- a/packages/service/test/support/wallet/sub/utils.test.ts +++ b/packages/service/test/support/wallet/sub/utils.test.ts @@ -219,25 +219,44 @@ describe('getStandardPlanConfig', () => { expect(result).toBe(mockAdvancedPlan); }); - it('返回 custom 级别配置', () => { - const mockCustomPlan: TeamStandardSubPlanItemType = { - price: 999, - totalPoints: 50000, + it('custom 级别缺失字段时继承 advanced,并保留自身覆盖字段', () => { + const mockAdvancedPlan: TeamStandardSubPlanItemType = { + ...baseConstants, + name: 'Advanced Plan', + totalPoints: 25000, + maxTeamMember: 50 + }; + const mockCustomPlan = { + name: 'Custom Plan', + customFormUrl: 'https://example.com/contact', maxTeamMember: 200, - maxAppAmount: 1000, - maxDatasetAmount: 500, - chatHistoryStoreDuration: 365, - maxDatasetSize: 2000 + maxDatasetSize: undefined }; (global as any).subPlans = { standard: { + [StandardSubLevelEnum.advanced]: mockAdvancedPlan, [StandardSubLevelEnum.custom]: mockCustomPlan } }; const result = getStandardPlanConfig(StandardSubLevelEnum.custom); - expect(result).toBe(mockCustomPlan); + expect(result).toEqual({ + ...mockAdvancedPlan, + name: mockCustomPlan.name, + customFormUrl: mockCustomPlan.customFormUrl, + maxTeamMember: mockCustomPlan.maxTeamMember + }); + }); + + it('缺少 advanced 配置时不构造 custom 运行时套餐', () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.custom]: { customFormUrl: 'https://example.com/contact' } + } + }; + + expect(getStandardPlanConfig(StandardSubLevelEnum.custom)).toBeUndefined(); }); it('global.subPlans 不存在时返回 undefined', () => { diff --git a/pro b/pro index 9ed224a69043..d6a3d9006667 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 9ed224a6904311cc93c8f720afc11c420ebe38e0 +Subproject commit d6a3d90066679d2b0e8940b3eb8fc56c4f2f3627 diff --git a/projects/app/src/pages/api/common/system/getInitData.ts b/projects/app/src/pages/api/common/system/getInitData.ts index acafa86f9ccd..79e150824af1 100644 --- a/projects/app/src/pages/api/common/system/getInitData.ts +++ b/projects/app/src/pages/api/common/system/getInitData.ts @@ -8,6 +8,7 @@ import { GetSystemInitDataResponseSchema, type GetSystemInitDataResponse } from '@fastgpt/global/openapi/common/system/api'; +import { getRuntimeSubPlansConfig } from '@fastgpt/global/support/wallet/sub/utils'; async function handler( req: ApiRequestProps, @@ -17,6 +18,7 @@ async function handler( req, querySchema: GetSystemInitDataQuerySchema }).query; + const subPlans = getRuntimeSubPlansConfig(global.subPlans); const response = await (async () => { try { @@ -33,7 +35,7 @@ async function handler( return { bufferId: global.systemInitBufferId, feConfigs: global.feConfigs, - subPlans: global.subPlans, + subPlans, systemVersion: global.systemVersion, activeModelList: global.systemActiveDesensitizedModels, defaultModels: global.systemDefaultModel, @@ -45,7 +47,7 @@ async function handler( if (referer?.includes('/price')) { return { feConfigs: global.feConfigs, - subPlans: global.subPlans, + subPlans, modelProviders: global.ModelProviderRawCache, aiproxyChannels: global.aiproxyChannelsCache, activeModelList: global.systemActiveDesensitizedModels From 621cb046f7ed930dbc9a1479d1866e7cc22b6ad0 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 12:27:25 +0800 Subject: [PATCH 14/15] docs: note admin config validation --- document/content/self-host/upgrading/4-16/41602.mdx | 1 + document/data/doc-last-modified.json | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/document/content/self-host/upgrading/4-16/41602.mdx b/document/content/self-host/upgrading/4-16/41602.mdx index 8acec97921fa..faa51efc807f 100644 --- a/document/content/self-host/upgrading/4-16/41602.mdx +++ b/document/content/self-host/upgrading/4-16/41602.mdx @@ -53,3 +53,4 @@ curl -X POST 'https://你的域名/api/admin/dataClean/cleanSystemModelConfigs' ## 🛠️ 代码优化 1. 审计日志归档,不再删除,改成转存到 S3 冷归档。 +2. 增加对 admin 配置的数据校验和清洗。 diff --git a/document/data/doc-last-modified.json b/document/data/doc-last-modified.json index 894eeca9436c..7c7c8f67ab9a 100644 --- a/document/data/doc-last-modified.json +++ b/document/data/doc-last-modified.json @@ -337,8 +337,8 @@ "content/self-host/upgrading/4-15/4157.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.en.mdx": "2026-08-07T16:45:18+08:00", "content/self-host/upgrading/4-16/41601.mdx": "2026-08-07T16:45:18+08:00", - "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-11T22:43:06+08:00", - "content/self-host/upgrading/4-16/41602.mdx": "2026-08-12T11:07:30+08:00", + "content/self-host/upgrading/4-16/41602.en.mdx": "2026-08-12T11:35:38+08:00", + "content/self-host/upgrading/4-16/41602.mdx": "2026-08-12T11:35:38+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-07-25T00:27:20+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-07-25T00:27:20+08:00", From 37bc1125a27ecc5571b2191140bcd379c1381abf Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Wed, 12 Aug 2026 14:01:46 +0800 Subject: [PATCH 15/15] fix: harden subscription config handling --- .../openapi/admin/routes/settings/api.ts | 38 +++++++++++++--- .../openapi/admin/routes/settings/index.ts | 4 +- packages/global/support/wallet/sub/utils.ts | 5 ++- .../openapi/admin/routes/settings.test.ts | 9 +++- .../test/support/wallet/sub/utils.test.ts | 33 ++++++++++++++ packages/service/support/wallet/sub/utils.ts | 5 ++- .../test/support/wallet/sub/utils.test.ts | 44 +++++++++++++++++++ pro | 2 +- 8 files changed, 128 insertions(+), 12 deletions(-) diff --git a/packages/global/openapi/admin/routes/settings/api.ts b/packages/global/openapi/admin/routes/settings/api.ts index b0c6ecea9fc9..8e5eb8664161 100644 --- a/packages/global/openapi/admin/routes/settings/api.ts +++ b/packages/global/openapi/admin/routes/settings/api.ts @@ -1,6 +1,14 @@ import z from 'zod'; import { SubPlanInputSchema } from '../../../../support/wallet/sub/type'; +/* ============================================================================ + * API: 获取系统配置 + * Route: GET /api/admin/routes/settings/getConfig + * Method: GET + * Description: 获取 FastGPT 和 FastGPT Pro 的当前系统配置 + * Tags: ['Admin', 'Settings', 'Read'] + * ============================================================================ */ + export const GetConfigResponseSchema = z.object({ fastgpt: z.any().optional().meta({ description: '系统 FastGPT 配置' }), fastgptPro: z @@ -9,14 +17,34 @@ export const GetConfigResponseSchema = z.object({ .meta({ description: '系统 FastGPT Pro 商业版配置(不含 license)' }) }); +/* ============================================================================ + * API: 更新系统配置 + * Route: POST /api/admin/routes/settings/updateConfig + * Method: POST + * Description: 校验、归一化并更新 FastGPT 和 FastGPT Pro 的系统配置 + * Tags: ['Admin', 'Settings', 'Write'] + * ============================================================================ */ + export const UpdateConfigBodySchema = z.object({ fastgpt: z .looseObject({ - feConfigs: z.looseObject({}), - systemEnv: z.looseObject({}), - subPlans: SubPlanInputSchema.optional() + feConfigs: z.looseObject({}).meta({ + example: {}, + description: '前端功能和展示配置' + }), + systemEnv: z.looseObject({}).meta({ + example: {}, + description: '服务端系统运行配置' + }), + subPlans: SubPlanInputSchema.optional().meta({ + example: {}, + description: '订阅套餐配置' + }) }) - .meta({ description: 'FastGPT 系统配置对象' }), - fastgptPro: z.looseObject({}).meta({ description: 'FastGPT Pro 商业版配置对象' }) + .meta({ example: { feConfigs: {}, systemEnv: {} }, description: 'FastGPT 系统配置对象' }), + fastgptPro: z.looseObject({}).meta({ example: {}, description: 'FastGPT Pro 商业版配置对象' }) }); export type UpdateConfigBody = z.infer; + +export const UpdateConfigResponseSchema = z.undefined().meta({ description: '更新成功' }); +export type UpdateConfigResponse = z.infer; diff --git a/packages/global/openapi/admin/routes/settings/index.ts b/packages/global/openapi/admin/routes/settings/index.ts index fdd29887e20a..7657a13dcc68 100644 --- a/packages/global/openapi/admin/routes/settings/index.ts +++ b/packages/global/openapi/admin/routes/settings/index.ts @@ -1,6 +1,6 @@ import type { OpenAPIPath } from '../../../type'; import { DevApiTagsMap } from '../../../tag'; -import { GetConfigResponseSchema, UpdateConfigBodySchema } from './api'; +import { GetConfigResponseSchema, UpdateConfigBodySchema, UpdateConfigResponseSchema } from './api'; export const AdminSettingsPath: OpenAPIPath = { '/admin/routes/settings/getConfig': { @@ -37,7 +37,7 @@ export const AdminSettingsPath: OpenAPIPath = { description: '更新成功', content: { 'application/json': { - schema: {} + schema: UpdateConfigResponseSchema } } } diff --git a/packages/global/support/wallet/sub/utils.ts b/packages/global/support/wallet/sub/utils.ts index 31a577cbd945..7448cf2644c6 100644 --- a/packages/global/support/wallet/sub/utils.ts +++ b/packages/global/support/wallet/sub/utils.ts @@ -18,8 +18,11 @@ export const getRuntimeStandardPlanConfig = ({ const advancedPlan = plans?.[StandardSubLevelEnum.advanced]; if (!plan || !advancedPlan) return; + // 兼容旧版 Pro 配置中由表单空值写入的 null/空字符串,避免覆盖高级版的有效默认值。 const configuredOverrides = Object.fromEntries( - Object.entries(plan).filter(([, value]) => value !== undefined) + Object.entries(plan).filter( + ([, value]) => value !== undefined && value !== null && value !== '' + ) ); return { ...advancedPlan, ...configuredOverrides }; }; diff --git a/packages/global/test/openapi/admin/routes/settings.test.ts b/packages/global/test/openapi/admin/routes/settings.test.ts index 5205340b8666..993a4e29da8d 100644 --- a/packages/global/test/openapi/admin/routes/settings.test.ts +++ b/packages/global/test/openapi/admin/routes/settings.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { UpdateConfigBodySchema } from '../../../../openapi/admin/routes/settings/api'; +import { + UpdateConfigBodySchema, + UpdateConfigResponseSchema +} from '../../../../openapi/admin/routes/settings/api'; import { StandardSubLevelEnum, SubTypeEnum } from '../../../../support/wallet/sub/constants'; const createBody = (subPlans: unknown) => ({ @@ -62,4 +65,8 @@ describe('UpdateConfigBodySchema', () => { ) ).toThrow(); }); + + it('uses an empty success response contract', () => { + expect(UpdateConfigResponseSchema.parse(undefined)).toBeUndefined(); + }); }); diff --git a/packages/global/test/support/wallet/sub/utils.test.ts b/packages/global/test/support/wallet/sub/utils.test.ts index bf5e8c702883..b95c3e994d5a 100644 --- a/packages/global/test/support/wallet/sub/utils.test.ts +++ b/packages/global/test/support/wallet/sub/utils.test.ts @@ -51,4 +51,37 @@ describe('getRuntimeSubPlansConfig', () => { expect(getRuntimeSubPlansConfig(source)).toBe(source); }); + + it('treats legacy null and empty custom values as missing overrides', () => { + const source = { + standard: { + [StandardSubLevelEnum.advanced]: { + name: 'Advanced', + price: 599, + totalPoints: 25000, + maxTeamMember: 50, + maxAppAmount: 200, + maxDatasetAmount: 100, + maxDatasetSize: 36000, + chatHistoryStoreDuration: 360, + priceDescription: 'Advanced price' + }, + [StandardSubLevelEnum.custom]: { + name: '', + maxTeamMember: null, + priceDescription: null, + customFormUrl: 'https://example.com/contact' + } + } + } as unknown as SubPlanType; + + const result = getRuntimeSubPlansConfig(source); + + expect(result?.standard?.custom).toMatchObject({ + name: 'Advanced', + maxTeamMember: 50, + priceDescription: 'Advanced price', + customFormUrl: 'https://example.com/contact' + }); + }); }); diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index 0bedf38948ff..3870266d5b59 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -267,10 +267,11 @@ export const getTeamPlanStatus = async ({ ? getStandardPlanConfig(standardPlan.currentSubLevel) : undefined; + // Redis 只承担积分读取加速,刷新失败或变慢都不应阻塞套餐主流程。 if (totalPoints === null || surplusPoints === null) { - await teamPointCache.clear(teamId); + void teamPointCache.clear(teamId); } else { - await teamPointCache.set({ teamId, totalPoints, surplusPoints }); + void teamPointCache.set({ teamId, totalPoints, surplusPoints }); } return { diff --git a/packages/service/test/support/wallet/sub/utils.test.ts b/packages/service/test/support/wallet/sub/utils.test.ts index 164ae658349e..dac7e2993114 100644 --- a/packages/service/test/support/wallet/sub/utils.test.ts +++ b/packages/service/test/support/wallet/sub/utils.test.ts @@ -21,6 +21,7 @@ import { teamQPM } from '@fastgpt/service/support/wallet/sub/utils'; import { MongoTeamSub } from '@fastgpt/service/support/wallet/sub/schema'; +import { redisCacheAdapter } from '@fastgpt/dal/redis/adapter'; // Valid ObjectId for testing const mockTeamId = '507f1f77bcf86cd799439011'; @@ -958,6 +959,49 @@ describe('getTeamPlanStatus', () => { expect(result[SubTypeEnum.standard]).toBeDefined(); }); + it('does not wait for a finite point cache refresh', async () => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([baseStandard]) + } as any); + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: baseConstants + } + }; + const setPairSpy = vi + .spyOn(redisCacheAdapter, 'setPair') + .mockReturnValueOnce(new Promise(() => {})); + const timeout = Symbol('timeout'); + + const result = await Promise.race([ + getTeamPlanStatus({ teamId: mockTeamId }), + new Promise((resolve) => setTimeout(() => resolve(timeout), 100)) + ]); + + expect(result).not.toBe(timeout); + expect(setPairSpy).toHaveBeenCalledOnce(); + setPairSpy.mockRestore(); + }); + + it('does not wait for an unlimited point cache cleanup', async () => { + vi.spyOn(MongoTeamSub, 'find').mockReturnValue({ + lean: vi.fn().mockResolvedValue([baseStandard]) + } as any); + const deleteManySpy = vi + .spyOn(redisCacheAdapter, 'deleteMany') + .mockReturnValueOnce(new Promise(() => {})); + const timeout = Symbol('timeout'); + + const result = await Promise.race([ + getTeamPlanStatus({ teamId: mockTeamId }), + new Promise((resolve) => setTimeout(() => resolve(timeout), 100)) + ]); + + expect(result).not.toBe(timeout); + expect(deleteManySpy).toHaveBeenCalledOnce(); + deleteManySpy.mockRestore(); + }); + it.each([ [StandardSubLevelEnum.experience, 60], [StandardSubLevelEnum.team, 600], diff --git a/pro b/pro index d6a3d9006667..999c207143b8 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit d6a3d90066679d2b0e8940b3eb8fc56c4f2f3627 +Subproject commit 999c207143b8f3178e929958c8f4e9753120c407