Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
465 changes: 465 additions & 0 deletions .agents/design/admin/license-instance-id-redesign.md

Large diffs are not rendered by default.

436 changes: 436 additions & 0 deletions .agents/design/admin/pro-admin-ui-migrate.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ pro/admin/worker/
/pro/llm_benchmark/content_benchmark/eval-runs/
/pro/llm_benchmark/content_benchmark/.cache/
.gstack/

.pi
6 changes: 5 additions & 1 deletion packages/global/common/system/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export enum SystemConfigsTypeEnum {
systemMsgModal = 'systemMsgModal',
license = 'license',
operationalAd = 'operationalAd',
activityAd = 'activityAd'
activityAd = 'activityAd',
instanceId = 'instanceId'
}

export const SystemConfigsTypeMap = {
Expand All @@ -25,5 +26,8 @@ export const SystemConfigsTypeMap = {
},
[SystemConfigsTypeEnum.activityAd]: {
label: 'activityAd'
},
[SystemConfigsTypeEnum.instanceId]: {
label: '部署实例 ID'
}
};
87 changes: 87 additions & 0 deletions packages/global/common/system/license/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* License 数据结构单一来源(决策版)— zod schema + 类型派生。
*
* 职责(方案 A):
* - 字段名 / 枚举 / 键清单的唯一权威,签发侧(license-server)与验证侧(normalize)共享,
* 杜绝两侧字段漂移(历史上 version vs licenseType 已漂移一次)
* - 签发侧用 licensePayloadSchema(严格:functions 必填全字段,默认关语义在 UI 层)
* - 验证侧只用这里的类型 + 键清单,宽容归一化在 normalize 模块(zod 不适合表达旧→新多分支映射)
*
* 决策版结构(飞书决策版 §2):
* { schemaVersion: 1|2, licenseType: trial|official, company, startTime, expiredTime,
* instanceId?, description?, limits{maxUsers,maxApps,maxDatasets}, functions{7 项} }
* 有效期 startTime <= now < expiredTime
*/
import { z } from 'zod';

/** License schema 版本:1 = 旧结构(存量),2 = 决策版(新签发) */
export const LicenseSchemaVersionSchema = z.union([z.literal(1), z.literal(2)]);
export type LicenseSchemaVersionType = z.infer<typeof LicenseSchemaVersionSchema>;

/** 授权类型 */
export const LicenseTypeSchema = z.enum(['trial', 'official']);
export type LicenseType = z.infer<typeof LicenseTypeSchema>;

/** functions 键清单(决策版 7 项,全部显式签发) */
export const licenseFunctionKeys = [
'sso',
'pay',
'eval',
'datasetEnhance',
'assistantGenerate',
'portal',
'sandboxSkills'
] as const;
export type LicenseFunctionKey = (typeof licenseFunctionKeys)[number];

/** limits 键清单 */
export const licenseLimitsKeys = ['maxUsers', 'maxApps', 'maxDatasets'] as const;
export type LicenseLimitsKey = (typeof licenseLimitsKeys)[number];

/** functions:布尔开关,决策版要求签发时显式全部给出(默认关在 UI 层,非 schema 默认)。
* strictObject:拒绝未知键(防旧字段 customTemplates/batchEval 混入新 license) */
export const LicenseFunctionsSchema = z.strictObject({
sso: z.boolean(),
pay: z.boolean(),
eval: z.boolean(),
datasetEnhance: z.boolean(),
assistantGenerate: z.boolean(),
portal: z.boolean(),
sandboxSkills: z.boolean()
});
export type LicenseFunctions = z.infer<typeof LicenseFunctionsSchema>;

/** limits:配额,0 = 不限制。strictObject:拒绝未知配额键 */
export const LicenseLimitsSchema = z.strictObject({
maxUsers: z.number().int().min(0),
maxApps: z.number().int().min(0),
maxDatasets: z.number().int().min(0)
});
export type LicenseLimits = z.infer<typeof LicenseLimitsSchema>;

/**
* 决策版 License payload(签发侧严格校验用)。
* - instanceId:32 位 hex,绑定部署实例(决策版 §3)
* - 不做 startTime/expiredTime 顺序校验的 default —— 由调用方 superRefine(签发服务)与
* 验证侧 isLicenseExpired 各自处理
*/
export const LicensePayloadSchema = z.object({
schemaVersion: LicenseSchemaVersionSchema.default(2),
licenseType: LicenseTypeSchema.default('official'),
company: z.string().min(1),
description: z.string().optional(),
startTime: z.iso.datetime(),
expiredTime: z.iso.datetime(),
instanceId: z
.string()
.regex(/^[0-9a-f]{32}$/)
.optional(),
limits: LicenseLimitsSchema,
functions: LicenseFunctionsSchema
});
export type LicensePayload = z.infer<typeof LicensePayloadSchema>;

/** 签发侧强制未知键拒绝(决策版:customTemplates/networkIds 不入新 license) */
export const StrictLicensePayloadSchema = z.strictObject({
...LicensePayloadSchema.shape
});
45 changes: 30 additions & 15 deletions packages/global/common/system/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import type { SubPlanType } from '../../../support/wallet/sub/type';
import type { AccountCancellationVerificationCapabilities } from '../../../support/user/account/cancellation/type';
import type { LicensePayload, LicenseSchemaVersionType, LicenseType, LicenseFunctions, LicenseLimits } from '../license/schema';

export type { LicensePayload, LicenseSchemaVersionType, LicenseType, LicenseFunctions, LicenseLimits };
export {
licenseFunctionKeys,
licenseLimitsKeys,
LicensePayloadSchema,
StrictLicensePayloadSchema,
LicenseFunctionsSchema,
LicenseLimitsSchema,
LicenseSchemaVersionSchema,
LicenseTypeSchema
} from '../license/schema';

export type NavbarItemType = {
id: string;
Expand Down Expand Up @@ -216,20 +229,22 @@ export type customPdfParseType = {
price?: number;
};

export type LicenseDataType = {
startTime: string;
expiredTime: string;
company: string;
description?: string; // 描述
hosts?: string[]; // 管理端有效域名
maxUsers?: number; // 最大用户数,不填默认不上限
maxApps?: number; // 最大应用数,不填默认不上限
maxDatasets?: number; // 最大数据集数,不填默认不上限
functions: {
sso: boolean;
pay: boolean;
customTemplates: boolean;
datasetEnhance: boolean;
batchEval: boolean;
/**
* 运行时 License 数据(global.licenseData / 前端展示)=
* 决策版 payload(schema 单一来源派生)+ deprecated 兼容视图。
*
* deprecated 视图(旧 UI/消费方直接读顶层字段,避免回归;新代码一律用 limits/functions 决策版键):
* - 顶层 maxUsers/maxApps/maxDatasets(旧结构,归一化回填自 limits)
* - hosts(仅兼容读取,不参与校验)
* - functions.batchEval / functions.customTemplates(旧 UI 展示兜底)
*/
export type LicenseDataType = LicensePayload & {
hosts?: string[];
maxUsers?: number;
maxApps?: number;
maxDatasets?: number;
functions: LicenseFunctions & {
batchEval?: boolean;
customTemplates?: boolean;
};
};
20 changes: 17 additions & 3 deletions packages/service/common/system/config/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { verifyLicenseSignature, normalizeLicenseData, isLicenseExpired } from '../license/verify';
import { SystemConfigsTypeEnum } from '@fastgpt/global/common/system/config/constants';
import { MongoSystemConfigs } from './schema';
import { type FastGPTConfigFileType } from '@fastgpt/global/common/system/types';
import type { FastGPTConfigFileType, LicenseDataType } from '@fastgpt/global/common/system/types';
import { FastGPTProUrl } from '../constants';
import { type LicenseDataType } from '@fastgpt/global/common/system/types';

export const getFastGPTConfigFromDB = async (): Promise<{
fastgptConfig: FastGPTConfigFileType;
Expand All @@ -29,7 +29,21 @@ export const getFastGPTConfigFromDB = async (): Promise<{
]);

const config = fastgptConfig?.value || {};
const licenseData = licenseConfig?.value?.data as LicenseDataType | undefined;
// 决策版 §4:DB 只存原始 license 字符串;启动时从原始 license 重新验签,
// 验签/归一化失败或过期则视为未激活(undefined),不信任落库的解析结果。
let licenseData: LicenseDataType | undefined;
const licenseStr = licenseConfig?.value?.license as string | undefined;
if (licenseStr) {
try {
const raw = verifyLicenseSignature(licenseStr);
const normalized = normalizeLicenseData(raw);
if (!isLicenseExpired(normalized)) {
licenseData = normalized;
}
} catch (error) {
licenseData = undefined;
}
}

const fastgptConfigTime = fastgptConfig?.createTime.getTime().toString();
const licenseConfigTime = licenseConfig?.createTime.getTime().toString();
Expand Down
36 changes: 36 additions & 0 deletions packages/service/common/system/config/instanceId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* 部署实例 ID 工具 — 替代原 hosts(域名)绑定标记。
*
* 设计(见 .agents/design/admin/license-instance-id-redesign.md §6.4):
* - 首次调用用 findOneAndUpdate + $setOnInsert 原子生成;固定 _id 利用主键唯一约束,
* 保证多实例并发首启时只有一个插入成功,其余返回已存在文档(type 索引非唯一,
* 不能依赖 type 做并发去重——fastgpt/fastgptPro 需要同 type 多文档历史)
* - 之后永不 update:换 license / 重签 / 删 license 均不影响身份
* - change stream 只对 fastgptPro/license 的 insert 触发配置重载,instanceId 的 insert 零副作用
* - 纯 DB 存储,不做文件双写(多实例不一致 + 迁移丢失)
*/
import crypto from 'crypto';
import { SystemConfigsTypeEnum } from '@fastgpt/global/common/system/config/constants';
import { MongoSystemConfigs } from './schema';

/** 固定 _id(24 位 hex,合法 ObjectId):主键唯一约束保证并发下只生成一次 */
const INSTANCE_ID_DOC_ID = '000000000000000000000001';

export const getInstanceId = async (): Promise<string> => {
const doc = await MongoSystemConfigs.findOneAndUpdate(
{ _id: INSTANCE_ID_DOC_ID },
{
$setOnInsert: {
_id: INSTANCE_ID_DOC_ID,
type: SystemConfigsTypeEnum.instanceId,
value: {
instanceId: crypto.randomBytes(16).toString('hex') // 32 位 hex
},
createTime: new Date()
}
},
{ upsert: true, new: true }
);

return doc.value.instanceId as string;
};
116 changes: 116 additions & 0 deletions packages/service/common/system/license/verify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* License 验证核心(决策版)— 无 DB/业务全局依赖,pro/admin 与 app 共用;公钥从服务环境读取。
*
* 设计(决策版 §2/§3):
* - payload 决策版结构:schemaVersion=2、licenseType、limits{}、functions 显式全字段
* - 旧 license(schemaVersion=1,无 instanceId/limits,functions 含 batchEval/customTemplates)
* 通过 normalizeLicenseData 归一化为决策版结构;customTemplates/networkIds 不入新结构
* - 有效期:startTime <= now < expiredTime
* - 验签对象是 base64 payload 字符串字节(RSA-SHA256),与签发侧一致
*/
import crypto from 'crypto';
import { readFileSync } from 'fs';
import { serviceEnv } from '../../../env';
import type { LicenseDataType, LicenseFunctions } from '@fastgpt/global/common/system/types';

/** 读取与 license-server 配对的公钥;不支持多把 key,轮换由重新部署配置完成。 */
const getLicensePublicKey = () => {
if (serviceEnv.LICENSE_PUBLIC_KEY_PATH) {
return readFileSync(serviceEnv.LICENSE_PUBLIC_KEY_PATH, 'utf8');
}
if (serviceEnv.LICENSE_PUBLIC_KEY) return serviceEnv.LICENSE_PUBLIC_KEY.replace(/\\n/g, '\n');
throw new Error('未配置 LICENSE_PUBLIC_KEY 或 LICENSE_PUBLIC_KEY_PATH');
};

/** RSA-4096 签名 base64 长度(512 字节) */
export const signatureLength = 684;

/** 决策版 functions 全字段默认关闭(缺失字段归一化补 false)。类型标注 LicenseFunctions,schema 键变化即编译报错 */
export const licenseDefaultFunctions: LicenseFunctions = {
sso: false,
pay: false,
eval: false,
datasetEnhance: false,
assistantGenerate: false,
portal: false,
sandboxSkills: false
};

/**
* 校验签名并解析 payload。
* @returns 原始解析对象(未归一化),供 normalizeLicenseData 消费
* @throws 验签失败 / 非合法 base64 JSON
*/
export const verifyLicenseSignature = (license: string) => {
const signature = license.substring(0, signatureLength);
const payloadBase64 = license.substring(signatureLength);

const verified = crypto
.createVerify('RSA-SHA256')
.update(payloadBase64)
.verify(getLicensePublicKey(), signature, 'base64');

if (!verified) {
throw new Error('License 签名不合法');
}

return JSON.parse(Buffer.from(payloadBase64, 'base64').toString('utf8')) as Record<string, any>;
};

/**
* 归一化为决策版结构(旧 schemaVersion=1 → 决策版)。
* - schemaVersion 缺省视为 1;licenseType 缺省 official
* - 旧顶层 maxUsers/maxApps/maxDatasets → limits;旧 batchEval → eval
* - customTemplates/networkIds/hosts 不进入新结构(hosts 仅读兼容,不校验)
* - functions 缺失字段补 false(决策版:functions 是唯一授权来源,默认关闭)
*/
export const normalizeLicenseData = (raw: Record<string, any>): LicenseDataType => {
const limits = raw.limits ?? {
maxUsers: raw.maxUsers ?? 0,
maxApps: raw.maxApps ?? 0,
maxDatasets: raw.maxDatasets ?? 0
};

const oldFunctions = raw.functions ?? {};
const functions = {
sso: oldFunctions.sso ?? false,
pay: oldFunctions.pay ?? false,
eval: oldFunctions.eval ?? oldFunctions.batchEval ?? false, // 旧 batchEval 归一化
datasetEnhance: oldFunctions.datasetEnhance ?? false,
assistantGenerate: oldFunctions.assistantGenerate ?? false,
portal: oldFunctions.portal ?? false,
sandboxSkills: oldFunctions.sandboxSkills ?? false
};

return {
schemaVersion: raw.schemaVersion ?? 1,
licenseType: raw.licenseType ?? 'official',
startTime: raw.startTime,
expiredTime: raw.expiredTime,
company: raw.company,
description: raw.description,
instanceId: raw.instanceId,
limits,
functions: {
...functions,
// deprecated 兼容键:旧 UI 读 batchEval/customTemplates,回填避免回归(新 license 不签发)
batchEval: functions.eval,
customTemplates: false
},
// deprecated 兼容视图:旧 UI 读顶层 maxUsers/maxApps/maxDatasets,回填避免回归(新代码读 limits)
maxUsers: limits.maxUsers || undefined,
maxApps: limits.maxApps || undefined,
maxDatasets: limits.maxDatasets || undefined,
hosts: raw.hosts // 仅兼容读取,不参与校验(决策版:新 license 不用 hosts)
};
};
/** 决策版有效期判定:startTime <= now < expiredTime。 */
export const isLicenseExpired = (
data: { startTime: string; expiredTime: string },
now = new Date()
) => {
const start = new Date(data.startTime).getTime();
const end = new Date(data.expiredTime).getTime();
const current = now.getTime();
return current < start || current >= end;
};
4 changes: 4 additions & 0 deletions packages/service/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export const serviceEnv = createEnv({
// Invoke 反向调用相关。该密钥用于签发/校验插件反向调用 JWT,必须显式配置,避免未配置时落到公开默认值。
INVOKE_TOKEN_SECRET: z.string().min(32, 'INVOKE_TOKEN_SECRET must be at least 32 characters'),

// License-server 公钥:与签发服务使用同一密钥对;支持内联 PEM 或文件路径。
LICENSE_PUBLIC_KEY: z.string().optional(),
LICENSE_PUBLIC_KEY_PATH: z.string().optional(),

// ==================== 服务地址与集成 ====================
// 插件
PLUGIN_BASE_URL: UrlSchema.default('http://localhost:3004'),
Expand Down
Loading
Loading