diff --git a/README.md b/README.md index 785f965..640eec8 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,11 @@ LOCAL_ENCRYPTION_KEY= 内置 PostgreSQL 部署时 `DATABASE_URL` 留空,由 Compose 自动指向内置数据库;只有外部 PostgreSQL 才填写真实连接串。`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 中的项目配置一致。 -授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 token”的分工;免费版的人数/素材数上限也通过 `license-limits-core` 模块下发,付费版再额外下发自定义模板、内网穿透等能力模块。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和模块 digest 指向当前可用镜像站。 +授权模块采用“镜像站放模块核心本体,验证服务器发授权清单、实例凭证和短期拉包 token”的分工;免费版的人数/素材数上限由验证系统通过签名模块清单下发,`license-limits-core` 在客户本机执行最终允许/拒绝判断,付费版再额外下发自定义模板、内网穿透等能力模块。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和模块 digest 指向当前可用镜像站。 -当前已提供第一批模块源码:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求对应模块处于 `running`、模块 `/health` 正常,并通过模块 `/authorize` 放行后才开放人数/素材限额、自定义模板和内网穿透能力。`custom-templates-core` 当前承接自定义模板授权执行层,模板 CRUD、迁移和数据处理代码后续还要继续迁到模块内部。 +当前已提供第一批模块源码:`premium-modules/license-limits-core`、`premium-modules/custom-templates-core`、`premium-modules/intranet-penetration-core`。模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule` 发布,发布后把脚本输出的模块镜像和 digest 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求对应模块处于 `running`、模块 `/health` 正常;人数/素材限额还必须经过 `license-limits-core` 的 `/limits/enforce` 返回允许,自定义模板和内网穿透能力也必须通过对应模块 `/authorize` 放行。`custom-templates-core` 当前承接自定义模板授权执行层,模板 CRUD、迁移和数据处理代码后续还要继续迁到模块内部。 + +许可证和授权模块不是“客户每次使用都实时联网验证”。联网主要发生在激活、续期心跳、授权变更同步、短期拉包 token 获取、模块镜像拉取和版本升级时;日常上传、创建用户、模板使用等动作优先依赖本地已验签许可证缓存、已缓存的模块授权凭证和本机正在运行的授权模块。也就是说,客户现场短时间断网不应影响已经授权且本地模块正常运行的日常使用,但许可证过期、授权被吊销、需要拉取新模块或升级时仍需要重新连验证服务器。 ### 初始化 Prisma diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 785caef..6926648 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -96,7 +96,8 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 - `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 - `LICENSE_LIMITS_MODULE_URL`、`CUSTOM_TEMPLATES_MODULE_URL`、`INTRANET_PENETRATION_MODULE_URL` 指向同一 Docker 网络里的模块容器。模块没运行或健康检查失败时,对应人数/素材限额、自定义模板、内网穿透能力都会被服务端拒绝。 - `MODULE_RUNTIME_STATUS_TOKEN` 用于 Agent 回写模块运行状态;Agent 侧 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` 必须和它一致,回调地址通常是 `http://<主应用地址>/api/license/premium-modules/runtime-status`。 -- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限也通过 `license-limits-core` 模块下发,并且需要模块运行态可用。 +- 授权模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。免费版人数/素材数上限由验证系统通过签名模块清单下发,`license-limits-core` 在客户本机执行最终允许/拒绝判断,并且需要模块运行态可用。 +- 客户日常使用不应设计成每次都实时访问验证服务器;首装激活、心跳续租、授权变更同步、模块拉取和升级需要联网,已经授权的日常操作依赖本地验签缓存和本机模块运行状态。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 @@ -166,6 +167,8 @@ Docker Desktop 需要在设置里的 Docker Engine 配置同样的 `insecure-reg 模块发布时应尽量固定 digest,并在 `license-server` 配置中写入对应模块的 `_DIGEST` 变量。Docker 拉取时会先取镜像清单、再取多个层文件;代理会校验短期 token、模块仓库、tag、清单 digest、许可证、激活记录和模块授权状态,客户侧 Agent 拉取后还会比对整镜像 digest,不匹配就拒绝启动模块容器。 +这条链路不是要求客户使用系统时一直在线。验证服务器负责“发证、续租、变更、发短期拉包权限”,客户现场负责“保存已验签缓存、运行模块、按模块返回结果执法”。所以离线客户要提前把基础镜像、模块镜像和当前许可证租约准备好;如果需要新增授权、续期、重新拉包或升级,就必须恢复到能访问验证服务器和镜像站的网络状态。 + 仓库内已提供可审查的代理示例: ```text diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index e178194..e00a930 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -124,6 +124,12 @@ docker compose exec -T license-server npm run init-project -- flnxi-sucai - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 +联网边界按下面理解: + +- 必须联网:首次激活、免费版注册、续期心跳、验证服务器授权变更同步、获取短期镜像拉包 token、拉取授权模块镜像、升级版本。 +- 不要求每次实时联网:用户登录后的日常上传、创建用户、素材浏览、模板使用等操作,优先使用本地已验签许可证缓存、已缓存模块授权凭证和 Agent 回写的本机模块运行状态。 +- 断网期间如果本地许可证仍在有效租约内,且需要的模块容器仍为 `running` 并通过 `/health`、`/authorize` 或 `/limits/enforce`,对应受控能力可以继续使用;一旦许可证过期、模块被吊销、需要重新拉包或本地模块不可用,就必须重新连验证服务器恢复。 + 主应用内部已经不会再直接访问无项目的 `license-server` 路径,而是会根据 `LICENSE_PROJECT_CODE` 转发到: ```text @@ -163,7 +169,7 @@ premium-modules/custom-templates-core premium-modules/intranet-penetration-core ``` -`license-limits-core` 承接免费版和付费版的人数/素材数上限门禁;`custom-templates-core` 承接自定义模板授权执行层;`intranet-penetration-core` 承接内网穿透模块门禁。主应用现在会同时检查模块授权凭证、Agent 回写的 `running` 状态、模块 `/health` 和模块 `/authorize`,任一环节不可用都会拒绝对应付费能力。自定义模板的 CRUD、迁移和复杂数据处理后续还需要继续从主应用迁到 `custom-templates-core` 内部。 +`license-limits-core` 承接免费版和付费版的人数/素材数上限门禁;主应用负责统计当前用户数和素材数,再把当前数量与验证系统签发的上限交给 `license-limits-core` 的 `/limits/enforce` 做最终允许/拒绝判断。`custom-templates-core` 承接自定义模板授权执行层;`intranet-penetration-core` 承接内网穿透模块门禁。主应用现在会同时检查模块授权凭证、Agent 回写的 `running` 状态、模块 `/health`、限额模块 `/limits/enforce` 或对应模块 `/authorize`,任一环节不可用都会拒绝对应受控能力。自定义模板的 CRUD、迁移和复杂数据处理后续还需要继续从主应用迁到 `custom-templates-core` 内部。 ## 6. 后台管理入口 diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index ac68fae..d9d16f5 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -21,6 +21,7 @@ jest.mock('../license/license-service', () => ({ jest.mock('../license/premium-module-service', () => ({ authorizePremiumModuleAction: jest.fn(), + enforceAuthorizedUsageLimit: jest.fn(), getAuthorizedUsageLimits: jest.fn(), PREMIUM_MODULE_IDS: { LICENSE_LIMITS_CORE: 'license-limits-core', @@ -40,6 +41,7 @@ import { prisma } from '@/lib/prisma'; import { isFeatureEnabled, getLicenseType } from '../license/license-service'; import { authorizePremiumModuleAction, + enforceAuthorizedUsageLimit, getAuthorizedUsageLimits, } from '../license/premium-module-service'; @@ -48,23 +50,31 @@ const mockGetLicenseType = getLicenseType as jest.MockedFunction; const mockAuthorizePremiumModuleAction = authorizePremiumModuleAction as jest.MockedFunction; +const mockEnforceAuthorizedUsageLimit = + enforceAuthorizedUsageLimit as jest.MockedFunction; const mockUserCount = prisma.user.count as jest.MockedFunction; const mockMaterialCount = prisma.material.count as jest.MockedFunction; describe('canCreateUser', () => { beforeEach(() => { jest.clearAllMocks(); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: true, - moduleId: 'license-limits-core', - action: 'licenseLimits:enforce', - status: 'running', + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: true, + type: 'users', + current: 5, + max: 10, }); }); it('当未达到限制时应该返回 allowed: true', async () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); mockUserCount.mockResolvedValue(5); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: true, + type: 'users', + current: 5, + max: 10, + }); const result = await canCreateUser(); @@ -74,12 +84,19 @@ describe('canCreateUser', () => { expect(result.message).toBeUndefined(); expect(mockGetAuthorizedUsageLimits).toHaveBeenCalled(); expect(mockUserCount).toHaveBeenCalled(); + expect(mockEnforceAuthorizedUsageLimit).toHaveBeenCalledWith('users', 5); }); it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); mockUserCount.mockResolvedValue(10); mockGetLicenseType.mockResolvedValue('FREE'); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: false, + type: 'users', + current: 10, + max: 10, + }); const result = await canCreateUser(); @@ -93,6 +110,12 @@ describe('canCreateUser', () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 100, maxMaterials: 1000 }); mockUserCount.mockResolvedValue(100); mockGetLicenseType.mockResolvedValue('STANDARD'); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: false, + type: 'users', + current: 100, + max: 100, + }); const result = await canCreateUser(); @@ -119,17 +142,23 @@ describe('canCreateUser', () => { describe('canUploadMaterial', () => { beforeEach(() => { jest.clearAllMocks(); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: true, - moduleId: 'license-limits-core', - action: 'licenseLimits:enforce', - status: 'running', + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: true, + type: 'materials', + current: 50, + max: 100, }); }); it('当未达到限制时应该返回 allowed: true', async () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); mockMaterialCount.mockResolvedValue(50); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: true, + type: 'materials', + current: 50, + max: 100, + }); const result = await canUploadMaterial(); @@ -138,12 +167,19 @@ describe('canUploadMaterial', () => { expect(result.max).toBe(100); expect(mockGetAuthorizedUsageLimits).toHaveBeenCalled(); expect(mockMaterialCount).toHaveBeenCalled(); + expect(mockEnforceAuthorizedUsageLimit).toHaveBeenCalledWith('materials', 50); }); it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); mockMaterialCount.mockResolvedValue(100); mockGetLicenseType.mockResolvedValue('FREE'); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: false, + type: 'materials', + current: 100, + max: 100, + }); const result = await canUploadMaterial(); @@ -157,6 +193,12 @@ describe('canUploadMaterial', () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: -1, maxMaterials: 1000 }); mockMaterialCount.mockResolvedValue(1000); mockGetLicenseType.mockResolvedValue('PRO'); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: false, + type: 'materials', + current: 1000, + max: 1000, + }); const result = await canUploadMaterial(); @@ -297,11 +339,18 @@ describe('功能开关检查', () => { describe('LimitCheckResult 接口', () => { it('应该正确处理无限制情况 (max = -1)', async () => { mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: -1, maxMaterials: -1 }); + mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + allowed: true, + type: 'users', + current: 0, + max: -1, + }); const result = await canCreateUser(); expect(result.allowed).toBe(true); expect(result.max).toBe(-1); expect(mockUserCount).not.toHaveBeenCalled(); + expect(mockEnforceAuthorizedUsageLimit).toHaveBeenCalledWith('users', 0); }); }); diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index c54b7ab..e861b8c 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -132,6 +132,7 @@ describe('许可证业务入口覆盖', () => { const source = readProjectFile('lib/license/check-limits.ts'); expect(source).toContain('getAuthorizedUsageLimits'); + expect(source).toContain('enforceAuthorizedUsageLimit'); expect(source).toContain('授权限额模块不可用'); }); diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index 4249d9b..8ca8eaf 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -578,6 +578,168 @@ describe('premium-module-service', () => { expect(mockFetch).not.toHaveBeenCalled(); }); + it('人数和素材数是否可新增应由限额模块执行判断', async () => { + mockFreeLicense(); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [mockLimitsManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_entitlements') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + entitlements: { + 'license-limits-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: limitsModuleArtifact, + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + if (where.key === 'premium_module_runtime_status') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + 'license-limits-core': { + moduleId: 'license-limits-core', + image: limitsModuleArtifact.image, + digest: null, + status: 'running', + lastSyncAt: new Date().toISOString(), + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + process.env.LICENSE_LIMITS_MODULE_URL = 'http://license-limits-module.test'; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'license-limits-core', status: 'healthy' }), + }).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'license-limits-core', + allowed: false, + type: 'users', + current: 10, + max: 10, + }), + }); + + const { enforceAuthorizedUsageLimit } = await import('../license/premium-module-service'); + + await expect(enforceAuthorizedUsageLimit('users', 10)).resolves.toEqual({ + allowed: false, + type: 'users', + current: 10, + max: 10, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://license-limits-module.test/limits/enforce', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"type":"users"'), + }) + ); + }); + + it('限额模块返回的数量或上限不一致时不应采信', async () => { + mockFreeLicense(); + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [mockLimitsManifestModule()], + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + signature: 'valid-signature', + syncedAt: new Date().toISOString(), + })}`, + }); + } + + if (where.key === 'premium_module_entitlements') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + entitlements: { + 'license-limits-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: limitsModuleArtifact, + syncedAt: new Date().toISOString(), + }, + }, + })}`, + }); + } + + if (where.key === 'premium_module_runtime_status') { + return Promise.resolve({ + value: `encrypted:${JSON.stringify({ + 'license-limits-core': { + moduleId: 'license-limits-core', + image: limitsModuleArtifact.image, + digest: null, + status: 'running', + lastSyncAt: new Date().toISOString(), + }, + })}`, + }); + } + + return Promise.resolve(null); + }); + process.env.LICENSE_LIMITS_MODULE_URL = 'http://license-limits-module.test'; + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'license-limits-core', status: 'healthy' }), + }).mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'license-limits-core', + allowed: true, + type: 'users', + current: 10, + max: 999, + }), + }); + + const { enforceAuthorizedUsageLimit } = await import('../license/premium-module-service'); + + await expect(enforceAuthorizedUsageLimit('users', 10)).resolves.toBeNull(); + }); + it('缓存中的短期拉包凭证有效时才返回给模块运行器', async () => { mockPaidLicense('PRO'); (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index 984a00b..2b54467 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -7,6 +7,7 @@ import { isFeatureEnabled, getLicenseType } from './license-service'; import { prisma } from '@/lib/prisma'; import { authorizePremiumModuleAction, + enforceAuthorizedUsageLimit, getAuthorizedUsageLimits, PREMIUM_MODULE_IDS, } from './premium-module-service'; @@ -32,11 +33,7 @@ async function checkAuthorizedLimit(type: 'users' | 'materials'): Promise { + if (!Number.isInteger(current) || current < 0) return null; + + const moduleInfo = await getPremiumModuleInfo(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE); + if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null; + if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) return null; + const expectedMax = type === 'users' ? moduleInfo.limits.maxUsers : moduleInfo.limits.maxMaterials; + + const gate = await isPremiumModuleRunning(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE); + if (!gate.available) return null; + + const baseUrl = getPremiumModuleServiceBaseUrl(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE); + try { + const result = await fetchModuleJson(baseUrl, '/limits/enforce', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type, + current, + limits: moduleInfo.limits, + }), + }); + const data = result.data || {}; + if (!result.ok || data.moduleId !== PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE) return null; + if (data.type !== type || typeof data.allowed !== 'boolean') return null; + if (!Number.isInteger(data.current) || !Number.isInteger(data.max)) return null; + if (data.current !== current || data.max !== expectedMax) return null; + + return { + allowed: data.allowed, + type, + current: data.current, + max: data.max, + }; + } catch (error) { + console.warn('[License] 授权限额模块执行判断失败:', error); + return null; + } +} + export async function getCachedModulePullCredential(moduleId: string): Promise { const cached = await getCachedPremiumModuleManifest(); if (!cached) return null; diff --git a/premium-modules/license-limits-core/README.md b/premium-modules/license-limits-core/README.md index 236a76b..bb4b5f4 100644 --- a/premium-modules/license-limits-core/README.md +++ b/premium-modules/license-limits-core/README.md @@ -9,6 +9,9 @@ - 提供 `/capabilities` 能力清单。 - 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `licenseLimits:check/read/validate/enforce`。 - 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。 +- 提供 `/limits/enforce` 限额执行入口,接收 `{ type, current, limits }`,由模块判断当前用户数/素材数是否还能新增。 + +执行边界:主应用负责从数据库统计当前用户数或素材数,并负责校验本地许可证、模块授权凭证和模块运行态;本模块负责根据验证系统签发的上限和当前数量给出最终允许/拒绝结果。它不要求客户每次操作都联网访问验证服务器,但本模块容器必须在客户本机运行并可被主应用访问。 发布后需要把固定镜像和 digest 写入 `license-server`: diff --git a/premium-modules/license-limits-core/__tests__/server.test.ts b/premium-modules/license-limits-core/__tests__/server.test.ts new file mode 100644 index 0000000..81535ea --- /dev/null +++ b/premium-modules/license-limits-core/__tests__/server.test.ts @@ -0,0 +1,136 @@ +import { spawn, type ChildProcess } from 'child_process'; +import * as net from 'net'; +import * as path from 'path'; + +let serverProcess: ChildProcess | null = null; +let baseUrl = ''; + +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + if (!address || typeof address === 'string') { + probe.close(); + reject(new Error('无法分配测试端口')); + return; + } + + probe.close(() => resolve(address.port)); + }); + }); +} + +async function startModuleServer() { + const port = await getFreePort(); + const serverPath = path.resolve(__dirname, '..', 'server.mjs'); + serverProcess = spawn(process.execPath, [serverPath], { + env: { + ...process.env, + PORT: String(port), + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + baseUrl = `http://127.0.0.1:${port}`; + + for (let attempt = 0; attempt < 50; attempt += 1) { + if (!serverProcess || serverProcess.exitCode !== null) { + throw new Error('限额模块测试服务启动失败'); + } + + try { + const response = await fetch(`${baseUrl}/health`); + if (response.ok) return; + } catch { + // 等待服务监听端口。 + } + + await delay(100); + } + + throw new Error('限额模块测试服务启动超时'); +} + +async function stopModuleServer() { + if (!serverProcess) return; + + const current = serverProcess; + serverProcess = null; + if (current.exitCode !== null) return; + + current.kill(); + await new Promise((resolve) => { + const timer = setTimeout(resolve, 1000); + current.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +async function enforceLimit(payload: unknown) { + return fetch(`${baseUrl}/limits/enforce`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +describe('license-limits-core /limits/enforce', () => { + beforeAll(startModuleServer); + afterAll(stopModuleServer); + + it('低于上限时允许新增,达到上限时拒绝新增', async () => { + const allowedResponse = await enforceLimit({ + type: 'users', + current: 9, + limits: { maxUsers: 10, maxMaterials: 100 }, + }); + await expect(allowedResponse.json()).resolves.toEqual( + expect.objectContaining({ + moduleId: 'license-limits-core', + capability: 'licenseLimits', + allowed: true, + current: 9, + max: 10, + }) + ); + + const deniedResponse = await enforceLimit({ + type: 'materials', + current: 100, + limits: { maxUsers: 10, maxMaterials: 100 }, + }); + await expect(deniedResponse.json()).resolves.toEqual( + expect.objectContaining({ + moduleId: 'license-limits-core', + capability: 'licenseLimits', + allowed: false, + current: 100, + max: 100, + }) + ); + }); + + it('非法限额请求返回 400', async () => { + const response = await enforceLimit({ + type: 'unknown', + current: 0, + limits: { maxUsers: 10, maxMaterials: 100 }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ + moduleId: 'license-limits-core', + allowed: false, + error: 'invalid_limit_type', + }) + ); + }); +}); diff --git a/premium-modules/license-limits-core/server.mjs b/premium-modules/license-limits-core/server.mjs index 4104a9b..7bf8478 100644 --- a/premium-modules/license-limits-core/server.mjs +++ b/premium-modules/license-limits-core/server.mjs @@ -58,6 +58,38 @@ function sendAuthorization(res, action) { }); } +function normalizeLimitValue(value) { + return Number.isInteger(value) && value >= -1 ? value : null; +} + +function buildLimitDecision(body) { + const type = typeof body.type === 'string' ? body.type : ''; + if (type !== 'users' && type !== 'materials') { + return { valid: false, error: 'invalid_limit_type' }; + } + + const current = Number.isInteger(body.current) && body.current >= 0 ? body.current : null; + if (current === null) { + return { valid: false, error: 'invalid_current_count' }; + } + + const limits = body.limits && typeof body.limits === 'object' ? body.limits : {}; + const maxUsers = normalizeLimitValue(limits.maxUsers); + const maxMaterials = normalizeLimitValue(limits.maxMaterials); + if (maxUsers === null || maxMaterials === null) { + return { valid: false, error: 'invalid_limits' }; + } + + const max = type === 'users' ? maxUsers : maxMaterials; + return { + valid: true, + type, + current, + max, + allowed: max === -1 || current < max, + }; +} + const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); @@ -94,6 +126,27 @@ const server = http.createServer((req, res) => { return; } + if (req.method === 'POST' && url.pathname === '/limits/enforce') { + readJsonBody(req) + .then((body) => { + const decision = buildLimitDecision(body); + if (!decision.valid) { + sendJson(res, 400, { moduleId, allowed: false, error: decision.error }); + return; + } + + sendJson(res, 200, { + moduleId, + capability: 'licenseLimits', + ...decision, + }); + }) + .catch((error) => { + sendJson(res, 400, { moduleId, allowed: false, error: error.message }); + }); + return; + } + if (req.method === 'POST' && url.pathname === '/authorize') { readJsonBody(req) .then((body) => {