From 028ab2ad17336bca50eb3b35062c92ae93eb648b Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 3 Jun 2026 18:41:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20paid-core=20=E4=BB=98?= =?UTF-8?q?=E8=B4=B9=E6=A0=B8=E5=BF=83=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 +- README.md | 4 +- .../__tests__/template-migrate.test.ts | 460 +++ .../upload-templates/[id]/migrate/route.ts | 540 +--- app/api/admin/upload-templates/[id]/route.ts | 602 +--- .../__tests__/template-crud.test.ts | 371 +++ app/api/admin/upload-templates/route.ts | 90 +- .../organization-permissions.test.ts | 34 + app/api/admin/users/route.ts | 24 +- app/api/admin/users/update/route.ts | 21 +- app/api/departments/__tests__/route.test.ts | 31 +- app/api/departments/route.ts | 254 +- .../__tests__/route-auth.test.ts | 27 + app/api/home-filter-fields/route.ts | 29 +- .../runtime-status/__tests__/route.test.ts | 20 +- .../__tests__/material-types-auth.test.ts | 26 +- app/api/material-types/route.ts | 102 +- app/api/materials/[id]/route.ts | 52 +- .../__tests__/materials-list-auth.test.ts | 47 + .../__tests__/template-fields-routes.test.ts | 4 +- app/api/materials/premium/route.ts | 39 +- app/api/materials/restore/route.ts | 56 +- app/api/materials/route.ts | 213 +- .../materials/stats/__tests__/route.test.ts | 10 + app/api/materials/stats/route.ts | 32 +- app/api/materials/update/route.ts | 52 +- app/api/materials/upload/route.ts | 46 +- .../__tests__/templates-route-auth.test.ts | 19 +- app/api/templates/route.ts | 34 +- .../__tests__/route-auth.test.ts | 20 + app/api/upload-templates/route.ts | 54 +- deploy-registry.ps1 | 6 +- deploy/registry-proxy/README.md | 6 +- docker-compose.prod.yml | 4 +- docs/deployment/部署说明.md | 14 +- docs/商业化地基排查问题清单_20260524.md | 39 +- docs/许可证服务接入说明.md | 16 +- docs/许可证链路安全评估_20260312.md | 4 +- flnxi-agent-go/README.md | 10 +- flnxi-agent-go/internal/api/handlers.go | 4 +- flnxi-agent-go/internal/api/handlers_test.go | 22 +- lib/__tests__/check-limits.test.ts | 380 +-- lib/__tests__/license-route-coverage.test.ts | 210 +- lib/__tests__/premium-module-service.test.ts | 2210 +++++++++++---- .../template-field-validation.test.ts | 47 +- lib/license/check-limits.ts | 77 +- lib/license/index.ts | 42 +- lib/license/premium-module-service.ts | 1879 ++++++++++++- lib/material-permissions.ts | 25 +- lib/template-field-validation.ts | 82 +- lib/tunnel/intranet-module.ts | 4 +- lib/upload-template-save-validation.ts | 470 ---- license-server/.env.example | 16 +- license-server/app/api/registry/auth/route.ts | 6 +- license-server/docker-compose.yml | 12 +- .../lib/__tests__/env-example.test.ts | 2 +- .../lib/__tests__/module-grants.test.ts | 28 +- .../lib/__tests__/module-pull-token.test.ts | 20 +- .../__tests__/premium-module-manifest.test.ts | 29 +- .../lib/__tests__/registry-auth-route.test.ts | 50 +- license-server/lib/premium-module-catalog.ts | 36 +- .../custom-templates-core/README.md | 18 - .../custom-templates-core/package.json | 9 - .../custom-templates-core/server.mjs | 109 - .../intranet-penetration-core/Dockerfile | 15 - .../intranet-penetration-core/README.md | 20 - .../intranet-penetration-core/VERSION.txt | 1 - .../intranet-penetration-core/package.json | 9 - .../intranet-penetration-core/server.mjs | 131 - .../license-limits-core/Dockerfile | 15 - premium-modules/license-limits-core/README.md | 21 - .../license-limits-core/VERSION.txt | 1 - .../__tests__/server.test.ts | 136 - .../license-limits-core/server.mjs | 166 -- .../Dockerfile | 2 +- premium-modules/paid-core/README.md | 43 + .../VERSION.txt | 0 .../package.json | 2 +- premium-modules/paid-core/server.mjs | 606 ++++ .../paid-core/template-validation.mjs | 2469 +++++++++++++++++ .../__tests__/deploy-registry-script.test.ts | 2 +- .../__tests__/premium-module-source.test.ts | 275 +- 82 files changed, 9141 insertions(+), 3978 deletions(-) delete mode 100644 premium-modules/custom-templates-core/README.md delete mode 100644 premium-modules/custom-templates-core/package.json delete mode 100644 premium-modules/custom-templates-core/server.mjs delete mode 100644 premium-modules/intranet-penetration-core/Dockerfile delete mode 100644 premium-modules/intranet-penetration-core/README.md delete mode 100644 premium-modules/intranet-penetration-core/VERSION.txt delete mode 100644 premium-modules/intranet-penetration-core/package.json delete mode 100644 premium-modules/intranet-penetration-core/server.mjs delete mode 100644 premium-modules/license-limits-core/Dockerfile delete mode 100644 premium-modules/license-limits-core/README.md delete mode 100644 premium-modules/license-limits-core/VERSION.txt delete mode 100644 premium-modules/license-limits-core/__tests__/server.test.ts delete mode 100644 premium-modules/license-limits-core/server.mjs rename premium-modules/{custom-templates-core => paid-core}/Dockerfile (84%) create mode 100644 premium-modules/paid-core/README.md rename premium-modules/{custom-templates-core => paid-core}/VERSION.txt (100%) rename premium-modules/{license-limits-core => paid-core}/package.json (74%) create mode 100644 premium-modules/paid-core/server.mjs create mode 100644 premium-modules/paid-core/template-validation.mjs diff --git a/.env.example b/.env.example index b83e490..160242a 100644 --- a/.env.example +++ b/.env.example @@ -11,10 +11,8 @@ LICENSE_PROJECT_CODE=flnxi-sucai # Shared HMAC secret between material app and license server (minimum 32 characters) LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret -# Paid module service URLs. Keep the defaults only when module containers run on the same Docker network. -LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787 -CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787 -INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 +# Paid module service URL. Keep the default only when the paid-core container runs on the same Docker network. +PAID_CORE_MODULE_URL=http://flnxi-module-paid-core:8787 # Agent -> app callback token for POST /api/license/premium-modules/runtime-status. MODULE_RUNTIME_STATUS_TOKEN=replace-with-strong-module-runtime-status-token diff --git a/README.md b/README.md index 640eec8..152f80c 100644 --- a/README.md +++ b/README.md @@ -130,9 +130,9 @@ 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 指向当前可用镜像站。 +授权模块采用“免费基础镜像 + 统一 `paid-core` 付费增强包”的分工。FREE 不拉取模块,人数/素材数上限由基础镜像读取本地已验签许可证执行;STANDARD/PRO 才会从镜像站拉取 `paid-core`,验证服务器只负责发签名模块清单、实例凭证和短期拉包 token。部署 `license-server` 时还要确认 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、`PREMIUM_MODULE_PAID_CORE_IMAGE`、模块版本和模块 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` 正常;人数/素材限额还必须经过 `license-limits-core` 的 `/limits/enforce` 返回允许,自定义模板和内网穿透能力也必须通过对应模块 `/authorize` 放行。`custom-templates-core` 当前承接自定义模板授权执行层,模板 CRUD、迁移和数据处理代码后续还要继续迁到模块内部。 +当前模块源码入口是 `premium-modules/paid-core`,模块镜像通过 `deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId paid-core` 发布,发布后把脚本输出的 `PREMIUM_MODULE_PAID_CORE_IMAGE`、`_TAG` 和 `_DIGEST` 写入 `license-server`。主应用现在不仅检查许可证和模块授权凭证,还会要求 `paid-core` 处于 `running`、模块 `/health` 正常,并通过 `paid-core` 的 `/authorize` 放行自定义模板、部门隔离、系统设置、内网穿透、API 访问等付费能力;自定义模板字段保存前校验/格式化、模板保存请求整体标准化、模板删除前使用中拦截规划、素材提交的自定义字段内容校验、素材 metadata 准备和大小检查、已有素材模板的结构变更风险判断、公开模板展示投影、模板字段填写率统计、素材列表搜索/筛选查询计划、素材列表自定义字段搜索规划、首页自定义筛选字段统计规划、部门隔离范围规划、部门结构创建/重命名/删除联动计划、API 访问运行门禁、模板迁移目标字段投影、兼容性判断、metadata 准备、旧字段备份计算、迁移批次规划、迁移预览和备份字段影响统计已迁入 `paid-core`。其中 `/templates/normalize-save` 负责创建/更新模板前的名称、状态、素材类型、素材大类、字段组、展示配置和字段列表标准化,`/templates/plan-delete` 负责删除模板前按使用素材数量生成放行或拦截计划,`/materials/validate-template-fields` 负责上传/编辑素材时的自定义字段可见性、必填和规则校验,`/materials/prepare-metadata` 负责自定义字段写入 metadata 前的整理、压缩和大小检查,`/templates/plan-migration-batch` 负责迁移批次大小、安全确认、预览模式和分批执行规划,`/templates/prepare-migration-metadata` 负责迁移时从旧素材字段中选出目标模板字段、判断目标模板必填字段是否可自动补齐、生成目标 metadata、计算丢弃字段备份并拦截超限,`/templates/build-migration-preview` 负责迁移预览、备份字段影响统计和样例生成,`/templates/project-public-options` 负责公开模板列表、上传页模板列表和素材类型 tag 的展示投影,`/templates/calculate-field-stats` 负责后台模板详情里的字段填写率和低填写率字段统计,`/materials/plan-list-query` 负责素材列表搜索字段、自定义字段搜索、首页筛选字段和 metadata 筛选的查询计划,`/materials/plan-custom-search` 负责把可搜索字段快照转换成素材列表应查询的自定义字段 key,`/materials/plan-home-filter-fields` 负责把首页筛选字段快照转换成标准列或 metadata 统计来源计划,`/departments/plan-scope` 负责计算部门管理员的可管理部门范围和单个目标部门是否在范围内,`/departments/plan-structure-change` 负责部门创建、重命名、删除时的部门和用户联动计划。注意:`paid-core` 的边界是承接付费能力的规则、计划、校验和展示投影;主程序继续保留登录、权限、Prisma 查询、SQL 执行、数据库事务和最终写入。这是当前商业化安全边界,不表示付费功能没有拆分完成,也不能表述为所有业务执行都离开主程序。 许可证和授权模块不是“客户每次使用都实时联网验证”。联网主要发生在激活、续期心跳、授权变更同步、短期拉包 token 获取、模块镜像拉取和版本升级时;日常上传、创建用户、模板使用等动作优先依赖本地已验签许可证缓存、已缓存的模块授权凭证和本机正在运行的授权模块。也就是说,客户现场短时间断网不应影响已经授权且本地模块正常运行的日常使用,但许可证过期、授权被吊销、需要拉取新模块或升级时仍需要重新连验证服务器。 diff --git a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts index 1fe899d..0467dcf 100644 --- a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts +++ b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts @@ -35,6 +35,9 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/license', () => ({ canUseCustomTemplates: jest.fn(), + buildTemplateMigrationPreviewWithPaidCore: jest.fn(), + planTemplateMigrationBatchWithPaidCore: jest.fn(), + prepareTemplateMigrationMetadataWithPaidCore: jest.fn(), })) jest.mock('@/lib/admin-permissions', () => ({ @@ -44,6 +47,9 @@ jest.mock('@/lib/admin-permissions', () => ({ const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { canUseCustomTemplates } = require('@/lib/license') +const { buildTemplateMigrationPreviewWithPaidCore } = require('@/lib/license') +const { planTemplateMigrationBatchWithPaidCore } = require('@/lib/license') +const { prepareTemplateMigrationMetadataWithPaidCore } = require('@/lib/license') const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') // 导入 API 处理函数 @@ -87,6 +93,391 @@ function createMaterials(count: number, overrides: Record = {}) { })) } +const STANDARD_MATERIAL_FIELDS = new Set([ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'materialType', + 'materialKind', + 'renderer', + 'renderTime', + 'imageResolution', + 'colorMode', + 'authorName', + 'platformName', +]) + +const MIGRATION_BACKUP_STANDARD_FIELDS = [ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'materialType', + 'materialKind', + 'renderer', + 'renderTime', + 'imageResolution', + 'colorMode', + 'authorName', + 'platformName', + 'audioDuration', + 'audioCategory', + 'audioScene', +] + +const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record = { + brandName: null, + seriesName: null, + skuCode: null, + materialTexture: null, + platformName: '', + renderTime: null, + imageResolution: null, + colorMode: null, + audioDuration: null, + audioCategory: null, + audioScene: null, +} + +function parseMetadataObject(metadata: string | null | undefined): Record { + if (!metadata) return {} + + try { + const parsed = JSON.parse(metadata) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {} + } catch { + return {} + } +} + +function hasBackupValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== '' +} + +function hasSubmittedFieldValue(value: unknown, type?: string | null): boolean { + if (type === 'checkbox') { + return value !== undefined && value !== null + } + + if (value === undefined || value === null) { + return false + } + + if (typeof value === 'string') { + return value.trim().length > 0 + } + + if (Array.isArray(value)) { + return value.length > 0 + } + + return true +} + +function mockPrepareMigrationMetadata(params: Record) { + const existingMetadata = parseMetadataObject(params.existingMetadata) + const targetFieldKeys = new Set(params.targetFieldKeys || []) + const standardData: Record = {} + const targetTemplateFields: Record = {} + const incomingTemplateFields = + params.templateFields || + (Array.isArray(params.targetFields) + ? Object.fromEntries( + params.targetFields + .filter((field: Record) => params.materialFieldValues?.[field.key] !== undefined) + .map((field: Record) => [field.key, params.materialFieldValues[field.key]]) + ) + : {}) + + for (const [key, value] of Object.entries(incomingTemplateFields)) { + if (STANDARD_MATERIAL_FIELDS.has(key)) { + standardData[key] = value === null || value === undefined ? '' : String(value) + } else { + targetTemplateFields[key] = value + } + } + for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) { + if (!targetFieldKeys.has(key)) { + standardData[key] = emptyValue as any + } + } + + const missingRequiredFields = Array.isArray(params.targetFields) + ? params.targetFields + .filter((field: Record) => field.required) + .filter((field: Record) => !hasSubmittedFieldValue(incomingTemplateFields?.[field.key], field.type)) + .map((field: Record) => field.label || field.key) + : [] + + if (missingRequiredFields.length > 0) { + return { + valid: false, + error: '目标模板存在无法从旧素材自动补齐的必填字段', + errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED', + details: { + missingRequiredFields, + targetTemplateId: params.targetTemplateId, + }, + } + } + + const droppedTemplateFields: Record = {} + const existingTemplateFields = + existingMetadata.templateFields && typeof existingMetadata.templateFields === 'object' + ? existingMetadata.templateFields + : {} + for (const [key, value] of Object.entries(existingTemplateFields)) { + if (!targetFieldKeys.has(key) && hasBackupValue(value)) { + droppedTemplateFields[key] = value + } + } + + const droppedStandardFields: Record = {} + for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) { + const value = params.materialStandardFields?.[key] + if (!targetFieldKeys.has(key) && hasBackupValue(value)) { + droppedStandardFields[key] = value + } + } + + const nextMetadata: Record = { + ...existingMetadata, + templateId: params.targetTemplateId, + } + + if (Object.keys(targetTemplateFields).length > 0) { + nextMetadata.templateFields = targetTemplateFields + } else { + delete nextMetadata.templateFields + } + + if (Array.isArray(params.previewUrls) && params.previewUrls.length > 0) { + nextMetadata.previewUrls = params.previewUrls + } + + const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length + const droppedStandardFieldCount = Object.keys(droppedStandardFields).length + if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) { + const backup: Record = { + sourceTemplateId: params.sourceTemplateId, + sourceTemplateName: params.sourceTemplateName, + targetTemplateId: params.targetTemplateId, + targetTemplateName: params.targetTemplateName, + migratedAt: params.migratedAt, + } + if (droppedTemplateFieldCount > 0) { + backup.droppedTemplateFields = droppedTemplateFields + } + if (droppedStandardFieldCount > 0) { + backup.droppedStandardFields = droppedStandardFields + } + nextMetadata.templateMigrationBackups = [ + ...(Array.isArray(existingMetadata.templateMigrationBackups) ? existingMetadata.templateMigrationBackups : []), + backup, + ] + } + + const metadata = JSON.stringify(nextMetadata) + const size = Buffer.byteLength(metadata, 'utf8') + const maxSize = 65535 + + if (size > maxSize) { + return { + valid: false, + error: '模板迁移后 metadata 数据过大,请先减少自定义字段内容后再迁移。', + errorCode: 'METADATA_TOO_LARGE', + details: { size, maxSize }, + } + } + + return { + valid: true, + standardData, + metadata, + metadataSize: { ok: true, size, maxSize }, + templateFields: incomingTemplateFields, + droppedTemplateFieldCount, + droppedStandardFieldCount, + droppedTemplateFields, + droppedStandardFields, + } +} + +const STANDARD_FIELD_LABELS: Record = { + productModel: '产品型号', + brandName: '品牌', + seriesName: '系列', + skuCode: 'SKU', + materialTexture: '材质纹理', + categoryName: '分类', + sceneName: '场景', + materialType: '素材类型', + materialKind: '素材大类', + renderer: '渲染器', + renderTime: '渲染时间', + imageResolution: '图片分辨率', + colorMode: '色彩模式', + authorName: '作者', + platformName: '平台', + audioDuration: '音频时长', + audioCategory: '音频分类', + audioScene: '音频场景', +} + +function mockBuildMigrationPreview(params: Record) { + const sourceFieldMap = new Map( + (params.sourceTemplateFields || []).map((field: Record) => [field.key, field]) + ) + const backupImpacts = new Map>() + const samples: Array> = [] + let materialsWithDroppedFields = 0 + let droppedTemplateFieldCount = 0 + let droppedStandardFieldCount = 0 + + function summarize(key: string, source: 'template' | 'standard') { + const templateField = sourceFieldMap.get(key) as Record | undefined + return { + key, + label: templateField?.label || STANDARD_FIELD_LABELS[key] || key, + type: templateField?.type, + source, + } + } + + function addImpact(field: Record) { + const mapKey = `${field.source}:${field.key}` + const existing = backupImpacts.get(mapKey) + if (existing) { + existing.affectedCount += 1 + return + } + backupImpacts.set(mapKey, { ...field, affectedCount: 1 }) + } + + for (const impact of params.impacts || []) { + const droppedTemplateFieldKeys = impact.droppedTemplateFieldKeys || [] + const droppedStandardFieldKeys = impact.droppedStandardFieldKeys || [] + if (droppedTemplateFieldKeys.length === 0 && droppedStandardFieldKeys.length === 0) continue + + materialsWithDroppedFields += 1 + droppedTemplateFieldCount += droppedTemplateFieldKeys.length + droppedStandardFieldCount += droppedStandardFieldKeys.length + const droppedTemplateFields = droppedTemplateFieldKeys.map((key: string) => summarize(key, 'template')) + const droppedStandardFields = droppedStandardFieldKeys.map((key: string) => summarize(key, 'standard')) + + droppedTemplateFields.forEach(addImpact) + droppedStandardFields.forEach(addImpact) + if (samples.length < 10) { + samples.push({ + materialId: impact.materialId, + productModel: impact.productModel ?? null, + droppedTemplateFields, + droppedStandardFields, + }) + } + } + + return { + valid: true, + migrationBackup: { + materialsWithDroppedFields, + droppedTemplateFieldCount, + droppedStandardFieldCount, + }, + migrationPreview: { + totalMaterialCount: params.totalMaterialCount, + totalMaterials: params.migratableCount, + previewLimited: params.previewOnly && params.totalMaterialCount > params.migratableCount, + previewLimit: params.previewLimit, + targetCurrentFields: (params.targetFields || []).map((field: Record) => ({ + key: field.key, + label: field.label || field.key, + type: field.type, + required: field.required, + })), + backupFields: { + templateFields: Array.from(backupImpacts.values()).filter((field) => field.source === 'template'), + standardFields: Array.from(backupImpacts.values()).filter((field) => field.source === 'standard'), + }, + samples, + }, + } +} + +function mockPlanMigrationBatch(params: Record) { + const safeLimit = 200 + const maxBatchLimit = 1000 + const request = params.request || {} + const rawLimit = Number.parseInt(String(request.migrationLimit || ''), 10) + const migrationLimit = Math.min( + Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : safeLimit, + maxBatchLimit + ) + const totalMaterialCount = Number(params.totalMaterialCount || 0) + const previewOnly = request.previewOnly === true + const forceLargeMigration = request.forceLargeMigration === true + + if (totalMaterialCount === 0) { + return { + valid: true, + allowed: false, + statusCode: 200, + response: { + success: true, + migratedCount: 0, + message: '没有需要迁移的素材', + }, + } + } + + if (!previewOnly && totalMaterialCount > safeLimit && !forceLargeMigration) { + return { + valid: true, + allowed: false, + statusCode: 409, + error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${safeLimit}。请先预览迁移影响,确认备份后再执行。`, + errorCode: 'MIGRATION_CONFIRM_REQUIRED', + details: { + totalMaterialCount, + safeLimit, + migrationLimit, + maxBatchLimit, + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。', + }, + } + } + + const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit) + + return { + valid: true, + allowed: true, + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + sourceTemplateName: params.sourceTemplateName, + targetTemplateName: params.targetTemplateName, + totalMaterialCount, + previewOnly, + forceLargeMigration, + updateMaterialType: request.updateMaterialType !== false, + migrationLimit, + safeLimit, + maxBatchLimit, + batchLimited, + materialTake: batchLimited ? migrationLimit : null, + migratedAt: '2026-06-03T00:00:00.000Z', + } +} + describe('模板素材迁移 API 测试', () => { beforeEach(() => { jest.resetAllMocks() @@ -95,6 +486,15 @@ describe('模板素材迁移 API 测试', () => { user: { id: 'admin-id', role: 'ADMIN' }, }) canUseCustomTemplates.mockResolvedValue(true) + planTemplateMigrationBatchWithPaidCore.mockImplementation(async (params: Record) => + mockPlanMigrationBatch(params) + ) + prepareTemplateMigrationMetadataWithPaidCore.mockImplementation(async (params: Record) => + mockPrepareMigrationMetadata(params) + ) + buildTemplateMigrationPreviewWithPaidCore.mockImplementation(async (params: Record) => + mockBuildMigrationPreview(params) + ) isCurrentSuperAdmin.mockResolvedValue(true) prisma.material.count.mockResolvedValue(undefined) prisma.material.findMany.mockResolvedValue([]) @@ -161,6 +561,15 @@ describe('模板素材迁移 API 测试', () => { expect(data.targetTemplate.id).toBe('template-target') expect(prisma.$transaction).toHaveBeenCalledTimes(1) expect(prisma.material.updateMany).toHaveBeenCalledTimes(5) + expect(prepareTemplateMigrationMetadataWithPaidCore).toHaveBeenCalledTimes(5) + expect(prepareTemplateMigrationMetadataWithPaidCore).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + sourceTemplateId: 'template-source', + targetTemplateId: 'template-target', + targetFieldKeys: [], + }) + ) }) test('应该只迁移未删除素材,不处理回收站素材', async () => { @@ -237,6 +646,7 @@ describe('模板素材迁移 API 测试', () => { expect(response.status).toBe(403) expect(data.error).toBe('无权限') + expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled() }) test('普通管理员不能迁移模板素材', async () => { @@ -263,6 +673,7 @@ describe('模板素材迁移 API 测试', () => { expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled() expect(prisma.material.findMany).not.toHaveBeenCalled() expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled() }) test('旧会话带最高权限标记但数据库已撤销时不能迁移模板素材', async () => { @@ -290,6 +701,7 @@ describe('模板素材迁移 API 测试', () => { expect(data.error).toContain('最高权限') expect(canUseCustomTemplates).not.toHaveBeenCalled() expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled() + expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled() }) test('许可证关闭时应拒绝迁移素材', async () => { @@ -314,6 +726,7 @@ describe('模板素材迁移 API 测试', () => { expect(data.errorCode).toBe('LICENSE_REQUIRED') expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled() expect(prisma.material.findMany).not.toHaveBeenCalled() + expect(prepareTemplateMigrationMetadataWithPaidCore).not.toHaveBeenCalled() }) test('应该验证目标模板ID必填', async () => { @@ -919,6 +1332,53 @@ describe('模板素材迁移 API 测试', () => { expect(prisma.material.updateMany).not.toHaveBeenCalled() }) + test('paid-core无法准备迁移数据时应拒绝并且不写库', async () => { + const sourceTemplate = createTemplate({ + id: 'template-source', + name: '旧模板', + _count: { materials: 1 }, + }) + + const targetTemplate = createTemplate({ + id: 'template-target', + name: '新模板', + fields: [ + { key: 'productModel', label: '产品型号', type: 'text', required: false }, + ], + }) + + prisma.uploadTemplate.findUnique + .mockResolvedValueOnce(sourceTemplate) + .mockResolvedValueOnce(targetTemplate) + prisma.material.findMany.mockResolvedValue(createMaterials(1)) + prepareTemplateMigrationMetadataWithPaidCore.mockResolvedValueOnce({ + valid: false, + error: '授权模块尚未运行', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE', + details: { moduleId: 'paid-core', status: 'missing' }, + }) + + const request = new NextRequest( + 'http://localhost/api/admin/upload-templates/template-source/migrate', + { + method: 'POST', + body: JSON.stringify({ + targetTemplateId: 'template-target', + }), + } + ) + + const response = await migrateTemplate(request, { + params: { id: 'template-source' }, + }) + const data = await response.json() + + expect(response.status).toBe(503) + expect(data.errorCode).toBe('PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE') + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + test('事务内某条素材已不属于源模板时应整体失败', async () => { const sourceTemplate = createTemplate({ id: 'template-source', diff --git a/app/api/admin/upload-templates/[id]/migrate/route.ts b/app/api/admin/upload-templates/[id]/migrate/route.ts index 0b2fb30..98ed7ce 100644 --- a/app/api/admin/upload-templates/[id]/migrate/route.ts +++ b/app/api/admin/upload-templates/[id]/migrate/route.ts @@ -8,16 +8,17 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { canUseCustomTemplates } from '@/lib/license' +import { + buildTemplateMigrationPreviewWithPaidCore, + canUseCustomTemplates, + planTemplateMigrationBatchWithPaidCore, + prepareTemplateMigrationMetadataWithPaidCore, +} from '@/lib/license' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { - checkMetadataSize, - compressMetadata, parseMetadata, - prepareMaterialData, readAllFieldValues, } from '@/lib/field-data-storage' -import { hasFieldValue } from '@/lib/template-field-validation' type TemplateFieldSummary = { key: string @@ -26,251 +27,6 @@ type TemplateFieldSummary = { required: boolean } -type TemplateMigrationBackup = { - sourceTemplateId: string - sourceTemplateName: string - targetTemplateId: string - targetTemplateName: string - migratedAt: string - droppedTemplateFields?: Record - droppedStandardFields?: Record -} - -type MigrationPreviewFieldSummary = { - key: string - label: string - type?: string - source: 'template' | 'standard' -} - -type MigrationPreviewFieldImpact = MigrationPreviewFieldSummary & { - affectedCount: number -} - -const DEFAULT_MIGRATION_SAFE_LIMIT = 200 -const MAX_MIGRATION_FORCE_LIMIT = 1000 - -const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record = { - brandName: null, - seriesName: null, - skuCode: null, - materialTexture: null, - platformName: '', - renderTime: null, - imageResolution: null, - colorMode: null, - audioDuration: null, - audioCategory: null, - audioScene: null, -} - -const MIGRATION_BACKUP_STANDARD_FIELDS = [ - 'productModel', - 'brandName', - 'seriesName', - 'skuCode', - 'materialTexture', - 'categoryName', - 'sceneName', - 'materialType', - 'materialKind', - 'renderer', - 'renderTime', - 'imageResolution', - 'colorMode', - 'authorName', - 'platformName', - 'audioDuration', - 'audioCategory', - 'audioScene', -] as const - -const STANDARD_FIELD_LABELS: Record = { - productModel: '产品型号', - brandName: '品牌', - seriesName: '系列', - skuCode: 'SKU', - materialTexture: '材质纹理', - categoryName: '分类', - sceneName: '场景', - materialType: '素材类型', - materialKind: '素材大类', - renderer: '渲染器', - renderTime: '渲染时间', - imageResolution: '图片分辨率', - colorMode: '色彩模式', - authorName: '作者', - platformName: '平台', - audioDuration: '音频时长', - audioCategory: '音频分类', - audioScene: '音频场景', -} - -function parseMetadataObject(metadata: string | null | undefined): Record { - if (!metadata) { - return {} - } - - try { - const parsed = JSON.parse(metadata) - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as Record - } - } catch { - // 保持迁移可继续,坏 metadata 不再扩大影响面。 - } - - return {} -} - -function getRecordValue(value: unknown): Record { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Record - } - return {} -} - -function hasBackupValue(value: unknown): boolean { - return value !== undefined && value !== null && value !== '' -} - -function getExistingMigrationBackups(metadata: Record): unknown[] { - return Array.isArray(metadata.templateMigrationBackups) - ? metadata.templateMigrationBackups - : [] -} - -function buildFieldSummary( - key: string, - sourceTemplateFieldsByKey: Map, - source: 'template' | 'standard' -): MigrationPreviewFieldSummary { - const templateField = sourceTemplateFieldsByKey.get(key) - return { - key, - label: templateField?.label || STANDARD_FIELD_LABELS[key] || key, - type: templateField?.type, - source, - } -} - -function addPreviewFieldImpact( - impacts: Map, - field: MigrationPreviewFieldSummary -) { - const mapKey = `${field.source}:${field.key}` - const existing = impacts.get(mapKey) - if (existing) { - existing.affectedCount += 1 - return - } - - impacts.set(mapKey, { - ...field, - affectedCount: 1, - }) -} - -function buildMigrationMetadata(params: { - existingMetadata: string | null - targetMetadata: string | null - droppedStandardFields: Record - targetFieldKeys: Set - sourceTemplateId: string - sourceTemplateName: string - targetTemplateId: string - targetTemplateName: string - migratedAt: string -}): { - metadata: string | null - droppedTemplateFieldCount: number - droppedStandardFieldCount: number - droppedTemplateFields: Record - droppedStandardFields: Record -} { - const existingMetadataObject = parseMetadataObject(params.existingMetadata) - const targetMetadataObject = parseMetadataObject(params.targetMetadata) - const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields) - const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields) - const droppedTemplateFields: Record = {} - const droppedStandardFields = Object.fromEntries( - Object.entries(params.droppedStandardFields).filter(([, value]) => hasBackupValue(value)) - ) - - for (const [key, value] of Object.entries(existingTemplateFields)) { - if (!params.targetFieldKeys.has(key) && hasBackupValue(value)) { - droppedTemplateFields[key] = value - } - } - - const nextMetadata: Record = { - ...existingMetadataObject, - ...targetMetadataObject, - templateId: params.targetTemplateId, - } - - if (Object.keys(targetTemplateFields).length > 0) { - nextMetadata.templateFields = targetTemplateFields - } else { - delete nextMetadata.templateFields - } - - const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length - const droppedStandardFieldCount = Object.keys(droppedStandardFields).length - - if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) { - const backup: TemplateMigrationBackup = { - sourceTemplateId: params.sourceTemplateId, - sourceTemplateName: params.sourceTemplateName, - targetTemplateId: params.targetTemplateId, - targetTemplateName: params.targetTemplateName, - migratedAt: params.migratedAt, - } - if (droppedTemplateFieldCount > 0) { - backup.droppedTemplateFields = droppedTemplateFields - } - if (droppedStandardFieldCount > 0) { - backup.droppedStandardFields = droppedStandardFields - } - nextMetadata.templateMigrationBackups = [ - ...getExistingMigrationBackups(existingMetadataObject), - backup, - ] - } - - return { - metadata: JSON.stringify(nextMetadata), - droppedTemplateFieldCount, - droppedStandardFieldCount, - droppedTemplateFields, - droppedStandardFields, - } -} - -function getDroppedStandardFields( - material: Record, - targetFieldKeys: Set -): Record { - const droppedFields: Record = {} - - for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) { - const value = material[key] - if (!targetFieldKeys.has(key) && hasBackupValue(value)) { - droppedFields[key] = value - } - } - - return droppedFields -} - -function clearNonTargetStandardFields(updateData: Record, targetFieldKeys: Set) { - for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) { - if (!targetFieldKeys.has(key)) { - updateData[key] = emptyValue - } - } -} - function buildMaterialFieldValues(material: Record) { const values = readAllFieldValues(material) @@ -284,26 +40,6 @@ function buildMaterialFieldValues(material: Record) { return values } -function normalizeOptionalString(value: unknown): string | null { - if (value === undefined || value === null || value === '') { - return null - } - return String(value) -} - -function parsePositiveInteger(value: unknown): number | null { - if (value === undefined || value === null || value === '') { - return null - } - - const parsed = Number.parseInt(String(value), 10) - if (!Number.isFinite(parsed) || parsed <= 0) { - return null - } - - return parsed -} - async function ensureAdmin() { const session = await getServerSession(authOptions) if (!session || (session.user as any)?.role !== 'ADMIN') { @@ -348,14 +84,7 @@ export async function POST( const body = await req.json() const { targetTemplateId, - updateMaterialType = true, - previewOnly = false, - forceLargeMigration = false, } = body - const migrationLimit = Math.min( - parsePositiveInteger(body?.migrationLimit) ?? DEFAULT_MIGRATION_SAFE_LIMIT, - MAX_MIGRATION_FORCE_LIMIT - ) if (!targetTemplateId) { return NextResponse.json({ @@ -436,35 +165,40 @@ export async function POST( ? activeMaterialCount : sourceTemplate._count.materials - if (totalMaterialCount === 0) { - return NextResponse.json({ - success: true, - migratedCount: 0, - message: '没有需要迁移的素材', - }) - } + const batchPlan = await planTemplateMigrationBatchWithPaidCore({ + sourceTemplateId, + targetTemplateId, + sourceTemplateName: sourceTemplate.name, + targetTemplateName: targetTemplate.name, + totalMaterialCount, + request: { + updateMaterialType: body?.updateMaterialType, + previewOnly: body?.previewOnly, + forceLargeMigration: body?.forceLargeMigration, + migrationLimit: body?.migrationLimit, + }, + }) - if ( - !previewOnly && - totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT && - !forceLargeMigration - ) { + if (!batchPlan.valid) { return NextResponse.json({ - error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`, - errorCode: 'MIGRATION_CONFIRM_REQUIRED', - details: { - totalMaterialCount, - safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT, - migrationLimit, - maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT, - sourceTemplateId, - targetTemplateId, - nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。', - }, - }, { status: 409 }) + error: batchPlan.error, + errorCode: batchPlan.errorCode, + details: batchPlan.details, + }, { status: batchPlan.statusCode || 503 }) + } + + if (!batchPlan.allowed) { + if (batchPlan.response) { + return NextResponse.json(batchPlan.response, { status: batchPlan.statusCode }) + } + + return NextResponse.json({ + error: batchPlan.error, + errorCode: batchPlan.errorCode, + details: batchPlan.details, + }, { status: batchPlan.statusCode }) } - const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit) const materials = await prisma.material.findMany({ where: { templateId: sourceTemplateId, @@ -499,7 +233,7 @@ export async function POST( { createdAt: 'asc' }, { id: 'asc' }, ], - take: batchLimited ? migrationLimit : undefined, + take: batchPlan.materialTake ?? undefined, }) const materialCount = materials.length @@ -519,10 +253,10 @@ export async function POST( targetTemplateName: targetTemplate.name, materialCount, totalMaterialCount, - migrationLimit, - previewOnly, - forceLargeMigration, - updateMaterialType, + migrationLimit: batchPlan.migrationLimit, + previewOnly: batchPlan.previewOnly, + forceLargeMigration: batchPlan.forceLargeMigration, + updateMaterialType: batchPlan.updateMaterialType, }) const incompatibleMaterials: Array<{ @@ -539,131 +273,92 @@ export async function POST( const targetFieldKeys = new Set( (targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key) ) - const sourceTemplateFieldsByKey = new Map( - (sourceTemplate.fields as TemplateFieldSummary[]).map((field) => [field.key, field]) - ) - const migratedAt = new Date().toISOString() - let materialsWithDroppedFields = 0 - let droppedTemplateFieldCount = 0 - let droppedStandardFieldCount = 0 - const previewFieldImpacts = new Map() - const previewSamples: Array<{ + const migratedAt = batchPlan.migratedAt + const migrationPreviewImpacts: Array<{ materialId: string productModel: string | null - droppedTemplateFields: MigrationPreviewFieldSummary[] - droppedStandardFields: MigrationPreviewFieldSummary[] + droppedTemplateFieldKeys: string[] + droppedStandardFieldKeys: string[] }> = [] - const preparedUpdates = materials.map((material) => { + const preparedUpdates: Array<{ + id: string + data: Record + }> = [] + + for (const material of materials) { const allValues = buildMaterialFieldValues(material as Record) - const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce>( - (acc, field) => { - if (allValues[field.key] !== undefined) { - acc[field.key] = allValues[field.key] - } - return acc - }, - {} - ) - - const missingRequiredFields = (targetTemplate.fields as TemplateFieldSummary[]) - .filter((field) => field.required) - .filter((field) => !hasFieldValue(nextTemplateFieldValues[field.key], field.type)) - .map((field) => field.label || field.key) - - if (missingRequiredFields.length > 0) { - incompatibleMaterials.push({ - materialId: material.id, - productModel: material.productModel, - missingRequiredFields, - }) - } const parsedMetadata = parseMetadata(material.metadata) - const { standardData, metadata: rawMetadata } = prepareMaterialData( - nextTemplateFieldValues as Record, - targetTemplateId, - parsedMetadata.previewUrls - ) - const droppedStandardFields = getDroppedStandardFields( - material as Record, - targetFieldKeys - ) - const migrationMetadata = buildMigrationMetadata({ + const migrationMetadata = await prepareTemplateMigrationMetadataWithPaidCore({ existingMetadata: material.metadata, - targetMetadata: rawMetadata, - droppedStandardFields, - targetFieldKeys, + materialFieldValues: allValues, + materialStandardFields: material as Record, + previewUrls: parsedMetadata.previewUrls, + targetFieldKeys: Array.from(targetFieldKeys), + targetFields: targetTemplate.fields as TemplateFieldSummary[], sourceTemplateId, sourceTemplateName: sourceTemplate.name, targetTemplateId, targetTemplateName: targetTemplate.name, migratedAt, }) - const metadata = compressMetadata(migrationMetadata.metadata) - const metadataSize = checkMetadataSize(metadata) - if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) { - materialsWithDroppedFields += 1 - droppedTemplateFieldCount += migrationMetadata.droppedTemplateFieldCount - droppedStandardFieldCount += migrationMetadata.droppedStandardFieldCount - - const droppedTemplateFields = Object.keys(migrationMetadata.droppedTemplateFields).map((key) => - buildFieldSummary(key, sourceTemplateFieldsByKey, 'template') - ) - const droppedStandardFields = Object.keys(migrationMetadata.droppedStandardFields).map((key) => - buildFieldSummary(key, sourceTemplateFieldsByKey, 'standard') - ) - - droppedTemplateFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field)) - droppedStandardFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field)) - - if (previewSamples.length < 10) { - previewSamples.push({ + if (!migrationMetadata.valid) { + if (migrationMetadata.errorCode === 'TARGET_TEMPLATE_FIELDS_UNRESOLVED') { + const missingRequiredFields = Array.isArray(migrationMetadata.details?.missingRequiredFields) + ? migrationMetadata.details.missingRequiredFields.map((field) => String(field)) + : [] + incompatibleMaterials.push({ materialId: material.id, productModel: material.productModel, - droppedTemplateFields, - droppedStandardFields, + missingRequiredFields, }) + continue } + + if (migrationMetadata.errorCode === 'METADATA_TOO_LARGE') { + oversizedMetadataMaterials.push({ + materialId: material.id, + productModel: material.productModel, + size: Number(migrationMetadata.details?.size || 0), + maxSize: Number(migrationMetadata.details?.maxSize || 0), + }) + continue + } + + return NextResponse.json({ + error: migrationMetadata.error || '付费增强包无法准备模板迁移数据', + errorCode: migrationMetadata.errorCode || 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED', + details: migrationMetadata.details, + }, { status: 503 }) } - if (!metadataSize.ok) { - oversizedMetadataMaterials.push({ + if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) { + migrationPreviewImpacts.push({ materialId: material.id, productModel: material.productModel, - size: metadataSize.size, - maxSize: metadataSize.maxSize, + droppedTemplateFieldKeys: Object.keys(migrationMetadata.droppedTemplateFields), + droppedStandardFieldKeys: Object.keys(migrationMetadata.droppedStandardFields), }) } const updateData: any = { templateId: targetTemplateId, materialKind: targetTemplate.materialKind, - metadata, - ...standardData, + metadata: migrationMetadata.metadata, + ...migrationMetadata.standardData, } - clearNonTargetStandardFields(updateData, targetFieldKeys) - if (updateMaterialType && targetTemplate.materialType) { + if (batchPlan.updateMaterialType && targetTemplate.materialType) { updateData.materialType = targetTemplate.materialType } - if ('audioDuration' in nextTemplateFieldValues) { - updateData.audioDuration = normalizeOptionalString(nextTemplateFieldValues.audioDuration) - } - if ('audioCategory' in nextTemplateFieldValues) { - updateData.audioCategory = normalizeOptionalString(nextTemplateFieldValues.audioCategory) - } - if ('audioScene' in nextTemplateFieldValues) { - updateData.audioScene = normalizeOptionalString(nextTemplateFieldValues.audioScene) - } - - return { + preparedUpdates.push({ id: material.id, data: updateData, - } - }) + }) + } if (incompatibleMaterials.length > 0) { return NextResponse.json({ @@ -689,25 +384,26 @@ export async function POST( }, { status: 409 }) } - const migrationPreview = { + const migrationPreviewResult = await buildTemplateMigrationPreviewWithPaidCore({ totalMaterialCount, - totalMaterials: preparedUpdates.length, - previewLimited: previewOnly && totalMaterialCount > preparedUpdates.length, - previewLimit: previewOnly ? migrationLimit : undefined, - targetCurrentFields: (targetTemplate.fields as TemplateFieldSummary[]).map((field) => ({ - key: field.key, - label: field.label || field.key, - type: field.type, - required: field.required, - })), - backupFields: { - templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'), - standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'), - }, - samples: previewSamples, - } + migratableCount: preparedUpdates.length, + previewOnly: batchPlan.previewOnly, + previewLimit: batchPlan.previewOnly ? batchPlan.migrationLimit : undefined, + targetFields: targetTemplate.fields as TemplateFieldSummary[], + sourceTemplateFields: sourceTemplate.fields as TemplateFieldSummary[], + impacts: migrationPreviewImpacts, + }) - if (previewOnly) { + if (!migrationPreviewResult.valid) { + return NextResponse.json({ + error: migrationPreviewResult.error || '付费增强包无法生成模板迁移预览', + errorCode: migrationPreviewResult.errorCode || 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED', + details: migrationPreviewResult.details, + }, { status: 503 }) + } + const { migrationBackup, migrationPreview } = migrationPreviewResult + + if (batchPlan.previewOnly) { return NextResponse.json({ success: true, previewOnly: true, @@ -721,11 +417,7 @@ export async function POST( id: targetTemplate.id, name: targetTemplate.name, }, - migrationBackup: { - materialsWithDroppedFields, - droppedTemplateFieldCount, - droppedStandardFieldCount, - }, + migrationBackup, migrationPreview, }) } @@ -751,9 +443,7 @@ export async function POST( sourceTemplateId, targetTemplateId, migratedCount: preparedUpdates.length, - materialsWithDroppedFields, - droppedTemplateFieldCount, - droppedStandardFieldCount, + ...migrationBackup, }) return NextResponse.json({ @@ -761,7 +451,7 @@ export async function POST( migratedCount: preparedUpdates.length, totalMaterialCount, remainingMaterialCount: Math.max(totalMaterialCount - preparedUpdates.length, 0), - batchLimited, + batchLimited: batchPlan.batchLimited, message: totalMaterialCount > preparedUpdates.length ? `已将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}",剩余 ${totalMaterialCount - preparedUpdates.length} 个将继续分批处理` @@ -774,11 +464,7 @@ export async function POST( id: targetTemplate.id, name: targetTemplate.name, }, - migrationBackup: { - materialsWithDroppedFields, - droppedTemplateFieldCount, - droppedStandardFieldCount, - }, + migrationBackup, }) } catch (error: any) { console.error('[TemplateMigrate] Migration error:', { diff --git a/app/api/admin/upload-templates/[id]/route.ts b/app/api/admin/upload-templates/[id]/route.ts index 0c9f4ef..a1b9693 100644 --- a/app/api/admin/upload-templates/[id]/route.ts +++ b/app/api/admin/upload-templates/[id]/route.ts @@ -3,12 +3,13 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { syncFieldPoolFromTemplates } from '@/lib/field-pool' -import { canUseCustomTemplates } from '@/lib/license' -import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions' import { - isValidUploadTemplateStatus, - validateAndFormatUploadTemplateFields, -} from '@/lib/upload-template-save-validation' + calculateTemplateFieldStatsWithPaidCore, + canUseCustomTemplates, + planUploadTemplateDeleteWithPaidCore, + planUploadTemplateUpdateWithPaidCore, +} from '@/lib/license' +import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions' const selectTemplate = { id: true, @@ -57,295 +58,6 @@ async function ensureAdmin() { return session } -type TemplateStructureChange = - | { - type: 'materialTypeChanged' - before: string - after: string - } - | { - type: 'materialKindChanged' - before: string - after: string - } - | { - type: 'fieldDeleted' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldKeyChanged' - fieldId: string - before: string - after: string - fieldLabel: string - } - | { - type: 'fieldTypeChanged' - fieldId: string - fieldKey: string - fieldLabel: string - before: string - after: string - } - | { - type: 'fieldCategoryDisabled' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldSearchDisabled' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldMadeReadOnly' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldMadeRequired' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldValidationChanged' - fieldId: string - fieldKey: string - fieldLabel: string - } - | { - type: 'fieldOptionsRemoved' - fieldId: string - fieldKey: string - fieldLabel: string - removedOptions: string[] - } - | { - type: 'fieldConditionsChanged' - fieldId: string - fieldKey: string - fieldLabel: string - } - -function normalizeJsonValue(value: unknown) { - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) return '' - try { - return JSON.parse(trimmed) - } catch { - return trimmed - } - } - - return value ?? null -} - -function stableStringify(value: unknown): string { - const normalized = normalizeJsonValue(value) - - if (Array.isArray(normalized)) { - return JSON.stringify(normalized.map((item) => stableStringify(item))) - } - - if (normalized && typeof normalized === 'object') { - const entries = Object.entries(normalized as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, item]) => [key, stableStringify(item)]) - return JSON.stringify(entries) - } - - return JSON.stringify(normalized) -} - -function parseStaticOptions(value: unknown): string[] { - const normalized = normalizeJsonValue(value) - if (!Array.isArray(normalized)) { - return [] - } - - return normalized - .filter((item): item is string => typeof item === 'string') - .map((item) => item.trim()) - .filter(Boolean) -} - -function detectDangerousTemplateChanges(args: { - existingTemplate: { - materialType: string - materialKind: string - fields: Array<{ - id: string - key: string - type: string - label: string - required: boolean - addToCategory: boolean - isSearchable: boolean - isReadOnly: boolean - validations: string | null - optionsStatic: string | null - conditions: string | null - }> - } - nextMaterialType?: string - nextMaterialKind?: string - nextFields?: Array<{ - id?: string | null - key: string - type: string - label: string - required?: boolean - addToCategory?: boolean - isSearchable?: boolean - isReadOnly?: boolean - validations?: string | null - optionsStatic?: string | null - conditions?: string | null - }> -}): TemplateStructureChange[] { - const { existingTemplate, nextMaterialType, nextMaterialKind, nextFields } = args - const changes: TemplateStructureChange[] = [] - - if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) { - changes.push({ - type: 'materialTypeChanged', - before: existingTemplate.materialType, - after: nextMaterialType, - }) - } - - if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) { - changes.push({ - type: 'materialKindChanged', - before: existingTemplate.materialKind, - after: nextMaterialKind, - }) - } - - if (!nextFields) { - return changes - } - - const incomingById = new Map( - nextFields - .filter((field) => field.id) - .map((field) => [field.id as string, field]) - ) - - for (const existingField of existingTemplate.fields) { - const incomingField = incomingById.get(existingField.id) - - if (!incomingField) { - changes.push({ - type: 'fieldDeleted', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - continue - } - - if (incomingField.key !== existingField.key) { - changes.push({ - type: 'fieldKeyChanged', - fieldId: existingField.id, - before: existingField.key, - after: incomingField.key, - fieldLabel: existingField.label, - }) - } - - if (incomingField.type !== existingField.type) { - changes.push({ - type: 'fieldTypeChanged', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - before: existingField.type, - after: incomingField.type, - }) - } - - if (existingField.addToCategory && incomingField.addToCategory === false) { - changes.push({ - type: 'fieldCategoryDisabled', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - - if (existingField.isSearchable && incomingField.isSearchable === false) { - changes.push({ - type: 'fieldSearchDisabled', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - - if (!existingField.isReadOnly && incomingField.isReadOnly === true) { - changes.push({ - type: 'fieldMadeReadOnly', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - - if (!existingField.required && incomingField.required === true) { - changes.push({ - type: 'fieldMadeRequired', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - - if (stableStringify(incomingField.validations) !== stableStringify(existingField.validations)) { - changes.push({ - type: 'fieldValidationChanged', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - - const existingOptions = parseStaticOptions(existingField.optionsStatic) - if (existingOptions.length > 0) { - const incomingOptions = new Set(parseStaticOptions(incomingField.optionsStatic)) - const removedOptions = existingOptions.filter((option) => !incomingOptions.has(option)) - if (removedOptions.length > 0) { - changes.push({ - type: 'fieldOptionsRemoved', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - removedOptions, - }) - } - } - - if (stableStringify(incomingField.conditions) !== stableStringify(existingField.conditions)) { - changes.push({ - type: 'fieldConditionsChanged', - fieldId: existingField.id, - fieldKey: existingField.key, - fieldLabel: existingField.label, - }) - } - } - - return changes -} - export async function GET(_: NextRequest, { params }: { params: { id: string } }) { try { const session = await ensureAdmin() @@ -401,7 +113,25 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } } maxSynchronousMaterials: MAX_SYNC_FIELD_STATS_MATERIALS, } } else if (template._count.materials > 0) { - fieldStats = await calculateFieldStats(id, template.fields) + const statsMaterials = await prisma.material.findMany({ + where: { templateId: id }, + select: { + id: true, + metadata: true, + }, + }) + const fieldStatsResult = await calculateTemplateFieldStatsWithPaidCore({ + fields: template.fields, + materials: statsMaterials, + }) + fieldStats = fieldStatsResult.valid + ? fieldStatsResult.fieldStats + : { + skipped: true, + reason: 'PAID_CORE_UNAVAILABLE', + error: fieldStatsResult.error, + errorCode: fieldStatsResult.errorCode, + } } const result = { @@ -434,73 +164,6 @@ export async function GET(_: NextRequest, { params }: { params: { id: string } } } } -/** - * 计算字段填写率统计 - */ -async function calculateFieldStats(templateId: string, fields: any[]) { - try { - // 获取所有使用该模板的素材 - const materials = await prisma.material.findMany({ - where: { templateId }, - select: { - id: true, - metadata: true, - }, - }) - - if (materials.length === 0) { - return null - } - - const totalMaterials = materials.length - const fieldStats = fields.map(field => { - let filledCount = 0 - - // 统计该字段在多少素材中被填写 - for (const material of materials) { - if (!material.metadata) continue - - try { - const metadata = JSON.parse(material.metadata) - const templateFields = metadata.templateFields || {} - const value = templateFields[field.key] - - // 判断字段是否被填写(非空、非null、非undefined) - if (value !== null && value !== undefined && value !== '') { - filledCount++ - } - } catch (error) { - // 忽略JSON解析错误 - } - } - - const fillRate = (filledCount / totalMaterials) * 100 - - return { - fieldKey: field.key, - fieldLabel: field.label, - fieldType: field.type, - filledCount, - totalCount: totalMaterials, - fillRate: Math.round(fillRate * 100) / 100, // 保留2位小数 - isLowFillRate: fillRate < 30, // 填写率低于30%标记为低填写率 - } - }) - - // 按填写率排序 - fieldStats.sort((a, b) => a.fillRate - b.fillRate) - - return { - totalMaterials, - fields: fieldStats, - lowFillRateFields: fieldStats.filter(f => f.isLowFillRate), - } - } catch (error) { - console.error('[TemplateGet] Error calculating field stats:', error) - return null - } -} - export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { try { const session = await ensureAdmin() @@ -518,17 +181,6 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string } const { id } = params const body = await req.json() - const { - name, - description, - status, - materialType, - materialKind, - fieldGroups, - displayConfig, - fields, - version, - } = body || {} const existing = await prisma.uploadTemplate.findUnique({ where: { id }, @@ -567,159 +219,35 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string } return NextResponse.json({ error: '模板不存在' }, { status: 404 }) } - // 版本冲突检测 - if (version !== undefined && version !== existing.version) { - console.warn('[TemplateUpdate] Version conflict detected:', { + const updatePlan = await planUploadTemplateUpdateWithPaidCore({ + payload: body || {}, + existingTemplate: { + id: existing.id, + version: existing.version, + name: existing.name, + updatedAt: existing.updatedAt, + materialType: existing.materialType, + materialKind: existing.materialKind, + materialCount: existing._count.materials, + fields: existing.fields, + }, + }) + if (!updatePlan.valid) { + console.warn('[TemplateUpdate] Paid core update planning rejected:', { templateId: id, templateName: existing.name, - clientVersion: version, - serverVersion: existing.version, - lastUpdated: existing.updatedAt, + updatePlan, }) - return NextResponse.json({ - error: '模板已被其他人修改,请刷新后重试', - errorCode: 'VERSION_CONFLICT', - details: { - templateName: existing.name, - clientVersion: version, - serverVersion: existing.version, - lastUpdated: existing.updatedAt, - } - }, { status: 409 }) - } - - console.log('[TemplateUpdate] Version check passed:', { - templateId: id, - templateName: existing.name, - currentVersion: existing.version, - newVersion: (existing.version || 1) + 1, - }) - - // 验证必填字段 - if (name !== undefined && (typeof name !== 'string' || !name.trim())) { - console.warn('[TemplateUpdate] Validation failed: invalid name') - return NextResponse.json({ - error: '模板名称不能为空', - errorCode: 'VALIDATION_ERROR', - field: 'name', - }, { status: 400 }) - } - - // 验证materialType - if (materialType !== undefined && (typeof materialType !== 'string' || !materialType.trim())) { - console.warn('[TemplateUpdate] Validation failed: invalid materialType') - return NextResponse.json({ - error: '素材类型不能为空', - errorCode: 'VALIDATION_ERROR', - field: 'materialType', - }, { status: 400 }) - } - - // 验证materialKind - if (materialKind !== undefined) { - const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const - const rawKind = typeof materialKind === 'string' ? materialKind.trim().toUpperCase() : '' - if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) { - console.warn('[TemplateUpdate] Validation failed: invalid materialKind:', materialKind) - return NextResponse.json({ - error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`, - errorCode: 'VALIDATION_ERROR', - field: 'materialKind', - }, { status: 400 }) - } - } - - if (status !== undefined && !isValidUploadTemplateStatus(status)) { - console.warn('[TemplateUpdate] Validation failed: invalid status:', status) return NextResponse.json({ - error: '模板状态必须是 draft、active 或 archived', - errorCode: 'VALIDATION_ERROR', - field: 'status', - }, { status: 400 }) + error: updatePlan.error, + errorCode: updatePlan.errorCode, + field: updatePlan.field, + details: updatePlan.details, + }, { status: updatePlan.statusCode || 400 }) } - const updateData: any = {} - if (name !== undefined) updateData.name = name.trim() - if (description !== undefined) updateData.description = description - if (status !== undefined) updateData.status = status - if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups - if (displayConfig !== undefined) updateData.displayConfig = displayConfig - if (materialType !== undefined) { - updateData.materialType = materialType.trim() - } - if (materialKind !== undefined) { - const rawKind = materialKind.trim().toUpperCase() - updateData.materialKind = rawKind - } - updateData.version = (existing.version || 1) + 1 - - let formattedFields = null - - if (fields !== undefined) { - const formattedFieldsResult = validateAndFormatUploadTemplateFields(fields) - if (!formattedFieldsResult.valid) { - console.warn('[TemplateUpdate] Validation failed:', formattedFieldsResult) - return NextResponse.json({ - error: formattedFieldsResult.error, - errorCode: 'VALIDATION_ERROR', - field: formattedFieldsResult.field, - details: formattedFieldsResult.details, - }, { status: 400 }) - } - - formattedFields = formattedFieldsResult.fields - - const existingFieldIds = new Set(existing.fields.map((field) => field.id)) - const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id)) - if (foreignField) { - console.warn('[TemplateUpdate] Field id does not belong to template:', { - templateId: id, - fieldId: foreignField.id, - fieldKey: foreignField.key, - }) - - return NextResponse.json({ - error: '字段不属于当前模板,请刷新后重试', - errorCode: 'FIELD_NOT_IN_TEMPLATE', - details: { - fieldId: foreignField.id, - fieldKey: foreignField.key, - }, - }, { status: 400 }) - } - } - - if (existing._count.materials > 0) { - const dangerousChanges = detectDangerousTemplateChanges({ - existingTemplate: { - materialType: existing.materialType, - materialKind: existing.materialKind, - fields: existing.fields, - }, - nextMaterialType: updateData.materialType, - nextMaterialKind: updateData.materialKind, - nextFields: formattedFields || undefined, - }) - - if (dangerousChanges.length > 0) { - console.warn('[TemplateUpdate] Dangerous structure change blocked:', { - templateId: id, - templateName: existing.name, - materialCount: existing._count.materials, - dangerousChanges, - }) - - return NextResponse.json({ - error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`, - errorCode: 'TEMPLATE_STRUCTURE_LOCKED', - details: { - templateName: existing.name, - materialCount: existing._count.materials, - dangerousChanges, - }, - }, { status: 409 }) - } - } + const updateData: any = updatePlan.updateData + const formattedFields = updatePlan.fieldsProvided ? (updatePlan.fields || []) : null const template = await prisma.$transaction(async (tx) => { console.log('[TemplateUpdate] Updating template:', { @@ -887,21 +415,36 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string return NextResponse.json({ error: '模板不存在' }, { status: 404 }) } - // 检查是否有素材使用该模板 const materialCount = template._count.materials - if (materialCount > 0) { + const deletePlan = await planUploadTemplateDeleteWithPaidCore({ + templateName: template.name, + materialCount, + }) + + if (!deletePlan.valid) { + console.warn('[TemplateDelete] Paid core delete planning failed:', { + templateId: id, + templateName: template.name, + materialCount, + deletePlan, + }) + return NextResponse.json({ + error: deletePlan.error, + errorCode: deletePlan.errorCode, + details: deletePlan.details, + }, { status: 403 }) + } + + if (!deletePlan.allowed) { console.warn('[TemplateDelete] Cannot delete template with materials:', { templateId: id, templateName: template.name, materialCount, }) - return NextResponse.json({ - error: `无法删除模板"${template.name}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`, - errorCode: 'TEMPLATE_IN_USE', - details: { - templateName: template.name, - materialCount, - } + return NextResponse.json({ + error: deletePlan.error, + errorCode: deletePlan.errorCode, + details: deletePlan.details, }, { status: 400 }) } @@ -947,4 +490,3 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string } } - diff --git a/app/api/admin/upload-templates/__tests__/template-crud.test.ts b/app/api/admin/upload-templates/__tests__/template-crud.test.ts index 32ae940..915e794 100644 --- a/app/api/admin/upload-templates/__tests__/template-crud.test.ts +++ b/app/api/admin/upload-templates/__tests__/template-crud.test.ts @@ -38,6 +38,11 @@ jest.mock('@/lib/field-pool', () => ({ jest.mock('@/lib/license', () => ({ canUseCustomTemplates: jest.fn(), + calculateTemplateFieldStatsWithPaidCore: jest.fn(), + detectTemplateStructureChangesWithPaidCore: jest.fn(), + normalizeUploadTemplateSaveWithPaidCore: jest.fn(), + planUploadTemplateDeleteWithPaidCore: jest.fn(), + planUploadTemplateUpdateWithPaidCore: jest.fn(), })) jest.mock('@/lib/admin-permissions', () => ({ @@ -49,6 +54,11 @@ const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { syncFieldPoolFromTemplates } = require('@/lib/field-pool') const { canUseCustomTemplates } = require('@/lib/license') +const { calculateTemplateFieldStatsWithPaidCore } = require('@/lib/license') +const { detectTemplateStructureChangesWithPaidCore } = require('@/lib/license') +const { normalizeUploadTemplateSaveWithPaidCore } = require('@/lib/license') +const { planUploadTemplateDeleteWithPaidCore } = require('@/lib/license') +const { planUploadTemplateUpdateWithPaidCore } = require('@/lib/license') const { isCurrentAdmin, isCurrentSuperAdmin } = require('@/lib/admin-permissions') function makeTemplateTransactionMock(overrides: any = {}) { @@ -67,6 +77,303 @@ function makeTemplateTransactionMock(overrides: any = {}) { } } +const ALLOWED_TEST_MATERIAL_KINDS = new Set(['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']) +const ALLOWED_TEST_FIELD_TYPES = new Set(['text', 'textarea', 'number', 'select', 'checkbox', 'radio', 'date', 'uppercase']) + +function normalizeTestOptions(value: any) { + if (Array.isArray(value)) { + return JSON.stringify(value.map((item) => String(item ?? '').trim()).filter(Boolean)) + } + if (value === null || value === undefined || value === '') return null + return typeof value === 'string' ? value : JSON.stringify(value) +} + +function parseTestOptions(value: any): string[] { + if (Array.isArray(value)) return value + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return String(value).split('\n').map((item) => item.trim()).filter(Boolean) + } +} + +function buildPaidCoreSaveMock(payload: any, mode: 'create' | 'update') { + const body = payload || {} + const isCreate = mode === 'create' + + if (isCreate || Object.prototype.hasOwnProperty.call(body, 'name')) { + if (!String(body.name || '').trim()) { + return { valid: false, error: '模板名称不能为空', field: 'name' } + } + } + + if (isCreate || Object.prototype.hasOwnProperty.call(body, 'materialType')) { + if (!String(body.materialType || '').trim()) { + return { valid: false, error: '素材类型不能为空', field: 'materialType' } + } + } + + if (isCreate || Object.prototype.hasOwnProperty.call(body, 'materialKind')) { + const materialKind = String(body.materialKind || '').trim().toUpperCase() + if (!ALLOWED_TEST_MATERIAL_KINDS.has(materialKind)) { + return { valid: false, error: '素材大类无效', field: 'materialKind' } + } + } + + if (Object.prototype.hasOwnProperty.call(body, 'status') && !['draft', 'active', 'archived'].includes(body.status)) { + return { valid: false, error: '模板状态无效', field: 'status' } + } + + const fieldsProvided = isCreate || Object.prototype.hasOwnProperty.call(body, 'fields') + const formattedFields: any[] = [] + if (fieldsProvided) { + const fields = Array.isArray(body.fields) ? body.fields : [] + if (fields.length > 80) { + return { + valid: false, + error: '单个模板最多支持 80 个字段,请拆分模板或减少字段数量', + field: 'fields', + details: { maxFields: 80, currentFields: fields.length }, + } + } + + const seen = new Set() + const duplicateKeys: string[] = [] + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index] || {} + const key = String(field.key || '').trim() + const type = String(field.type || 'text').trim() || 'text' + const label = String(field.label || key || `字段${index + 1}`).trim() + if (seen.has(key)) duplicateKeys.push(key) + seen.add(key) + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { + return { valid: false, error: '字段 key 格式不正确', field: 'fields', details: { fieldKey: key } } + } + if (!ALLOWED_TEST_FIELD_TYPES.has(type)) { + return { + valid: false, + error: `字段“${label}”的类型不支持:${type}`, + field: 'fields', + details: { fieldKey: key, fieldType: type, allowedTypes: Array.from(ALLOWED_TEST_FIELD_TYPES) }, + } + } + + const optionsStatic = normalizeTestOptions(field.optionsStatic) + const options = parseTestOptions(optionsStatic) + if ((type === 'select' || type === 'radio') && options.length > 200) { + return { valid: false, error: '字段选项过多', field: 'fields', details: { fieldKey: key, maxOptions: 200 } } + } + if (field.required && type === 'radio' && options.length === 0) { + return { valid: false, error: '必填单选字段至少需要一个选项', field: 'fields', details: { fieldKey: key } } + } + if (type === 'text' && field.validations && typeof field.validations === 'object' && 'min' in field.validations) { + return { valid: false, error: '字段类型 text 不支持验证规则:min', field: 'fields', details: { fieldKey: key } } + } + + formattedFields.push({ + id: field.id ?? null, + key, + label, + type, + required: !!field.required, + orderIndex: field.orderIndex ?? index, + layoutWidth: field.layoutWidth || 'full', + validations: field.validations ? JSON.stringify(field.validations) : null, + optionsSource: field.optionsSource ?? null, + optionsStatic, + allowedExtensions: field.allowedExtensions ?? null, + visibleOnHomeFilter: !!field.visibleOnHomeFilter, + isSearchable: !!field.isSearchable, + isReadOnly: !!field.isReadOnly, + addToCategory: !!field.addToCategory, + conditions: field.conditions ? JSON.stringify(field.conditions) : null, + }) + } + + if (duplicateKeys.length > 0) { + return { valid: false, error: `字段key重复:${duplicateKeys.join(', ')}`, field: 'fields', details: { duplicateKeys } } + } + + const keys = new Set(formattedFields.map((field) => field.key)) + const deps = new Map() + for (const field of formattedFields) { + const conditions = field.conditions ? JSON.parse(field.conditions) : null + const conditionItems = Array.isArray(conditions?.conditions) ? conditions.conditions : [] + deps.set(field.key, conditionItems.map((condition: any) => condition.fieldKey).filter(Boolean)) + for (const condition of conditionItems) { + if (!keys.has(condition.fieldKey)) { + return { + valid: false, + error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`, + field: 'fields', + details: { fieldKey: field.key, dependencyKey: condition.fieldKey }, + } + } + } + } + for (const field of formattedFields) { + for (const dep of deps.get(field.key) || []) { + if ((deps.get(dep) || []).includes(field.key)) { + return { + valid: false, + error: `字段显示条件存在循环依赖:${field.key}, ${dep}`, + field: 'fields', + details: { circularFields: [field.key, dep] }, + } + } + } + } + } + + return { + valid: true, + mode, + fieldsProvided, + template: { + name: Object.prototype.hasOwnProperty.call(body, 'name') ? String(body.name).trim() : undefined, + description: Object.prototype.hasOwnProperty.call(body, 'description') ? body.description ?? null : isCreate ? null : undefined, + status: Object.prototype.hasOwnProperty.call(body, 'status') ? body.status : isCreate ? 'draft' : undefined, + materialType: Object.prototype.hasOwnProperty.call(body, 'materialType') ? String(body.materialType).trim() : undefined, + materialKind: Object.prototype.hasOwnProperty.call(body, 'materialKind') ? String(body.materialKind).trim().toUpperCase() : isCreate ? 'IMAGE' : undefined, + fieldGroups: Object.prototype.hasOwnProperty.call(body, 'fieldGroups') ? body.fieldGroups ?? null : isCreate ? null : undefined, + displayConfig: Object.prototype.hasOwnProperty.call(body, 'displayConfig') ? body.displayConfig ?? null : isCreate ? null : undefined, + fields: formattedFields, + }, + } +} + +function buildStructureChangeMock(params: any) { + const existing = params.existingTemplate || {} + const changes: any[] = [] + if (params.nextMaterialType && params.nextMaterialType !== existing.materialType) { + changes.push({ type: 'materialTypeChanged', before: existing.materialType, after: params.nextMaterialType }) + } + if (params.nextMaterialKind && params.nextMaterialKind !== existing.materialKind) { + changes.push({ type: 'materialKindChanged', before: existing.materialKind, after: params.nextMaterialKind }) + } + + if (Array.isArray(params.nextFields)) { + const nextById = new Map( + params.nextFields.filter((field: any) => field.id).map((field: any) => [field.id, field]) + ) + for (const field of existing.fields || []) { + const next = nextById.get(field.id) + if (!next) { + changes.push({ type: 'fieldDeleted', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + continue + } + if (next.key !== field.key) changes.push({ type: 'fieldKeyChanged', fieldId: field.id, before: field.key, after: next.key, fieldLabel: field.label }) + if (next.type !== field.type) changes.push({ type: 'fieldTypeChanged', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label, before: field.type, after: next.type }) + if (field.addToCategory && !next.addToCategory) changes.push({ type: 'fieldCategoryDisabled', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + if (field.isSearchable && !next.isSearchable) changes.push({ type: 'fieldSearchDisabled', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + if (!field.isReadOnly && next.isReadOnly) changes.push({ type: 'fieldMadeReadOnly', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + if (!field.required && next.required) changes.push({ type: 'fieldMadeRequired', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + if ((field.validations || null) !== (next.validations || null)) changes.push({ type: 'fieldValidationChanged', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + if ((field.optionsStatic || null) !== (next.optionsStatic || null)) changes.push({ type: 'fieldOptionsRemoved', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label, removedOptions: [] }) + if ((field.conditions || null) !== (next.conditions || null)) changes.push({ type: 'fieldConditionsChanged', fieldId: field.id, fieldKey: field.key, fieldLabel: field.label }) + } + } + + return { valid: true, changes } +} + +function buildPaidCoreUpdatePlanMock(payload: any, existingTemplate: any) { + const body = payload || {} + + if (Object.prototype.hasOwnProperty.call(body, 'version') && body.version !== existingTemplate.version) { + return { + valid: false, + error: '模板已被其他人修改,请刷新后重试', + errorCode: 'VERSION_CONFLICT', + field: 'version', + statusCode: 409, + details: { + templateName: existingTemplate.name, + clientVersion: body.version, + serverVersion: existingTemplate.version, + lastUpdated: existingTemplate.updatedAt, + }, + } + } + + const normalizedResult = buildPaidCoreSaveMock(body, 'update') + if (!normalizedResult.valid) { + return { + valid: false, + error: normalizedResult.error, + errorCode: 'VALIDATION_ERROR', + field: normalizedResult.field, + statusCode: 400, + details: normalizedResult.details, + } + } + + const nextVersion = (existingTemplate.version || 1) + 1 + const updateData: any = { version: nextVersion } + const normalized: any = normalizedResult.template || {} + for (const key of ['name', 'description', 'status', 'fieldGroups', 'displayConfig', 'materialType', 'materialKind']) { + if (normalized[key] !== undefined) updateData[key] = normalized[key] + } + + const formattedFields = normalizedResult.fieldsProvided ? (normalized.fields || []) : null + if (formattedFields) { + const existingFieldIds = new Set((existingTemplate.fields || []).map((field: any) => field.id)) + const foreignField = formattedFields.find((field: any) => field.id && !existingFieldIds.has(field.id)) + if (foreignField) { + return { + valid: false, + error: '字段不属于当前模板,请刷新后重试', + errorCode: 'FIELD_NOT_IN_TEMPLATE', + field: 'fields', + statusCode: 400, + details: { + fieldId: foreignField.id, + fieldKey: foreignField.key, + }, + } + } + } + + if ((existingTemplate.materialCount || 0) > 0) { + const structureChangeResult = buildStructureChangeMock({ + existingTemplate: { + materialType: existingTemplate.materialType, + materialKind: existingTemplate.materialKind, + fields: existingTemplate.fields || [], + }, + nextMaterialType: updateData.materialType, + nextMaterialKind: updateData.materialKind, + nextFields: formattedFields || undefined, + }) + + if (structureChangeResult.changes.length > 0) { + return { + valid: false, + error: `模板“${existingTemplate.name}”已有 ${existingTemplate.materialCount} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`, + errorCode: 'TEMPLATE_STRUCTURE_LOCKED', + field: 'template', + statusCode: 409, + details: { + templateName: existingTemplate.name, + materialCount: existingTemplate.materialCount, + dangerousChanges: structureChangeResult.changes, + }, + } + } + } + + return { + valid: true, + updateData, + fieldsProvided: normalizedResult.fieldsProvided, + fields: formattedFields, + nextVersion, + } +} + // 导入API处理函数 import { GET as listTemplates, POST as createTemplate } from '../route' import { @@ -83,6 +390,62 @@ describe('模板CRUD API测试', () => { user: { id: 'admin-id', role: 'ADMIN' } }) canUseCustomTemplates.mockResolvedValue(true) + calculateTemplateFieldStatsWithPaidCore.mockImplementation(async ({ fields, materials }: any) => ({ + valid: true, + fieldStats: materials.length === 0 + ? null + : { + totalMaterials: materials.length, + fields: fields.map((field: any) => ({ + fieldKey: field.key, + fieldLabel: field.label, + fieldType: field.type, + filledCount: materials.filter((material: any) => { + try { + const metadata = JSON.parse(material.metadata || '{}') + const value = metadata.templateFields?.[field.key] + return value !== undefined && value !== null && value !== '' + } catch { + return false + } + }).length, + totalCount: materials.length, + fillRate: 0, + isLowFillRate: true, + })), + lowFillRateFields: [], + }, + })) + detectTemplateStructureChangesWithPaidCore.mockImplementation(async (params: any) => + buildStructureChangeMock(params) + ) + normalizeUploadTemplateSaveWithPaidCore.mockImplementation(async (payload: any, mode: 'create' | 'update') => + buildPaidCoreSaveMock(payload, mode) + ) + planUploadTemplateDeleteWithPaidCore.mockImplementation(async ({ templateName, materialCount }: any) => { + if (materialCount > 0) { + return { + valid: true, + allowed: false, + error: `无法删除模板"${templateName}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`, + errorCode: 'TEMPLATE_IN_USE', + details: { + templateName, + materialCount, + }, + } + } + + return { + valid: true, + allowed: true, + templateName, + materialCount, + } + }) + planUploadTemplateUpdateWithPaidCore.mockImplementation(async ({ payload, existingTemplate }: any) => + buildPaidCoreUpdatePlanMock(payload, existingTemplate) + ) isCurrentAdmin.mockResolvedValue(true) isCurrentSuperAdmin.mockResolvedValue(true) prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise) => @@ -1416,6 +1779,10 @@ describe('模板CRUD API测试', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) + expect(planUploadTemplateDeleteWithPaidCore).toHaveBeenCalledWith({ + templateName: '测试模板', + materialCount: 0, + }) expect(syncFieldPoolFromTemplates).toHaveBeenCalled() }) @@ -1464,6 +1831,10 @@ describe('模板CRUD API测试', () => { expect(response.status).toBe(400) expect(data.errorCode).toBe('TEMPLATE_IN_USE') expect(data.details.materialCount).toBe(5) + expect(planUploadTemplateDeleteWithPaidCore).toHaveBeenCalledWith({ + templateName: '使用中的模板', + materialCount: 5, + }) expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled() }) diff --git a/app/api/admin/upload-templates/route.ts b/app/api/admin/upload-templates/route.ts index 8497c8b..ddd288c 100644 --- a/app/api/admin/upload-templates/route.ts +++ b/app/api/admin/upload-templates/route.ts @@ -4,12 +4,8 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { Prisma } from '@prisma/client' import { syncFieldPoolFromTemplates } from '@/lib/field-pool' -import { canUseCustomTemplates } from '@/lib/license' +import { canUseCustomTemplates, normalizeUploadTemplateSaveWithPaidCore } from '@/lib/license' import { isCurrentAdmin, isCurrentSuperAdmin } from '@/lib/admin-permissions' -import { - isValidUploadTemplateStatus, - validateAndFormatUploadTemplateFields, -} from '@/lib/upload-template-save-validation' const baseSelect = { id: true, @@ -114,82 +110,28 @@ export async function POST(req: NextRequest) { } body = await req.json() - const { - name, - description, - status = 'draft', - materialType, - materialKind, - fieldGroups, - displayConfig, - fields = [], - } = body || {} - - // 验证必填字段 - if (!name || typeof name !== 'string' || !name.trim()) { - console.warn('[TemplateCreate] Validation failed: name is required') - return NextResponse.json({ - error: '模板名称不能为空', - errorCode: 'VALIDATION_ERROR', - field: 'name', - }, { status: 400 }) - } - - // 验证materialType - if (!materialType || typeof materialType !== 'string' || !materialType.trim()) { - console.warn('[TemplateCreate] Validation failed: materialType is required') - return NextResponse.json({ - error: '素材类型不能为空', - errorCode: 'VALIDATION_ERROR', - field: 'materialType', - }, { status: 400 }) - } - - // 验证materialKind - const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const - const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : '' - if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) { - console.warn('[TemplateCreate] Validation failed: invalid materialKind:', materialKind) - return NextResponse.json({ - error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`, - errorCode: 'VALIDATION_ERROR', - field: 'materialKind', - }, { status: 400 }) - } - - const finalMaterialType = materialType.trim() - const finalMaterialKind = rawKind - - if (!isValidUploadTemplateStatus(status)) { - console.warn('[TemplateCreate] Validation failed: invalid status:', status) + const normalizedResult = await normalizeUploadTemplateSaveWithPaidCore(body || {}, 'create') + if (!normalizedResult.valid) { + console.warn('[TemplateCreate] Paid core validation failed:', normalizedResult) return NextResponse.json({ - error: '模板状态必须是 draft、active 或 archived', + error: normalizedResult.error, errorCode: 'VALIDATION_ERROR', - field: 'status', + field: normalizedResult.field, + details: normalizedResult.details, }, { status: 400 }) } - const formattedFieldsResult = validateAndFormatUploadTemplateFields(fields) - if (!formattedFieldsResult.valid) { - console.warn('[TemplateCreate] Validation failed:', formattedFieldsResult) - return NextResponse.json({ - error: formattedFieldsResult.error, - errorCode: 'VALIDATION_ERROR', - field: formattedFieldsResult.field, - details: formattedFieldsResult.details, - }, { status: 400 }) - } - - const formattedFields = formattedFieldsResult.fields.map(({ id: _id, ...field }) => field) + const normalized = normalizedResult.template + const formattedFields = (normalized.fields || []).map(({ id: _id, ...field }) => field) const createData = { - name, - description: description ?? null, - status, - materialType: finalMaterialType, - materialKind: finalMaterialKind, - fieldGroups: fieldGroups ?? null, - displayConfig: displayConfig ?? null, + name: normalized.name, + description: normalized.description ?? null, + status: normalized.status, + materialType: normalized.materialType, + materialKind: normalized.materialKind, + fieldGroups: normalized.fieldGroups ?? null, + displayConfig: normalized.displayConfig ?? null, fields: formattedFields.length ? { createMany: { data: formattedFields } } : undefined, diff --git a/app/api/admin/users/__tests__/organization-permissions.test.ts b/app/api/admin/users/__tests__/organization-permissions.test.ts index 2fee368..39ecafa 100644 --- a/app/api/admin/users/__tests__/organization-permissions.test.ts +++ b/app/api/admin/users/__tests__/organization-permissions.test.ts @@ -26,8 +26,13 @@ jest.mock('@/lib/prisma', () => ({ }, })) +jest.mock('@/lib/license', () => ({ + planDepartmentScopeWithPaidCore: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { planDepartmentScopeWithPaidCore } = require('@/lib/license') import { GET as listUsers, DELETE as deleteUser } from '../route' import { POST as updateUser } from '../update/route' @@ -44,6 +49,11 @@ import { describe('admin user organization permissions', () => { beforeEach(() => { jest.resetAllMocks() + planDepartmentScopeWithPaidCore.mockResolvedValue({ + valid: true, + scopedDepartments: ['设计部', '设计部-建模组'], + withinScope: true, + }) }) it('最高权限账号可以查看全量用户列表', async () => { @@ -69,6 +79,7 @@ describe('admin user organization permissions', () => { expect(response.status).toBe(200) expect(data.pagination.total).toBe(2) expect(prisma.department.findMany).not.toHaveBeenCalled() + expect(planDepartmentScopeWithPaidCore).not.toHaveBeenCalled() expect(prisma.user.count).toHaveBeenCalledWith({ where: {} }) }) @@ -99,6 +110,14 @@ describe('admin user organization permissions', () => { expect(response.status).toBe(200) expect(data.users).toHaveLength(2) + expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({ + scopeDepartment: '设计部', + departments: [ + { name: '设计部' }, + { name: '设计部-建模组' }, + { name: '财务部' }, + ], + }) expect(prisma.user.count).toHaveBeenCalledWith({ where: { AND: [ @@ -227,6 +246,11 @@ describe('admin user organization permissions', () => { const data = await response.json() expect(response.status).toBe(403) + expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({ + scopeDepartment: '设计部', + targetDepartment: '设计部', + departments: [{ name: '设计部' }], + }) expect(data.error).toContain('最高权限') expect(prisma.$transaction).not.toHaveBeenCalled() }) @@ -253,6 +277,11 @@ describe('admin user organization permissions', () => { const data = await response.json() expect(response.status).toBe(403) + expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({ + scopeDepartment: '设计部', + targetDepartment: '设计部', + departments: [{ name: '设计部' }], + }) expect(data.error).toContain('最高权限') expect(prisma.$transaction).not.toHaveBeenCalled() }) @@ -293,6 +322,11 @@ describe('admin user organization permissions', () => { const data = await response.json() expect(response.status).toBe(200) + expect(planDepartmentScopeWithPaidCore).toHaveBeenCalledWith({ + scopeDepartment: '设计部', + targetDepartment: '设计部', + departments: [{ name: '设计部' }], + }) expect(data.materialsUpdated).toBe(false) expect(tx.user.update).toHaveBeenCalledWith({ where: { id: 'user-2' }, diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index c372c8e..fa3e3b5 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -4,7 +4,7 @@ import { Prisma } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' -import { buildDepartmentDescendants } from '@/lib/departments' +import { planDepartmentScopeWithPaidCore } from '@/lib/license' // Get all users (admin only) - 支持分页 export async function GET(request: NextRequest) { @@ -57,16 +57,20 @@ export async function GET(request: NextRequest) { if (scopeDepartment) { const departments = await prisma.department.findMany({ select: { name: true } }) - const descendants = buildDepartmentDescendants(departments).get(scopeDepartment) - const scopedDepartments = Array.from(descendants || new Set([scopeDepartment])) + const scopePlan = await planDepartmentScopeWithPaidCore({ + scopeDepartment, + departments, + }) - scopeWhere = { - OR: [ - { id: currentUser.id }, - { department: { in: scopedDepartments } }, - { department: { startsWith: `${scopeDepartment}-` } }, - ], - } + scopeWhere = scopePlan.valid && scopePlan.scopedDepartments.length > 0 + ? { + OR: [ + { id: currentUser.id }, + { department: { in: scopePlan.scopedDepartments } }, + { department: { startsWith: `${scopeDepartment}-` } }, + ], + } + : { id: currentUser.id } } else { scopeWhere = { id: currentUser.id } } diff --git a/app/api/admin/users/update/route.ts b/app/api/admin/users/update/route.ts index 22d03ab..bf4702f 100644 --- a/app/api/admin/users/update/route.ts +++ b/app/api/admin/users/update/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' +import { planDepartmentScopeWithPaidCore } from '@/lib/license' // 统一更新用户信息;历史素材作者名只允许最高权限账号显式同步。 export async function POST(req: NextRequest) { @@ -43,12 +43,6 @@ export async function POST(req: NextRequest) { where: { id: (session.user as any).id }, }) as any - let departmentDescendantsMap: Map> | undefined - if (currentUser.managedDepartment && !currentUserFull?.isSuperAdmin) { - const departments = await prisma.department.findMany({ select: { name: true } }) - departmentDescendantsMap = buildDepartmentDescendants(departments) - } - // 获取更新前的用户信息 const oldUser = await prisma.user.findUnique({ where: { id: userId }, @@ -75,12 +69,13 @@ export async function POST(req: NextRequest) { if (!currentUserFull?.isSuperAdmin) { // 如果是部门管理员,只能编辑自己管理的部门下的用户 if (currentUser.managedDepartment) { - const allowed = isDepartmentWithinScope( - currentUser.managedDepartment, - oldUser.department, - departmentDescendantsMap - ) - if (!allowed) { + const departments = await prisma.department.findMany({ select: { name: true } }) + const scopePlan = await planDepartmentScopeWithPaidCore({ + scopeDepartment: currentUser.managedDepartment, + targetDepartment: oldUser.department, + departments, + }) + if (!scopePlan.valid || !scopePlan.withinScope) { return NextResponse.json( { error: '权限不足:只能编辑自己管理的部门树范围内的用户' }, { status: 403 } diff --git a/app/api/departments/__tests__/route.test.ts b/app/api/departments/__tests__/route.test.ts index c5970fd..0a8c4ae 100644 --- a/app/api/departments/__tests__/route.test.ts +++ b/app/api/departments/__tests__/route.test.ts @@ -18,7 +18,9 @@ jest.mock('@/lib/prisma', () => ({ }, user: { findUnique: jest.fn(), + findMany: jest.fn(), updateMany: jest.fn(), + update: jest.fn(), }, $transaction: jest.fn(), }, @@ -26,11 +28,12 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/license', () => ({ canUseDepartmentIsolation: jest.fn(), + planDepartmentStructureChangeWithPaidCore: jest.fn(), })) const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') -const { canUseDepartmentIsolation } = require('@/lib/license') +const { canUseDepartmentIsolation, planDepartmentStructureChangeWithPaidCore } = require('@/lib/license') import { POST, PATCH, DELETE } from '../route' @@ -41,6 +44,26 @@ describe('departments route license guard', () => { user: { id: 'admin-id', role: 'ADMIN' }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + prisma.department.findMany.mockResolvedValue([]) + planDepartmentStructureChangeWithPaidCore.mockImplementation(async ({ action, target }: any) => { + if (action === 'create') { + return { + valid: true, + allowed: true, + action, + departmentOps: [{ type: 'create', data: { name: String(target.name).trim() } }], + userOps: [], + } + } + + return { + valid: false, + allowed: false, + error: '未设置测试计划', + errorCode: 'TEST_PLAN_MISSING', + statusCode: 500, + } + }) canUseDepartmentIsolation.mockResolvedValue(true) }) @@ -62,7 +85,6 @@ describe('departments route license guard', () => { }) test('POST 最高权限账号可以创建部门', async () => { - prisma.department.findUnique.mockResolvedValue(null) prisma.department.create.mockResolvedValue({ id: 'dept-1', name: '设计部' }) const request = new NextRequest('http://localhost/api/departments', { @@ -75,6 +97,11 @@ describe('departments route license guard', () => { expect(response.status).toBe(201) expect(data.name).toBe('设计部') + expect(planDepartmentStructureChangeWithPaidCore).toHaveBeenCalledWith({ + action: 'create', + target: { name: '设计部' }, + departments: [], + }) expect(prisma.department.create).toHaveBeenCalledWith({ data: { name: '设计部' } }) }) diff --git a/app/api/departments/route.ts b/app/api/departments/route.ts index fb26be0..2e4ef09 100644 --- a/app/api/departments/route.ts +++ b/app/api/departments/route.ts @@ -2,9 +2,39 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { canUseDepartmentIsolation } from '@/lib/license' +import { canUseDepartmentIsolation, planDepartmentStructureChangeWithPaidCore } from '@/lib/license' import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +function departmentPlanResponse(plan: { + valid: boolean + allowed: boolean + statusCode?: number + error?: string + errorCode?: string + response?: Record + details?: Record +}) { + if (plan.response) { + return NextResponse.json(plan.response, { status: plan.statusCode || 200 }) + } + + return NextResponse.json( + { + error: plan.error || '付费增强包无法规划部门结构变更', + errorCode: plan.errorCode, + details: plan.details, + }, + { status: plan.statusCode || 503 } + ) +} + +async function loadDepartmentPlanSnapshot() { + return prisma.department.findMany({ + select: { id: true, name: true }, + orderBy: { createdAt: 'asc' }, + }) +} + // Get all departments export async function GET() { try { @@ -45,28 +75,23 @@ export async function POST(req: NextRequest) { } const { name } = await req.json() - - if (!name || name.trim() === '') { - return NextResponse.json( - { error: '部门名称不能为空' }, - { status: 400 } - ) - } - - // Check if department already exists - const existing = await prisma.department.findUnique({ - where: { name }, + const departments = await loadDepartmentPlanSnapshot() + const plan = await planDepartmentStructureChangeWithPaidCore({ + action: 'create', + target: { name }, + departments, }) - if (existing) { - return NextResponse.json( - { error: '部门已存在' }, - { status: 400 } - ) + if (!plan.valid || !plan.allowed) { + return departmentPlanResponse(plan) } + const createOp = plan.departmentOps.find((op) => op.type === 'create') as any + if (!createOp?.data) { + return NextResponse.json({ error: '部门创建计划无效' }, { status: 503 }) + } const department = await prisma.department.create({ - data: { name }, + data: createOp.data, }) return NextResponse.json(department, { status: 201 }) @@ -99,131 +124,40 @@ export async function PATCH(req: NextRequest) { } const { id, name } = await req.json() - - if (!id) { - return NextResponse.json( - { error: '缺少部门ID' }, - { status: 400 } - ) - } - - if (!name || name.trim() === '') { - return NextResponse.json( - { error: '部门名称不能为空' }, - { status: 400 } - ) - } - - const targetDepartment = await prisma.department.findUnique({ - where: { id }, - }) - - if (!targetDepartment) { - return NextResponse.json( - { error: '未找到对应的部门' }, - { status: 404 } - ) - } - - const trimmedName = name.trim() - - if (targetDepartment.name === trimmedName) { - return NextResponse.json(targetDepartment) - } - - // 构建所有需要重命名的部门(自身 + 子级) - const oldName = targetDepartment.name - const oldPrefix = `${oldName}-` - const newPrefix = `${trimmedName}-` - - const descendantDepartments = await prisma.department.findMany({ - where: { - name: { - startsWith: oldPrefix, + const [departments, users] = await Promise.all([ + loadDepartmentPlanSnapshot(), + prisma.user.findMany({ + select: { + id: true, + department: true, + managedDepartment: true, }, - }, + }), + ]) + const plan = await planDepartmentStructureChangeWithPaidCore({ + action: 'rename', + target: { id, name }, + departments, + users, }) - type RenameItem = { id: string; oldName: string; newName: string } - const renameItems: RenameItem[] = [ - { id: targetDepartment.id, oldName, newName: trimmedName }, - ...descendantDepartments.map((dept) => ({ - id: dept.id, - oldName: dept.name, - newName: `${newPrefix}${dept.name.slice(oldPrefix.length)}`, - })), - ] - - // 检查是否会与其他部门重名 - const newNameSet = Array.from(new Set(renameItems.map((item) => item.newName))) - const renameIds = renameItems.map((item) => item.id) - const conflicting = await prisma.department.findMany({ - where: { - name: { in: newNameSet }, - NOT: { - id: { in: renameIds }, - }, - }, - select: { name: true }, - }) - - if (conflicting.length > 0) { - return NextResponse.json( - { error: `存在同名分支:${conflicting.map((item) => item.name).join('、')}` }, - { status: 400 } - ) - } - - const renameValue = (value?: string | null) => { - if (!value) return value - if (value === oldName) return trimmedName - if (value.startsWith(oldPrefix)) { - return `${newPrefix}${value.slice(oldPrefix.length)}` - } - return value + if (!plan.valid || !plan.allowed) { + return departmentPlanResponse(plan) } await prisma.$transaction(async (tx) => { - for (const item of renameItems) { + for (const item of plan.departmentOps.filter((op) => op.type === 'rename') as any[]) { await tx.department.update({ where: { id: item.id }, data: { name: item.newName }, }) } - const affectedUsers = await tx.user.findMany({ - where: { - OR: [ - { department: oldName }, - { department: { startsWith: oldPrefix } }, - { managedDepartment: oldName }, - { managedDepartment: { startsWith: oldPrefix } }, - ], - }, - select: { - id: true, - department: true, - managedDepartment: true, - }, - }) - - for (const user of affectedUsers) { - const nextDepartment = renameValue(user.department) ?? '' - const nextManaged = renameValue(user.managedDepartment) - const data: { department?: string; managedDepartment?: string | null } = {} - - if (nextDepartment !== user.department) { - data.department = nextDepartment - } - - if (nextManaged !== user.managedDepartment) { - data.managedDepartment = nextManaged ?? null - } - - if (Object.keys(data).length > 0) { + for (const userOp of plan.userOps.filter((op) => op.type === 'update') as any[]) { + if (userOp.id && userOp.data && Object.keys(userOp.data).length > 0) { await tx.user.update({ - where: { id: user.id }, - data, + where: { id: userOp.id }, + data: userOp.data, }) } } @@ -265,50 +199,36 @@ export async function DELETE(req: NextRequest) { const { searchParams } = new URL(req.url) const id = searchParams.get('id') - if (!id) { - return NextResponse.json( - { error: '缺少部门ID' }, - { status: 400 } - ) - } - - const department = await prisma.department.findUnique({ - where: { id }, - select: { id: true, name: true }, + const departments = await loadDepartmentPlanSnapshot() + const plan = await planDepartmentStructureChangeWithPaidCore({ + action: 'delete', + target: { id }, + departments, }) - if (!department) { - return NextResponse.json( - { error: '未找到对应的部门' }, - { status: 404 } - ) + if (!plan.valid || !plan.allowed) { + return departmentPlanResponse(plan) } - const prefix = `${department.name}-` - const relatedDepartments = await prisma.department.findMany({ - where: { - OR: [ - { name: department.name }, - { name: { startsWith: prefix } }, - ], - }, - select: { id: true, name: true }, - }) - - const targetIds = relatedDepartments.map((dept) => dept.id) - const targetNames = relatedDepartments.map((dept) => dept.name) + const deleteOp = plan.departmentOps.find((op) => op.type === 'deleteMany') as any + const targetIds = Array.isArray(deleteOp?.ids) ? deleteOp.ids : [] + const targetNames = Array.isArray(deleteOp?.names) ? deleteOp.names : [] await prisma.$transaction(async (tx) => { - if (targetNames.length > 0) { - await tx.user.updateMany({ - where: { department: { in: targetNames } }, - data: { department: '' }, - }) + for (const userOp of plan.userOps as any[]) { + if (userOp.type === 'clearDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) { + await tx.user.updateMany({ + where: { department: { in: userOp.names } }, + data: { department: '' }, + }) + } - await tx.user.updateMany({ - where: { managedDepartment: { in: targetNames } }, - data: { managedDepartment: null }, - }) + if (userOp.type === 'clearManagedDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) { + await tx.user.updateMany({ + where: { managedDepartment: { in: userOp.names } }, + data: { managedDepartment: null }, + }) + } } if (targetIds.length > 0) { diff --git a/app/api/home-filter-fields/__tests__/route-auth.test.ts b/app/api/home-filter-fields/__tests__/route-auth.test.ts index 6ac249c..520d79b 100644 --- a/app/api/home-filter-fields/__tests__/route-auth.test.ts +++ b/app/api/home-filter-fields/__tests__/route-auth.test.ts @@ -21,9 +21,14 @@ jest.mock('@/lib/field-pool', () => ({ formatFieldOptionEntries: jest.fn((options) => options), })) +jest.mock('@/lib/license', () => ({ + planHomeFilterFieldCountsWithPaidCore: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } = require('@/lib/field-pool') +const { planHomeFilterFieldCountsWithPaidCore } = require('@/lib/license') function getSqlText(query: unknown) { const sqlQuery = query as { sql?: string; text?: string } @@ -34,6 +39,11 @@ describe('GET /api/home-filter-fields 权限边界', () => { beforeEach(() => { jest.resetAllMocks() formatFieldOptionEntries.mockImplementation((options: Array<{ label: string; value: string }>) => options) + planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({ + valid: true, + fieldPlans: [], + moduleAvailable: true, + }) }) it('未登录时拒绝读取首页筛选字段', async () => { @@ -45,6 +55,7 @@ describe('GET /api/home-filter-fields 权限边界', () => { expect(response.status).toBe(401) expect(data.error).toBe('请先登录') expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled() + expect(planHomeFilterFieldCountsWithPaidCore).not.toHaveBeenCalled() expect(getFieldPoolRecords).not.toHaveBeenCalled() expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() }) @@ -62,6 +73,11 @@ describe('GET /api/home-filter-fields 权限边界', () => { options: [{ label: '店铺A', value: '店铺A' }], }, ]) + planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({ + valid: true, + fieldPlans: [{ key: 'categoryName', source: 'column' }], + moduleAvailable: true, + }) prisma.$queryRaw .mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }]) .mockResolvedValueOnce([{ count: BigInt(3) }]) @@ -71,6 +87,9 @@ describe('GET /api/home-filter-fields 权限边界', () => { expect(response.status).toBe(200) expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled() + expect(planHomeFilterFieldCountsWithPaidCore).toHaveBeenCalledWith({ + fields: [{ key: 'categoryName' }], + }) expect(prisma.$queryRaw).toHaveBeenCalledTimes(2) expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() expect(data).toEqual({ @@ -98,6 +117,11 @@ describe('GET /api/home-filter-fields 权限边界', () => { options: [{ label: 'CODE-001', value: 'CODE-001' }], }, ]) + planHomeFilterFieldCountsWithPaidCore.mockResolvedValue({ + valid: true, + fieldPlans: [{ key: 'customCode', source: 'metadata' }], + moduleAvailable: true, + }) prisma.$queryRaw .mockResolvedValueOnce([{ value: 'CODE-001', count: BigInt(2) }]) .mockResolvedValueOnce([{ count: BigInt(2) }]) @@ -107,6 +131,9 @@ describe('GET /api/home-filter-fields 权限边界', () => { expect(response.status).toBe(200) expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled() + expect(planHomeFilterFieldCountsWithPaidCore).toHaveBeenCalledWith({ + fields: [{ key: 'customCode' }], + }) expect(prisma.$queryRaw).toHaveBeenCalledTimes(2) expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() const optionCountSql = getSqlText(prisma.$queryRaw.mock.calls[0][0]) diff --git a/app/api/home-filter-fields/route.ts b/app/api/home-filter-fields/route.ts index 798e048..0dd07f2 100644 --- a/app/api/home-filter-fields/route.ts +++ b/app/api/home-filter-fields/route.ts @@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool' +import { planHomeFilterFieldCountsWithPaidCore } from '@/lib/license' // 字段池key到数据库列名的映射 // 注意:只有在 materials 表中存在对应列的字段才能统计 @@ -23,6 +24,11 @@ const MATERIAL_FIELD_COLUMN_MAP: Record = { authorName: Prisma.sql`"authorName"`, } +type HomeFilterCountPlan = { + key: string + source: 'column' | 'metadata' +} + function metadataTemplateFieldsJson() { return Prisma.sql`COALESCE( ( @@ -40,14 +46,14 @@ function metadataTemplateFieldsJson() { * 构建每个字段的素材统计 * @returns counts: 每个选项的素材数量, totals: 每个字段有非空值的素材总数 */ -async function buildMaterialCounts(fieldKeys: string[]) { +async function buildMaterialCounts(fieldPlans: HomeFilterCountPlan[]) { const counts: Record> = {} const totals: Record = {} await Promise.all( - fieldKeys.map(async (fieldKey) => { - const columnSql = MATERIAL_FIELD_COLUMN_MAP[fieldKey] - if (!columnSql) { + fieldPlans.map(async ({ key: fieldKey, source }) => { + const columnSql = source === 'column' ? MATERIAL_FIELD_COLUMN_MAP[fieldKey] : null + if (source === 'metadata' || !columnSql) { const fieldValueSql = Prisma.sql`${metadataTemplateFieldsJson()} ->> ${fieldKey}` const rows = await prisma.$queryRaw>(Prisma.sql` SELECT value, COUNT(*)::bigint AS count @@ -130,11 +136,20 @@ export async function GET() { isFilterEnabled?: boolean options: Array<{ value: string; label?: string | null }> }> - const fieldKeys = fields.filter((field) => field.isFilterEnabled).map((field) => field.key) - const { counts: countsMap, totals: totalsMap } = await buildMaterialCounts(fieldKeys) + const enabledFields = fields.filter((field) => field.isFilterEnabled) + const countPlan = await planHomeFilterFieldCountsWithPaidCore({ + fields: enabledFields.map((field) => ({ key: field.key })), + }) + const fieldPlans = countPlan.valid + ? countPlan.fieldPlans.filter((field): field is HomeFilterCountPlan => ( + field.source === 'metadata' || Boolean(MATERIAL_FIELD_COLUMN_MAP[field.key]) + )) + : [] + const plannableFieldKeys = new Set(fieldPlans.map((field) => field.key)) + const { counts: countsMap, totals: totalsMap } = await buildMaterialCounts(fieldPlans) const payload = fields - .filter((field) => field.isFilterEnabled) + .filter((field) => field.isFilterEnabled && plannableFieldKeys.has(field.key)) .map((field) => ({ key: field.key, label: field.filterDisplayName || field.label, diff --git a/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts b/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts index 60df109..2d97765 100644 --- a/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts +++ b/app/api/license/premium-modules/runtime-status/__tests__/route.test.ts @@ -4,9 +4,7 @@ import { updatePremiumModuleRuntimeStatus } from '@/lib/license/premium-module-s jest.mock('@/lib/license/premium-module-service', () => ({ PREMIUM_MODULE_IDS: { - LICENSE_LIMITS_CORE: 'license-limits-core', - CUSTOM_TEMPLATES_CORE: 'custom-templates-core', - INTRANET_PENETRATION_CORE: 'intranet-penetration-core', + PAID_CORE: 'paid-core', }, updatePremiumModuleRuntimeStatus: jest.fn(), })); @@ -38,8 +36,8 @@ describe('POST /api/license/premium-modules/runtime-status', () => { it('需要回写 token,避免公开接口被任意写入模块运行态', async () => { const response = await POST(createRequest({ - moduleId: 'custom-templates-core', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + moduleId: 'paid-core', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', status: 'running', lastSyncAt: new Date().toISOString(), }, 'wrong-token')); @@ -52,8 +50,8 @@ describe('POST /api/license/premium-modules/runtime-status', () => { delete process.env.MODULE_RUNTIME_STATUS_TOKEN; const response = await POST(createRequest({ - moduleId: 'custom-templates-core', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + moduleId: 'paid-core', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', status: 'running', lastSyncAt: new Date().toISOString(), })); @@ -65,8 +63,8 @@ describe('POST /api/license/premium-modules/runtime-status', () => { it('接受 Agent 上报的合法模块运行状态并持久化', async () => { const lastSyncAt = new Date().toISOString(); const response = await POST(createRequest({ - moduleId: 'custom-templates-core', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + moduleId: 'paid-core', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', digest: 'sha256:' + 'a'.repeat(64), status: 'running', lastSyncAt, @@ -76,8 +74,8 @@ describe('POST /api/license/premium-modules/runtime-status', () => { expect(response.status).toBe(200); expect(data).toEqual({ success: true, updated: 1 }); expect(mockUpdatePremiumModuleRuntimeStatus).toHaveBeenCalledWith({ - moduleId: 'custom-templates-core', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + moduleId: 'paid-core', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', digest: 'sha256:' + 'a'.repeat(64), status: 'running', lastSyncAt, diff --git a/app/api/material-types/__tests__/material-types-auth.test.ts b/app/api/material-types/__tests__/material-types-auth.test.ts index 6b82768..f790fed 100644 --- a/app/api/material-types/__tests__/material-types-auth.test.ts +++ b/app/api/material-types/__tests__/material-types-auth.test.ts @@ -21,11 +21,13 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/license', () => ({ canUseCustomTemplates: jest.fn(), + projectPublicTemplateOptionsWithPaidCore: jest.fn(), })) const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { canUseCustomTemplates } = require('@/lib/license') +const { projectPublicTemplateOptionsWithPaidCore } = require('@/lib/license') describe('GET /api/material-types 权限与模板过滤', () => { beforeEach(() => { @@ -33,6 +35,22 @@ describe('GET /api/material-types 权限与模板过滤', () => { prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: null }]) prisma.uploadTemplate.findMany.mockResolvedValue([]) canUseCustomTemplates.mockResolvedValue(true) + projectPublicTemplateOptionsWithPaidCore.mockImplementation(async ({ builtinMaterialTypes, customTemplates }: any) => ({ + valid: true, + templates: [], + uploadTemplates: [], + materialTypes: [ + ...builtinMaterialTypes, + ...customTemplates + .filter((template: any) => template.status === 'active' && !template.id.startsWith('builtin-')) + .map((template: any) => { + const rawName = String(template.name || '').trim() + const name = rawName.replace(/\s*素材模板\s*$/g, '').trim() + return name ? `template:${template.id}:${name}` : null + }) + .filter(Boolean), + ].sort(), + })) }) it('未登录时拒绝读取素材类型', async () => { @@ -59,7 +77,12 @@ describe('GET /api/material-types 权限与模板过滤', () => { expect(data).toContain('template:custom-active:客户模板') expect(prisma.uploadTemplate.findMany).toHaveBeenCalledTimes(1) expect(prisma.uploadTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ status: 'active' }), + select: { id: true, name: true, materialType: true, status: true }, + })) + expect(projectPublicTemplateOptionsWithPaidCore).toHaveBeenCalledWith(expect.objectContaining({ + customTemplates: [ + { id: 'custom-active', name: '客户模板素材模板', materialType: '客户模板', status: 'active' }, + ], })) }) @@ -74,5 +97,6 @@ describe('GET /api/material-types 权限与模板过滤', () => { expect(data).toContain('3DMAX') expect(data.every((item: string) => !item.startsWith('template:'))).toBe(true) expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + expect(projectPublicTemplateOptionsWithPaidCore).not.toHaveBeenCalled() }) }) diff --git a/app/api/material-types/route.ts b/app/api/material-types/route.ts index 4a84582..189a143 100644 --- a/app/api/material-types/route.ts +++ b/app/api/material-types/route.ts @@ -8,7 +8,7 @@ import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { canUseCustomTemplates } from '@/lib/license' +import { canUseCustomTemplates, projectPublicTemplateOptionsWithPaidCore } from '@/lib/license' import { BUILTIN_TEMPLATE_KEYS, BUILTIN_TEMPLATE_TYPES, @@ -17,6 +17,17 @@ import { // 强制此 API 每次请求都在运行时执行,而不是在构建阶段预渲染成静态结果 export const dynamic = 'force-dynamic' +function noCacheJson(payload: unknown, status = 200) { + return NextResponse.json(payload, { + status, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }, + }) +} + export async function GET() { // 使用 console.error 确保日志输出到 stderr,在 Docker 中更容易被捕获 console.error('[material-types] ========== API CALLED ==========') @@ -45,39 +56,26 @@ export async function GET() { console.error('[material-types] Failed to parse enabledBuiltinTemplates:', error) } - const typeSet = new Set() - - BUILTIN_TEMPLATE_KEYS + const builtinMaterialTypes = BUILTIN_TEMPLATE_KEYS .filter((key) => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key)) - .forEach((key) => { + .map((key) => { const mapped = BUILTIN_TEMPLATE_TYPES[key] - if (mapped) { - typeSet.add(mapped) - } + return typeof mapped === 'string' ? mapped.trim() : '' }) + .filter(Boolean) const customTemplatesAllowed = await canUseCustomTemplates() - // 查询所有自定义模板(排除内置模板,避免重复显示) let customTemplates: Array<{ id: string; name: string | null; materialType: string | null; status: string }> = [] if (customTemplatesAllowed) { try { console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======') - console.error('[material-types] Querying active custom templates (excluding builtin)...') + console.error('[material-types] Querying custom template snapshot for paid-core projection...') customTemplates = await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板,内置模板的ID以 'builtin-' 开头 - NOT: { - id: { - startsWith: 'builtin-' - } - } - }, select: { id: true, name: true, materialType: true, status: true }, }) - console.error(`[material-types] ✓ Found ${customTemplates.length} active custom templates`) + console.error(`[material-types] ✓ Found ${customTemplates.length} custom template rows`) if (customTemplates.length > 0) { - console.error('[material-types] Active templates:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2)) + console.error('[material-types] Template rows:', JSON.stringify(customTemplates.map(t => ({ id: t.id, name: t.name, status: t.status })), null, 2)) } } catch (error) { @@ -88,34 +86,27 @@ export async function GET() { } else { console.error('[material-types] Custom templates disabled by current license') } - - // 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag - // 使用特殊前缀 "template:" 来标识这是自定义模板,避免与内置类型冲突 - // 注意:不再使用 materialType,直接使用模板名称作为tag - // 去掉模板名称中的"素材模板"后缀,让tag按钮显示更简洁 - let addedCount = 0 - customTemplates.forEach((template) => { - let templateName = template.name?.trim() - if (!templateName) { - console.warn(`[material-types] Skipping template with empty name: ${template.id}`) - return - } - // 去掉"素材模板"后缀(包括可能的空格) - templateName = templateName.replace(/\s*素材模板\s*$/g, '').trim() - if (!templateName) { - // 如果去掉后缀后为空,使用原始名称 - templateName = template.name?.trim() || `模板-${template.id.substring(0, 8)}` - } - // 使用 "template:模板ID:模板名称" 格式,前端可以解析出模板ID和名称 - // 模板名称可能包含冒号,所以使用 split(':').slice(2).join(':') 来提取 - const templateIdentifier = `template:${template.id}:${templateName}` - typeSet.add(templateIdentifier) - addedCount++ - console.log(`[material-types] ✓ Added custom template: "${templateName}" (ID: ${template.id}, status: ${template.status})`) + + if (!customTemplatesAllowed) { + const builtinOnly = Array.from(new Set(builtinMaterialTypes)).sort() + console.log(`[material-types] Final material types list (${builtinOnly.length} items):`, JSON.stringify(builtinOnly, null, 2)) + return noCacheJson(builtinOnly) + } + + const projected = await projectPublicTemplateOptionsWithPaidCore({ + builtinMaterialTypes, + customTemplates, }) - console.log(`[material-types] ✓ Added ${addedCount} custom templates`) - const result = Array.from(typeSet).sort() + if (!projected.valid) { + return noCacheJson({ + error: projected.error || '付费增强包无法生成素材类型列表', + errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED', + details: projected.details, + }, 503) + } + + const result = projected.materialTypes console.log(`[material-types] Final material types list (${result.length} items):`, JSON.stringify(result, null, 2)) console.log(`[material-types] Built-in types: ${result.filter(t => !t.startsWith('template:')).join(', ')}`) const customTemplateNames = result.filter(t => t.startsWith('template:')).map(t => { @@ -126,14 +117,7 @@ export async function GET() { console.log(`[material-types] Custom template identifiers: ${result.filter(t => t.startsWith('template:')).join(', ') || '(none)'}`) console.log('[material-types] ====== END QUERYING CUSTOM TEMPLATES ======') - // 强制不缓存,确保每次请求都返回最新数据 - return NextResponse.json(result, { - headers: { - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - }) + return noCacheJson(result) } catch (error) { console.error('[material-types] ========== ERROR IN API ==========') console.error('[material-types] Error fetching material types:', error) @@ -141,13 +125,7 @@ export async function GET() { // 返回内置类型作为fallback(与首页 FALLBACK_MATERIAL_TYPES 保持一致,但去掉"素材模板"后缀) const fallbackTypes = Array.from(new Set(Object.values(BUILTIN_TEMPLATE_TYPES))) console.error('[material-types] Returning fallback types:', fallbackTypes) - return NextResponse.json(fallbackTypes, { - headers: { - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - }) + return noCacheJson(fallbackTypes) } } diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index 58c8820..624938a 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -10,7 +10,9 @@ import { prepareMaterialData, readAllFieldValues, } from '@/lib/field-data-storage' +import { prepareMaterialMetadataWithPaidCore } from '@/lib/license' import { validateSubmittedTemplateFields } from '@/lib/template-field-validation' +import { isBuiltinTemplate } from '@/lib/template-validator' import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' // GET /api/materials/[id] - 获取单个素材详情 @@ -306,22 +308,42 @@ export async function PUT( ) } - const { metadata: rawMetadata } = prepareMaterialData( - templateFieldsValidation.templateFields as Record, - effectiveTemplateId, - undefined, - material.metadata - ) - const metadata = compressMetadata(rawMetadata) - const metadataSize = checkMetadataSize(metadata) - if (!metadataSize.ok) { - return NextResponse.json( - { - error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, - errorCode: 'METADATA_TOO_LARGE', - }, - { status: 400 } + let metadata: string | null = null + if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) { + const metadataResult = await prepareMaterialMetadataWithPaidCore({ + templateId: effectiveTemplateId, + templateFields: templateFieldsValidation.templateFields as Record, + existingMetadata: material.metadata, + }) + if (!metadataResult.valid) { + return NextResponse.json( + { + error: metadataResult.error, + errorCode: metadataResult.errorCode, + details: metadataResult.details, + }, + { status: 400 } + ) + } + metadata = metadataResult.metadata + } else { + const { metadata: rawMetadata } = prepareMaterialData( + templateFieldsValidation.templateFields as Record, + effectiveTemplateId, + undefined, + material.metadata ) + metadata = compressMetadata(rawMetadata) + const metadataSize = checkMetadataSize(metadata) + if (!metadataSize.ok) { + return NextResponse.json( + { + error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, + errorCode: 'METADATA_TOO_LARGE', + }, + { status: 400 } + ) + } } updateData.templateId = effectiveTemplateId diff --git a/app/api/materials/__tests__/materials-list-auth.test.ts b/app/api/materials/__tests__/materials-list-auth.test.ts index 978f9f3..182ccbc 100644 --- a/app/api/materials/__tests__/materials-list-auth.test.ts +++ b/app/api/materials/__tests__/materials-list-auth.test.ts @@ -45,6 +45,11 @@ jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn(), })) +jest.mock('@/lib/license', () => ({ + planDepartmentScopeWithPaidCore: jest.fn(), + planMaterialListQueryWithPaidCore: jest.fn(), +})) + jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), isS3Url: jest.fn(() => false), @@ -52,6 +57,7 @@ jest.mock('@/lib/s3', () => ({ const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { planDepartmentScopeWithPaidCore, planMaterialListQueryWithPaidCore } = require('@/lib/license') describe('GET /api/materials 权限边界', () => { beforeEach(() => { @@ -70,6 +76,20 @@ describe('GET /api/materials 权限边界', () => { { name: '设计部-建模组' }, { name: '运营部' }, ]) + planMaterialListQueryWithPaidCore.mockResolvedValue({ + valid: true, + search: '', + standardSearchFields: [], + customSearchFieldKeys: [], + standardHomeFilters: [], + customHomeFilters: [], + moduleAvailable: true, + }) + planDepartmentScopeWithPaidCore.mockResolvedValue({ + valid: true, + scopedDepartments: ['设计部', '设计部-建模组'], + withinScope: true, + }) }) it('未登录时拒绝读取素材列表', async () => { @@ -220,6 +240,15 @@ describe('GET /api/materials 权限边界', () => { { key: 'customCode' }, { key: 'productModel' }, ]) + planMaterialListQueryWithPaidCore.mockResolvedValue({ + valid: true, + search: 'CODE-001', + standardSearchFields: ['productModel', 'brandName'], + customSearchFieldKeys: ['customCode'], + standardHomeFilters: [], + customHomeFilters: [], + moduleAvailable: true, + }) prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }]) prisma.fieldDefinition.findMany.mockResolvedValue([]) prisma.material.count.mockResolvedValue(1) @@ -232,6 +261,15 @@ describe('GET /api/materials 权限边界', () => { where: { isSearchable: true }, select: { key: true }, }) + expect(planMaterialListQueryWithPaidCore).toHaveBeenCalledWith({ + search: 'CODE-001', + searchableFields: [ + { key: 'customCode' }, + { key: 'productModel' }, + ], + homeFieldFilters: [], + enabledHomeFilterFields: [], + }) expect(prisma.$queryRaw).toHaveBeenCalled() expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual( expect.arrayContaining([ @@ -247,6 +285,15 @@ describe('GET /api/materials 权限边界', () => { user: { id: 'user-1', role: 'USER' }, }) prisma.fieldDefinition.findMany.mockResolvedValue([{ key: 'customCode' }]) + planMaterialListQueryWithPaidCore.mockResolvedValueOnce({ + valid: true, + search: '', + standardSearchFields: [], + customSearchFieldKeys: [], + standardHomeFilters: [], + customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }], + moduleAvailable: true, + }) prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }]) prisma.material.count.mockResolvedValue(1) prisma.material.findMany.mockResolvedValue([]) diff --git a/app/api/materials/__tests__/template-fields-routes.test.ts b/app/api/materials/__tests__/template-fields-routes.test.ts index 62f53f6..599018f 100644 --- a/app/api/materials/__tests__/template-fields-routes.test.ts +++ b/app/api/materials/__tests__/template-fields-routes.test.ts @@ -56,6 +56,7 @@ jest.mock('@/lib/license', () => ({ jest.mock('@/lib/template-validator', () => ({ validateTemplateId: jest.fn(), getTemplateWithFields: jest.fn(), + isBuiltinTemplate: jest.fn(), })) const { getServerSession } = require('next-auth') @@ -64,7 +65,7 @@ const { createMaterialLog } = require('@/lib/material-log') const { uploadToS3, uploadStreamToS3 } = require('@/lib/s3') const { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } = require('@/lib/audio-processor') const { canUploadMaterial } = require('@/lib/license') -const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator') +const { validateTemplateId, getTemplateWithFields, isBuiltinTemplate } = require('@/lib/template-validator') const { processImage, getProcessedFileName, sliceImage } = require('@/lib/image-processor') import { POST as uploadMaterial } from '../upload/route' @@ -253,6 +254,7 @@ describe('素材模板字段链路测试', () => { parseAudioMetadata.mockResolvedValue(null) parseAudioMetadataFromFile.mockResolvedValue(null) isAudioFile.mockImplementation((mimeType: string) => mimeType.startsWith('audio/')) + isBuiltinTemplate.mockReturnValue(true) validateTemplateId.mockResolvedValue({ valid: true, diff --git a/app/api/materials/premium/route.ts b/app/api/materials/premium/route.ts index 18fb4ca..924f6e9 100644 --- a/app/api/materials/premium/route.ts +++ b/app/api/materials/premium/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' -import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' +import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' export const dynamic = 'force-dynamic' @@ -20,27 +20,17 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '参数错误' }, { status: 400 }) } - const operatorId = (session.user as any).id as string - - const operator = await prisma.user.findUnique({ - where: { id: operatorId }, - select: { - id: true, - role: true, - isSuperAdmin: true, - department: true, - managedDepartment: true, - }, - }) - - if (!operator) { + const actor = await getMaterialPermissionActor(session) + if (!actor) { return NextResponse.json({ error: '未找到当前用户信息' }, { status: 403 }) } + const operatorId = actor.id const targetMaterial = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, + userId: true, isPremium: true, user: { select: { @@ -55,33 +45,22 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } - if (!operator.isSuperAdmin) { - const operatorScope = (operator.managedDepartment || '').trim() + if (!actor.isSuperAdmin) { + const operatorScope = (actor.managedDepartment || '').trim() if (!operatorScope) { return NextResponse.json( { error: '尚未配置管理组织,无法设置精品' }, { status: 403 } ) } - const targetDepartment = (targetMaterial.user?.department || '').trim() - if (!targetDepartment) { + if (!(targetMaterial.user?.department || '').trim()) { return NextResponse.json( { error: '目标素材未设置所属组织,暂不支持跨组织加精' }, { status: 403 } ) } - let hasAccess = - targetDepartment === operatorScope || - targetDepartment.startsWith(`${operatorScope}-`) - - if (!hasAccess) { - const departments = await prisma.department.findMany({ select: { name: true } }) - const descendantMap = buildDepartmentDescendants(departments) - hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap) - } - - if (!hasAccess) { + if (!(await canManageMaterial(targetMaterial, actor))) { return NextResponse.json( { error: '只能为所属组织及下级组织的素材设置精品' }, { status: 403 } diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts index 0f2014e..e8dec25 100644 --- a/app/api/materials/restore/route.ts +++ b/app/api/materials/restore/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' +import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' // Restore material from trash (admin only) export async function POST(req: NextRequest) { @@ -27,21 +27,11 @@ export async function POST(req: NextRequest) { ) } - const operatorId = (session.user as any).id - const operator = await prisma.user.findUnique({ - where: { id: operatorId }, - select: { - id: true, - department: true, - managedDepartment: true, - isSuperAdmin: true, - }, - }) - - if (!operator) { + const actor = await getMaterialPermissionActor(session) + if (!actor) { return NextResponse.json( - { error: '当前用户不存在' }, - { status: 404 } + { error: '当前用户不存在或已被禁用' }, + { status: 403 } ) } @@ -64,37 +54,11 @@ export async function POST(req: NextRequest) { ) } - if (!operator.isSuperAdmin) { - const isOwnMaterial = material.userId === operatorId - - if (!isOwnMaterial) { - const operatorScope = (operator.managedDepartment || '').trim() - const targetDepartment = (material.user?.department || '').trim() - - if (!operatorScope) { - return NextResponse.json( - { error: '您只能恢复自己上传的素材' }, - { status: 403 } - ) - } - - let hasAccess = - targetDepartment === operatorScope || - targetDepartment.startsWith(`${operatorScope}-`) - - if (!hasAccess) { - const departments = await prisma.department.findMany({ select: { name: true } }) - const descendantMap = buildDepartmentDescendants(departments) - hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap) - } - - if (!hasAccess) { - return NextResponse.json( - { error: '您只能恢复自己或所管理部门成员上传的素材' }, - { status: 403 } - ) - } - } + if (!actor.isSuperAdmin && !(await canManageMaterial(material, actor))) { + return NextResponse.json( + { error: '您只能恢复自己或所管理部门成员上传的素材' }, + { status: 403 } + ) } // Restore material: set deletedAt to null diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 8fb22ae..e423867 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -5,9 +5,8 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' -import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' -import { isStandardField } from '@/lib/field-data-storage' -import { buildMaterialManagementScopeWhere, getMaterialPermissionActor } from '@/lib/material-permissions' +import { planMaterialListQueryWithPaidCore } from '@/lib/license' +import { buildMaterialManagementScopeWhere, canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' import { collectMaterialStorageUrls, deleteMaterialStorageFiles, @@ -26,22 +25,6 @@ function parseDisplayImages(value: string | null): string[] { return value ? [value] : [] } -const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([ - 'renderer', - 'brandName', - 'seriesName', - 'skuCode', - 'materialTexture', - 'renderTime', - 'imageResolution', - 'colorMode', - 'productModel', - 'platformName', - 'categoryName', - 'sceneName', - 'authorName', -]) - function buildMaterialSnapshot(material: { productModel?: string | null categoryName?: string | null @@ -71,42 +54,42 @@ function metadataTemplateFieldsJson() { )` } -async function getSearchableCustomFieldKeys(): Promise { - const fields = await prisma.uploadField.findMany({ - where: { - isSearchable: true, - }, - select: { - key: true, - }, - }) +async function planMaterialListQuery( + search: string, + homeFieldFilters: Array<{ key: string; value: string }> +) { + const requestedHomeFieldKeys = Array.from(new Set(homeFieldFilters.map((filter) => filter.key))) + const [searchableFields, enabledHomeFilterFields] = await Promise.all([ + prisma.uploadField.findMany({ + where: { + isSearchable: true, + }, + select: { + key: true, + }, + }), + requestedHomeFieldKeys.length > 0 + ? prisma.fieldDefinition.findMany({ + where: { + key: { in: requestedHomeFieldKeys }, + isFilterEnabled: true, + }, + select: { + key: true, + }, + }) + : Promise.resolve([]), + ]) - return Array.from( - new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key))) - ) + return planMaterialListQueryWithPaidCore({ + search, + searchableFields, + homeFieldFilters, + enabledHomeFilterFields, + }) } -async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise> { - const customKeys = fieldKeys.filter((key) => !ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) - if (customKeys.length === 0) { - return new Set() - } - - const enabledFields = await prisma.fieldDefinition.findMany({ - where: { - key: { in: customKeys }, - isFilterEnabled: true, - }, - select: { - key: true, - }, - }) - - return new Set(enabledFields.map((field) => field.key)) -} - -async function findMaterialIdsByCustomFieldSearch(search: string): Promise { - const fieldKeys = await getSearchableCustomFieldKeys() +async function findMaterialIdsByCustomFieldSearch(search: string, fieldKeys: string[]): Promise { if (fieldKeys.length === 0) { return [] } @@ -172,26 +155,20 @@ export async function GET(req: NextRequest) { { status: 403 } ) } - const dynamicFieldFilters: Array<{ key: string; value: string }> = [] - const requestedDynamicFieldKeys: string[] = [] + const homeFieldFilters: Array<{ key: string; value: string }> = [] searchParams.forEach((value, key) => { if (key.startsWith('homeField_') && value) { const fieldKey = key.replace('homeField_', '') - requestedDynamicFieldKeys.push(fieldKey) - if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) { - dynamicFieldFilters.push({ key: fieldKey, value }) - } - } - }) - const enabledCustomHomeFilterKeys = await getEnabledCustomHomeFilterKeys(requestedDynamicFieldKeys) - searchParams.forEach((value, key) => { - if (key.startsWith('homeField_') && value) { - const fieldKey = key.replace('homeField_', '') - if (enabledCustomHomeFilterKeys.has(fieldKey)) { - dynamicFieldFilters.push({ key: fieldKey, value }) - } + homeFieldFilters.push({ key: fieldKey, value }) } }) + const queryPlan = await planMaterialListQuery(search, homeFieldFilters) + if (!queryPlan.valid) { + return NextResponse.json( + { error: queryPlan.error || '无法规划素材查询条件' }, + { status: 503 } + ) + } // Build where clause const where: any = {} @@ -216,21 +193,21 @@ export async function GET(req: NextRequest) { } // Add search condition (search in SPU, shop, scene category, author) - if (search) { - const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(search) + if (queryPlan.search) { + const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch( + queryPlan.search, + queryPlan.customSearchFieldKeys + ) + const standardSearchConditions = queryPlan.standardSearchFields.map((field) => ({ + [field]: { contains: queryPlan.search, mode: 'insensitive' }, + })) + const customSearchConditions = customSearchMaterialIds.length > 0 + ? [{ id: { in: customSearchMaterialIds } }] + : [] andConditions.push({ OR: [ - { productModel: { contains: search, mode: 'insensitive' } }, // SPU - { brandName: { contains: search, mode: 'insensitive' } }, // 品牌 - { seriesName: { contains: search, mode: 'insensitive' } }, // 系列 - { skuCode: { contains: search, mode: 'insensitive' } }, // SKU - { materialTexture: { contains: search, mode: 'insensitive' } }, // 材质 - { categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺 - { sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别 - { authorName: { contains: search, mode: 'insensitive' } }, // 作者 - ...(customSearchMaterialIds.length > 0 - ? [{ id: { in: customSearchMaterialIds } }] - : []), + ...standardSearchConditions, + ...customSearchConditions, ] }) } @@ -313,13 +290,12 @@ export async function GET(req: NextRequest) { } } - for (const { key, value } of dynamicFieldFilters) { - if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) { - andConditions.push({ [key]: value } as Record) - } else { - const materialIds = await findMaterialIdsByCustomFieldValue(key, value) - andConditions.push({ id: { in: materialIds } }) - } + for (const { key, value } of queryPlan.standardHomeFilters) { + andConditions.push({ [key]: value } as Record) + } + for (const { key, value } of queryPlan.customHomeFilters) { + const materialIds = await findMaterialIdsByCustomFieldValue(key, value) + andConditions.push({ id: { in: materialIds } }) } // 合并所有条件 @@ -496,20 +472,14 @@ export async function DELETE(req: NextRequest) { ) } - // 获取当前操作者信息 - const operatorId = (session.user as any).id - - // 获取操作者的部门管理信息 - const operator = await prisma.user.findUnique({ - where: { id: operatorId }, - select: { - id: true, - department: true, - managedDepartment: true, - isSuperAdmin: true, - }, - }) - const isSuperAdmin = Boolean(operator?.isSuperAdmin) + const actor = await getMaterialPermissionActor(session) + if (!actor) { + return NextResponse.json( + { error: '当前用户不存在或已被禁用' }, + { status: 403 } + ) + } + const isSuperAdmin = actor.isSuperAdmin // Get material to delete files, including uploader's department info const material = await prisma.material.findUnique({ @@ -539,43 +509,14 @@ export async function DELETE(req: NextRequest) { } // 获取用户ID用于日志记录 - const userId = operatorId + const userId = actor.id // 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。 - if (!isSuperAdmin) { - // 检查是否是自己上传的素材 - const isOwnMaterial = material.userId === operatorId - - if (!isOwnMaterial) { - // 检查是否有权限管理素材上传者所在的部门 - const operatorScope = (operator?.managedDepartment || '').trim() - const targetDepartment = (material.user?.department || '').trim() - - if (!operatorScope) { - return NextResponse.json( - { error: '您只能删除自己上传的素材' }, - { status: 403 } - ) - } - - // 检查目标素材的上传者是否在管理范围内 - let hasAccess = targetDepartment === operatorScope || - targetDepartment.startsWith(`${operatorScope}-`) - - if (!hasAccess) { - // 使用部门层级关系检查 - const departments = await prisma.department.findMany({ select: { name: true } }) - const descendantMap = buildDepartmentDescendants(departments) - hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap) - } - - if (!hasAccess) { - return NextResponse.json( - { error: '您只能删除自己或所管理部门成员上传的素材' }, - { status: 403 } - ) - } - } + if (!isSuperAdmin && !(await canManageMaterial(material, actor))) { + return NextResponse.json( + { error: '您只能删除自己或所管理部门成员上传的素材' }, + { status: 403 } + ) } if (permanent) { diff --git a/app/api/materials/stats/__tests__/route.test.ts b/app/api/materials/stats/__tests__/route.test.ts index c8d4923..39066d2 100644 --- a/app/api/materials/stats/__tests__/route.test.ts +++ b/app/api/materials/stats/__tests__/route.test.ts @@ -10,6 +10,10 @@ jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) +jest.mock('@/lib/license', () => ({ + planDepartmentScopeWithPaidCore: jest.fn(), +})) + jest.mock('@/lib/prisma', () => ({ prisma: { material: { @@ -27,6 +31,7 @@ jest.mock('@/lib/prisma', () => ({ const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { planDepartmentScopeWithPaidCore } = require('@/lib/license') describe('GET /api/materials/stats', () => { beforeEach(() => { @@ -45,6 +50,11 @@ describe('GET /api/materials/stats', () => { { name: '设计部-建模组' }, { name: '运营部' }, ]) + planDepartmentScopeWithPaidCore.mockResolvedValue({ + valid: true, + scopedDepartments: ['设计部', '设计部-建模组'], + withinScope: true, + }) }) it('未登录时不暴露素材总量和月份趋势', async () => { diff --git a/app/api/materials/stats/route.ts b/app/api/materials/stats/route.ts index d243d9d..e1a638c 100644 --- a/app/api/materials/stats/route.ts +++ b/app/api/materials/stats/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth' import { Prisma } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { buildDepartmentDescendants } from '@/lib/departments' +import { planDepartmentScopeWithPaidCore } from '@/lib/license' import { buildMaterialManagementScopeWhere, getMaterialPermissionActor, @@ -26,20 +26,24 @@ async function buildMonthlyStatsWhereSql(actor: MaterialPermissionActor) { if (actor.role === 'ADMIN' && scopeDepartment) { const departments = await prisma.department.findMany({ select: { name: true } }) - const descendants = buildDepartmentDescendants(departments).get(scopeDepartment) - const scopedDepartments = Array.from(descendants || new Set([scopeDepartment])) + const scopePlan = await planDepartmentScopeWithPaidCore({ + scopeDepartment, + departments, + }) - visibleConditions.push(Prisma.sql` - EXISTS ( - SELECT 1 - FROM "users" u - WHERE u."id" = m."userId" - AND ( - u."department" IN (${Prisma.join(scopedDepartments)}) - OR u."department" LIKE ${`${scopeDepartment}-%`} - ) - ) - `) + if (scopePlan.valid && scopePlan.scopedDepartments.length > 0) { + visibleConditions.push(Prisma.sql` + EXISTS ( + SELECT 1 + FROM "users" u + WHERE u."id" = m."userId" + AND ( + u."department" IN (${Prisma.join(scopePlan.scopedDepartments)}) + OR u."department" LIKE ${`${scopeDepartment}-%`} + ) + ) + `) + } } conditions.push(Prisma.sql`(${Prisma.join(visibleConditions, ' OR ')})`) diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 20f22c4..5fe3705 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' import { uploadToS3, deleteFromS3 } from '@/lib/s3' import { processImage, getProcessedFileName } from '@/lib/image-processor' +import { isBuiltinTemplate } from '@/lib/template-validator' import { validateSubmittedTemplateFields } from '@/lib/template-field-validation' import { prepareMaterialData, @@ -12,6 +13,7 @@ import { readAllFieldValues, checkMetadataSize, } from '@/lib/field-data-storage' +import { prepareMaterialMetadataWithPaidCore } from '@/lib/license' import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions' export async function PUT(req: NextRequest) { @@ -196,22 +198,42 @@ export async function PUT(req: NextRequest) { templateFields = templateFieldsValidation.templateFields - const { metadata: rawMetadata } = prepareMaterialData( - templateFields, - effectiveTemplateId, - undefined, - existingMaterial.metadata - ) - const metadata = compressMetadata(rawMetadata) - const metadataSize = checkMetadataSize(metadata) - if (!metadataSize.ok) { - return NextResponse.json( - { - error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, - errorCode: 'METADATA_TOO_LARGE', - }, - { status: 400 } + let metadata: string | null = null + if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) { + const metadataResult = await prepareMaterialMetadataWithPaidCore({ + templateId: effectiveTemplateId, + templateFields, + existingMetadata: existingMaterial.metadata, + }) + if (!metadataResult.valid) { + return NextResponse.json( + { + error: metadataResult.error, + errorCode: metadataResult.errorCode, + details: metadataResult.details, + }, + { status: 400 } + ) + } + metadata = metadataResult.metadata + } else { + const { metadata: rawMetadata } = prepareMaterialData( + templateFields, + effectiveTemplateId, + undefined, + existingMaterial.metadata ) + metadata = compressMetadata(rawMetadata) + const metadataSize = checkMetadataSize(metadata) + if (!metadataSize.ok) { + return NextResponse.json( + { + error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, + errorCode: 'METADATA_TOO_LARGE', + }, + { status: 400 } + ) + } } updateData.templateId = effectiveTemplateId diff --git a/app/api/materials/upload/route.ts b/app/api/materials/upload/route.ts index d4ce541..1ac576c 100644 --- a/app/api/materials/upload/route.ts +++ b/app/api/materials/upload/route.ts @@ -6,10 +6,10 @@ import { createMaterialLog } from '@/lib/material-log' import { uploadToS3, uploadStreamToS3, deleteFromS3, isS3Url } from '@/lib/s3' import { processImage, getProcessedFileName, sliceImage } from '@/lib/image-processor' import { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } from '@/lib/audio-processor' -import { validateTemplateId } from '@/lib/template-validator' +import { isBuiltinTemplate, validateTemplateId } from '@/lib/template-validator' import { validateSubmittedTemplateFields } from '@/lib/template-field-validation' import { prepareMaterialData, compressMetadata, checkMetadataSize } from '@/lib/field-data-storage' -import { canUploadMaterial } from '@/lib/license' +import { canUploadMaterial, prepareMaterialMetadataWithPaidCore } from '@/lib/license' import { enqueueVideoTranscodeJob } from '@/lib/video-transcode-service' import formidable from 'formidable' import { Readable } from 'stream' @@ -788,17 +788,37 @@ export async function POST(req: NextRequest) { } // 标准字段直接落列,自定义字段与预览图数组进入 metadata - const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls) - const metadata = compressMetadata(rawMetadata) - const metadataSize = checkMetadataSize(metadata) - if (!metadataSize.ok) { - return await failWithCleanup(NextResponse.json( - { - error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, - errorCode: 'METADATA_TOO_LARGE', - }, - { status: 400 } - )) + let metadata: string | null = null + if (templateId && !isBuiltinTemplate(templateId)) { + const metadataResult = await prepareMaterialMetadataWithPaidCore({ + templateId, + templateFields, + previewUrls, + }) + if (!metadataResult.valid) { + return await failWithCleanup(NextResponse.json( + { + error: metadataResult.error, + errorCode: metadataResult.errorCode, + details: metadataResult.details, + }, + { status: 400 } + )) + } + metadata = metadataResult.metadata + } else { + const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls) + metadata = compressMetadata(rawMetadata) + const metadataSize = checkMetadataSize(metadata) + if (!metadataSize.ok) { + return await failWithCleanup(NextResponse.json( + { + error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`, + errorCode: 'METADATA_TOO_LARGE', + }, + { status: 400 } + )) + } } console.log('[Upload] Material data to create:', { diff --git a/app/api/templates/__tests__/templates-route-auth.test.ts b/app/api/templates/__tests__/templates-route-auth.test.ts index 11b05d5..4d203e9 100644 --- a/app/api/templates/__tests__/templates-route-auth.test.ts +++ b/app/api/templates/__tests__/templates-route-auth.test.ts @@ -19,16 +19,29 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/license', () => ({ canUseCustomTemplates: jest.fn(), + projectPublicTemplateOptionsWithPaidCore: jest.fn(), })) const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { canUseCustomTemplates } = require('@/lib/license') +const { projectPublicTemplateOptionsWithPaidCore } = require('@/lib/license') describe('GET /api/templates 权限边界', () => { beforeEach(() => { jest.resetAllMocks() canUseCustomTemplates.mockResolvedValue(true) + projectPublicTemplateOptionsWithPaidCore.mockImplementation(async ({ builtinTemplates, customTemplates }: any) => ({ + valid: true, + templates: [ + ...builtinTemplates, + ...customTemplates + .filter((template: any) => template.status === 'active' && !template.id.startsWith('builtin-')) + .map((template: any) => ({ id: template.id, name: template.name, isBuiltin: false })), + ], + uploadTemplates: [], + materialTypes: [], + })) }) it('未登录时拒绝读取模板列表', async () => { @@ -49,7 +62,7 @@ describe('GET /api/templates 权限边界', () => { }) prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["3dmax-material"]' }]) prisma.uploadTemplate.findMany.mockResolvedValue([ - { id: 'custom-template', name: '自定义模板' }, + { id: 'custom-template', name: '自定义模板', status: 'active' }, ]) const response = await GET() @@ -60,6 +73,9 @@ describe('GET /api/templates 权限边界', () => { { id: 'builtin-3dmax-material', name: '3DMAX 素材模板', isBuiltin: true }, { id: 'custom-template', name: '自定义模板', isBuiltin: false }, ]) + expect(projectPublicTemplateOptionsWithPaidCore).toHaveBeenCalledWith(expect.objectContaining({ + customTemplates: [{ id: 'custom-template', name: '自定义模板', status: 'active' }], + })) }) it('许可证不包含自定义模板时,只返回内置模板且不读取历史自定义模板', async () => { @@ -77,5 +93,6 @@ describe('GET /api/templates 权限边界', () => { { id: 'builtin-3dmax-material', name: '3DMAX 素材模板', isBuiltin: true }, ]) expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + expect(projectPublicTemplateOptionsWithPaidCore).not.toHaveBeenCalled() }) }) diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index ff64de4..20fd20a 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -9,7 +9,7 @@ import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { canUseCustomTemplates } from '@/lib/license' +import { canUseCustomTemplates, projectPublicTemplateOptionsWithPaidCore } from '@/lib/license' import { BUILTIN_TEMPLATE_KEYS, createBuiltinTemplate, @@ -27,7 +27,6 @@ export async function GET() { // 获取启用的预制模板列表 let enabledBuiltinTemplates: string[] = [] - const ALL_BUILTIN_KEYS = BUILTIN_TEMPLATE_KEYS // 所有预制模板key try { // 使用原始 SQL 查询,避免字段不存在时的错误 const settings = await prisma.$queryRaw>` @@ -49,16 +48,10 @@ export async function GET() { const customTemplatesAllowed = await canUseCustomTemplates() const customTemplates = customTemplatesAllowed ? await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) - NOT: { - id: { startsWith: 'builtin-' }, - }, - }, select: { id: true, name: true, + status: true, }, orderBy: { updatedAt: 'desc' }, }) @@ -77,13 +70,24 @@ export async function GET() { } }) - // 合并预制模板和自定义模板,预制模板在前 - const allTemplates = [ - ...builtinTemplates, - ...customTemplates.map(t => ({ ...t, isBuiltin: false })), - ] + if (!customTemplatesAllowed) { + return NextResponse.json(builtinTemplates) + } - return NextResponse.json(allTemplates) + const projected = await projectPublicTemplateOptionsWithPaidCore({ + builtinTemplates, + customTemplates, + }) + + if (!projected.valid) { + return NextResponse.json({ + error: projected.error || '付费增强包无法生成模板列表', + errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED', + details: projected.details, + }, { status: 503 }) + } + + return NextResponse.json(projected.templates) } catch (error) { console.error('Error fetching templates:', error) return NextResponse.json({ error: '获取模板列表失败' }, { status: 500 }) diff --git a/app/api/upload-templates/__tests__/route-auth.test.ts b/app/api/upload-templates/__tests__/route-auth.test.ts index 331ae17..0690c38 100644 --- a/app/api/upload-templates/__tests__/route-auth.test.ts +++ b/app/api/upload-templates/__tests__/route-auth.test.ts @@ -22,16 +22,34 @@ jest.mock('@/lib/prisma', () => ({ jest.mock('@/lib/license', () => ({ canUseCustomTemplates: jest.fn(), + projectPublicTemplateOptionsWithPaidCore: jest.fn(), })) const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { canUseCustomTemplates } = require('@/lib/license') +const { projectPublicTemplateOptionsWithPaidCore } = require('@/lib/license') describe('GET /api/upload-templates 权限边界', () => { beforeEach(() => { jest.resetAllMocks() canUseCustomTemplates.mockResolvedValue(true) + projectPublicTemplateOptionsWithPaidCore.mockImplementation(async ({ builtinTemplates, customTemplates, materialCounts }: any) => ({ + valid: true, + templates: [], + uploadTemplates: [ + ...builtinTemplates.map((template: any) => ({ ...template, isBuiltin: true, materialCount: materialCounts?.[template.id] || 0 })), + ...customTemplates + .filter((template: any) => template.status === 'active' && !template.id.startsWith('builtin-')) + .map((template: any) => ({ + ...template, + isBuiltin: false, + materialCount: materialCounts?.[template.id] || 0, + fields: (template.fields || []).filter((field: any) => field.isReadOnly !== true && field.key !== 'materialType'), + })), + ], + materialTypes: [], + })) }) it('未登录时拒绝读取上传模板', async () => { @@ -65,6 +83,7 @@ describe('GET /api/upload-templates 权限边界', () => { isBuiltin: true, materialCount: 0, }) + expect(projectPublicTemplateOptionsWithPaidCore).toHaveBeenCalled() }) it('许可证不包含自定义模板时,上传页只拿到内置模板', async () => { @@ -85,5 +104,6 @@ describe('GET /api/upload-templates 权限边界', () => { isBuiltin: true, }) expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + expect(projectPublicTemplateOptionsWithPaidCore).not.toHaveBeenCalled() }) }) diff --git a/app/api/upload-templates/route.ts b/app/api/upload-templates/route.ts index 970d618..0f0a9bf 100644 --- a/app/api/upload-templates/route.ts +++ b/app/api/upload-templates/route.ts @@ -10,7 +10,7 @@ import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { canUseCustomTemplates } from '@/lib/license' +import { canUseCustomTemplates, projectPublicTemplateOptionsWithPaidCore } from '@/lib/license' import { BUILTIN_TEMPLATE_KEYS, BUILTIN_TEMPLATE_TYPES, @@ -29,12 +29,6 @@ const publicSelect = { materialType: true, materialKind: true, fields: { - where: { - // 只返回非只读字段,只读字段不需要用户填写 - // 自定义模板中过滤掉 materialType 字段 - isReadOnly: false, - key: { not: 'materialType' }, - }, orderBy: { orderIndex: 'asc' }, select: { id: true, @@ -89,13 +83,6 @@ export async function GET() { const customTemplatesAllowed = await canUseCustomTemplates() const customTemplates = customTemplatesAllowed ? await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) - NOT: { - id: { startsWith: 'builtin-' }, - }, - }, orderBy: { updatedAt: 'desc' }, select: publicSelect, }) @@ -107,15 +94,13 @@ export async function GET() { .filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key)) .map(key => createBuiltinTemplate(key)) - // 合并预制模板和自定义模板,预制模板在前 - const allTemplates = [ + // 统计每个模板的素材数量,用于前端动态排序 + const countTargets = [ ...builtinTemplates.map(t => ({ ...t, isBuiltin: true })), ...customTemplates.map(t => ({ ...t, isBuiltin: false })), ] - - // 统计每个模板的素材数量,用于前端动态排序 const templateCounts = await Promise.all( - allTemplates.map((template) => { + countTargets.map((template) => { if (template.isBuiltin) { const key = template.id.replace('builtin-', '') const materialType = BUILTIN_TEMPLATE_TYPES[key] || template.materialType @@ -137,9 +122,36 @@ export async function GET() { }) ) - const templatesWithCounts = allTemplates.map((template, index) => ({ + const materialCounts = Object.fromEntries( + countTargets.map((template, index) => [template.id, templateCounts[index] || 0]) + ) + + if (!customTemplatesAllowed) { + const templatesWithCounts = countTargets.map((template, index) => ({ + ...template, + materialCount: templateCounts[index], + })) + + return NextResponse.json(templatesWithCounts) + } + + const projected = await projectPublicTemplateOptionsWithPaidCore({ + builtinTemplates, + customTemplates, + materialCounts, + }) + + if (!projected.valid) { + return NextResponse.json({ + error: projected.error || '付费增强包无法生成上传模板列表', + errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED', + details: projected.details, + }, { status: 503 }) + } + + const templatesWithCounts = projected.uploadTemplates.map((template) => ({ ...template, - materialCount: templateCounts[index], + materialCount: Number((template as any).materialCount || 0), })) return NextResponse.json(templatesWithCounts) diff --git a/deploy-registry.ps1 b/deploy-registry.ps1 index 2798fa8..51f8701 100644 --- a/deploy-registry.ps1 +++ b/deploy-registry.ps1 @@ -43,7 +43,7 @@ param( [string]$Version = "", [string]$AgentRepository = "agent", [string]$AgentContext = "flnxi-agent-go", - [string]$PremiumModuleId = "intranet-penetration-core", + [string]$PremiumModuleId = "paid-core", [string]$PremiumModuleContextRoot = "premium-modules", [string]$PremiumModuleRepositoryPrefix = "premium", [switch]$AllowMissingPremiumModuleDigest, @@ -109,7 +109,7 @@ function Show-Help { Write-Host " .\deploy-registry.ps1 -Action DeployApp -DeployChannel test -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -PushLatest -RegistryPassword `$registryPassword" -ForegroundColor Yellow - Write-Host " .\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId intranet-penetration-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow + Write-Host " .\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId paid-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword `$registryPassword" -ForegroundColor Yellow Write-Host " .\deploy-registry.ps1 -Action Menu -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword `$registryPassword -Password `$sshPassword" -ForegroundColor Yellow Write-Host "" Write-Host "只构建和推送,不远程部署:" -ForegroundColor White @@ -138,7 +138,7 @@ function Show-Help { Write-Host " -Version 正式版或 Agent 版本号" Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)" Write-Host " -AgentRepository Agent 镜像路径,默认 agent" - Write-Host " -PremiumModuleId 付费模块 ID,默认 intranet-penetration-core" + Write-Host " -PremiumModuleId 付费模块 ID,默认 paid-core" Write-Host " -PremiumModuleContextRoot 付费模块源码根目录,默认 premium-modules" Write-Host " -PremiumModuleRepositoryPrefix 付费模块镜像仓库前缀,默认 premium" Write-Host " -AllowMissingPremiumModuleDigest 允许付费模块发布后暂时不输出 digest;正式商用不建议启用" diff --git a/deploy/registry-proxy/README.md b/deploy/registry-proxy/README.md index 1530f6e..03b161b 100644 --- a/deploy/registry-proxy/README.md +++ b/deploy/registry-proxy/README.md @@ -54,7 +54,7 @@ docker compose -f docker-compose.existing-registry.yml up -d ```bash docker login registry.example.com:5000 -u flnxi-module -p '' -docker pull registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 +docker pull registry.example.com:5000/flnxi/premium/paid-core:1.0.0 ``` 没有短期 token、token 过期、仓库路径不匹配、tag 不匹配、镜像清单 digest 不匹配、许可证过期、激活失效或模块授权被吊销,都会被代理拒绝。Docker 拉取层文件时的 blob digest 不是整镜像 digest,最终整镜像 digest 由客户侧 Agent 拉取完成后再校验。 @@ -64,8 +64,8 @@ docker pull registry.example.com:5000/flnxi/premium/intranet-penetration-core:1. 这个代理示例默认只放行客户拉取,不开放推送。发布付费模块镜像时应使用内部发布入口或临时直连 Registry 内网入口,再把固定 digest 写入 `license-server` 的模块配置: ```env -PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 -PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=sha256:... +PREMIUM_MODULE_PAID_CORE_IMAGE=registry.example.com:5000/flnxi/premium/paid-core:1.0.0 +PREMIUM_MODULE_PAID_CORE_DIGEST=sha256:... ``` 正式客户环境不要把发布账号、发布密码或 Registry 管理界面口令写进主应用 `.env`。Registry 原始后端也不要直接暴露给客户访问;客户入口应只到这个前置代理。 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fa2d969..12cd735 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -135,9 +135,7 @@ services: - LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required} - LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:?LICENSE_PUBLIC_KEY is required} - LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:?LOCAL_ENCRYPTION_KEY is required} - - LICENSE_LIMITS_MODULE_URL=${LICENSE_LIMITS_MODULE_URL:-http://flnxi-module-license-limits-core:8787} - - CUSTOM_TEMPLATES_MODULE_URL=${CUSTOM_TEMPLATES_MODULE_URL:-http://flnxi-module-custom-templates-core:8787} - - INTRANET_PENETRATION_MODULE_URL=${INTRANET_PENETRATION_MODULE_URL:-http://flnxi-module-intranet-penetration-core:8787} + - PAID_CORE_MODULE_URL=${PAID_CORE_MODULE_URL:-http://flnxi-module-paid-core:8787} - MODULE_RUNTIME_STATUS_TOKEN=${MODULE_RUNTIME_STATUS_TOKEN:-} # 初始管理员账号(供 /api/system/init 使用) - INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi} diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index 6926648..d107f92 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -74,9 +74,7 @@ LICENSE_PROJECT_CODE=flnxi-sucai LICENSE_REQUEST_SIGNING_SECRET= LICENSE_PUBLIC_KEY= LOCAL_ENCRYPTION_KEY= -LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787 -CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787 -INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787 +PAID_CORE_MODULE_URL=http://flnxi-module-paid-core:8787 MODULE_RUNTIME_STATUS_TOKEN= MAX_UPLOAD_FILE_SIZE_BYTES=10737418240 MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240 @@ -94,9 +92,9 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152 - `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。 - `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。 - `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。 -- `LICENSE_LIMITS_MODULE_URL`、`CUSTOM_TEMPLATES_MODULE_URL`、`INTRANET_PENETRATION_MODULE_URL` 指向同一 Docker 网络里的模块容器。模块没运行或健康检查失败时,对应人数/素材限额、自定义模板、内网穿透能力都会被服务端拒绝。 +- `PAID_CORE_MODULE_URL` 指向同一 Docker 网络里的 `paid-core` 模块容器。模块没运行或健康检查失败时,自定义模板、部门隔离、系统设置、内网穿透、API 访问等付费能力都会被服务端拒绝。 - `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` 和 `PREMIUM_MODULE_PAID_CORE_IMAGE`/`_DIGEST`,用于给客户实例下发签名模块清单、授权凭证和短期拉包 token。FREE 不拉模块,人数/素材数上限由基础镜像读取本地已验签许可证执行;STANDARD/PRO 拉取统一 `paid-core`。 - 客户日常使用不应设计成每次都实时访问验证服务器;首装激活、心跳续租、授权变更同步、模块拉取和升级需要联网,已经授权的日常操作依赖本地验签缓存和本机模块运行状态。 - `MAX_UPLOAD_FILE_SIZE_BYTES` 是单个素材文件上限;`MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES` 是一次上传请求总上限;`MAX_UPLOAD_FIELD_SIZE_BYTES` 是表单文字字段总上限。示例值分别是 10GB、10GB、2MB,低配 NAS 可按客户机器内存和磁盘调小。 - 不要把真实密码、许可证地址、对象存储密钥写入 Git。 @@ -278,9 +276,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString .\deploy-registry.ps1 -Action DeployApp -DeployChannel release -Version 1.0.1 -RegistryHost registry.example.com:5000 -Server 192.168.1.20 -RegistryPassword $registryPassword -Password $sshPassword .\deploy-registry.ps1 -Action PublishRelease -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword .\deploy-registry.ps1 -Action PublishAgent -Version 1.0.1 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword -.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId license-limits-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword -.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId custom-templates-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword -.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId intranet-penetration-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword +.\deploy-registry.ps1 -Action PublishPremiumModule -PremiumModuleId paid-core -Version 1.0.0 -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword .\deploy-registry.ps1 -Action SyncRuntime -RegistryHost registry.example.com:5000 -RegistryPassword $registryPassword ``` @@ -288,7 +284,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString - 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。 - 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。 -- 授权模块发布使用 `-Action PublishPremiumModule`,默认发布到 `flnxi/premium/{moduleId}:{version}`,脚本会输出 `PREMIUM_MODULE_{MODULE}_IMAGE`、`_TAG` 和 `_DIGEST`,这些值要写入 `license-server`。第一版至少发布 `license-limits-core`、`custom-templates-core`、`intranet-penetration-core` 三个模块,避免验证系统下发了模块清单但镜像站缺包。 +- 授权模块发布使用 `-Action PublishPremiumModule -PremiumModuleId paid-core`,默认发布到 `flnxi/premium/paid-core:{version}`,脚本会输出 `PREMIUM_MODULE_PAID_CORE_IMAGE`、`_TAG` 和 `_DIGEST`,这些值要写入 `license-server`。当前不要再发布旧三模块;FREE 不拉模块,STANDARD/PRO 统一拉 `paid-core`。 - 客户 SSH 端口、登录用户和部署目录必须按现场显式确认;脚本默认端口是常见的 `22`,默认目录是 `/opt/flnxi`,内部测试机或自定义环境要显式传 `-SshPort`、`-Username`、`-RemotePath`。 - `DeployApp` 远程部署时默认会更新远端 `.env` 的 `APP_IMAGE`、访问地址和运行时镜像;只有临时验证时才使用 `-NoUpdateRemoteEnv`。 - `DeployApp` 默认会做远端维护窗口停写入、`.env` 备份、Compose PostgreSQL 备份、Compose MinIO 备份、恢复分级说明、失败自动回滚、运行镜像校验、版本文件校验和 Prisma 迁移状态校验。 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index b70bc0b..aa14e5c 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -290,7 +290,7 @@ 客户影响: -当前只有 `USER` 和 `ADMIN`,部门管理员本质上也是管理员。此前很多组织、模板和删除类写操作只判断 `role === ADMIN`,会导致普通管理员或部门管理员做全局管理动作。当前已完成阶段性收口:删除用户、调整用户部门、任免部门管理员、部门结构增删改、模板创建/编辑/删除、模板迁移、素材永久删除、过期清理、系统维护、全局字典/字段配置和转码恢复维护,都改为最高权限账号才能执行;统一用户更新接口也已禁止非最高权限账号修改角色、部门、启用状态和下载权限;部门管理员改成员姓名时不再批量改写该成员历史素材作者名,只有最高权限账号显式要求时才允许同步;素材编辑、导出、分享链接管理和单个素材转码触发已接入统一素材管理范围。这些敏感写操作会回查数据库里的 `isSuperAdmin` 或使用数据库回查结果,不只信旧会话。 +当前只有 `USER` 和 `ADMIN`,部门管理员本质上也是管理员。此前很多组织、模板和删除类写操作只判断 `role === ADMIN`,会导致普通管理员或部门管理员做全局管理动作。当前已完成阶段性收口:删除用户、调整用户部门、任免部门管理员、部门结构增删改、模板创建/编辑/删除、模板迁移、素材永久删除、过期清理、系统维护、全局字典/字段配置和转码恢复维护,都改为最高权限账号才能执行;统一用户更新接口也已禁止非最高权限账号修改角色、部门、启用状态和下载权限;部门管理员改成员姓名时不再批量改写该成员历史素材作者名,只有最高权限账号显式要求时才允许同步;素材编辑、删除、恢复、精品设置、统计、导出、分享链接管理和单个素材转码触发已接入统一素材管理范围。其中部门范围规划已交给 `paid-core` 的 `/departments/plan-scope`,最终数据库查询、写入、删除仍由主程序执行。这些敏感写操作会回查数据库里的 `isSuperAdmin` 或使用数据库回查结果,不只信旧会话。 邀请链接管理也已收口到最高权限账号。邀请链接会直接影响谁能注册进入私有化系统,不应由普通管理员或部门管理员创建、启停或删除;接口现在会回查数据库里的 `isSuperAdmin`,前端管理入口也只给最高权限账号显示。 证据: @@ -316,6 +316,7 @@ - `app/api/system/integrity/route.ts`:完整性检查 GET 仍允许管理员查看;POST 修复动作必须是最高权限账号。 - `app/api/tunnel/start/route.ts`、`app/api/tunnel/stop/route.ts`、`app/api/tunnel/status/route.ts`:内网穿透启动、停止和状态维护会回查最高权限账号,不只相信旧会话。 - `app/api/materials/restore/route.ts`:恢复软删除素材按删除同一范围控制,最高权限账号可恢复任意素材,普通管理员只能恢复自己或管理部门范围内素材。 +- `app/api/materials/route.ts`、`app/api/materials/restore/route.ts`、`app/api/materials/premium/route.ts`、`app/api/materials/stats/route.ts`:素材软删除、恢复、精品设置和素材统计入口已不再直接构建部门树,改为复用统一素材管理范围或 `paid-core` 的部门范围规划;主程序只保留最终数据库条件、写入和删除。 - `app/api/categories/route.ts`、`app/api/scenes/route.ts`:新增、修改和删除店铺/场景分类现在必须是最高权限账号。 - `app/api/platforms/route.ts`:新增、修改和删除平台分类现在必须是最高权限账号。 - `app/api/category-fields/route.ts`:分类字段 GET 现在只读;手动同步字段池、分类字段启用、显示名和排序写入现在必须是最高权限账号。 @@ -336,6 +337,7 @@ - `app/api/admin/upload-templates/__tests__/template-crud.test.ts`:补充普通管理员不能创建/更新/删除模板,最高权限账号原有成功路径继续可用。 - `app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:补充普通管理员和旧会话最高权限已撤销时不能迁移模板素材。 - `app/api/materials/__tests__/delete-storage-safety.test.ts`:补充普通管理员不能永久删除、旧会话最高权限已撤销时不能永久删除、普通管理员仍可软删除自己素材、普通管理员不能清理过期删除素材。 +- `app/api/materials/stats/__tests__/route.test.ts`、`lib/__tests__/license-route-coverage.test.ts`:补充素材统计范围使用 `paid-core` 部门规划,以及素材删除、恢复、精品设置和统计入口不得重新引入旧部门树判断的回归锁。 - `app/api/admin/__tests__/destructive-actions-superadmin.test.ts`:补充素材字段迁移、完整性修复等维护动作必须是最高权限账号。 - `app/api/admin/__tests__/global-dictionaries-superadmin.test.ts`:补充店铺、场景、平台、分类字段和模板分类选项的普通管理员拒绝与最高权限账号成功路径;新增店铺/场景分类也已覆盖。 - `app/api/tunnel/__tests__/route.test.ts`:补充内网穿透维护接口普通管理员拒绝、最高权限账号可执行。 @@ -360,6 +362,9 @@ - `npm test -- --runTestsByPath "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" "app/api/materials/share/__tests__/route.test.ts" "app/api/materials/share/tokens/__tests__/route.test.ts" "app/api/materials/share/tokens/[id]/__tests__/route.test.ts" --runInBand` - `npm test -- --runTestsByPath "lib/__tests__/public-share-settings.test.ts" "app/api/system-settings/__tests__/route.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/home-filter-fields/__tests__/route-auth.test.ts" --runInBand` - `npm test -- --runTestsByPath "app/api/system/init/__tests__/route.test.ts" --runInBand` +- `npm test -- --runTestsByPath "app/api/materials/__tests__/delete-storage-safety.test.ts" --runInBand` +- `npm test -- --runTestsByPath "app/api/materials/stats/__tests__/route.test.ts" --runInBand` +- `npm test -- --runTestsByPath "lib/__tests__/license-route-coverage.test.ts" --runInBand` - `npx prisma validate` - `npm run type-check` @@ -369,6 +374,7 @@ - 已完成:邀请链接管理已收紧到最高权限账号;普通管理员不能创建、启停、删除或查看邀请链接列表,前端入口也不再显示给普通管理员。 - 已完成验证:`department` 只表示用户所属部门,`managedDepartment` 才表示管理范围;取消部门管理员会同时清空 `managedDepartment` 并把角色降回 `USER`,把用户角色降为 `USER` 时也会清空旧管理部门,避免旧权限复活。验证命令:`npm test -- --runTestsByPath "app/api/admin/users/__tests__/organization-permissions.test.ts" --runInBand`、`npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/materials/__tests__/delete-storage-safety.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:素材编辑入口和后台素材导出已接入统一素材管理范围;当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可管理对应素材,历史上传者只保留追溯含义,不再自动代表当前管理权;普通管理员不能跨管理部门编辑或导出全量素材。验证命令:`npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/admin/materials/export/__tests__/route.test.ts" --runInBand`、`npm run type-check`。 +- 已完成验证:素材删除、恢复、精品设置和素材统计已接入 `paid-core` 部门范围规划或统一素材管理范围;普通管理员不能跨管理部门操作素材,统计接口也不再直接使用旧部门树判断。验证命令:`npm test -- --runTestsByPath "app/api/materials/__tests__/delete-storage-safety.test.ts" --runInBand`、`npm test -- --runTestsByPath "app/api/materials/stats/__tests__/route.test.ts" --runInBand`、`npm test -- --runTestsByPath "lib/__tests__/license-route-coverage.test.ts" --runInBand`、`npm run check`。 - 已完成验证:素材分享链接创建、列表查看和撤销已接入统一素材管理范围;普通管理员只能操作管理部门范围内素材的分享链接,不能跨部门创建、查看或撤销。验证命令:`npm test -- --runTestsByPath "app/api/materials/share/__tests__/route.test.ts" "app/api/materials/share/tokens/__tests__/route.test.ts" "app/api/materials/share/tokens/[id]/__tests__/route.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:店铺/场景分类新增已收紧到最高权限账号;普通用户、普通管理员和部门管理员不能新增全局店铺/场景字典项,上传流程仍可读取已有选项。验证命令:`npm test -- --runTestsByPath "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" --runInBand`、`npm run type-check`。 - 已完成验证:转码状态查询和单个素材转码触发已接入统一素材管理范围;历史上传者不再自动拥有转码管理权,普通管理员不能跨部门触发转码任务。验证命令:`npm test -- --runTestsByPath "app/api/materials/transcode/__tests__/route.test.ts" --runInBand`、`npm run type-check`。 @@ -455,10 +461,10 @@ - `license-server/docker-compose.yml`、`license-server/nginx/nginx.conf`:许可证服务默认 Compose 已改为由 Nginx 对外暴露 `9527`,应用容器只在内部网络暴露 `3000`;现有登录和 API 限流不再只是孤立配置。 - `license-server/lib/abuse-detection.ts`、`license-server/app/api/license/[projectCode]/register-free/route.ts`、`activate/verify/heartbeat`:免费版新增同设备重复注册、多实例/多指纹复用阻断;付费版多设备疑似复用先告警,不直接误伤客户。 - `license-server/app/api/license/[projectCode]/register-free/route.ts`:免费注册复用已有激活前会检查许可证禁用和过期状态;首条心跳记录统一写入硬件指纹哈希,避免后续被误判为多设备。 -- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:授权模块链路已调整为“镜像站放模块核心本体,验证服务器发签名清单和实例绑定授权凭证”。免费版只返回 `license-limits-core`,标准版/专业版按许可证类型额外返回付费能力模块;硬件指纹明显不匹配时拒绝并告警。 +- `license-server/app/api/license/[projectCode]/premium-modules/route.ts`、`license-server/app/api/license/[projectCode]/premium-modules/[moduleId]/route.ts`、`license-server/lib/premium-module-manifest.ts`:授权模块链路已调整为“免费基础镜像 + 统一 `paid-core` 付费增强包”。FREE 清单为空,不拉额外模块;STANDARD/PRO 返回 `paid-core`,验证服务器发签名清单、实例绑定授权凭证和短期拉包 token;硬件指纹明显不匹配时拒绝并告警。当前 `paid-core` 已迁入自定义模板字段保存前校验/格式化、模板保存请求整体标准化、模板删除前使用中拦截规划、素材提交的自定义字段内容校验、素材 metadata 准备和大小检查、已有素材模板的结构变更风险判断、公开模板展示投影、模板字段填写率统计、素材列表搜索/筛选查询计划、素材列表自定义字段搜索规划、首页筛选字段统计来源规划、部门隔离范围规划、部门结构创建/重命名/删除联动计划、API 访问运行门禁,以及模板迁移目标字段投影、兼容性判断、metadata 准备、旧字段备份计算、迁移批次规划、迁移预览和备份字段影响统计。当前边界是:`paid-core` 负责规则、计划、校验和展示投影,主应用负责登录、权限、Prisma 查询、SQL 执行、数据库事务和最终写入。 - `app/api/license/activate/route.ts`、`app/api/license/activate-free/route.ts`、`app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`、`lib/license/premium-module-service.ts`:主应用在许可证保存或续证成功后同步授权模块 manifest,再获取小型授权凭证,本地校验 RSA 签名、实例、租约、当前许可证类型、镜像地址、大小和 SHA256 后加密缓存,避免降级后继续使用旧 PRO 模块凭证。 -- `lib/license/check-limits.ts`:人数/素材数上限、自定义模板和内网穿透已升级为“许可证功能开关或限额字段 + 签名授权模块清单 + 本地有效授权凭证”门槛;没有验证服务器下发并校验通过的授权凭证时,即使本地许可证字段被篡改,也不会通过服务端检查。 -- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。 +- `lib/license/check-limits.ts`:人数/素材数上限、自定义模板、部门隔离、系统设置、内网穿透和 API 访问已升级为“许可证功能开关或限额字段 + 签名授权模块清单 + 本地有效授权凭证 + paid-core 动作授权”门槛;没有验证服务器下发并校验通过的授权凭证时,即使本地许可证字段被篡改,也不会通过服务端检查。人数/素材数上限仍留在基础镜像本地已验签许可证链路,因为 FREE 不拉取付费模块。 +- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透、API 访问等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖统一付费模块授权。 - `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。 - `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。 - `app/api/license/activate/__tests__/route.test.ts`、`app/api/license/activate-free/__tests__/route.test.ts`、`app/api/license/verify/__tests__/route.test.ts`、`app/api/license/heartbeat/__tests__/route.test.ts`、`app/api/license/check/__tests__/route.test.ts`、`lib/__tests__/license-service.test.ts`:新增测试锁定已有最高权限账号后未授权请求不能改写本地授权状态、服务端定时心跳不依赖 HTTP 登录态、许可证 check 保持只读。 @@ -670,7 +676,16 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 证据: -- `app/api/admin/upload-templates/[id]/migrate/route.ts`:先完成必填字段、metadata 大小和迁移数据预检查。 +- `app/api/admin/upload-templates/[id]/migrate/route.ts`:先完成迁移数据预检查;迁移批次大小、安全确认、预览模式和分批执行规划由 `paid-core` 的 `/templates/plan-migration-batch` 执行;从旧素材字段中选出目标模板字段、目标模板必填字段兼容性、迁移 metadata 准备、旧字段备份计算、音频字段回写素材列前的整理和大小检查由 `paid-core` 的 `/templates/prepare-migration-metadata` 执行;迁移预览、备份字段影响统计和样例生成由 `paid-core` 的 `/templates/build-migration-preview` 执行。 +- `app/api/templates/route.ts`、`app/api/upload-templates/route.ts`、`app/api/material-types/route.ts`:主应用只读取内置模板、数据库模板快照、素材计数和内置素材类型;自定义模板 active 过滤、排除 builtin 历史行、上传页可填写字段过滤、公开模板 DTO、素材类型 `template::` 标识和“素材模板”后缀处理由 `paid-core` 的 `/templates/project-public-options` 执行。 +- `app/api/admin/upload-templates/[id]/route.ts`:主应用只读取模板字段和素材 metadata 快照;字段填写率、低填写率字段和排序结果由 `paid-core` 的 `/templates/calculate-field-stats` 执行。 +- `app/api/admin/upload-templates/[id]/route.ts`:删除模板前主应用只读取模板名称和使用素材数量;是否允许删除、使用中模板的错误码和提示文案由 `paid-core` 的 `/templates/plan-delete` 执行。最终事务删除和字段池同步仍在主应用执行。 +- `app/api/materials/route.ts`:主应用只读取可搜索字段快照;哪些 key 属于自定义字段、需要进入 metadata 搜索,由 `paid-core` 的 `/materials/plan-custom-search` 执行。 +- `app/api/materials/route.ts`:素材列表搜索字段、自定义字段搜索、首页筛选字段和 metadata 筛选的查询计划已统一交给 `paid-core` 的 `/materials/plan-list-query`;主应用继续保留最终 Prisma 条件、metadata SQL 查询和分页查询执行。 +- `app/api/home-filter-fields/route.ts`:标准列筛选仍在基础镜像统计;首页筛选字段的标准列或 metadata 统计来源计划,由 `paid-core` 的 `/materials/plan-home-filter-fields` 执行。 +- `lib/material-permissions.ts`:主应用读取用户和部门快照;部门管理员可管理部门集合、目标部门是否在范围内,由 `paid-core` 的 `/departments/plan-scope` 执行。最终 Prisma 查询条件仍在主应用内生成和执行。 +- `app/api/admin/users/route.ts`、`app/api/admin/users/update/route.ts`:后台用户列表范围和部门管理员编辑成员前的部门范围判断已交给 `paid-core` 的 `/departments/plan-scope`;主应用只保留用户查询、更新事务和受保护字段拦截。 +- `lib/upload-template-save-validation.ts`:主应用中旧的自定义模板保存校验实现已清理为纯类型文件;字段数量限制、状态限制、字段格式化、选项和显示条件校验只保留在 `paid-core`,避免免费基础镜像继续携带一份可直接复用的付费校验实现。 - `app/api/admin/upload-templates/[id]/migrate/route.ts`:执行阶段通过 `prisma.$transaction()` 统一提交。 - `app/api/admin/upload-templates/[id]/migrate/route.ts`:迁移查询默认只读取 `deletedAt: null` 的未删除素材,不处理回收站素材。 - `app/api/admin/upload-templates/[id]/migrate/route.ts`:事务内使用 `id + sourceTemplateId + deletedAt: null` 更新素材,素材已不属于源模板或已进入回收站时抛错并整体回滚。 @@ -714,7 +729,7 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 - `app/api/materials/upload/route.ts`:metadata 超限会返回 `METADATA_TOO_LARGE`,不会继续创建素材记录。 - `app/api/materials/upload/route.ts`:保存数据库时直接写入已验证的 metadata,不再对 metadata 做 `substring`。 - `app/api/materials/__tests__/template-fields-routes.test.ts`:新增测试,锁定自定义字段过大时拒绝上传且不会创建素材记录。 -- `app/api/admin/upload-templates/[id]/migrate/route.ts`:模板迁移生成新 metadata 后调用 `checkMetadataSize()`。 +- `app/api/admin/upload-templates/[id]/migrate/route.ts`、`premium-modules/paid-core/template-validation.mjs`:模板迁移生成新 metadata、压缩、旧字段备份计算和大小检查已交给 `paid-core`。 - `app/api/admin/upload-templates/[id]/migrate/route.ts`:迁移 metadata 超限返回 `METADATA_TOO_LARGE`,不会进入事务。 - `app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:新增英文大字段和中文多字节内容超限测试。 - `app/api/materials/update/route.ts`:编辑入口重写 metadata 前调用 `checkMetadataSize()`,超限返回 `METADATA_TOO_LARGE`。 @@ -724,7 +739,7 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 建议动作: - 已完成:上传保存前用 `checkMetadataSize()` 拦截超限 metadata。 -- 已完成:模板迁移保存前用 `checkMetadataSize()` 拦截超限 metadata。 +- 已完成:模板迁移保存前由 `paid-core` 拦截超限 metadata。 - 已完成:表单编辑入口和旧 JSON 更新入口保存前用 `checkMetadataSize()` 拦截超限 metadata。 - 仍需继续:后续若新增素材 metadata 写入入口,必须复用同一套大小检查。 @@ -737,14 +752,14 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久 证据: - `lib/field-conditions.ts`:提供 `getVisibleTemplateFields()`、`filterVisibleFieldValues()`、`hasFieldValue()`,前后端共用同一套可见字段判断。 -- `lib/template-field-validation.ts`:后端只对可见字段执行必填校验,并返回过滤后的 `templateFields`。 +- `lib/template-field-validation.ts`、`premium-modules/paid-core/template-validation.mjs`、`app/api/materials/upload/route.ts`、`app/api/materials/update/route.ts`、`app/api/materials/[id]/route.ts`、`app/api/admin/upload-templates/[id]/migrate/route.ts`:内置模板仍由基础镜像本地校验;自定义模板的素材字段提交校验交给 `paid-core`,只对可见字段执行必填和规则校验,并返回过滤后的 `templateFields`;上传/编辑写入 metadata 前由 `paid-core` 整理、压缩并检查大小;模板迁移时也由 `paid-core` 从旧素材字段中选出目标模板字段、判断目标模板必填字段是否可自动补齐、准备目标 metadata、计算旧字段备份并检查大小。 - `app/admin/_components/template-manager/editor/FieldConditionsConfig.tsx:31`:存在条件配置界面。 - `components/UploadModal.tsx`:上传弹窗按可见字段渲染、校验和构建 `templateFields`;材质模板保持文件和预览图可选。 - `components/EditMaterialModal.tsx`:编辑弹窗按可见字段渲染、校验和提交;表单使用系统中文校验,不再依赖浏览器原生必填提示。 - `app/api/materials/upload/route.ts`:上传入口使用后端模板校验返回的过滤字段保存 metadata。 - `app/api/materials/update/route.ts`:表单编辑入口用现有素材值和本次提交值合并判断条件规则。 - `app/api/materials/[id]/route.ts`:旧 JSON 更新入口也接入同一套模板条件校验,避免绕过主编辑接口。 -- `lib/__tests__/template-field-validation.test.ts`:覆盖条件不满足时跳过必填、条件满足时拦截、checkbox false 不误判。 +- `lib/__tests__/template-field-validation.test.ts`、`lib/__tests__/premium-module-service.test.ts`、`lib/__tests__/license-route-coverage.test.ts`、`app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:覆盖条件不满足时跳过必填、条件满足时拦截、checkbox false 不误判,以及自定义模板字段提交校验、metadata 准备和模板迁移 metadata 准备委托 `paid-core`。 - `app/api/materials/__tests__/template-fields-routes.test.ts`:覆盖上传、表单编辑、旧 JSON 更新入口的条件字段行为。 - `components/__tests__/UploadModal.test.tsx`、`components/__tests__/EditMaterialModal.test.tsx`:覆盖条件字段隐藏不提交、依赖字段变化后显示并参与必填。 @@ -958,10 +973,10 @@ AI 改图以后会消耗外部额度、CPU、存储和客户素材数据。即 证据: - `lib/field-conditions.ts`:`hasFieldValue()` 对 checkbox 使用 `value !== undefined && value !== null`,未勾选的 `false` 也算已填写。 -- `lib/template-field-validation.ts`:后端模板必填校验复用 `hasFieldValue()`。 +- `lib/template-field-validation.ts`:内置模板后端必填校验复用 `hasFieldValue()`;自定义模板字段提交校验已委托 `paid-core`。 - `components/UploadModal.tsx`:上传弹窗必填校验复用 `hasFieldValue()`,checkbox 初始值为 `false`。 - `components/EditMaterialModal.tsx`:编辑弹窗必填校验复用 `hasFieldValue()`。 -- `lib/__tests__/template-field-validation.test.ts`:覆盖 checkbox `false` 后端视为已填写。 +- `lib/__tests__/template-field-validation.test.ts`:覆盖 checkbox `false` 后端视为已填写,并覆盖自定义模板委托 `paid-core`。 - `components/__tests__/UploadModal.test.tsx`、`components/__tests__/EditMaterialModal.test.tsx`:覆盖必填 checkbox 未勾选时仍允许提交 `false`。 建议动作: @@ -1347,7 +1362,7 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器 2. 修 `isSearchable`、首页筛选计数、动态筛选查询。 3. 模板迁移小批量事务化已完成;大批量任务化迁移仍待做。 4. 旧字段默认备份保留和迁移前字段差异预览已完成;前端确认流程仍待接入。 -5. 上传、编辑和模板迁移 metadata 超限已直接报错。 +5. 上传、编辑和模板迁移 metadata 超限已直接报错;其中自定义模板相关 metadata 准备和模板迁移备份计算已委托 `paid-core`。 6. 条件显示规则前后端都执行。(已完成第一轮) 第四轮,做权限和体验收口: diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index e00a930..50375b6 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -44,7 +44,7 @@ PREMIUM_MODULE_REGISTRY_BASE=registry.example.com/flnxi/premium PREMIUM_MODULE_VERSION=1.0.0 ``` -如某个模块要单独指定镜像,可使用 `PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE`、`PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE`、`PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE` 这类变量覆盖;固定 digest 可使用对应的 `_DIGEST` 变量。原则是:模块核心本体在镜像站,验证服务器只发授权清单、实例绑定凭证和短期拉包 token。 +如需要单独指定付费模块镜像,可使用 `PREMIUM_MODULE_PAID_CORE_IMAGE` 覆盖;固定 digest 可使用 `PREMIUM_MODULE_PAID_CORE_DIGEST`。原则是:模块核心本体在镜像站,验证服务器只发授权清单、实例绑定凭证和短期拉包 token。 ## 2. 联调入口规则 @@ -120,7 +120,7 @@ docker compose exec -T license-server npm run init-project -- flnxi-sucai - `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。 - `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。 - `activate`、`activate-free`、`verify`、`heartbeat` 返回的签名许可证必须包含 `projectCode`、`licenseKey`、`instanceId` 和 `fingerprintHash`;主应用保存和读取本地许可证时会核对这些绑定字段,缺少绑定字段的旧许可证需要重新激活。 -- `activate`、`activate-free`、`verify`、`heartbeat` 在许可证保存或续证成功后会尝试同步 `premium-modules` 授权模块清单、对应授权凭证和短期镜像拉取凭证;免费版也会同步 `license-limits-core`,用于保护人数/素材数上限。 +- `activate`、`activate-free`、`verify`、`heartbeat` 在许可证保存或续证成功后会尝试同步 `premium-modules` 授权模块清单、对应授权凭证和短期镜像拉取凭证。FREE 清单为空,不拉额外模块;STANDARD/PRO 清单包含统一 `paid-core`。FREE 人数/素材数上限由基础镜像读取本地已验签许可证执行。 - `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。 - `info` 用于读取当前许可证类型、用量和功能开关。 @@ -128,7 +128,7 @@ docker compose exec -T license-server npm run init-project -- flnxi-sucai - 必须联网:首次激活、免费版注册、续期心跳、验证服务器授权变更同步、获取短期镜像拉包 token、拉取授权模块镜像、升级版本。 - 不要求每次实时联网:用户登录后的日常上传、创建用户、素材浏览、模板使用等操作,优先使用本地已验签许可证缓存、已缓存模块授权凭证和 Agent 回写的本机模块运行状态。 -- 断网期间如果本地许可证仍在有效租约内,且需要的模块容器仍为 `running` 并通过 `/health`、`/authorize` 或 `/limits/enforce`,对应受控能力可以继续使用;一旦许可证过期、模块被吊销、需要重新拉包或本地模块不可用,就必须重新连验证服务器恢复。 +- 断网期间如果本地许可证仍在有效租约内,且需要的模块容器仍为 `running` 并通过 `/health` 和 `/authorize`,对应受控能力可以继续使用;一旦许可证过期、模块被吊销、需要重新拉包或本地模块不可用,就必须重新连验证服务器恢复。 主应用内部已经不会再直接访问无项目的 `license-server` 路径,而是会根据 `LICENSE_PROJECT_CODE` 转发到: @@ -141,7 +141,7 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... 补充: - 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。 -- 远端 `POST /api/license/{projectCode}/premium-modules` 是授权模块清单接口,要求同一套 HMAC 请求签名;清单来源是 `LicenseModuleGrant` 授权台账。默认规则是:免费版只有 `license-limits-core`,标准版增加 `custom-templates-core`,专业版再增加 `intranet-penetration-core`;禁用、过期、吊销或硬件指纹明显不匹配会被拒绝。 +- 远端 `POST /api/license/{projectCode}/premium-modules` 是授权模块清单接口,要求同一套 HMAC 请求签名;清单来源是 `LicenseModuleGrant` 授权台账。默认规则是:FREE 清单为空,STANDARD/PRO 返回统一 `paid-core`;禁用、过期、吊销或硬件指纹明显不匹配会被拒绝。 - 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个授权模块凭证接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块授权台账。 - 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token` 是短期镜像拉取凭证接口,要求 `premium-module-pull-token` HMAC 请求签名;返回 `registryHost`、`repository`、`tag`、`digest`、用户名 `flnxi-module`、15 分钟有效的 `pullToken` 和 `expiresAt`。 - 远端 `GET /api/registry/auth` 是给 Registry 前置代理使用的鉴权接口,用来校验客户拉取请求中的短期 token、token 审计台账、仓库路径、清单 digest、许可证状态、激活实例和模块授权状态。它不是普通前端接口。 @@ -161,15 +161,13 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/... 主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。模块运行状态变化后,Agent 可通过 `POST /api/license/premium-modules/runtime-status` 回写主应用;主应用会把回写状态持久化,并把运行态纳入付费能力判断。 -第一批模块源码已经放在: +当前模块源码入口是: ```text -premium-modules/license-limits-core -premium-modules/custom-templates-core -premium-modules/intranet-penetration-core +premium-modules/paid-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` 内部。 +`paid-core` 承接 STANDARD/PRO 的统一付费能力运行门禁;主应用会同时检查模块授权凭证、Agent 回写的 `running` 状态、模块 `/health` 和 `/authorize`,任一环节不可用都会拒绝对应受控能力。自定义模板字段保存前校验和格式化已迁入 `paid-core` 的 `/templates/validate-fields`,模板保存请求整体标准化已迁入 `/templates/normalize-save`,模板删除前使用中拦截规划已迁入 `/templates/plan-delete`,素材提交的自定义字段内容校验已迁入 `/materials/validate-template-fields`,素材 metadata 准备和大小检查已迁入 `/materials/prepare-metadata`,已有素材模板的结构变更风险判断已迁入 `/templates/detect-structure-changes`,模板迁移批次大小、安全确认、预览模式和分批执行规划已迁入 `/templates/plan-migration-batch`,模板迁移目标字段投影、兼容性判断、metadata 准备和旧字段备份计算已迁入 `/templates/prepare-migration-metadata`,迁移预览和备份字段影响统计已迁入 `/templates/build-migration-preview`,公开模板列表、上传页模板列表和素材类型 tag 的展示投影已迁入 `/templates/project-public-options`,后台模板详情字段填写率统计已迁入 `/templates/calculate-field-stats`,素材列表搜索/筛选查询计划已迁入 `/materials/plan-list-query`,素材列表自定义字段搜索规划已迁入 `/materials/plan-custom-search`,首页筛选字段统计来源规划已迁入 `/materials/plan-home-filter-fields`,部门隔离范围规划已迁入 `/departments/plan-scope`,部门结构创建/重命名/删除联动计划已迁入 `/departments/plan-structure-change`,API 访问预留能力也已纳入 `/authorize` 的 `apiAccess:use` 门禁。当前标准版和专业版默认仍未把 `apiAccess` 作为客户功能出售;如果未来开启,也必须同时通过本地许可证、模块凭证、模块运行状态和 `paid-core` 授权。这些接口都是本地模块入口,不是每次保存都联网请求验证服务器;`/templates/normalize-save` 负责模板名称、状态、素材类型、素材大类、字段组、展示配置和字段列表标准化,`/templates/plan-delete` 负责删除模板前按使用素材数量生成放行或拦截计划,`/materials/validate-template-fields` 负责上传/编辑素材时的自定义字段可见性、必填和规则校验,`/materials/prepare-metadata` 负责自定义字段写入 metadata 前的整理、压缩和大小检查,`/templates/plan-migration-batch` 负责迁移批次大小、安全确认、预览模式和分批执行规划,`/templates/prepare-migration-metadata` 负责迁移时从旧素材字段中选出目标模板字段、判断目标模板必填字段是否可自动补齐、生成目标 metadata、计算丢弃字段备份并拦截超限,`/templates/build-migration-preview` 负责把丢弃字段清单转换成迁移预览、备份统计和受影响样例,`/templates/project-public-options` 负责把主应用查到的数据库快照转换成公开展示 DTO,`/templates/calculate-field-stats` 负责从主应用传入的素材 metadata 快照中计算字段填写率、低填写率字段和排序结果,`/materials/plan-list-query` 负责从主应用传入的搜索字段、筛选字段和启用字段快照中生成素材列表查询计划,`/materials/plan-custom-search` 负责从主应用传入的可搜索字段快照中挑出应查询 metadata 的自定义字段 key,`/materials/plan-home-filter-fields` 负责从主应用传入的首页筛选字段快照中生成标准列或 metadata 统计来源计划,`/departments/plan-scope` 负责从主应用传入的部门快照中计算管理范围,`/departments/plan-structure-change` 负责根据部门和用户快照生成创建、重命名、删除的联动计划。FREE 人数/素材数上限不走额外模块,内置模板基础校验也仍在基础镜像内执行。注意:`paid-core` 承接的是付费能力的规则、计划、校验和展示投影;主应用继续保留登录、权限、Prisma 查询、SQL 执行、数据库事务和最终写入,这是当前商业化安全边界。 ## 6. 后台管理入口 diff --git a/docs/许可证链路安全评估_20260312.md b/docs/许可证链路安全评估_20260312.md index 5400766..736bfa2 100644 --- a/docs/许可证链路安全评估_20260312.md +++ b/docs/许可证链路安全评估_20260312.md @@ -78,6 +78,8 @@ 2026-06-01 补充:`intranetPenetration` 已在内网穿透启动、状态、停止入口做服务端许可证拦截,并且要求付费模块凭证;`apiAccess` 当前没有作为素材系统客户功能交付,标准版和专业版默认配置已改为 `false`,对应展示文案调整为预留能力。此前“apiAccess 已定义但未见生产执法点”的判断,对当前代码应理解为“暂不交付、不可售卖”,而不是“已售卖但缺拦截”。 +2026-06-03 补充:`apiAccess` 仍是预留能力,但服务端门禁已统一到 `paid-core`。如果未来许可证把 `apiAccess` 打开,也必须同时通过本地许可证、授权模块凭证、模块运行状态和 `paid-core` 的 `apiAccess:use` 动作授权。 + 2026-06-01 补充:自定义模板不只在管理维护入口做许可证拦截,普通读取和使用入口也已按许可证收口。`/api/templates`、`/api/upload-templates`、`/api/material-types` 在当前许可证不包含 `customTemplates` 时只返回内置模板,不继续暴露历史自定义模板,避免客户降级后仍使用付费模板能力。 2026-06-02 补充:付费模块链路已进一步拆成“模块授权台账 + 短期镜像拉取凭证”。`license-server` 新增 `LicenseModuleGrant` 和 `ModulePullTokenAudit`,`premium-modules` 清单来源改为授权台账;客户侧获取模块核心镜像前必须先拿 15 分钟有效的 `pullToken`,Registry 前置代理再通过 `/api/registry/auth` 校验 token、token 审计记录、仓库路径、清单 digest、许可证状态、激活实例和模块授权状态。主应用只缓存授权凭证和短期拉包凭证,不直接挂 Docker Socket;Go Agent 已提供最小模块同步入口,负责拉取模块镜像、校验 digest、启动模块容器并在健康检查失败时尝试恢复旧模块容器。 @@ -411,7 +413,7 @@ - `middleware.ts` 已明确不再承担许可证快速检查,当前主要依赖各个 API 自己做许可证判断 - 已确认的部门隔离漏口已补齐:`POST / PATCH / DELETE` 现在都要求同级许可证 - 已确认的自定义模板漏口已补齐:模板编辑、删除、迁移现在都要求同级许可证 -- `apiAccess` 和 `intranetPenetration` 已定义为许可证能力,但当前仓库里未见明确的生产执法点 +- `apiAccess` 当前仍是预留能力,标准版和专业版默认不出售;若未来开启,已要求走 `paid-core` 的 `apiAccess:use` 门禁 - `/api/license/check` 已改为先做本地验签,再恢复激活状态 直接影响: diff --git a/flnxi-agent-go/README.md b/flnxi-agent-go/README.md index 6f4e06f..673e39b 100644 --- a/flnxi-agent-go/README.md +++ b/flnxi-agent-go/README.md @@ -92,12 +92,12 @@ GET /api/modules/status ```json { - "moduleId": "intranet-penetration-core", - "image": "registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0", + "moduleId": "paid-core", + "image": "registry.example.com/flnxi/premium/paid-core:1.0.0", "digest": "sha256:...", "pullCredential": { "registryHost": "registry.example.com", - "repository": "flnxi/premium/intranet-penetration-core", + "repository": "flnxi/premium/paid-core", "tag": "1.0.0", "digest": "sha256:...", "username": "flnxi-module", @@ -113,8 +113,8 @@ Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少 ```json { - "moduleId": "intranet-penetration-core", - "image": "registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0", + "moduleId": "paid-core", + "image": "registry.example.com/flnxi/premium/paid-core:1.0.0", "digest": "sha256:...", "status": "running", "lastSyncAt": "2026-06-02T00:20:00Z", diff --git a/flnxi-agent-go/internal/api/handlers.go b/flnxi-agent-go/internal/api/handlers.go index 8e59058..2f9de60 100644 --- a/flnxi-agent-go/internal/api/handlers.go +++ b/flnxi-agent-go/internal/api/handlers.go @@ -191,9 +191,7 @@ func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig In "NEXTAUTH_SECRET=" + secrets.NextAuthSecret, "NEXTAUTH_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), "APP_PUBLIC_URL=http://" + localIP + ":" + fmt.Sprintf("%d", webPort), - "LICENSE_LIMITS_MODULE_URL=http://flnxi-module-license-limits-core:8787", - "CUSTOM_TEMPLATES_MODULE_URL=http://flnxi-module-custom-templates-core:8787", - "INTRANET_PENETRATION_MODULE_URL=http://flnxi-module-intranet-penetration-core:8787", + "PAID_CORE_MODULE_URL=http://flnxi-module-paid-core:8787", "MODULE_RUNTIME_STATUS_TOKEN=" + moduleStatusToken, } } diff --git a/flnxi-agent-go/internal/api/handlers_test.go b/flnxi-agent-go/internal/api/handlers_test.go index b506b9c..42c059a 100644 --- a/flnxi-agent-go/internal/api/handlers_test.go +++ b/flnxi-agent-go/internal/api/handlers_test.go @@ -139,9 +139,7 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) { "S3_PUBLIC_DOMAIN", "NEXTAUTH_URL", "APP_PUBLIC_URL", - "LICENSE_LIMITS_MODULE_URL", - "CUSTOM_TEMPLATES_MODULE_URL", - "INTRANET_PENETRATION_MODULE_URL", + "PAID_CORE_MODULE_URL", "MODULE_RUNTIME_STATUS_TOKEN", } { if env[want] == "" { @@ -238,12 +236,12 @@ func TestRunUpdateRestoresOldContainerAfterHealthCheckFailure(t *testing.T) { func TestValidateModuleSyncRequestRejectsExpiredPullToken(t *testing.T) { req := ModuleSyncRequest{ - ModuleID: "intranet-penetration-core", - Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:1.0.0", + ModuleID: "paid-core", + Image: "registry.local:5000/flnxi/premium/paid-core:1.0.0", Digest: testModuleDigest, PullCredential: ModulePullCredential{ RegistryHost: "registry.local:5000", - Repository: "flnxi/premium/intranet-penetration-core", + Repository: "flnxi/premium/paid-core", Tag: "1.0.0", Digest: testModuleDigest, Username: "flnxi-module", @@ -302,7 +300,7 @@ func TestRunModuleSyncStartsContainerAfterDigestMatch(t *testing.T) { req := validModuleSyncRequest() fake := &fakeModuleDocker{ - repoDigests: []string{"registry.local:5000/flnxi/premium/intranet-penetration-core@" + testModuleDigest}, + repoDigests: []string{"registry.local:5000/flnxi/premium/paid-core@" + testModuleDigest}, } runModuleSync(fake, req) @@ -343,7 +341,7 @@ func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) { ImageID: "sha256:old-module", State: "running", Config: &container.Config{ - Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:0.9.0", + Image: "registry.local:5000/flnxi/premium/paid-core:0.9.0", Env: []string{"OLD=1"}, }, HostConfig: &container.HostConfig{ @@ -353,7 +351,7 @@ func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) { } fake := &fakeModuleDocker{ container: old, - repoDigests: []string{"registry.local:5000/flnxi/premium/intranet-penetration-core@" + testModuleDigest}, + repoDigests: []string{"registry.local:5000/flnxi/premium/paid-core@" + testModuleDigest}, healthErr: errors.New("not healthy"), } @@ -488,12 +486,12 @@ const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa func validModuleSyncRequest() ModuleSyncRequest { return ModuleSyncRequest{ - ModuleID: "intranet-penetration-core", - Image: "registry.local:5000/flnxi/premium/intranet-penetration-core:1.0.0", + ModuleID: "paid-core", + Image: "registry.local:5000/flnxi/premium/paid-core:1.0.0", Digest: testModuleDigest, PullCredential: ModulePullCredential{ RegistryHost: "registry.local:5000", - Repository: "flnxi/premium/intranet-penetration-core", + Repository: "flnxi/premium/paid-core", Tag: "1.0.0", Digest: testModuleDigest, Username: "flnxi-module", diff --git a/lib/__tests__/check-limits.test.ts b/lib/__tests__/check-limits.test.ts index d9d16f5..94cd0bd 100644 --- a/lib/__tests__/check-limits.test.ts +++ b/lib/__tests__/check-limits.test.ts @@ -1,102 +1,70 @@ /** - * 许可证限制检查工具测试 - * 测试 lib/license/check-limits.ts 中的限制检查函数 + * 许可证限制检查工具测试。 + * + * 当前商业化口径: + * - 免费版人数/素材数限制由基础镜像读取本地签名许可证执行。 + * - 付费能力统一要求 paid-core 授权模块运行并放行。 */ import { canCreateUser, canUploadMaterial, + canUseApiAccess, canUseCustomTemplates, canUseDepartmentIsolation, canUseIntranetPenetration, canUseSystemSettings, - canUseApiAccess, } from '../license/check-limits'; -// Mock license-service 模块 jest.mock('../license/license-service', () => ({ - isFeatureEnabled: jest.fn(), + checkLimit: jest.fn(), getLicenseType: jest.fn(), + isFeatureEnabled: jest.fn(), })); jest.mock('../license/premium-module-service', () => ({ authorizePremiumModuleAction: jest.fn(), - enforceAuthorizedUsageLimit: jest.fn(), - getAuthorizedUsageLimits: jest.fn(), PREMIUM_MODULE_IDS: { - LICENSE_LIMITS_CORE: 'license-limits-core', - CUSTOM_TEMPLATES_CORE: 'custom-templates-core', - INTRANET_PENETRATION_CORE: 'intranet-penetration-core', + PAID_CORE: 'paid-core', }, })); -jest.mock('@/lib/prisma', () => ({ - prisma: { - user: { count: jest.fn() }, - material: { count: jest.fn() }, - }, -})); +import { checkLimit, getLicenseType, isFeatureEnabled } from '../license/license-service'; +import { authorizePremiumModuleAction } from '../license/premium-module-service'; -import { prisma } from '@/lib/prisma'; -import { isFeatureEnabled, getLicenseType } from '../license/license-service'; -import { - authorizePremiumModuleAction, - enforceAuthorizedUsageLimit, - getAuthorizedUsageLimits, -} from '../license/premium-module-service'; - -const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction; +const mockCheckLimit = checkLimit as jest.MockedFunction; const mockGetLicenseType = getLicenseType as jest.MockedFunction; -const mockGetAuthorizedUsageLimits = getAuthorizedUsageLimits as jest.MockedFunction; +const mockIsFeatureEnabled = isFeatureEnabled 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', () => { +describe('基础镜像限额检查', () => { beforeEach(() => { jest.clearAllMocks(); - 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({ + it('未达到用户限制时允许创建用户', async () => { + mockCheckLimit.mockResolvedValue({ allowed: true, - type: 'users', current: 5, max: 10, }); - const result = await canCreateUser(); - - expect(result.allowed).toBe(true); - expect(result.current).toBe(5); - expect(result.max).toBe(10); - expect(result.message).toBeUndefined(); - expect(mockGetAuthorizedUsageLimits).toHaveBeenCalled(); - expect(mockUserCount).toHaveBeenCalled(); - expect(mockEnforceAuthorizedUsageLimit).toHaveBeenCalledWith('users', 5); + await expect(canCreateUser()).resolves.toEqual({ + allowed: true, + current: 5, + max: 10, + }); + expect(mockCheckLimit).toHaveBeenCalledWith('users'); }); - it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => { - mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 10, maxMaterials: 100 }); - mockUserCount.mockResolvedValue(10); - mockGetLicenseType.mockResolvedValue('FREE'); - mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + it('FREE 达到用户限制时返回授权处理提示', async () => { + mockCheckLimit.mockResolvedValue({ allowed: false, - type: 'users', current: 10, max: 10, }); + mockGetLicenseType.mockResolvedValue('FREE'); const result = await canCreateUser(); @@ -106,80 +74,28 @@ describe('canCreateUser', () => { expect(result.message).toContain('联系管理员'); }); - it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { - mockGetAuthorizedUsageLimits.mockResolvedValue({ maxUsers: 100, maxMaterials: 1000 }); - mockUserCount.mockResolvedValue(100); - mockGetLicenseType.mockResolvedValue('STANDARD'); - mockEnforceAuthorizedUsageLimit.mockResolvedValue({ + it('未达到素材限制时允许上传素材', async () => { + mockCheckLimit.mockResolvedValue({ + allowed: true, + current: 50, + max: 100, + }); + + await expect(canUploadMaterial()).resolves.toEqual({ + allowed: true, + current: 50, + max: 100, + }); + expect(mockCheckLimit).toHaveBeenCalledWith('materials'); + }); + + it('FREE 达到素材限制时返回授权处理提示', async () => { + mockCheckLimit.mockResolvedValue({ allowed: false, - type: 'users', current: 100, max: 100, }); - - const result = await canCreateUser(); - - expect(result.allowed).toBe(false); - expect(result.message).toContain('联系管理员'); - expect(result.message).toContain('100'); - }); - - it('缺少限额授权模块时应该拒绝创建用户', async () => { - mockGetAuthorizedUsageLimits.mockResolvedValue(null); - - const result = await canCreateUser(); - - expect(result).toEqual({ - allowed: false, - current: 0, - max: 0, - message: '授权限额模块不可用,请重新验证许可证或联系管理员。', - }); - expect(mockUserCount).not.toHaveBeenCalled(); - }); -}); - -describe('canUploadMaterial', () => { - beforeEach(() => { - jest.clearAllMocks(); - 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(); - - expect(result.allowed).toBe(true); - expect(result.current).toBe(50); - 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(); @@ -189,168 +105,112 @@ describe('canUploadMaterial', () => { expect(result.message).toContain('联系管理员'); }); - it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => { - 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(); - - expect(result.allowed).toBe(false); - expect(result.message).toContain('联系管理员'); - }); - - it('缺少限额授权模块时应该拒绝上传素材', async () => { - mockGetAuthorizedUsageLimits.mockResolvedValue(null); - - const result = await canUploadMaterial(); - - expect(result).toEqual({ + it('本地许可证限制已有错误信息时直接返回', async () => { + mockCheckLimit.mockResolvedValue({ allowed: false, current: 0, max: 0, - message: '授权限额模块不可用,请重新验证许可证或联系管理员。', + message: '许可证不可用', + } as any); + + await expect(canCreateUser()).resolves.toEqual({ + allowed: false, + current: 0, + max: 0, + message: '许可证不可用', }); - expect(mockMaterialCount).not.toHaveBeenCalled(); + expect(mockGetLicenseType).not.toHaveBeenCalled(); }); }); -describe('功能开关检查', () => { +describe('paid-core 付费功能门禁', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('canUseCustomTemplates', () => { - it('当功能启用时应该返回 true', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: true, - moduleId: 'custom-templates-core', - action: 'customTemplates:use', - status: 'running', - }); - expect(await canUseCustomTemplates()).toBe(true); - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); - expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith( - 'custom-templates-core', - 'customTemplates:use' - ); + it('自定义模板必须同时满足许可证功能开关和 paid-core 授权', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'paid-core', + action: 'customTemplates:use', + status: 'running', }); - it('当功能禁用时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(false); - expect(await canUseCustomTemplates()).toBe(false); - expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled(); - }); - - it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: false, - moduleId: 'custom-templates-core', - action: 'customTemplates:use', - status: 'missing', - }); - expect(await canUseCustomTemplates()).toBe(false); - }); + await expect(canUseCustomTemplates()).resolves.toBe(true); + expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates'); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'customTemplates:use'); }); - describe('canUseDepartmentIsolation', () => { - it('当功能启用时应该返回 true', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - expect(await canUseDepartmentIsolation()).toBe(true); - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('departmentIsolation'); - }); + it('功能开关关闭时不访问 paid-core', async () => { + mockIsFeatureEnabled.mockResolvedValue(false); - it('当功能禁用时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(false); - expect(await canUseDepartmentIsolation()).toBe(false); - }); + await expect(canUseCustomTemplates()).resolves.toBe(false); + expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled(); }); - describe('canUseIntranetPenetration', () => { - it('当功能启用时应该返回 true', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: true, - moduleId: 'intranet-penetration-core', - action: 'intranetPenetration:use', - status: 'running', - }); - expect(await canUseIntranetPenetration()).toBe(true); - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration'); - expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith( - 'intranet-penetration-core', - 'intranetPenetration:use' - ); + it('部门隔离必须走 paid-core 授权', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'paid-core', + action: 'departmentIsolation:use', + status: 'running', }); - it('当功能禁用时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(false); - expect(await canUseIntranetPenetration()).toBe(false); - expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled(); - }); - - it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - mockAuthorizePremiumModuleAction.mockResolvedValue({ - available: false, - moduleId: 'intranet-penetration-core', - action: 'intranetPenetration:use', - status: 'unhealthy', - }); - expect(await canUseIntranetPenetration()).toBe(false); - }); + await expect(canUseDepartmentIsolation()).resolves.toBe(true); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'departmentIsolation:use'); }); - describe('canUseSystemSettings', () => { - it('当功能启用时应该返回 true', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - expect(await canUseSystemSettings()).toBe(true); - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('systemSettings'); + it('内网穿透必须走 paid-core 授权', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'paid-core', + action: 'intranetPenetration:use', + status: 'running', }); - it('当功能禁用时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(false); - expect(await canUseSystemSettings()).toBe(false); - }); + await expect(canUseIntranetPenetration()).resolves.toBe(true); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'intranetPenetration:use'); }); - describe('canUseApiAccess', () => { - it('当功能启用时应该返回 true', async () => { - mockIsFeatureEnabled.mockResolvedValue(true); - expect(await canUseApiAccess()).toBe(true); - expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess'); + it('系统设置必须走 paid-core 授权', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'paid-core', + action: 'systemSettings:use', + status: 'running', }); - it('当功能禁用时应该返回 false', async () => { - mockIsFeatureEnabled.mockResolvedValue(false); - expect(await canUseApiAccess()).toBe(false); + await expect(canUseSystemSettings()).resolves.toBe(true); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'systemSettings:use'); + }); + + it('paid-core 拒绝时付费能力不可用', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: false, + moduleId: 'paid-core', + action: 'customTemplates:use', + status: 'missing', }); - }); -}); - -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); + + await expect(canUseCustomTemplates()).resolves.toBe(false); + }); + + it('API 访问必须走 paid-core 授权', async () => { + mockIsFeatureEnabled.mockResolvedValue(true); + mockAuthorizePremiumModuleAction.mockResolvedValue({ + available: true, + moduleId: 'paid-core', + action: 'apiAccess:use', + status: 'running', + }); + + await expect(canUseApiAccess()).resolves.toBe(true); + expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess'); + expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'apiAccess:use'); }); }); diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index e861b8c..d334960 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -113,8 +113,10 @@ describe('许可证业务入口覆盖', () => { const source = readProjectFile('lib/license/check-limits.ts'); expect(source).toContain('authorizePremiumModuleAction'); - expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE'); - expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE'); + expect(source).toContain('PREMIUM_MODULE_IDS.PAID_CORE'); + expect(source).toContain('customTemplates:use'); + expect(source).toContain('intranetPenetration:use'); + expect(source).toContain('apiAccess:use'); }); it('内网穿透入口通过许可证后必须代理到模块服务', () => { @@ -128,12 +130,208 @@ describe('许可证业务入口覆盖', () => { } }); - it('用户数和素材数限制必须来自签名授权模块', () => { + it('用户数和素材数限制必须来自基础镜像本地签名许可证', () => { const source = readProjectFile('lib/license/check-limits.ts'); - expect(source).toContain('getAuthorizedUsageLimits'); - expect(source).toContain('enforceAuthorizedUsageLimit'); - expect(source).toContain('授权限额模块不可用'); + expect(source).toContain('checkLimit(type)'); + expect(source).toContain("checkAuthorizedLimit('users')"); + expect(source).toContain("checkAuthorizedLimit('materials')"); + }); + + it('自定义模板保存请求标准化必须交给 paid-core', () => { + for (const file of ['app/api/admin/upload-templates/route.ts']) { + const source = readProjectFile(file); + expect(source).toContain('normalizeUploadTemplateSaveWithPaidCore'); + expect(source).not.toContain('validateAndFormatUploadTemplateFields'); + expect(source).not.toContain('validateUploadTemplateFieldsWithPaidCore'); + expect(source).not.toContain('isValidUploadTemplateStatus'); + } + + const typeSource = readProjectFile('lib/upload-template-save-validation.ts'); + expect(typeSource).not.toContain('validateAndFormatUploadTemplateFields'); + expect(typeSource).not.toContain('UPLOAD_TEMPLATE_FIELD_LIMITS'); + expect(typeSource).not.toContain('ALLOWED_TEMPLATE_STATUSES'); + }); + + it('自定义模板创建和更新必须使用正确的 paid-core 入口', () => { + const createSource = readProjectFile('app/api/admin/upload-templates/route.ts'); + const updateSource = readProjectFile('app/api/admin/upload-templates/[id]/route.ts'); + + expect(createSource).toContain("normalizeUploadTemplateSaveWithPaidCore(body || {}, 'create')"); + expect(updateSource).toContain('planUploadTemplateUpdateWithPaidCore'); + expect(updateSource).not.toContain("normalizeUploadTemplateSaveWithPaidCore(body || {}, 'update')"); + expect(updateSource).not.toContain('new Set(existing.fields.map'); + expect(updateSource).not.toContain("errorCode: 'FIELD_NOT_IN_TEMPLATE'"); + expect(updateSource).not.toContain("errorCode: 'VERSION_CONFLICT'"); + }); + + it('自定义模板删除规划必须交给 paid-core', () => { + const source = readProjectFile('app/api/admin/upload-templates/[id]/route.ts'); + + expect(source).toContain('planUploadTemplateDeleteWithPaidCore'); + expect(source).not.toContain('因为有 ${materialCount} 个素材正在使用该模板'); + expect(source).not.toContain("errorCode: 'TEMPLATE_IN_USE'"); + }); + + it('自定义模板素材字段提交校验必须交给 paid-core', () => { + const source = readProjectFile('lib/template-field-validation.ts'); + + expect(source).toContain('validateSubmittedTemplateFieldsWithPaidCore'); + expect(source).toContain('isBuiltinTemplate'); + expect(source).toContain('validateSubmittedTemplateFieldsLocally'); + }); + + it('自定义模板素材 metadata 准备必须交给 paid-core', () => { + for (const file of [ + 'app/api/materials/upload/route.ts', + 'app/api/materials/update/route.ts', + 'app/api/materials/[id]/route.ts', + ]) { + const source = readProjectFile(file); + expect(source).toContain('prepareMaterialMetadataWithPaidCore'); + expect(source).toContain('isBuiltinTemplate'); + } + }); + + it('自定义模板结构变更风险判断必须交给 paid-core 更新计划', () => { + const source = readProjectFile('app/api/admin/upload-templates/[id]/route.ts'); + const paidCoreSource = readProjectFile('premium-modules/paid-core/template-validation.mjs'); + + expect(source).toContain('planUploadTemplateUpdateWithPaidCore'); + expect(source).not.toContain('detectTemplateStructureChangesWithPaidCore'); + expect(source).not.toContain('TEMPLATE_STRUCTURE_LOCKED'); + expect(paidCoreSource).toContain('detectDangerousTemplateChanges'); + expect(paidCoreSource).toContain('planUploadTemplateUpdateRequest'); + expect(source).not.toContain('function detectDangerousTemplateChanges'); + expect(source).not.toContain('function stableStringify'); + }); + + it('自定义模板迁移 metadata 准备必须交给 paid-core', () => { + const source = readProjectFile('app/api/admin/upload-templates/[id]/migrate/route.ts'); + + expect(source).toContain('planTemplateMigrationBatchWithPaidCore'); + expect(source).toContain('buildTemplateMigrationPreviewWithPaidCore'); + expect(source).toContain('prepareTemplateMigrationMetadataWithPaidCore'); + expect(source).not.toContain('DEFAULT_MIGRATION_SAFE_LIMIT'); + expect(source).not.toContain('MAX_MIGRATION_FORCE_LIMIT'); + expect(source).not.toContain('parsePositiveInteger'); + expect(source).not.toContain('const STANDARD_FIELD_LABELS'); + expect(source).not.toContain('function buildFieldSummary'); + expect(source).not.toContain('function addPreviewFieldImpact'); + expect(source).not.toContain('function buildMigrationMetadata'); + expect(source).not.toContain('function getDroppedStandardFields'); + expect(source).not.toContain('checkMetadataSize(metadata)'); + expect(source).not.toContain('prepareMaterialData('); + expect(source).not.toContain('hasFieldValue'); + expect(source).not.toContain('nextTemplateFieldValues'); + expect(source).not.toContain('function clearNonTargetStandardFields'); + expect(source).not.toContain('CLEARABLE_NON_TARGET_FIELD_DEFAULTS'); + expect(source).not.toContain('templateMigrationBackups ='); + expect(source).not.toContain('normalizeOptionalString'); + expect(source).not.toContain("if ('audioDuration' in migrationMetadata.templateFields)"); + }); + + it('自定义模板公开列表投影必须交给 paid-core', () => { + for (const file of [ + 'app/api/templates/route.ts', + 'app/api/upload-templates/route.ts', + 'app/api/material-types/route.ts', + ]) { + const source = readProjectFile(file); + + expect(source).toContain('projectPublicTemplateOptionsWithPaidCore'); + expect(source).not.toContain("status: 'active'"); + expect(source).not.toContain("startsWith('builtin-'"); + expect(source).not.toContain("startsWith: 'builtin-'"); + expect(source).not.toContain('replace(/\\s*素材模板'); + expect(source).not.toContain('`template:${template.id}:'); + expect(source).not.toContain('isReadOnly: false'); + expect(source).not.toContain("key: { not: 'materialType' }"); + } + }); + + it('自定义模板字段填写率统计必须交给 paid-core', () => { + const source = readProjectFile('app/api/admin/upload-templates/[id]/route.ts'); + + expect(source).toContain('calculateTemplateFieldStatsWithPaidCore'); + expect(source).toContain('MAX_SYNC_FIELD_STATS_MATERIALS'); + expect(source).not.toContain('function calculateFieldStats'); + expect(source).not.toContain('JSON.parse(material.metadata)'); + expect(source).not.toContain('isLowFillRate: fillRate < 30'); + expect(source).not.toContain('stats.sort((a, b) => a.fillRate - b.fillRate)'); + }); + + it('素材列表自定义字段搜索规划必须交给 paid-core', () => { + const source = readProjectFile('app/api/materials/route.ts'); + + expect(source).toContain('planMaterialListQueryWithPaidCore'); + expect(source).not.toContain('planMaterialCustomSearchWithPaidCore'); + expect(source).not.toContain('ALLOWED_DYNAMIC_FILTER_FIELDS'); + expect(source).not.toContain('isStandardField'); + expect(source).not.toContain("filter((key) => !isStandardField(key))"); + }); + + it('首页自定义筛选字段统计规划必须交给 paid-core', () => { + const source = readProjectFile('app/api/home-filter-fields/route.ts'); + + expect(source).toContain('planHomeFilterFieldCountsWithPaidCore'); + expect(source).not.toContain('planHomeFilterCustomFieldCountsWithPaidCore'); + expect(source).toContain("source === 'metadata'"); + expect(source).not.toContain('customFieldKeys'); + }); + + it('后端部门隔离管理范围规划必须交给 paid-core', () => { + const delegatedFiles: Array<{ file: string; helpers: string[] }> = [ + { + file: 'lib/material-permissions.ts', + helpers: ['planDepartmentScopeWithPaidCore'], + }, + { + file: 'app/api/admin/users/route.ts', + helpers: ['planDepartmentScopeWithPaidCore'], + }, + { + file: 'app/api/admin/users/update/route.ts', + helpers: ['planDepartmentScopeWithPaidCore'], + }, + { + file: 'app/api/materials/route.ts', + helpers: ['buildMaterialManagementScopeWhere', 'canManageMaterial'], + }, + { + file: 'app/api/materials/restore/route.ts', + helpers: ['canManageMaterial'], + }, + { + file: 'app/api/materials/premium/route.ts', + helpers: ['canManageMaterial'], + }, + { + file: 'app/api/materials/stats/route.ts', + helpers: ['planDepartmentScopeWithPaidCore', 'buildMaterialManagementScopeWhere'], + }, + ]; + + for (const { file, helpers } of delegatedFiles) { + const source = readProjectFile(file); + + for (const helper of helpers) { + expect(source).toContain(helper); + } + expect(source).not.toContain('buildDepartmentDescendants'); + expect(source).not.toContain('isDepartmentWithinScope'); + } + }); + + it('部门结构变更规划必须交给 paid-core', () => { + const source = readProjectFile('app/api/departments/route.ts'); + + expect(source).toContain('planDepartmentStructureChangeWithPaidCore'); + expect(source).not.toContain('oldPrefix'); + expect(source).not.toContain('newPrefix'); + expect(source).not.toContain('renameValue'); + expect(source).not.toContain('conflicting'); + expect(source).not.toContain('startsWith: prefix'); }); it('本地许可证变更必须写入审计日志', () => { diff --git a/lib/__tests__/premium-module-service.test.ts b/lib/__tests__/premium-module-service.test.ts index 8ca8eaf..8e8a825 100644 --- a/lib/__tests__/premium-module-service.test.ts +++ b/lib/__tests__/premium-module-service.test.ts @@ -54,37 +54,31 @@ const mockVerifySignature = verifySignature as jest.MockedFunction; const mockIsLicenseConfigExpired = isLicenseConfigExpired as jest.MockedFunction; -const moduleArtifact = { +const paidCoreArtifact = { type: 'registry-image' as const, - image: 'registry.test/flnxi/premium/intranet-penetration-core:1.0.0', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', digest: null, version: '1.0.0', }; -const limitsModuleArtifact = { - type: 'registry-image' as const, - image: 'registry.test/flnxi/premium/license-limits-core:1.0.0', - digest: null, - version: '1.0.0', -}; - -function mockPullCredential(moduleId = 'intranet-penetration-core') { +function mockPullCredential(overrides: Partial> = {}) { return { registryHost: 'registry.test', - repository: `flnxi/premium/${moduleId}`, + repository: 'flnxi/premium/paid-core', tag: '1.0.0', digest: null, username: 'flnxi-module', - pullToken: `pull-token:${moduleId}`, + pullToken: 'pull-token:paid-core', expiresAt: new Date(Date.now() + 60_000).toISOString(), + ...overrides, }; } -function mockManifestModule(overrides: Record = {}) { +function mockPaidCoreManifestModule(overrides: Record = {}) { return { - id: 'intranet-penetration-core', - minLicenseType: 'PRO', - artifact: moduleArtifact, + id: 'paid-core', + minLicenseType: 'STANDARD', + artifact: paidCoreArtifact, sizeBytes: 18, sha256: 'hash', entitlementVersion: 1, @@ -92,43 +86,11 @@ function mockManifestModule(overrides: Record = {}) { }; } -function mockLimitsManifestModule(overrides: Record = {}) { - return { - id: 'license-limits-core', - minLicenseType: 'FREE', - artifact: limitsModuleArtifact, - sizeBytes: 18, - sha256: 'hash', - entitlementVersion: 1, - limits: { maxUsers: 10, maxMaterials: 100 }, - ...overrides, - }; +function encryptedConfig(value: unknown) { + return `encrypted:${JSON.stringify(value)}`; } -function mockFreeLicense() { - mockGetLocalLicense.mockResolvedValue({ - instanceId: 'instance-1', - licenseKey: 'FLNXI-FREE-TEST', - licenseData: 'encrypted-license', - lastVerified: new Date().toISOString(), - cachedConfig: { - type: 'FREE', - limits: { maxUsers: 10, maxMaterials: 100 }, - features: { - customTemplates: false, - systemSettings: false, - departmentIsolation: false, - apiAccess: false, - intranetPenetration: false, - }, - expiresAt: null, - leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(), - }, - } as any); - mockIsLicenseConfigExpired.mockReturnValue(false); -} - -function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { +function mockLicense(type: 'FREE' | 'STANDARD' | 'PRO') { mockGetLocalLicense.mockResolvedValue({ instanceId: 'instance-1', licenseKey: `FLNXI-${type}-TEST`, @@ -136,12 +98,12 @@ function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { lastVerified: new Date().toISOString(), cachedConfig: { type, - limits: { maxUsers: -1, maxMaterials: -1 }, + limits: type === 'FREE' ? { maxUsers: 10, maxMaterials: 100 } : { maxUsers: -1, maxMaterials: -1 }, features: { - customTemplates: true, - systemSettings: true, - departmentIsolation: true, - apiAccess: true, + customTemplates: type !== 'FREE', + systemSettings: type !== 'FREE', + departmentIsolation: type !== 'FREE', + apiAccess: type !== 'FREE', intranetPenetration: type === 'PRO', }, expiresAt: null, @@ -151,18 +113,111 @@ function mockPaidLicense(type: 'STANDARD' | 'PRO' = 'PRO') { mockIsLicenseConfigExpired.mockReturnValue(false); } +function mockCachedPaidCore(params: { + withEntitlement?: boolean; + runtimeStatus?: 'missing' | 'pulling' | 'running' | 'unhealthy' | 'expired' | 'revoked'; + pullCredential?: ReturnType | null; +}) { + (prisma.systemConfig.findUnique as jest.Mock).mockImplementation(({ where }) => { + if (where.key === 'premium_module_manifest') { + return Promise.resolve({ + value: encryptedConfig({ + manifest: { + version: 1, + licenseType: 'PRO', + instanceId: 'instance-1', + modules: [mockPaidCoreManifestModule()], + 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' && params.withEntitlement) { + return Promise.resolve({ + value: encryptedConfig({ + entitlements: { + 'paid-core': { + entitlementBase64: 'module-entitlement', + sha256: 'hash', + sizeBytes: 18, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + artifact: paidCoreArtifact, + pullCredential: params.pullCredential === undefined ? mockPullCredential() : params.pullCredential, + syncedAt: new Date().toISOString(), + }, + }, + }), + }); + } + + if (where.key === 'premium_module_runtime_status' && params.runtimeStatus) { + return Promise.resolve({ + value: encryptedConfig({ + 'paid-core': { + moduleId: 'paid-core', + image: paidCoreArtifact.image, + digest: null, + status: params.runtimeStatus, + lastSyncAt: new Date().toISOString(), + }, + }), + }); + } + + return Promise.resolve(null); + }); +} + describe('premium-module-service', () => { beforeEach(() => { jest.clearAllMocks(); mockFetch.mockReset(); mockVerifySignature.mockReturnValue(true); - delete process.env.LICENSE_LIMITS_MODULE_URL; - delete process.env.CUSTOM_TEMPLATES_MODULE_URL; - delete process.env.INTRANET_PENETRATION_MODULE_URL; + (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue(null); + delete process.env.PAID_CORE_MODULE_URL; }); - it('免费版也应同步限额授权模块', async () => { - mockFreeLicense(); + afterEach(() => { + delete process.env.PAID_CORE_MODULE_URL; + }); + + it('免费版只同步空模块清单,不拉取 paid-core', async () => { + mockLicense('FREE'); + const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString(); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + manifest: { + version: 1, + licenseType: 'FREE', + instanceId: 'instance-1', + modules: [], + issuedAt: new Date().toISOString(), + expiresAt: manifestExpiresAt, + }, + signature: 'valid-signature', + }), + }); + + const { syncPremiumModules } = await import('../license/premium-module-service'); + const result = await syncPremiumModules(); + + expect(result).toEqual({ synced: true }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + 'https://license.test/api/license/flnxi/premium-modules', + expect.objectContaining({ method: 'POST' }) + ); + expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3); + }); + + it('付费版应拉取 paid-core 授权凭证和短期拉包凭证', async () => { + mockLicense('PRO'); const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString(); mockFetch .mockResolvedValueOnce({ @@ -171,9 +226,9 @@ describe('premium-module-service', () => { success: true, manifest: { version: 1, - licenseType: 'FREE', + licenseType: 'PRO', instanceId: 'instance-1', - modules: [mockLimitsManifestModule()], + modules: [mockPaidCoreManifestModule()], issuedAt: new Date().toISOString(), expiresAt: manifestExpiresAt, }, @@ -185,12 +240,12 @@ describe('premium-module-service', () => { json: async () => ({ success: true, entitlement: { - moduleId: 'license-limits-core', + moduleId: 'paid-core', entitlementBase64: 'module-entitlement', sha256: 'hash', sizeBytes: 18, expiresAt: new Date(Date.now() + 60_000).toISOString(), - artifact: limitsModuleArtifact, + artifact: paidCoreArtifact, }, }), }) @@ -198,7 +253,7 @@ describe('premium-module-service', () => { ok: true, json: async () => ({ success: true, - pullCredential: mockPullCredential('license-limits-core'), + pullCredential: mockPullCredential(), }), }); @@ -206,637 +261,1584 @@ describe('premium-module-service', () => { const result = await syncPremiumModules(); expect(result).toEqual({ synced: true }); - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - 'https://license.test/api/license/flnxi/premium-modules', - expect.objectContaining({ method: 'POST' }) - ); expect(mockFetch).toHaveBeenNthCalledWith( 2, - 'https://license.test/api/license/flnxi/premium-modules/license-limits-core', + 'https://license.test/api/license/flnxi/premium-modules/paid-core', expect.objectContaining({ method: 'POST' }) ); expect(mockFetch).toHaveBeenNthCalledWith( 3, - 'https://license.test/api/license/flnxi/premium-modules/license-limits-core/pull-token', + 'https://license.test/api/license/flnxi/premium-modules/paid-core/pull-token', expect.objectContaining({ method: 'POST' }) ); expect(JSON.parse(mockFetch.mock.calls[1][1].body).manifestExpiresAt).toBe(manifestExpiresAt); expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3); }); - it('付费版应拉取、验签并加密缓存授权模块清单', async () => { - mockPaidLicense('PRO'); - const manifestExpiresAt = new Date(Date.now() + 60_000).toISOString(); + it('没有有效授权凭证时不应视为已拥有 paid-core', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: false }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('paid-core')).resolves.toBe(false); + }); + + it('清单和本地授权凭证都有效时应视为已拥有 paid-core', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true }); + + const { hasPremiumModule } = await import('../license/premium-module-service'); + + await expect(hasPremiumModule('paid-core')).resolves.toBe(true); + }); + + it('缓存中的短期拉包凭证有效时才返回给 Agent', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, pullCredential: mockPullCredential() }); + + const { getCachedModulePullCredential } = await import('../license/premium-module-service'); + + await expect(getCachedModulePullCredential('paid-core')).resolves.toEqual( + expect.objectContaining({ + username: 'flnxi-module', + repository: 'flnxi/premium/paid-core', + }) + ); + }); + + it('过期的短期拉包凭证不应返回给 Agent', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ + withEntitlement: true, + pullCredential: mockPullCredential({ expiresAt: new Date(Date.now() - 1_000).toISOString() }), + }); + + const { getCachedModulePullCredential } = await import('../license/premium-module-service'); + + await expect(getCachedModulePullCredential('paid-core')).resolves.toBeNull(); + }); + + it('paid-core 未处于运行态时不允许使用受控能力', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' }); + + const { authorizePremiumModuleAction } = await import('../license/premium-module-service'); + + await expect(authorizePremiumModuleAction('paid-core', 'customTemplates:use')).resolves.toEqual( + expect.objectContaining({ + available: false, + moduleId: 'paid-core', + action: 'customTemplates:use', + status: 'missing', + }) + ); + }); + + it('paid-core 运行且授权接口放行时允许使用受控能力', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; mockFetch .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - success: true, - manifest: { - version: 1, - licenseType: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule()], - issuedAt: new Date().toISOString(), - expiresAt: manifestExpiresAt, - }, - signature: 'valid-signature', - }), + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), }) .mockResolvedValueOnce({ ok: true, - json: async () => ({ - success: true, - entitlement: { - moduleId: 'intranet-penetration-core', - entitlementBase64: 'module-entitlement', - sha256: 'hash', - sizeBytes: 18, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - artifact: moduleArtifact, - }, - }), + status: 200, + json: async () => ({ moduleId: 'paid-core', allowed: true }), + }); + + const { authorizePremiumModuleAction } = await import('../license/premium-module-service'); + + await expect(authorizePremiumModuleAction('paid-core', 'customTemplates:use')).resolves.toEqual( + expect.objectContaining({ + available: true, + moduleId: 'paid-core', + action: 'customTemplates:use', + status: 'running', + }) + ); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/authorize', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"action":"customTemplates:use"'), + }) + ); + }); + + it('paid-core 运行时应由模块执行自定义模板字段校验和格式化', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), }) .mockResolvedValueOnce({ ok: true, + status: 200, json: async () => ({ - success: true, - pullCredential: mockPullCredential('intranet-penetration-core'), + moduleId: 'paid-core', + valid: true, + fields: [ + { + id: null, + key: 'brandName', + label: '品牌', + type: 'text', + required: false, + orderIndex: 0, + layoutWidth: 'full', + validations: null, + optionsSource: null, + optionsStatic: null, + allowedExtensions: null, + visibleOnHomeFilter: false, + isSearchable: false, + isReadOnly: false, + addToCategory: false, + conditions: null, + }, + ], }), }); - const { syncPremiumModules } = await import('../license/premium-module-service'); - const result = await syncPremiumModules(); + const { validateUploadTemplateFieldsWithPaidCore } = await import('../license/premium-module-service'); + const result = await validateUploadTemplateFieldsWithPaidCore([ + { key: 'brandName', label: '品牌', type: 'text' }, + ]); - expect(result).toEqual({ synced: true }); - expect(mockFetch).toHaveBeenNthCalledWith( - 1, - 'https://license.test/api/license/flnxi/premium-modules', - expect.objectContaining({ method: 'POST' }) + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.fields[0].key).toBe('brandName'); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/validate-fields', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"brandName"'), + }) ); - expect(mockFetch).toHaveBeenNthCalledWith( - 2, - 'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core', - expect.objectContaining({ method: 'POST' }) - ); - expect(mockFetch).toHaveBeenNthCalledWith( - 3, - 'https://license.test/api/license/flnxi/premium-modules/intranet-penetration-core/pull-token', - expect.objectContaining({ method: 'POST' }) - ); - expect(JSON.parse(mockFetch.mock.calls[1][1].body).manifestExpiresAt).toBe(manifestExpiresAt); - expect(prisma.systemConfig.upsert).toHaveBeenCalledTimes(3); }); - it('授权模块清单同步网络异常时应返回失败结果而不是抛出', async () => { - mockPaidLicense('PRO'); - mockFetch.mockRejectedValue(new Error('network down')); + it('paid-core 运行时应由模块校验素材提交的自定义模板字段内容', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + templateName: '自定义模板', + templateFields: { + customCode: 'CODE-001', + }, + visibleFieldKeys: ['customCode'], + }), + }); - const { syncPremiumModules } = await import('../license/premium-module-service'); - const result = await syncPremiumModules(); - - expect(result).toEqual({ synced: false, error: 'network down' }); - expect(prisma.systemConfig.upsert).not.toHaveBeenCalled(); - }); - - it('缓存清单签名无效时不应视为可用模块', async () => { - mockPaidLicense('PRO'); - mockVerifySignature.mockReturnValue(false); - (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ - value: `encrypted:${JSON.stringify({ - manifest: { - version: 1, - licenseType: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule({ sizeBytes: 4096 })], - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), + const { validateSubmittedTemplateFieldsWithPaidCore } = await import('../license/premium-module-service'); + const result = await validateSubmittedTemplateFieldsWithPaidCore({ + templateId: 'template-1', + templateName: '自定义模板', + fields: [ + { + key: 'customCode', + label: '自定义编码', + type: 'text', + required: true, + validations: JSON.stringify({ pattern: '^CODE-' }), }, - signature: 'bad-signature', - syncedAt: new Date().toISOString(), - })}`, + ], + templateFields: { + customCode: 'CODE-001', + }, + allowUnknownKeys: false, }); - const { hasPremiumModule } = await import('../license/premium-module-service'); - - await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + expect(result).toEqual({ + valid: true, + templateName: '自定义模板', + templateFields: { + customCode: 'CODE-001', + }, + visibleFieldKeys: ['customCode'], + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/validate-template-fields', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"templateId":"template-1"'), + }) + ); }); - it('只有清单但缺少本地授权凭证时不应视为可用模块', async () => { - mockPaidLicense('PRO'); - (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: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule()], - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }, - signature: 'valid-signature', - syncedAt: new Date().toISOString(), - })}`, - }); - } + it('paid-core 拒绝素材自定义字段内容时应透传错误码和详情', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + moduleId: 'paid-core', + valid: false, + error: '模板必填字段未填写:自定义编码', + errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING', + details: { + missingRequiredFields: [{ key: 'customCode', label: '自定义编码', type: 'text' }], + }, + }), + }); - return Promise.resolve(null); + const { validateSubmittedTemplateFieldsWithPaidCore } = await import('../license/premium-module-service'); + const result = await validateSubmittedTemplateFieldsWithPaidCore({ + templateId: 'template-1', + templateName: '自定义模板', + fields: [{ key: 'customCode', label: '自定义编码', type: 'text', required: true }], + templateFields: {}, + allowUnknownKeys: false, }); - const { hasPremiumModule } = await import('../license/premium-module-service'); - - await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + expect(result).toEqual({ + valid: false, + error: '模板必填字段未填写:自定义编码', + errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING', + details: { + missingRequiredFields: [{ key: 'customCode', label: '自定义编码', type: 'text' }], + }, + }); }); - it('清单和本地授权凭证都有效时应视为可用模块', async () => { - mockPaidLicense('PRO'); - (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: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule()], - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }, - signature: 'valid-signature', - syncedAt: new Date().toISOString(), - })}`, - }); - } + it('paid-core 运行时应由模块准备自定义模板素材 metadata', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + standardData: { productModel: 'MX-001' }, + metadata: JSON.stringify({ + templateId: 'template-1', + templateFields: { customCode: 'CODE-001' }, + }), + metadataSize: { ok: true, size: 72, maxSize: 65535 }, + }), + }); - if (where.key === 'premium_module_entitlements') { - return Promise.resolve({ - value: `encrypted:${JSON.stringify({ - entitlements: { - 'intranet-penetration-core': { - entitlementBase64: 'module-entitlement', - sha256: 'hash', - sizeBytes: 18, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - artifact: moduleArtifact, - syncedAt: new Date().toISOString(), + const { prepareMaterialMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareMaterialMetadataWithPaidCore({ + templateId: 'template-1', + templateFields: { + productModel: 'MX-001', + customCode: 'CODE-001', + }, + previewUrls: ['s3://bucket/preview.webp'], + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.standardData.productModel).toBe('MX-001'); + expect(result.metadata).toContain('customCode'); + expect(result.metadataSize.ok).toBe(true); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/prepare-metadata', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"templateId":"template-1"'), + }) + ); + }); + + it('paid-core 拒绝超限 metadata 时应透传错误码和详情', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + moduleId: 'paid-core', + valid: false, + error: '自定义字段数据过大', + errorCode: 'METADATA_TOO_LARGE', + details: { size: 70000, maxSize: 65535 }, + }), + }); + + const { prepareMaterialMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareMaterialMetadataWithPaidCore({ + templateId: 'template-1', + templateFields: { customCode: 'x'.repeat(70_000) }, + }); + + expect(result).toEqual({ + valid: false, + error: '自定义字段数据过大', + errorCode: 'METADATA_TOO_LARGE', + details: { size: 70000, maxSize: 65535 }, + }); + }); + + it('paid-core 运行时应由模块准备模板迁移 metadata', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', allowed: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + standardData: { productModel: 'MX-001' }, + metadata: JSON.stringify({ + templateId: 'template-target', + templateFields: { customCode: 'CODE-001' }, + templateMigrationBackups: [ + { + sourceTemplateId: 'template-source', + targetTemplateId: 'template-target', + droppedTemplateFields: { oldOnlyField: '旧值' }, }, - }, - })}`, - }); - } + ], + }), + metadataSize: { ok: true, size: 180, maxSize: 65535 }, + droppedTemplateFieldCount: 1, + droppedStandardFieldCount: 1, + droppedTemplateFields: { oldOnlyField: '旧值' }, + droppedStandardFields: { categoryName: '旧分类' }, + }), + }); - return Promise.resolve(null); + const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareTemplateMigrationMetadataWithPaidCore({ + sourceTemplateId: 'template-source', + sourceTemplateName: '旧模板', + targetTemplateId: 'template-target', + targetTemplateName: '新模板', + migratedAt: '2026-06-03T00:00:00.000Z', + targetFieldKeys: ['productModel', 'customCode'], + templateFields: { + productModel: 'MX-001', + customCode: 'CODE-001', + }, + materialStandardFields: { + productModel: 'MX-001', + categoryName: '旧分类', + }, }); - const { hasPremiumModule } = await import('../license/premium-module-service'); - - await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(true); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.standardData.productModel).toBe('MX-001'); + expect(result.droppedTemplateFieldCount).toBe(1); + expect(result.droppedStandardFields.categoryName).toBe('旧分类'); + expect(result.metadata).toContain('templateMigrationBackups'); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/prepare-migration-metadata', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"targetTemplateId":"template-target"'), + }) + ); }); - it('缓存清单许可证类型和当前许可证不一致时不应视为可用模块', async () => { - mockPaidLicense('STANDARD'); - (prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({ - value: `encrypted:${JSON.stringify({ - manifest: { - version: 1, - licenseType: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule({ sizeBytes: 4096 })], - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), + it('paid-core 运行时应由模块生成模板迁移预览和备份统计', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + migrationBackup: { + materialsWithDroppedFields: 1, + droppedTemplateFieldCount: 1, + droppedStandardFieldCount: 1, + }, + migrationPreview: { + totalMaterialCount: 5, + totalMaterials: 1, + previewLimited: true, + previewLimit: 1, + targetCurrentFields: [ + { key: 'productModel', label: '产品型号', type: 'text', required: false }, + ], + backupFields: { + templateFields: [ + { + key: 'oldOnlyField', + label: '旧字段', + type: 'text', + source: 'template', + affectedCount: 1, + }, + ], + standardFields: [ + { + key: 'categoryName', + label: '分类', + source: 'standard', + affectedCount: 1, + }, + ], + }, + samples: [ + { + materialId: 'mat-1', + productModel: 'MX-001', + droppedTemplateFields: [ + { key: 'oldOnlyField', label: '旧字段', type: 'text', source: 'template' }, + ], + droppedStandardFields: [ + { key: 'categoryName', label: '分类', source: 'standard' }, + ], + }, + ], + }, + }), + }); + + const { buildTemplateMigrationPreviewWithPaidCore } = await import('../license/premium-module-service'); + const result = await buildTemplateMigrationPreviewWithPaidCore({ + totalMaterialCount: 5, + migratableCount: 1, + previewOnly: true, + previewLimit: 1, + targetFields: [{ key: 'productModel', label: '产品型号', type: 'text', required: false }], + sourceTemplateFields: [{ key: 'oldOnlyField', label: '旧字段', type: 'text', required: false }], + impacts: [ + { + materialId: 'mat-1', + productModel: 'MX-001', + droppedTemplateFieldKeys: ['oldOnlyField'], + droppedStandardFieldKeys: ['categoryName'], }, - signature: 'valid-signature', - syncedAt: new Date().toISOString(), - })}`, + ], }); - const { hasPremiumModule } = await import('../license/premium-module-service'); - - await expect(hasPremiumModule('intranet-penetration-core')).resolves.toBe(false); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.migrationBackup.materialsWithDroppedFields).toBe(1); + expect(result.migrationPreview.previewLimited).toBe(true); + expect(result.migrationPreview.backupFields.templateFields[0].key).toBe('oldOnlyField'); + expect(result.migrationPreview.samples[0].materialId).toBe('mat-1'); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/build-migration-preview', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"droppedTemplateFieldKeys":["oldOnlyField"]'), + }) + ); }); - 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(), - })}`, - }); - } + it('paid-core 运行时应由模块规划模板迁移批处理', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + allowed: false, + statusCode: 409, + error: '该模板当前有 300 个素材,超过默认安全迁移数量 200。请先预览迁移影响,确认备份后再执行。', + errorCode: 'MIGRATION_CONFIRM_REQUIRED', + details: { + totalMaterialCount: 300, + safeLimit: 200, + migrationLimit: 300, + maxBatchLimit: 1000, + }, + }), + }); - 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(), + const { planTemplateMigrationBatchWithPaidCore } = await import('../license/premium-module-service'); + const result = await planTemplateMigrationBatchWithPaidCore({ + sourceTemplateId: 'template-source', + sourceTemplateName: '旧模板', + targetTemplateId: 'template-target', + targetTemplateName: '新模板', + totalMaterialCount: 300, + request: { + migrationLimit: 300, + }, + }); + + expect(result).toEqual({ + valid: true, + allowed: false, + statusCode: 409, + error: '该模板当前有 300 个素材,超过默认安全迁移数量 200。请先预览迁移影响,确认备份后再执行。', + errorCode: 'MIGRATION_CONFIRM_REQUIRED', + response: undefined, + details: { + totalMaterialCount: 300, + safeLimit: 200, + migrationLimit: 300, + maxBatchLimit: 1000, + }, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/plan-migration-batch', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"totalMaterialCount":300'), + }) + ); + }); + + it('paid-core 运行时应由模块生成公开模板列表和素材类型投影', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + templates: [ + { id: 'builtin-other-material', name: '其他素材模板', isBuiltin: true }, + { id: 'custom-template', name: '客户模板素材模板', isBuiltin: false }, + ], + uploadTemplates: [ + { id: 'builtin-other-material', name: '其他素材模板', isBuiltin: true, materialCount: 3 }, + { + id: 'custom-template', + name: '客户模板素材模板', + isBuiltin: false, + materialCount: 2, + fields: [{ key: 'customCode', label: '编码', isReadOnly: false }], + }, + ], + materialTypes: ['其他', 'template:custom-template:客户模板'], + }), + }); + + const { projectPublicTemplateOptionsWithPaidCore } = await import('../license/premium-module-service'); + const result = await projectPublicTemplateOptionsWithPaidCore({ + builtinTemplates: [{ id: 'builtin-other-material', name: '其他素材模板' }], + customTemplates: [ + { + id: 'custom-template', + name: '客户模板素材模板', + status: 'active', + fields: [ + { key: 'customCode', label: '编码', isReadOnly: false }, + { key: 'materialType', label: '素材类型', isReadOnly: false }, + ], + }, + ], + materialCounts: { + 'builtin-other-material': 3, + 'custom-template': 2, + }, + builtinMaterialTypes: ['其他'], + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.templates).toHaveLength(2); + expect(result.uploadTemplates[1]).toMatchObject({ id: 'custom-template', materialCount: 2 }); + expect(result.materialTypes).toContain('template:custom-template:客户模板'); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/project-public-options', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"builtinMaterialTypes":["其他"]'), + }) + ); + }); + + it('paid-core 运行时应由模块计算自定义模板字段填写率', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + fieldStats: { + totalMaterials: 2, + fields: [ + { + fieldKey: 'brandName', + fieldLabel: '品牌', + fieldType: 'text', + filledCount: 1, + totalCount: 2, + fillRate: 50, + isLowFillRate: false, }, - }, - })}`, - }); - } + ], + lowFillRateFields: [], + }, + }), + }); - 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 }), + const { calculateTemplateFieldStatsWithPaidCore } = await import('../license/premium-module-service'); + const result = await calculateTemplateFieldStatsWithPaidCore({ + fields: [{ key: 'brandName', label: '品牌', type: 'text' }], + materials: [ + { id: 'mat-1', metadata: JSON.stringify({ templateFields: { brandName: 'FLNXI' } }) }, + { id: 'mat-2', metadata: JSON.stringify({ templateFields: { brandName: '' } }) }, + ], }); - const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.fieldStats?.totalMaterials).toBe(2); + expect(result.fieldStats?.fields[0]).toMatchObject({ + fieldKey: 'brandName', + fillRate: 50, + }); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/calculate-field-stats', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"fields":[{"key":"brandName"'), + }) + ); + }); - await expect(getAuthorizedUsageLimits()).resolves.toEqual({ - maxUsers: 10, - maxMaterials: 100, + it('paid-core 运行时应由模块规划素材列表自定义字段搜索', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + search: 'CODE-001', + customFieldKeys: ['customCode'], + }), + }); + + const { planMaterialCustomSearchWithPaidCore } = await import('../license/premium-module-service'); + const result = await planMaterialCustomSearchWithPaidCore({ + search: 'CODE-001', + searchableFields: [ + { key: 'customCode' }, + { key: 'productModel' }, + ], + }); + + expect(result).toEqual({ + valid: true, + search: 'CODE-001', + customFieldKeys: ['customCode'], + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/plan-custom-search', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"searchableFields":[{"key":"customCode"},{"key":"productModel"}]'), + }) + ); + }); + + it('paid-core 运行时应由模块规划素材列表查询条件', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + search: 'CODE-001', + standardSearchFields: ['productModel', 'brandName'], + customSearchFieldKeys: ['customCode'], + standardHomeFilters: [{ key: 'brandName', value: 'FLNXI' }], + customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }], + }), + }); + + const { planMaterialListQueryWithPaidCore } = await import('../license/premium-module-service'); + const result = await planMaterialListQueryWithPaidCore({ + search: 'CODE-001', + searchableFields: [{ key: 'customCode' }, { key: 'productModel' }], + homeFieldFilters: [ + { key: 'brandName', value: 'FLNXI' }, + { key: 'customCode', value: 'CODE-001' }, + ], + enabledHomeFilterFields: [{ key: 'customCode' }], + }); + + expect(result).toEqual({ + valid: true, + search: 'CODE-001', + standardSearchFields: ['productModel', 'brandName'], + customSearchFieldKeys: ['customCode'], + standardHomeFilters: [{ key: 'brandName', value: 'FLNXI' }], + customHomeFilters: [{ key: 'customCode', value: 'CODE-001' }], + moduleAvailable: true, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/plan-list-query', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"homeFieldFilters"'), + }) + ); + }); + + it('paid-core 运行时应由模块规划首页自定义筛选字段统计', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + customFieldKeys: ['customCode'], + }), + }); + + const { planHomeFilterCustomFieldCountsWithPaidCore } = await import('../license/premium-module-service'); + const result = await planHomeFilterCustomFieldCountsWithPaidCore({ + fields: [ + { key: 'categoryName' }, + { key: 'customCode' }, + ], + }); + + expect(result).toEqual({ + valid: true, + customFieldKeys: ['customCode'], + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/plan-home-filter-counts', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"fields":[{"key":"categoryName"},{"key":"customCode"}]'), + }) + ); + }); + + it('paid-core 运行时应由模块规划首页筛选字段统计来源', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + fieldPlans: [ + { key: 'categoryName', source: 'column' }, + { key: 'customCode', source: 'metadata' }, + ], + }), + }); + + const { planHomeFilterFieldCountsWithPaidCore } = await import('../license/premium-module-service'); + const result = await planHomeFilterFieldCountsWithPaidCore({ + fields: [ + { key: 'categoryName' }, + { key: 'customCode' }, + ], + }); + + expect(result).toEqual({ + valid: true, + fieldPlans: [ + { key: 'categoryName', source: 'column' }, + { key: 'customCode', source: 'metadata' }, + ], + moduleAvailable: true, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/materials/plan-home-filter-fields', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"customCode"'), + }) + ); + }); + + it('paid-core 运行时应由模块规划部门隔离管理范围', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + scopedDepartments: ['设计部', '设计部-建模组'], + withinScope: true, + }), + }); + + const { planDepartmentScopeWithPaidCore } = await import('../license/premium-module-service'); + const result = await planDepartmentScopeWithPaidCore({ + scopeDepartment: '设计部', + targetDepartment: '设计部-建模组', + departments: [ + { name: '设计部' }, + { name: '设计部-建模组' }, + { name: '运营部' }, + ], + }); + + expect(result).toEqual({ + valid: true, + scopedDepartments: ['设计部', '设计部-建模组'], + withinScope: true, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/departments/plan-scope', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"scopeDepartment":"设计部"'), + }) + ); + }); + + it('paid-core 运行时应由模块规划部门结构变更', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + allowed: true, + action: 'rename', + departmentOps: [ + { type: 'rename', id: 'dept-1', oldName: '设计部', newName: '产品部' }, + ], + userOps: [ + { type: 'update', id: 'user-1', data: { department: '产品部' } }, + ], + summary: { + renamedDepartmentCount: 1, + updatedUserCount: 1, + }, + }), + }); + + const { planDepartmentStructureChangeWithPaidCore } = await import('../license/premium-module-service'); + const result = await planDepartmentStructureChangeWithPaidCore({ + action: 'rename', + target: { id: 'dept-1', name: '产品部' }, + departments: [{ id: 'dept-1', name: '设计部' }], + users: [{ id: 'user-1', department: '设计部', managedDepartment: null }], + }); + + expect(result).toEqual({ + valid: true, + allowed: true, + action: 'rename', + departmentOps: [ + { type: 'rename', id: 'dept-1', oldName: '设计部', newName: '产品部' }, + ], + userOps: [ + { type: 'update', id: 'user-1', data: { department: '产品部' } }, + ], + summary: { + renamedDepartmentCount: 1, + updatedUserCount: 1, + }, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/departments/plan-structure-change', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"action":"rename"'), + }) + ); + }); + + it('paid-core 拒绝模板迁移超限 metadata 时应透传错误码和详情', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', allowed: true }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + moduleId: 'paid-core', + valid: false, + error: '模板迁移后 metadata 数据过大', + errorCode: 'METADATA_TOO_LARGE', + details: { size: 70000, maxSize: 65535 }, + }), + }); + + const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareTemplateMigrationMetadataWithPaidCore({ + sourceTemplateId: 'template-source', + sourceTemplateName: '旧模板', + targetTemplateId: 'template-target', + targetTemplateName: '新模板', + migratedAt: '2026-06-03T00:00:00.000Z', + targetFieldKeys: ['productModel'], + templateFields: { productModel: 'MX-001' }, + materialStandardFields: { productModel: 'MX-001' }, + }); + + expect(result).toEqual({ + valid: false, + error: '模板迁移后 metadata 数据过大', + errorCode: 'METADATA_TOO_LARGE', + details: { size: 70000, maxSize: 65535 }, }); }); - 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(), - })}`, - }); - } + it('paid-core 拒绝模板迁移动作授权时不应继续准备迁移 metadata', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ moduleId: 'paid-core', allowed: false, error: '授权模块拒绝迁移' }), + }); - return Promise.resolve(null); + const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareTemplateMigrationMetadataWithPaidCore({ + sourceTemplateId: 'template-source', + sourceTemplateName: '旧模板', + targetTemplateId: 'template-target', + targetTemplateName: '新模板', + migratedAt: '2026-06-03T00:00:00.000Z', + targetFieldKeys: ['productModel'], + targetFields: [{ key: 'productModel', label: '产品型号', type: 'text', required: false }], + materialFieldValues: { productModel: 'MX-001' }, + materialStandardFields: { productModel: 'MX-001' }, }); - const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); - - await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); + expect(result).toEqual( + expect.objectContaining({ + valid: false, + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAUTHORIZED', + }) + ); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/authorize', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('customTemplates:migrate'), + }) + ); }); - 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(), - })}`, - }); - } + it('paid-core 未运行时不得准备模板迁移 metadata', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; - 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: 'missing', - lastSyncAt: new Date().toISOString(), - }, - })}`, - }); - } - - return Promise.resolve(null); + const { prepareTemplateMigrationMetadataWithPaidCore } = await import('../license/premium-module-service'); + const result = await prepareTemplateMigrationMetadataWithPaidCore({ + sourceTemplateId: 'template-source', + sourceTemplateName: '旧模板', + targetTemplateId: 'template-target', + targetTemplateName: '新模板', + migratedAt: '2026-06-03T00:00:00.000Z', + targetFieldKeys: ['productModel'], + templateFields: { productModel: 'MX-001' }, + materialStandardFields: { productModel: 'MX-001' }, }); - const { getAuthorizedUsageLimits } = await import('../license/premium-module-service'); - - await expect(getAuthorizedUsageLimits()).resolves.toBeNull(); + expect(result).toEqual( + expect.objectContaining({ + valid: false, + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE', + }) + ); 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(), + it('paid-core 运行时应由模块执行自定义模板保存请求标准化', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + mode: 'create', + fieldsProvided: true, + template: { + name: '成品图模板', + description: null, + status: 'draft', + materialType: '成品图', + materialKind: 'IMAGE', + fieldGroups: null, + displayConfig: null, + fields: [ + { + id: null, + key: 'brandName', + label: '品牌', + type: 'text', + required: false, + orderIndex: 0, + layoutWidth: 'full', + validations: null, + optionsSource: null, + optionsStatic: null, + allowedExtensions: null, + visibleOnHomeFilter: false, + isSearchable: false, + isReadOnly: false, + addToCategory: false, + conditions: null, }, - }, - })}`, - }); - } + ], + }, + }), + }); - 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(), - }, - })}`, - }); - } + const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service'); + const result = await normalizeUploadTemplateSaveWithPaidCore( + { + name: ' 成品图模板 ', + materialType: '成品图', + materialKind: 'image', + fields: [{ key: 'brandName', label: '品牌', type: 'text' }], + }, + 'create' + ); - 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(result.valid).toBe(true); + if (result.valid) { + expect(result.template).toEqual( + expect.objectContaining({ + name: '成品图模板', + materialKind: 'IMAGE', + }) + ); + expect(result.fieldsProvided).toBe(true); + } expect(mockFetch).toHaveBeenLastCalledWith( - 'http://license-limits-module.test/limits/enforce', + 'http://paid-core.test/templates/normalize-save', expect.objectContaining({ method: 'POST', - body: expect.stringContaining('"type":"users"'), + body: expect.stringContaining('"mode":"create"'), }) ); + expect(mockFetch.mock.calls.at(-1)?.[1]?.body).toContain('"payload"'); }); - 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(), - })}`, - }); - } + it('paid-core 保存标准化支持模板局部更新', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + mode: 'update', + fieldsProvided: false, + template: { + description: '只更新说明', + }, + }), + }); - 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(), - }, - }, - })}`, - }); - } + const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service'); + const result = await normalizeUploadTemplateSaveWithPaidCore({ description: '只更新说明' }, 'update'); - 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); + expect(result).toEqual({ + valid: true, + mode: 'update', + fieldsProvided: false, + template: { + description: '只更新说明', + }, }); - 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 }) => { - if (where.key === 'premium_module_manifest') { - return Promise.resolve({ - value: `encrypted:${JSON.stringify({ - manifest: { - version: 1, - licenseType: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule()], - 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: { - 'intranet-penetration-core': { - entitlementBase64: 'module-entitlement', - sha256: 'hash', - sizeBytes: 18, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - artifact: moduleArtifact, - pullCredential: mockPullCredential('intranet-penetration-core'), - syncedAt: new Date().toISOString(), - }, - }, - })}`, - }); - } - - return Promise.resolve(null); - }); - - const { getCachedModulePullCredential } = await import('../license/premium-module-service'); - - await expect(getCachedModulePullCredential('intranet-penetration-core')).resolves.toEqual( + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/normalize-save', expect.objectContaining({ - username: 'flnxi-module', - repository: 'flnxi/premium/intranet-penetration-core', + method: 'POST', + body: expect.stringContaining('"mode":"update"'), }) ); }); - it('过期的短期拉包凭证不应返回给模块运行器', async () => { - mockPaidLicense('PRO'); - (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: 'PRO', - instanceId: 'instance-1', - modules: [mockManifestModule()], - issuedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 60_000).toISOString(), - }, - signature: 'valid-signature', - syncedAt: new Date().toISOString(), - })}`, - }); - } + it('paid-core 未运行时不得执行模板保存标准化', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; - if (where.key === 'premium_module_entitlements') { - return Promise.resolve({ - value: `encrypted:${JSON.stringify({ - entitlements: { - 'intranet-penetration-core': { - entitlementBase64: 'module-entitlement', - sha256: 'hash', - sizeBytes: 18, - expiresAt: new Date(Date.now() + 60_000).toISOString(), - artifact: moduleArtifact, - pullCredential: { - ...mockPullCredential('intranet-penetration-core'), - expiresAt: new Date(Date.now() - 1_000).toISOString(), - }, - syncedAt: new Date().toISOString(), - }, - }, - })}`, - }); - } + const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service'); + const result = await normalizeUploadTemplateSaveWithPaidCore({ description: '只更新说明' }, 'update'); - return Promise.resolve(null); + expect(result).toEqual( + expect.objectContaining({ + valid: false, + field: 'template', + }) + ); + if (!result.valid) { + expect(result.details).toEqual(expect.objectContaining({ moduleId: 'paid-core', status: 'missing' })); + } + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('paid-core 运行时应由模块规划自定义模板删除', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + allowed: false, + error: '无法删除模板"使用中的模板",因为有 5 个素材正在使用该模板。请先删除或迁移这些素材。', + errorCode: 'TEMPLATE_IN_USE', + details: { + templateName: '使用中的模板', + materialCount: 5, + }, + }), + }); + + const { planUploadTemplateDeleteWithPaidCore } = await import('../license/premium-module-service'); + const result = await planUploadTemplateDeleteWithPaidCore({ + templateName: '使用中的模板', + materialCount: 5, }); - const { getCachedModulePullCredential } = await import('../license/premium-module-service'); + expect(result).toEqual({ + valid: true, + allowed: false, + error: '无法删除模板"使用中的模板",因为有 5 个素材正在使用该模板。请先删除或迁移这些素材。', + errorCode: 'TEMPLATE_IN_USE', + details: { + templateName: '使用中的模板', + materialCount: 5, + }, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/plan-delete', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"materialCount":5'), + }) + ); + }); - await expect(getCachedModulePullCredential('intranet-penetration-core')).resolves.toBeNull(); + it('paid-core 未运行时不得规划自定义模板删除', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'missing' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + + const { planUploadTemplateDeleteWithPaidCore } = await import('../license/premium-module-service'); + const result = await planUploadTemplateDeleteWithPaidCore({ + templateName: '使用中的模板', + materialCount: 5, + }); + + expect(result).toEqual( + expect.objectContaining({ + valid: false, + allowed: false, + errorCode: 'PAID_CORE_TEMPLATE_DELETE_PLAN_UNAVAILABLE', + }) + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('paid-core 运行时应由模块规划自定义模板更新', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + updateData: { + description: '更新说明', + version: 2, + }, + fieldsProvided: false, + fields: null, + nextVersion: 2, + }), + }); + + const { planUploadTemplateUpdateWithPaidCore } = await import('../license/premium-module-service'); + const result = await planUploadTemplateUpdateWithPaidCore({ + payload: { description: '更新说明', version: 1 }, + existingTemplate: { + id: 'template-1', + version: 1, + name: '模板', + updatedAt: '2026-06-03T00:00:00.000Z', + materialType: '3DMAX', + materialKind: 'IMAGE', + materialCount: 0, + fields: [], + }, + }); + + expect(result).toEqual({ + valid: true, + updateData: { + description: '更新说明', + version: 2, + }, + fieldsProvided: false, + fields: null, + nextVersion: 2, + }); + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/plan-update', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"existingTemplate"'), + }) + ); + }); + + it('paid-core 更新规划拒绝结构变更时应透传状态码和详情', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + moduleId: 'paid-core', + valid: false, + error: '模板已有素材在使用', + errorCode: 'TEMPLATE_STRUCTURE_LOCKED', + field: 'template', + statusCode: 409, + details: { + templateName: '使用中的模板', + materialCount: 3, + dangerousChanges: [{ type: 'fieldDeleted', fieldId: 'field-1' }], + }, + }), + }); + + const { planUploadTemplateUpdateWithPaidCore } = await import('../license/premium-module-service'); + const result = await planUploadTemplateUpdateWithPaidCore({ + payload: { version: 1, fields: [] }, + existingTemplate: { + id: 'template-1', + version: 1, + name: '使用中的模板', + updatedAt: '2026-06-03T00:00:00.000Z', + materialType: '3DMAX', + materialKind: 'IMAGE', + materialCount: 3, + fields: [{ + id: 'field-1', + key: 'productModel', + type: 'text', + label: '型号', + required: false, + addToCategory: false, + isSearchable: false, + isReadOnly: false, + validations: null, + optionsStatic: null, + conditions: null, + }], + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + valid: false, + errorCode: 'TEMPLATE_STRUCTURE_LOCKED', + statusCode: 409, + details: expect.objectContaining({ + materialCount: 3, + }), + }) + ); + }); + + it('paid-core 拒绝保存标准化时应透传错误字段和详情', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + moduleId: 'paid-core', + valid: false, + error: '模板状态必须是 draft、active 或 archived', + field: 'status', + details: { allowedStatuses: ['draft', 'active', 'archived'] }, + }), + }); + + const { normalizeUploadTemplateSaveWithPaidCore } = await import('../license/premium-module-service'); + const result = await normalizeUploadTemplateSaveWithPaidCore({ status: 'invalid' }, 'update'); + + expect(result).toEqual({ + valid: false, + error: '模板状态必须是 draft、active 或 archived', + field: 'status', + details: { allowedStatuses: ['draft', 'active', 'archived'] }, + }); + }); + + it('paid-core 运行时应由模块判断自定义模板结构变更风险', async () => { + mockLicense('PRO'); + mockCachedPaidCore({ withEntitlement: true, runtimeStatus: 'running' }); + process.env.PAID_CORE_MODULE_URL = 'http://paid-core.test'; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ moduleId: 'paid-core', status: 'healthy' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + moduleId: 'paid-core', + valid: true, + changes: [ + { + type: 'fieldDeleted', + fieldId: 'field-1', + fieldKey: 'brandName', + fieldLabel: '品牌', + }, + ], + }), + }); + + const { detectTemplateStructureChangesWithPaidCore } = await import('../license/premium-module-service'); + const result = await detectTemplateStructureChangesWithPaidCore({ + existingTemplate: { + materialType: 'image', + materialKind: 'IMAGE', + fields: [ + { + id: 'field-1', + key: 'brandName', + label: '品牌', + type: 'text', + required: false, + addToCategory: false, + isSearchable: true, + isReadOnly: false, + validations: null, + optionsStatic: null, + conditions: null, + }, + ], + }, + nextFields: [], + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.changes[0]).toEqual( + expect.objectContaining({ + type: 'fieldDeleted', + fieldKey: 'brandName', + }) + ); + } + expect(mockFetch).toHaveBeenLastCalledWith( + 'http://paid-core.test/templates/detect-structure-changes', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"existingTemplate"'), + }) + ); }); }); diff --git a/lib/__tests__/template-field-validation.test.ts b/lib/__tests__/template-field-validation.test.ts index 199eb51..ad83064 100644 --- a/lib/__tests__/template-field-validation.test.ts +++ b/lib/__tests__/template-field-validation.test.ts @@ -2,13 +2,20 @@ import { validateSubmittedTemplateFields } from '../template-field-validation' jest.mock('../template-validator', () => ({ getTemplateWithFields: jest.fn(), + isBuiltinTemplate: jest.fn(), })) -const { getTemplateWithFields } = require('../template-validator') +jest.mock('../license/premium-module-service', () => ({ + validateSubmittedTemplateFieldsWithPaidCore: jest.fn(), +})) + +const { getTemplateWithFields, isBuiltinTemplate } = require('../template-validator') +const { validateSubmittedTemplateFieldsWithPaidCore } = require('../license/premium-module-service') describe('template-field-validation', () => { beforeEach(() => { jest.resetAllMocks() + isBuiltinTemplate.mockReturnValue(true) }) test('没有 templateId 时应直接通过', async () => { @@ -199,4 +206,42 @@ describe('template-field-validation', () => { ]) } }) + + test('自定义模板字段内容校验应委托 paid-core', async () => { + isBuiltinTemplate.mockReturnValue(false) + getTemplateWithFields.mockResolvedValue({ + id: 'template-1', + name: '自定义模板', + fields: [ + { key: 'customCode', label: '自定义编码', type: 'text', required: true }, + ], + }) + validateSubmittedTemplateFieldsWithPaidCore.mockResolvedValue({ + valid: true, + templateName: '自定义模板', + templateFields: { customCode: 'CODE-001' }, + visibleFieldKeys: ['customCode'], + }) + + const result = await validateSubmittedTemplateFields({ + templateId: 'template-1', + templateFields: { + customCode: 'CODE-001', + }, + allowUnknownKeys: false, + }) + + expect(result.valid).toBe(true) + expect(validateSubmittedTemplateFieldsWithPaidCore).toHaveBeenCalledWith({ + templateId: 'template-1', + templateName: '自定义模板', + fields: [ + { key: 'customCode', label: '自定义编码', type: 'text', required: true }, + ], + templateFields: { + customCode: 'CODE-001', + }, + allowUnknownKeys: false, + }) + }) }) diff --git a/lib/license/check-limits.ts b/lib/license/check-limits.ts index 2b54467..263b31d 100644 --- a/lib/license/check-limits.ts +++ b/lib/license/check-limits.ts @@ -3,12 +3,9 @@ * 用于在各个 API 中检查是否达到许可证限制 */ -import { isFeatureEnabled, getLicenseType } from './license-service'; -import { prisma } from '@/lib/prisma'; +import { checkLimit, isFeatureEnabled, getLicenseType } from './license-service'; import { authorizePremiumModuleAction, - enforceAuthorizedUsageLimit, - getAuthorizedUsageLimits, PREMIUM_MODULE_IDS, } from './premium-module-service'; @@ -19,50 +16,8 @@ export interface LimitCheckResult { message?: string; } -const LIMIT_MODULE_UNAVAILABLE_MESSAGE = '授权限额模块不可用,请重新验证许可证或联系管理员。'; - async function checkAuthorizedLimit(type: 'users' | 'materials'): Promise { - const limits = await getAuthorizedUsageLimits(); - if (!limits) { - return { - allowed: false, - current: 0, - max: 0, - message: LIMIT_MODULE_UNAVAILABLE_MESSAGE, - }; - } - - const max = type === 'users' ? limits.maxUsers : limits.maxMaterials; - if (!Number.isInteger(max) || max < -1) { - return { - allowed: false, - current: 0, - max: 0, - message: LIMIT_MODULE_UNAVAILABLE_MESSAGE, - }; - } - - const current = max === -1 - ? 0 - : type === 'users' - ? await prisma.user.count() - : await prisma.material.count(); - - const decision = await enforceAuthorizedUsageLimit(type, current); - if (!decision) { - return { - allowed: false, - current: 0, - max: 0, - message: LIMIT_MODULE_UNAVAILABLE_MESSAGE, - }; - } - - return { - allowed: decision.allowed, - current: decision.current, - max: decision.max, - }; + return checkLimit(type); } /** @@ -116,7 +71,7 @@ export async function canUseCustomTemplates(): Promise { if (!(await isFeatureEnabled('customTemplates'))) return false; return ( await authorizePremiumModuleAction( - PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE, + PREMIUM_MODULE_IDS.PAID_CORE, 'customTemplates:use' ) ).available; @@ -126,7 +81,13 @@ export async function canUseCustomTemplates(): Promise { * 检查部门隔离功能是否可用 */ export async function canUseDepartmentIsolation(): Promise { - return await isFeatureEnabled('departmentIsolation'); + if (!(await isFeatureEnabled('departmentIsolation'))) return false; + return ( + await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.PAID_CORE, + 'departmentIsolation:use' + ) + ).available; } /** @@ -136,7 +97,7 @@ export async function canUseIntranetPenetration(): Promise { if (!(await isFeatureEnabled('intranetPenetration'))) return false; return ( await authorizePremiumModuleAction( - PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE, + PREMIUM_MODULE_IDS.PAID_CORE, 'intranetPenetration:use' ) ).available; @@ -146,12 +107,24 @@ export async function canUseIntranetPenetration(): Promise { * 检查系统设置功能是否可用 */ export async function canUseSystemSettings(): Promise { - return await isFeatureEnabled('systemSettings'); + if (!(await isFeatureEnabled('systemSettings'))) return false; + return ( + await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.PAID_CORE, + 'systemSettings:use' + ) + ).available; } /** * 检查 API 访问功能是否可用 */ export async function canUseApiAccess(): Promise { - return await isFeatureEnabled('apiAccess'); + if (!(await isFeatureEnabled('apiAccess'))) return false; + return ( + await authorizePremiumModuleAction( + PREMIUM_MODULE_IDS.PAID_CORE, + 'apiAccess:use' + ) + ).available; } diff --git a/lib/license/index.ts b/lib/license/index.ts index 2e31cf6..76f26e9 100644 --- a/lib/license/index.ts +++ b/lib/license/index.ts @@ -34,16 +34,54 @@ export { getCachedPremiumModuleManifest, getCachedPremiumModuleRuntimeStatus, getCachedModulePullCredential, - getAuthorizedUsageLimits, - enforceAuthorizedUsageLimit, syncPremiumModules, hasPremiumModule, hasUsablePremiumModule, isPremiumModuleRunning, authorizePremiumModuleAction, + buildTemplateMigrationPreviewWithPaidCore, + calculateTemplateFieldStatsWithPaidCore, + detectTemplateStructureChangesWithPaidCore, + normalizeUploadTemplateSaveWithPaidCore, + planHomeFilterCustomFieldCountsWithPaidCore, + planDepartmentScopeWithPaidCore, + planDepartmentStructureChangeWithPaidCore, + planHomeFilterFieldCountsWithPaidCore, + planMaterialListQueryWithPaidCore, + planMaterialCustomSearchWithPaidCore, + planTemplateMigrationBatchWithPaidCore, + planUploadTemplateDeleteWithPaidCore, + planUploadTemplateUpdateWithPaidCore, + prepareMaterialMetadataWithPaidCore, + prepareTemplateMigrationMetadataWithPaidCore, + projectPublicTemplateOptionsWithPaidCore, + validateSubmittedTemplateFieldsWithPaidCore, + validateUploadTemplateFieldsWithPaidCore, updatePremiumModuleRuntimeStatus, } from './premium-module-service'; +export type { + TemplateStructureChange, + TemplateStructureChangeDetectionResult, + MaterialMetadataPrepareResult, + MaterialCustomSearchPlanResult, + MaterialListQueryPlanResult, + HomeFilterCustomFieldCountsPlanResult, + HomeFilterFieldCountsPlanResult, + DepartmentScopePlanResult, + DepartmentStructureChangePlanResult, + TemplateMigrationMetadataPrepareResult, + TemplateMigrationBatchPlanResult, + TemplateMigrationPreviewBuildResult, + PublicTemplateOptionsProjectionResult, + TemplateFieldStatsCalculationResult, + NormalizedUploadTemplateSaveResult, + UploadTemplateDeletePlanResult, + UploadTemplateUpdatePlanResult, + SubmittedTemplateFieldsValidationResult, + UploadTemplateSaveMode, +} from './premium-module-service'; + // 导出指纹服务 export { generateFingerprint, diff --git a/lib/license/premium-module-service.ts b/lib/license/premium-module-service.ts index fbe1c0f..57ddccc 100644 --- a/lib/license/premium-module-service.ts +++ b/lib/license/premium-module-service.ts @@ -5,6 +5,10 @@ import { buildSignedLicenseRequest } from './request-signature'; import { buildLicenseServerLicenseApiUrl, getLicenseProjectCode, getLicenseServerUrl } from './server-config'; import { getLocalLicense, isLicenseConfigExpired } from './license-service'; import type { LicenseType } from './types'; +import type { + FormattedUploadTemplateField, + UploadTemplateSaveValidationResult, +} from '../upload-template-save-validation'; export interface PremiumModuleManifest { version: number; @@ -112,23 +116,9 @@ const PREMIUM_MODULE_PULL_USERNAME = 'flnxi-module'; const PREMIUM_MODULE_HEALTH_TIMEOUT_MS = 3000; export const PREMIUM_MODULE_IDS = { - LICENSE_LIMITS_CORE: 'license-limits-core', - CUSTOM_TEMPLATES_CORE: 'custom-templates-core', - INTRANET_PENETRATION_CORE: 'intranet-penetration-core', + PAID_CORE: 'paid-core', } as const; -export interface AuthorizedUsageLimits { - maxUsers: number; - maxMaterials: number; -} - -export interface AuthorizedUsageLimitDecision { - allowed: boolean; - type: 'users' | 'materials'; - current: number; - max: number; -} - export interface PremiumModuleRuntimeGateResult { available: boolean; moduleId: string; @@ -142,6 +132,460 @@ export interface PremiumModuleAuthorizeResult extends PremiumModuleRuntimeGateRe type PremiumModuleActionContext = Record; +export type TemplateStructureChange = + | { + type: 'materialTypeChanged'; + before: string; + after: string; + } + | { + type: 'materialKindChanged'; + before: string; + after: string; + } + | { + type: 'fieldDeleted'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldKeyChanged'; + fieldId: string; + before: string; + after: string; + fieldLabel: string; + } + | { + type: 'fieldTypeChanged'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + before: string; + after: string; + } + | { + type: 'fieldCategoryDisabled'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldSearchDisabled'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldMadeReadOnly'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldMadeRequired'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldValidationChanged'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + } + | { + type: 'fieldOptionsRemoved'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + removedOptions: string[]; + } + | { + type: 'fieldConditionsChanged'; + fieldId: string; + fieldKey: string; + fieldLabel: string; + }; + +export type TemplateStructureChangeDetectionResult = + | { valid: true; changes: TemplateStructureChange[] } + | { + valid: false; + error: string; + field: string; + details?: Record; + }; + +export type UploadTemplateSaveMode = 'create' | 'update'; + +export type NormalizedUploadTemplateSaveResult = + | { + valid: true; + mode: UploadTemplateSaveMode; + fieldsProvided: boolean; + template: { + name?: string; + description?: unknown; + status?: string; + materialType?: string; + materialKind?: string; + fieldGroups?: unknown; + displayConfig?: unknown; + fields?: FormattedUploadTemplateField[]; + }; + } + | { + valid: false; + error: string; + field: string; + details?: Record; + }; + +export type UploadTemplateDeletePlanResult = + | { + valid: true; + allowed: true; + templateName: string; + materialCount: number; + } + | { + valid: true; + allowed: false; + error: string; + errorCode: string; + details?: Record; + } + | { + valid: false; + allowed: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type UploadTemplateUpdatePlanResult = + | { + valid: true; + updateData: Record; + fieldsProvided: boolean; + fields: FormattedUploadTemplateField[] | null; + nextVersion: number; + } + | { + valid: false; + error: string; + errorCode: string; + field?: string; + statusCode?: number; + details?: Record; + }; + +type TemplateFieldForSubmittedValidation = { + key: string; + label?: string | null; + type?: string | null; + required?: boolean | null; + conditions?: string | null; + validations?: string | null; +}; + +export type SubmittedTemplateFieldsValidationResult = + | { + valid: true; + templateName?: string; + templateFields: Record; + visibleFieldKeys: string[]; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type MaterialMetadataPrepareResult = + | { + valid: true; + standardData: Record; + metadata: string | null; + metadataSize: { + ok: true; + size: number; + maxSize: number; + }; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type TemplateMigrationMetadataPrepareResult = + | { + valid: true; + standardData: Record; + metadata: string | null; + metadataSize: { + ok: true; + size: number; + maxSize: number; + }; + templateFields: Record; + droppedTemplateFieldCount: number; + droppedStandardFieldCount: number; + droppedTemplateFields: Record; + droppedStandardFields: Record; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type TemplateMigrationPreviewBuildResult = + | { + valid: true; + migrationBackup: { + materialsWithDroppedFields: number; + droppedTemplateFieldCount: number; + droppedStandardFieldCount: number; + }; + migrationPreview: { + totalMaterialCount: number; + totalMaterials: number; + previewLimited: boolean; + previewLimit?: number; + targetCurrentFields: Array<{ + key: string; + label: string; + type?: string; + required: boolean; + }>; + backupFields: { + templateFields: Array<{ + key: string; + label: string; + type?: string; + source: 'template'; + affectedCount: number; + }>; + standardFields: Array<{ + key: string; + label: string; + type?: string; + source: 'standard'; + affectedCount: number; + }>; + }; + samples: Array<{ + materialId: string; + productModel: string | null; + droppedTemplateFields: Array<{ + key: string; + label: string; + type?: string; + source: 'template'; + }>; + droppedStandardFields: Array<{ + key: string; + label: string; + type?: string; + source: 'standard'; + }>; + }>; + }; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type TemplateMigrationBatchPlanResult = + | { + valid: true; + allowed: true; + sourceTemplateId: string; + targetTemplateId: string; + sourceTemplateName: string; + targetTemplateName: string; + totalMaterialCount: number; + previewOnly: boolean; + forceLargeMigration: boolean; + updateMaterialType: boolean; + migrationLimit: number; + safeLimit: number; + maxBatchLimit: number; + batchLimited: boolean; + materialTake: number | null; + migratedAt: string; + } + | { + valid: true; + allowed: false; + statusCode: number; + error?: string; + errorCode?: string; + response?: Record; + details?: Record; + } + | { + valid: false; + allowed: false; + error: string; + errorCode: string; + statusCode?: number; + details?: Record; + }; + +export type PublicTemplateOptionsProjectionResult = + | { + valid: true; + templates: Array>; + uploadTemplates: Array>; + materialTypes: string[]; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type TemplateFieldStatsCalculationResult = + | { + valid: true; + fieldStats: null | { + totalMaterials: number; + fields: Array<{ + fieldKey: string; + fieldLabel: string; + fieldType: string; + filledCount: number; + totalCount: number; + fillRate: number; + isLowFillRate: boolean; + }>; + lowFillRateFields: Array<{ + fieldKey: string; + fieldLabel: string; + fieldType: string; + filledCount: number; + totalCount: number; + fillRate: number; + isLowFillRate: boolean; + }>; + }; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type MaterialCustomSearchPlanResult = + | { + valid: true; + search: string; + customFieldKeys: string[]; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type MaterialListQueryPlanResult = + | { + valid: true; + search: string; + standardSearchFields: string[]; + customSearchFieldKeys: string[]; + standardHomeFilters: Array<{ key: string; value: string }>; + customHomeFilters: Array<{ key: string; value: string }>; + moduleAvailable: boolean; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type HomeFilterCustomFieldCountsPlanResult = + | { + valid: true; + customFieldKeys: string[]; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type HomeFilterFieldCountsPlanResult = + | { + valid: true; + fieldPlans: Array<{ + key: string; + source: 'column' | 'metadata'; + }>; + moduleAvailable: boolean; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type DepartmentScopePlanResult = + | { + valid: true; + scopedDepartments: string[]; + withinScope: boolean; + } + | { + valid: false; + error: string; + errorCode: string; + details?: Record; + }; + +export type DepartmentStructureChangePlanResult = + | { + valid: true; + allowed: true; + action: 'create' | 'rename' | 'delete'; + departmentOps: Array>; + userOps: Array>; + summary?: Record; + } + | { + valid: true; + allowed: false; + statusCode: number; + error?: string; + errorCode?: string; + response?: Record; + details?: Record; + } + | { + valid: false; + allowed: false; + error: string; + errorCode: string; + statusCode?: number; + details?: Record; + }; + function isManifestExpired(manifest: PremiumModuleManifest): boolean { const expiresAt = new Date(manifest.expiresAt).getTime(); return Number.isNaN(expiresAt) || expiresAt <= Date.now(); @@ -151,16 +595,6 @@ function verifyPremiumModuleManifest(manifest: PremiumModuleManifest, signature: return verifySignature(JSON.stringify(manifest), signature); } -function isValidLimitValue(value: unknown): value is number { - return Number.isInteger(value) && (value as number) >= -1; -} - -function isValidUsageLimits(limits: unknown): limits is AuthorizedUsageLimits { - if (!limits || typeof limits !== 'object') return false; - const value = limits as Partial; - return isValidLimitValue(value.maxUsers) && isValidLimitValue(value.maxMaterials); -} - async function savePremiumModuleManifest( manifest: PremiumModuleManifest, signature: string @@ -306,9 +740,7 @@ function isPullCredentialValid( function getPremiumModuleServiceBaseUrl(moduleId: string): string { const envByModuleId: Record = { - [PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE]: process.env.LICENSE_LIMITS_MODULE_URL, - [PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE]: process.env.CUSTOM_TEMPLATES_MODULE_URL, - [PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE]: process.env.INTRANET_PENETRATION_MODULE_URL, + [PREMIUM_MODULE_IDS.PAID_CORE]: process.env.PAID_CORE_MODULE_URL, }; return (envByModuleId[moduleId] || '').trim().replace(/\/+$/, ''); @@ -582,66 +1014,6 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?: } } -export async function getAuthorizedUsageLimits(): Promise { - const moduleInfo = await getPremiumModuleInfo(PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE); - if (!moduleInfo || !isValidUsageLimits(moduleInfo.limits)) return null; - - if (!(await hasValidPremiumModuleEntitlement(moduleInfo))) return null; - const authorization = await authorizePremiumModuleAction( - PREMIUM_MODULE_IDS.LICENSE_LIMITS_CORE, - 'licenseLimits:enforce' - ); - if (!authorization.available) return null; - - return { - maxUsers: moduleInfo.limits.maxUsers, - maxMaterials: moduleInfo.limits.maxMaterials, - }; -} - -export async function enforceAuthorizedUsageLimit( - type: 'users' | 'materials', - current: number -): 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; @@ -764,6 +1136,1337 @@ export async function authorizePremiumModuleAction( } } +export async function validateUploadTemplateFieldsWithPaidCore( + fields: unknown +): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法校验自定义模板字段', + field: 'fields', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/validate-fields', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fields }), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.fields)) { + return { + valid: true, + fields: data.fields, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝该模板字段配置', + field: typeof data.field === 'string' ? data.field : 'fields', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板字段校验失败', + field: 'fields', + details: { moduleId }, + }; + } +} + +export async function normalizeUploadTemplateSaveWithPaidCore( + payload: unknown, + mode: UploadTemplateSaveMode +): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法校验自定义模板保存数据', + field: 'template', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/normalize-save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, payload }), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && data.template && typeof data.template === 'object') { + return { + valid: true, + mode: data.mode === 'update' ? 'update' : 'create', + fieldsProvided: data.fieldsProvided === true, + template: data.template, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝该模板保存数据', + field: typeof data.field === 'string' ? data.field : 'template', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板保存数据校验失败', + field: 'template', + details: { moduleId }, + }; + } +} + +export async function planUploadTemplateDeleteWithPaidCore(params: { + templateName: string; + materialCount: number; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + allowed: false, + error: gate.message || '付费增强包不可用,无法规划自定义模板删除', + errorCode: 'PAID_CORE_TEMPLATE_DELETE_PLAN_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/plan-delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && data.allowed === true) { + return { + valid: true, + allowed: true, + templateName: typeof data.templateName === 'string' ? data.templateName : params.templateName, + materialCount: Number.isFinite(Number(data.materialCount)) + ? Number(data.materialCount) + : params.materialCount, + }; + } + + if (data.valid === true && data.allowed === false) { + return { + valid: true, + allowed: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝删除该模板', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_DELETE_DENIED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } + + return { + valid: false, + allowed: false, + error: typeof data.error === 'string' ? data.error : '付费增强包无法规划自定义模板删除', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'PAID_CORE_TEMPLATE_DELETE_PLAN_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + allowed: false, + error: error instanceof Error ? error.message : '付费增强包模板删除规划失败', + errorCode: 'PAID_CORE_TEMPLATE_DELETE_PLAN_FAILED', + details: { moduleId }, + }; + } +} + +export async function planUploadTemplateUpdateWithPaidCore(params: { + payload: unknown; + existingTemplate: { + id: string; + version: number; + name: string; + updatedAt: unknown; + materialType: string; + materialKind: string; + materialCount: number; + fields: Array<{ + id: string; + key: string; + type: string; + label: string; + required: boolean; + addToCategory: boolean; + isSearchable: boolean; + isReadOnly: boolean; + validations: string | null; + optionsStatic: string | null; + conditions: string | null; + }>; + }; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法规划自定义模板更新', + errorCode: 'PAID_CORE_TEMPLATE_UPDATE_PLAN_UNAVAILABLE', + field: 'template', + statusCode: 403, + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/plan-update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && data.updateData && typeof data.updateData === 'object') { + return { + valid: true, + updateData: data.updateData, + fieldsProvided: data.fieldsProvided === true, + fields: Array.isArray(data.fields) ? data.fields : null, + nextVersion: Number.isFinite(Number(data.nextVersion)) + ? Number(data.nextVersion) + : Number(params.existingTemplate.version || 1) + 1, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包无法规划自定义模板更新', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'PAID_CORE_TEMPLATE_UPDATE_PLAN_FAILED', + field: typeof data.field === 'string' ? data.field : 'template', + statusCode: Number.isFinite(Number(data.statusCode)) ? Number(data.statusCode) : result.status || 400, + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板更新规划失败', + errorCode: 'PAID_CORE_TEMPLATE_UPDATE_PLAN_FAILED', + field: 'template', + statusCode: 500, + details: { moduleId }, + }; + } +} + +export async function validateSubmittedTemplateFieldsWithPaidCore(params: { + templateId: string; + templateName?: string; + fields: TemplateFieldForSubmittedValidation[]; + templateFields?: Record; + allowUnknownKeys?: boolean; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法校验自定义模板字段内容', + errorCode: 'PAID_CORE_TEMPLATE_FIELD_VALIDATION_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + templateId: params.templateId, + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/validate-template-fields', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + data.templateFields && + typeof data.templateFields === 'object' && + Array.isArray(data.visibleFieldKeys) + ) { + return { + valid: true, + templateName: typeof data.templateName === 'string' ? data.templateName : undefined, + templateFields: data.templateFields, + visibleFieldKeys: data.visibleFieldKeys, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝该自定义模板字段内容', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_FIELD_VALIDATION_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包自定义模板字段内容校验失败', + errorCode: 'PAID_CORE_TEMPLATE_FIELD_VALIDATION_FAILED', + details: { moduleId, templateId: params.templateId }, + }; + } +} + +export async function prepareMaterialMetadataWithPaidCore(params: { + templateId: string | null; + templateFields?: Record; + previewUrls?: string[]; + existingMetadata?: string | null; + maxSizeBytes?: number; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法准备自定义模板素材数据', + errorCode: 'PAID_CORE_METADATA_PREPARE_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + templateId: params.templateId, + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/prepare-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + data.metadataSize && + typeof data.metadataSize === 'object' + ) { + return { + valid: true, + standardData: data.standardData && typeof data.standardData === 'object' ? data.standardData : {}, + metadata: typeof data.metadata === 'string' ? data.metadata : null, + metadataSize: { + ok: true, + size: Number(data.metadataSize.size || 0), + maxSize: Number(data.metadataSize.maxSize || 0), + }, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝该自定义模板素材数据', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'METADATA_PREPARE_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包自定义模板素材数据准备失败', + errorCode: 'PAID_CORE_METADATA_PREPARE_FAILED', + details: { moduleId, templateId: params.templateId }, + }; + } +} + +export async function planTemplateMigrationBatchWithPaidCore(params: { + sourceTemplateId: string; + targetTemplateId?: string | null; + sourceTemplateName?: string | null; + targetTemplateName?: string | null; + totalMaterialCount: number; + request: { + updateMaterialType?: unknown; + previewOnly?: unknown; + forceLargeMigration?: unknown; + migrationLimit?: unknown; + }; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + allowed: false, + error: gate.message || '付费增强包不可用,无法规划模板迁移批次', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_BATCH_PLAN_UNAVAILABLE', + statusCode: 403, + details: { + moduleId, + status: gate.status || 'missing', + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId || null, + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/plan-migration-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (data.valid === true && data.allowed === true) { + return { + valid: true, + allowed: true, + sourceTemplateId: typeof data.sourceTemplateId === 'string' ? data.sourceTemplateId : params.sourceTemplateId, + targetTemplateId: + typeof data.targetTemplateId === 'string' ? data.targetTemplateId : String(params.targetTemplateId || ''), + sourceTemplateName: + typeof data.sourceTemplateName === 'string' ? data.sourceTemplateName : String(params.sourceTemplateName || ''), + targetTemplateName: + typeof data.targetTemplateName === 'string' ? data.targetTemplateName : String(params.targetTemplateName || ''), + totalMaterialCount: Number.isFinite(Number(data.totalMaterialCount)) + ? Number(data.totalMaterialCount) + : params.totalMaterialCount, + previewOnly: data.previewOnly === true, + forceLargeMigration: data.forceLargeMigration === true, + updateMaterialType: data.updateMaterialType !== false, + migrationLimit: Number.isFinite(Number(data.migrationLimit)) ? Number(data.migrationLimit) : 200, + safeLimit: Number.isFinite(Number(data.safeLimit)) ? Number(data.safeLimit) : 200, + maxBatchLimit: Number.isFinite(Number(data.maxBatchLimit)) ? Number(data.maxBatchLimit) : 1000, + batchLimited: data.batchLimited === true, + materialTake: Number.isFinite(Number(data.materialTake)) ? Number(data.materialTake) : null, + migratedAt: typeof data.migratedAt === 'string' ? data.migratedAt : new Date().toISOString(), + }; + } + + if (data.valid === true && data.allowed === false) { + return { + valid: true, + allowed: false, + statusCode: Number.isFinite(Number(data.statusCode)) ? Number(data.statusCode) : result.status || 400, + error: typeof data.error === 'string' ? data.error : undefined, + errorCode: typeof data.errorCode === 'string' ? data.errorCode : undefined, + response: data.response && typeof data.response === 'object' ? data.response : undefined, + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } + + return { + valid: false, + allowed: false, + error: typeof data.error === 'string' ? data.error : '付费增强包无法规划模板迁移批次', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_MIGRATION_BATCH_PLAN_FAILED', + statusCode: Number.isFinite(Number(data.statusCode)) ? Number(data.statusCode) : result.status || 400, + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + allowed: false, + error: error instanceof Error ? error.message : '付费增强包模板迁移批次规划失败', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_BATCH_PLAN_FAILED', + statusCode: 500, + details: { + moduleId, + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId || null, + }, + }; + } +} + +export async function prepareTemplateMigrationMetadataWithPaidCore(params: { + sourceTemplateId: string; + sourceTemplateName: string; + targetTemplateId: string; + targetTemplateName: string; + migratedAt: string; + targetFieldKeys: string[]; + targetFields?: Array<{ + key: string; + label?: string | null; + type?: string | null; + required?: boolean | null; + }>; + templateFields?: Record; + materialFieldValues?: Record; + materialStandardFields: Record; + previewUrls?: string[]; + existingMetadata?: string | null; + maxSizeBytes?: number; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法准备模板迁移数据', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const authorization = await fetchModuleJson(baseUrl, '/authorize', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'customTemplates:migrate', + context: { + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + }, + }), + }); + + if (!authorization.ok || authorization.data?.allowed !== true || authorization.data?.moduleId !== moduleId) { + return { + valid: false, + error: authorization.data?.error || '付费增强包未授权执行模板迁移', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAUTHORIZED', + details: { + moduleId, + status: 'running', + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + }, + }; + } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板迁移动作授权失败', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_UNAUTHORIZED', + details: { + moduleId, + status: 'running', + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + }, + }; + } + + try { + const result = await fetchModuleJson(baseUrl, '/templates/prepare-migration-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + data.metadataSize && + typeof data.metadataSize === 'object' + ) { + return { + valid: true, + standardData: data.standardData && typeof data.standardData === 'object' ? data.standardData : {}, + metadata: typeof data.metadata === 'string' ? data.metadata : null, + metadataSize: { + ok: true, + size: Number(data.metadataSize.size || 0), + maxSize: Number(data.metadataSize.maxSize || 0), + }, + templateFields: + data.templateFields && typeof data.templateFields === 'object' + ? data.templateFields + : {}, + droppedTemplateFieldCount: Number(data.droppedTemplateFieldCount || 0), + droppedStandardFieldCount: Number(data.droppedStandardFieldCount || 0), + droppedTemplateFields: + data.droppedTemplateFields && typeof data.droppedTemplateFields === 'object' + ? data.droppedTemplateFields + : {}, + droppedStandardFields: + data.droppedStandardFields && typeof data.droppedStandardFields === 'object' + ? data.droppedStandardFields + : {}, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝该模板迁移数据', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板迁移数据准备失败', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_FAILED', + details: { + moduleId, + sourceTemplateId: params.sourceTemplateId, + targetTemplateId: params.targetTemplateId, + }, + }; + } +} + +export async function buildTemplateMigrationPreviewWithPaidCore(params: { + totalMaterialCount: number; + migratableCount: number; + previewOnly: boolean; + previewLimit?: number; + targetFields: Array<{ + key: string; + label?: string | null; + type?: string | null; + required?: boolean | null; + }>; + sourceTemplateFields: Array<{ + key: string; + label?: string | null; + type?: string | null; + required?: boolean | null; + }>; + impacts: Array<{ + materialId: string; + productModel?: string | null; + droppedTemplateFieldKeys?: string[]; + droppedStandardFieldKeys?: string[]; + droppedTemplateFields?: Record; + droppedStandardFields?: Record; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法生成模板迁移预览', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_PREVIEW_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/build-migration-preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + data.migrationBackup && + typeof data.migrationBackup === 'object' && + data.migrationPreview && + typeof data.migrationPreview === 'object' + ) { + const migrationPreview = data.migrationPreview; + const backupFields = migrationPreview.backupFields && typeof migrationPreview.backupFields === 'object' + ? migrationPreview.backupFields + : {}; + + return { + valid: true, + migrationBackup: { + materialsWithDroppedFields: Number(data.migrationBackup.materialsWithDroppedFields || 0), + droppedTemplateFieldCount: Number(data.migrationBackup.droppedTemplateFieldCount || 0), + droppedStandardFieldCount: Number(data.migrationBackup.droppedStandardFieldCount || 0), + }, + migrationPreview: { + totalMaterialCount: Number(migrationPreview.totalMaterialCount || 0), + totalMaterials: Number(migrationPreview.totalMaterials || 0), + previewLimited: migrationPreview.previewLimited === true, + previewLimit: Number.isFinite(migrationPreview.previewLimit) + ? Number(migrationPreview.previewLimit) + : undefined, + targetCurrentFields: Array.isArray(migrationPreview.targetCurrentFields) + ? migrationPreview.targetCurrentFields + : [], + backupFields: { + templateFields: Array.isArray(backupFields.templateFields) ? backupFields.templateFields : [], + standardFields: Array.isArray(backupFields.standardFields) ? backupFields.standardFields : [], + }, + samples: Array.isArray(migrationPreview.samples) ? migrationPreview.samples : [], + }, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝生成模板迁移预览', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板迁移预览生成失败', + errorCode: 'PAID_CORE_TEMPLATE_MIGRATION_PREVIEW_FAILED', + details: { moduleId }, + }; + } +} + +export async function projectPublicTemplateOptionsWithPaidCore(params: { + builtinTemplates?: Array>; + customTemplates?: Array>; + materialCounts?: Record; + builtinMaterialTypes?: string[]; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法生成自定义模板展示列表', + errorCode: 'PAID_CORE_PUBLIC_TEMPLATE_OPTIONS_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/project-public-options', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + Array.isArray(data.templates) && + Array.isArray(data.uploadTemplates) && + Array.isArray(data.materialTypes) + ) { + return { + valid: true, + templates: data.templates, + uploadTemplates: data.uploadTemplates, + materialTypes: data.materialTypes.map((item: unknown) => String(item)), + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝生成自定义模板展示列表', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包自定义模板展示列表生成失败', + errorCode: 'PAID_CORE_PUBLIC_TEMPLATE_OPTIONS_FAILED', + details: { moduleId }, + }; + } +} + +export async function calculateTemplateFieldStatsWithPaidCore(params: { + fields: Array<{ + key: string; + label?: string | null; + type?: string | null; + }>; + materials: Array<{ + id?: string; + metadata?: string | null; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法计算模板字段填写率', + errorCode: 'PAID_CORE_TEMPLATE_FIELD_STATS_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/calculate-field-stats', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true) { + return { + valid: true, + fieldStats: data.fieldStats && typeof data.fieldStats === 'object' ? data.fieldStats : null, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝计算模板字段填写率', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'TEMPLATE_FIELD_STATS_CALCULATE_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板字段填写率计算失败', + errorCode: 'PAID_CORE_TEMPLATE_FIELD_STATS_FAILED', + details: { moduleId }, + }; + } +} + +export async function planMaterialCustomSearchWithPaidCore(params: { + search: string; + searchableFields: Array<{ + key: string; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法规划自定义字段搜索', + errorCode: 'PAID_CORE_MATERIAL_CUSTOM_SEARCH_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/plan-custom-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.customFieldKeys)) { + return { + valid: true, + search: typeof data.search === 'string' ? data.search : params.search, + customFieldKeys: data.customFieldKeys.map((key: unknown) => String(key)).filter(Boolean), + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝规划自定义字段搜索', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'MATERIAL_CUSTOM_SEARCH_PLAN_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包自定义字段搜索规划失败', + errorCode: 'PAID_CORE_MATERIAL_CUSTOM_SEARCH_PLAN_FAILED', + details: { moduleId }, + }; + } +} + +const MATERIAL_LIST_SEARCH_COLUMN_FIELDS = [ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'authorName', +]; + +const MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS = new Set([ + 'renderer', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'renderTime', + 'imageResolution', + 'colorMode', + 'productModel', + 'platformName', + 'categoryName', + 'sceneName', + 'authorName', +]); + +const HOME_FILTER_COLUMN_FIELDS = new Set([ + 'categoryName', + 'sceneName', + 'platformName', + 'materialType', + 'renderer', + 'brandName', + 'colorMode', + 'renderTime', + 'imageResolution', + 'seriesName', + 'skuCode', + 'materialTexture', + 'authorName', +]); + +function normalizeMaterialListQueryFallback(params: { + search: string; + homeFieldFilters: Array<{ key: string; value: string }>; +}): MaterialListQueryPlanResult { + const homeFieldFilters = params.homeFieldFilters + .map((filter) => ({ + key: String(filter.key || '').trim(), + value: String(filter.value || '').trim(), + })) + .filter((filter) => filter.key && filter.value); + + return { + valid: true, + search: params.search, + standardSearchFields: params.search ? MATERIAL_LIST_SEARCH_COLUMN_FIELDS : [], + customSearchFieldKeys: [], + standardHomeFilters: homeFieldFilters.filter((filter) => + MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key) + ), + customHomeFilters: [], + moduleAvailable: false, + }; +} + +function normalizeHomeFilterFieldCountsFallback(params: { + fields: Array<{ key: string }>; +}): HomeFilterFieldCountsPlanResult { + return { + valid: true, + fieldPlans: params.fields + .map((field) => String(field.key || '').trim()) + .filter(Boolean) + .filter((key) => HOME_FILTER_COLUMN_FIELDS.has(key)) + .map((key) => ({ key, source: 'column' })), + moduleAvailable: false, + }; +} + +export async function planMaterialListQueryWithPaidCore(params: { + search: string; + searchableFields: Array<{ + key: string; + }>; + homeFieldFilters: Array<{ + key: string; + value: string; + }>; + enabledHomeFilterFields: Array<{ + key: string; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return normalizeMaterialListQueryFallback(params); + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/plan-list-query', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + result.ok && + data.valid === true && + Array.isArray(data.standardSearchFields) && + Array.isArray(data.customSearchFieldKeys) && + Array.isArray(data.standardHomeFilters) && + Array.isArray(data.customHomeFilters) + ) { + const normalizeFilters = (filters: unknown) => + (Array.isArray(filters) ? filters : []) + .map((filter) => ({ + key: String((filter as any)?.key || '').trim(), + value: String((filter as any)?.value || '').trim(), + })) + .filter((filter) => filter.key && filter.value); + + return { + valid: true, + search: typeof data.search === 'string' ? data.search : params.search, + standardSearchFields: data.standardSearchFields.map((key: unknown) => String(key)).filter(Boolean), + customSearchFieldKeys: data.customSearchFieldKeys.map((key: unknown) => String(key)).filter(Boolean), + standardHomeFilters: normalizeFilters(data.standardHomeFilters), + customHomeFilters: normalizeFilters(data.customHomeFilters), + moduleAvailable: true, + }; + } + + return normalizeMaterialListQueryFallback(params); + } catch { + return normalizeMaterialListQueryFallback(params); + } +} + +export async function planHomeFilterFieldCountsWithPaidCore(params: { + fields: Array<{ + key: string; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return normalizeHomeFilterFieldCountsFallback(params); + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/plan-home-filter-fields', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.fieldPlans)) { + return { + valid: true, + fieldPlans: data.fieldPlans + .map((plan: unknown) => ({ + key: String((plan as any)?.key || '').trim(), + source: (plan as any)?.source === 'metadata' ? 'metadata' : 'column', + })) + .filter((plan: { key: string; source: 'column' | 'metadata' }) => plan.key), + moduleAvailable: true, + }; + } + + return normalizeHomeFilterFieldCountsFallback(params); + } catch { + return normalizeHomeFilterFieldCountsFallback(params); + } +} + +export async function planHomeFilterCustomFieldCountsWithPaidCore(params: { + fields: Array<{ + key: string; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法规划首页自定义筛选统计', + errorCode: 'PAID_CORE_HOME_FILTER_COUNTS_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/materials/plan-home-filter-counts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.customFieldKeys)) { + return { + valid: true, + customFieldKeys: data.customFieldKeys.map((key: unknown) => String(key)).filter(Boolean), + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝规划首页自定义筛选统计', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'HOME_FILTER_COUNTS_PLAN_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包首页自定义筛选统计规划失败', + errorCode: 'PAID_CORE_HOME_FILTER_COUNTS_PLAN_FAILED', + details: { moduleId }, + }; + } +} + +export async function planDepartmentScopeWithPaidCore(params: { + scopeDepartment: string; + targetDepartment?: string | null; + departments: Array<{ + name: string; + }> | string[]; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法规划部门管理范围', + errorCode: 'PAID_CORE_DEPARTMENT_SCOPE_UNAVAILABLE', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/departments/plan-scope', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.scopedDepartments)) { + return { + valid: true, + scopedDepartments: data.scopedDepartments.map((name: unknown) => String(name)).filter(Boolean), + withinScope: data.withinScope === true, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包拒绝规划部门管理范围', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'DEPARTMENT_SCOPE_PLAN_FAILED', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包部门管理范围规划失败', + errorCode: 'PAID_CORE_DEPARTMENT_SCOPE_PLAN_FAILED', + details: { moduleId }, + }; + } +} + +export async function planDepartmentStructureChangeWithPaidCore(params: { + action: 'create' | 'rename' | 'delete'; + target: Record; + departments: Array<{ + id: string; + name: string; + }>; + users?: Array<{ + id: string; + department?: string | null; + managedDepartment?: string | null; + }>; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + allowed: false, + error: gate.message || '付费增强包不可用,无法规划部门结构变更', + errorCode: 'PAID_CORE_DEPARTMENT_STRUCTURE_PLAN_UNAVAILABLE', + statusCode: 403, + details: { + moduleId, + status: gate.status || 'missing', + action: params.action, + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/departments/plan-structure-change', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if ( + data.valid === true && + data.allowed === true && + Array.isArray(data.departmentOps) && + Array.isArray(data.userOps) + ) { + return { + valid: true, + allowed: true, + action: ['create', 'rename', 'delete'].includes(data.action) ? data.action : params.action, + departmentOps: data.departmentOps, + userOps: data.userOps, + summary: data.summary && typeof data.summary === 'object' ? data.summary : undefined, + }; + } + + if (data.valid === true && data.allowed === false) { + return { + valid: true, + allowed: false, + statusCode: Number.isFinite(Number(data.statusCode)) ? Number(data.statusCode) : result.status || 400, + error: typeof data.error === 'string' ? data.error : undefined, + errorCode: typeof data.errorCode === 'string' ? data.errorCode : undefined, + response: data.response && typeof data.response === 'object' ? data.response : undefined, + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } + + return { + valid: false, + allowed: false, + error: typeof data.error === 'string' ? data.error : '付费增强包无法规划部门结构变更', + errorCode: typeof data.errorCode === 'string' ? data.errorCode : 'DEPARTMENT_STRUCTURE_CHANGE_PLAN_FAILED', + statusCode: Number.isFinite(Number(data.statusCode)) ? Number(data.statusCode) : result.status || 400, + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + allowed: false, + error: error instanceof Error ? error.message : '付费增强包部门结构变更规划失败', + errorCode: 'PAID_CORE_DEPARTMENT_STRUCTURE_PLAN_FAILED', + statusCode: 500, + details: { moduleId, action: params.action }, + }; + } +} + +export async function detectTemplateStructureChangesWithPaidCore(params: { + existingTemplate: { + materialType: string; + materialKind: string; + fields: Array<{ + id: string; + key: string; + type: string; + label: string; + required: boolean; + addToCategory: boolean; + isSearchable: boolean; + isReadOnly: boolean; + validations: string | null; + optionsStatic: string | null; + conditions: string | null; + }>; + }; + nextMaterialType?: string; + nextMaterialKind?: string; + nextFields?: FormattedUploadTemplateField[]; +}): Promise { + const moduleId = PREMIUM_MODULE_IDS.PAID_CORE; + const gate = await isPremiumModuleRunning(moduleId); + if (!gate.available) { + return { + valid: false, + error: gate.message || '付费增强包不可用,无法判断模板结构变更风险', + field: 'template', + details: { + moduleId, + status: gate.status || 'missing', + }, + }; + } + + const baseUrl = getPremiumModuleServiceBaseUrl(moduleId); + try { + const result = await fetchModuleJson(baseUrl, '/templates/detect-structure-changes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + const data = result.data || {}; + + if (result.ok && data.valid === true && Array.isArray(data.changes)) { + return { + valid: true, + changes: data.changes, + }; + } + + return { + valid: false, + error: typeof data.error === 'string' ? data.error : '付费增强包无法判断模板结构变更风险', + field: typeof data.field === 'string' ? data.field : 'template', + details: data.details && typeof data.details === 'object' ? data.details : undefined, + }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : '付费增强包模板结构变更判断失败', + field: 'template', + details: { moduleId }, + }; + } +} + export async function updatePremiumModuleRuntimeStatus(status: PremiumModuleRuntimeStatus): Promise { if (!status.moduleId || !status.image || !status.lastSyncAt) { throw new Error('授权模块运行状态缺少必要字段'); diff --git a/lib/material-permissions.ts b/lib/material-permissions.ts index 049fee7..c71fc1d 100644 --- a/lib/material-permissions.ts +++ b/lib/material-permissions.ts @@ -1,6 +1,6 @@ import type { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' -import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' +import { planDepartmentScopeWithPaidCore } from '@/lib/license' export type MaterialPermissionActor = { id: string @@ -104,13 +104,13 @@ export async function canManageMaterial( return false } - if (targetDepartment === scopeDepartment || targetDepartment.startsWith(`${scopeDepartment}-`)) { - return true - } - const departments = await prisma.department.findMany({ select: { name: true } }) - const descendantMap = buildDepartmentDescendants(departments) - return isDepartmentWithinScope(scopeDepartment, targetDepartment, descendantMap) + const scopePlan = await planDepartmentScopeWithPaidCore({ + scopeDepartment, + targetDepartment, + departments, + }) + return scopePlan.valid ? scopePlan.withinScope : false } export async function buildMaterialManagementScopeWhere( @@ -135,13 +135,18 @@ export async function buildMaterialManagementScopeWhere( } const departments = await prisma.department.findMany({ select: { name: true } }) - const descendants = buildDepartmentDescendants(departments).get(scopeDepartment) - const scopedDepartments = Array.from(descendants || new Set([scopeDepartment])) + const scopePlan = await planDepartmentScopeWithPaidCore({ + scopeDepartment, + departments, + }) + if (!scopePlan.valid || scopePlan.scopedDepartments.length === 0) { + return { OR: ownWhere } + } return { OR: [ ...ownWhere, - { user: { department: { in: scopedDepartments } } }, + { user: { department: { in: scopePlan.scopedDepartments } } }, { user: { department: { startsWith: `${scopeDepartment}-` } } }, ], } diff --git a/lib/template-field-validation.ts b/lib/template-field-validation.ts index 72b1c38..339629c 100644 --- a/lib/template-field-validation.ts +++ b/lib/template-field-validation.ts @@ -1,6 +1,7 @@ -import { getTemplateWithFields } from './template-validator' +import { getTemplateWithFields, isBuiltinTemplate } from './template-validator' import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from './field-conditions' import { parseValidationRules, validateFieldValue } from './field-validation' +import { validateSubmittedTemplateFieldsWithPaidCore } from './license/premium-module-service' type TemplateFieldLike = { key: string @@ -27,31 +28,14 @@ export type TemplateFieldValidationResult = export { hasFieldValue } from './field-conditions' -export async function validateSubmittedTemplateFields(args: { - templateId: string | null | undefined +function validateSubmittedTemplateFieldsLocally(args: { + templateId: string + templateName?: string + fields: TemplateFieldLike[] templateFields: Record - allowUnknownKeys?: boolean -}): Promise { - const { templateId, templateFields, allowUnknownKeys = true } = args - - if (!templateId) { - return { - valid: true, - templateFields, - visibleFieldKeys: Object.keys(templateFields), - } - } - - const template = await getTemplateWithFields(templateId) - if (!template) { - return { - valid: false, - error: '模板不存在或不可用', - errorCode: 'TEMPLATE_NOT_FOUND', - } - } - - const fields = (template.fields || []) as TemplateFieldLike[] + allowUnknownKeys: boolean +}): TemplateFieldValidationResult { + const { templateId, templateName, fields, templateFields, allowUnknownKeys } = args const fieldMap = new Map(fields.map((field) => [field.key, field])) const visibleFields = getVisibleTemplateFields(fields, templateFields) const visibleTemplateFields = filterVisibleFieldValues(fields, templateFields) @@ -124,8 +108,54 @@ export async function validateSubmittedTemplateFields(args: { return { valid: true, - templateName: typeof template.name === 'string' ? template.name : undefined, + templateName, templateFields: visibleTemplateFields, visibleFieldKeys: visibleFields.map((field) => field.key), } } + +export async function validateSubmittedTemplateFields(args: { + templateId: string | null | undefined + templateFields: Record + allowUnknownKeys?: boolean +}): Promise { + const { templateId, templateFields, allowUnknownKeys = true } = args + + if (!templateId) { + return { + valid: true, + templateFields, + visibleFieldKeys: Object.keys(templateFields), + } + } + + const template = await getTemplateWithFields(templateId) + if (!template) { + return { + valid: false, + error: '模板不存在或不可用', + errorCode: 'TEMPLATE_NOT_FOUND', + } + } + + const fields = (template.fields || []) as TemplateFieldLike[] + const templateName = typeof template.name === 'string' ? template.name : undefined + + if (!isBuiltinTemplate(templateId)) { + return validateSubmittedTemplateFieldsWithPaidCore({ + templateId, + templateName, + fields, + templateFields, + allowUnknownKeys, + }) + } + + return validateSubmittedTemplateFieldsLocally({ + templateId, + templateName, + fields, + templateFields, + allowUnknownKeys, + }) +} diff --git a/lib/tunnel/intranet-module.ts b/lib/tunnel/intranet-module.ts index 78d6ff1..ee1e26c 100644 --- a/lib/tunnel/intranet-module.ts +++ b/lib/tunnel/intranet-module.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'; type TunnelAction = 'status' | 'start' | 'stop'; function getModuleBaseUrl(): string { - return (process.env.INTRANET_PENETRATION_MODULE_URL || '').trim().replace(/\/+$/, ''); + return (process.env.PAID_CORE_MODULE_URL || '').trim().replace(/\/+$/, ''); } export async function proxyIntranetModule(action: TunnelAction): Promise { @@ -11,7 +11,7 @@ export async function proxyIntranetModule(action: TunnelAction): Promise ({ error: '内网穿透模块返回数据异常', })); diff --git a/lib/upload-template-save-validation.ts b/lib/upload-template-save-validation.ts index 48fc931..1a3b939 100644 --- a/lib/upload-template-save-validation.ts +++ b/lib/upload-template-save-validation.ts @@ -1,43 +1,3 @@ -import { - detectCircularDependencies, - getAllOperators, - parseFieldConditions, - type FieldConditions, -} from './field-conditions' -import { - parseValidationRules, - validateValidationRules, - getSupportedValidations, - type ValidationRule, -} from './field-validation' - -const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived']) -export const UPLOAD_TEMPLATE_FIELD_LIMITS = { - maxFields: 80, - maxKeyLength: 64, - maxLabelLength: 80, - maxOptions: 200, - maxOptionLength: 80, -} as const - -const ALLOWED_FIELD_TYPES = new Set([ - 'text', - 'textarea', - 'number', - 'select', - 'checkbox', - 'radio', - 'date', - 'uppercase', -]) -const OPTION_FIELD_TYPES = new Set(['select', 'radio']) -const CONDITION_OPERATORS = new Set(getAllOperators().map((operator) => operator.value)) -const CONDITION_OPERATORS_REQUIRING_VALUE = new Set( - getAllOperators() - .filter((operator) => operator.needsValue) - .map((operator) => operator.value) -) - export type FormattedUploadTemplateField = { id?: string | null key: string @@ -65,433 +25,3 @@ export type UploadTemplateSaveValidationResult = field: string details?: Record } - -export function isValidUploadTemplateStatus(status: unknown): status is string { - return typeof status === 'string' && ALLOWED_TEMPLATE_STATUSES.has(status) -} - -function normalizeStaticOptions(value: unknown): { value: string | null; invalid: boolean } { - if (value === null || value === undefined) { - return { value: null, invalid: false } - } - - if (Array.isArray(value)) { - const options = value - .map((option) => String(option ?? '').trim()) - .filter(Boolean) - return { - value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null, - invalid: false, - } - } - - if (typeof value === 'string') { - const trimmed = value.trim() - if (!trimmed) { - return { value: null, invalid: false } - } - - try { - const parsed = JSON.parse(trimmed) - return normalizeStaticOptions(parsed) - } catch { - const options = trimmed - .split('\n') - .map((option) => option.trim()) - .filter(Boolean) - return { - value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null, - invalid: false, - } - } - } - - return { value: null, invalid: true } -} - -function parseNormalizedOptions(value: string | null): string[] { - if (!value) { - return [] - } - - try { - const parsed = JSON.parse(value) - return Array.isArray(parsed) - ? parsed.map((option) => String(option ?? '').trim()).filter(Boolean) - : [] - } catch { - return [] - } -} - -function normalizeValidations( - value: unknown, - fieldType: string, -): { value: string | null; invalid?: string; rules?: ValidationRule } { - if (value === null || value === undefined) { - return { value: null } - } - - if (typeof value === 'string' && value.trim() === '') { - return { value: null } - } - - let raw: unknown = value - if (typeof value === 'string') { - try { - raw = JSON.parse(value) - } catch { - return { value: null, invalid: '验证规则必须是合法 JSON 对象' } - } - } - - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return { value: null, invalid: '验证规则必须是对象' } - } - - const parsedRules = parseValidationRules(JSON.stringify(raw)) - if (!parsedRules) { - return { value: null, invalid: '验证规则必须是对象' } - } - - const validation = validateValidationRules(parsedRules) - if (!validation.valid) { - return { value: null, invalid: validation.error || '验证规则配置无效' } - } - - const supportedValidations = new Set(getSupportedValidations(fieldType)) - const unsupportedRules = [ - ['minLength', parsedRules.minLength], - ['maxLength', parsedRules.maxLength], - ['pattern', parsedRules.pattern], - ['min', parsedRules.min], - ['max', parsedRules.max], - ] - .filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey))) - .map(([ruleKey]) => String(ruleKey)) - - if (unsupportedRules.length > 0) { - return { - value: null, - invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`, - } - } - - return { - value: JSON.stringify(parsedRules), - rules: parsedRules, - } -} - -function normalizeConditions( - value: unknown, -): { value: string | null; invalid: boolean; conditions?: FieldConditions } { - if (value === null || value === undefined) { - return { value: null, invalid: false } - } - - if (typeof value === 'string' && value.trim() === '') { - return { value: null, invalid: false } - } - - let raw: any - try { - raw = typeof value === 'string' ? JSON.parse(value) : value - } catch { - return { value: null, invalid: true } - } - - if (!raw || typeof raw !== 'object') { - return { value: null, invalid: true } - } - - if (raw.logic !== undefined && raw.logic !== 'AND' && raw.logic !== 'OR') { - return { value: null, invalid: true } - } - - if (!Array.isArray(raw.conditions)) { - return { value: null, invalid: true } - } - - if (raw.conditions.length === 0) { - return { value: null, invalid: false } - } - - for (const condition of raw.conditions) { - if (!condition || typeof condition !== 'object') { - return { value: null, invalid: true } - } - if (!condition.fieldKey || !condition.operator) { - return { value: null, invalid: true } - } - } - - const parsed = parseFieldConditions(raw as FieldConditions) - if (!parsed || parsed.conditions.length !== raw.conditions.length) { - return { value: null, invalid: true } - } - - return { value: JSON.stringify(parsed), invalid: false, conditions: parsed } -} - -export function validateAndFormatUploadTemplateFields( - fields: unknown, -): UploadTemplateSaveValidationResult { - if (!Array.isArray(fields)) { - return { - valid: false, - error: '字段列表必须是数组', - field: 'fields', - } - } - - if (fields.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields) { - return { - valid: false, - error: `单个模板最多支持 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields} 个字段,请拆分模板或减少字段数量`, - field: 'fields', - details: { - maxFields: UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields, - currentFields: fields.length, - }, - } - } - - const fieldKeys = new Set() - const duplicateKeys: string[] = [] - const formattedFields: FormattedUploadTemplateField[] = [] - - for (let index = 0; index < fields.length; index += 1) { - const rawField = fields[index] - const field = (rawField || {}) as Record - const key = String(field.key || '').trim() - - if (!key) { - return { - valid: false, - error: '字段key不能为空', - field: 'fields', - } - } - - if (fieldKeys.has(key)) { - duplicateKeys.push(key) - } else { - fieldKeys.add(key) - } - - const label = String(field.label || '').trim() || `字段${index + 1}` - const type = String(field.type || 'text').trim() || 'text' - const addToCategory = !!field.addToCategory - const normalizedOptions = normalizeStaticOptions(field.optionsStatic) - const optionsStatic = normalizedOptions.value - const normalizedValidations = normalizeValidations(field.validations, type) - const normalizedConditions = normalizeConditions(field.conditions) - const conditions = normalizedConditions.value - - if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { - return { - valid: false, - error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`, - field: 'fields', - details: { fieldKey: key }, - } - } - - if (key.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength) { - return { - valid: false, - error: `字段“${label}”的 key 不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength} 个字符`, - field: 'fields', - details: { - fieldKey: key, - maxKeyLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength, - }, - } - } - - if (label.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength) { - return { - valid: false, - error: `字段“${label.slice(0, 20)}...”的名称不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength} 个字符`, - field: 'fields', - details: { - fieldKey: key, - maxLabelLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength, - }, - } - } - - if (!ALLOWED_FIELD_TYPES.has(type)) { - return { - valid: false, - error: `字段“${label}”的类型不支持:${type}`, - field: 'fields', - details: { - fieldKey: key, - fieldType: type, - allowedTypes: Array.from(ALLOWED_FIELD_TYPES), - }, - } - } - - if (OPTION_FIELD_TYPES.has(type)) { - if (normalizedOptions.invalid) { - return { - valid: false, - error: `字段“${label}”的选项必须是数组或每行一个选项`, - field: 'fields', - details: { fieldKey: key }, - } - } - - const options = parseNormalizedOptions(optionsStatic) - if (options.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions) { - return { - valid: false, - error: `字段“${label}”的选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions} 个`, - field: 'fields', - details: { - fieldKey: key, - maxOptions: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions, - currentOptions: options.length, - }, - } - } - - const tooLongOption = options.find((option) => option.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength) - if (tooLongOption) { - return { - valid: false, - error: `字段“${label}”的单个选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength} 个字符`, - field: 'fields', - details: { - fieldKey: key, - maxOptionLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength, - sample: tooLongOption.slice(0, 20), - }, - } - } - - if (field.required && !addToCategory && options.length === 0) { - return { - valid: false, - error: `字段“${label}”是必填${type === 'radio' ? '单选' : '下拉'}字段,至少需要配置一个选项`, - field: 'fields', - details: { fieldKey: key }, - } - } - } - - if (normalizedValidations.invalid) { - return { - valid: false, - error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`, - field: 'fields', - details: { fieldKey: key }, - } - } - - if (normalizedConditions.invalid) { - return { - valid: false, - error: `字段“${label}”的显示条件格式不正确`, - field: 'fields', - details: { fieldKey: key }, - } - } - - formattedFields.push({ - id: field.id ?? null, - key, - label, - type, - required: !!field.required, - orderIndex: field.orderIndex ?? index, - layoutWidth: field.layoutWidth || 'full', - validations: normalizedValidations.value, - optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource), - optionsStatic, - allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions), - visibleOnHomeFilter: !!field.visibleOnHomeFilter, - isSearchable: !!field.isSearchable, - isReadOnly: !!field.isReadOnly, - addToCategory, - conditions, - }) - } - - if (duplicateKeys.length > 0) { - return { - valid: false, - error: `字段key重复:${duplicateKeys.join(', ')}`, - field: 'fields', - details: { duplicateKeys }, - } - } - - const availableKeys = new Set(formattedFields.map((field) => field.key)) - - for (const field of formattedFields) { - const conditions = parseFieldConditions(field.conditions) - if (!conditions) { - continue - } - - for (const condition of conditions.conditions) { - if (!CONDITION_OPERATORS.has(condition.operator)) { - return { - valid: false, - error: `字段“${field.label}”的显示条件包含不支持的判断方式`, - field: 'fields', - details: { fieldKey: field.key, operator: condition.operator }, - } - } - - if (!availableKeys.has(condition.fieldKey)) { - return { - valid: false, - error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`, - field: 'fields', - details: { fieldKey: field.key, dependencyKey: condition.fieldKey }, - } - } - - if (condition.fieldKey === field.key) { - return { - valid: false, - error: `字段“${field.label}”的显示条件不能引用自己`, - field: 'fields', - details: { fieldKey: field.key }, - } - } - - if ( - CONDITION_OPERATORS_REQUIRING_VALUE.has(condition.operator) && - String(condition.value ?? '').trim() === '' - ) { - return { - valid: false, - error: `字段“${field.label}”的显示条件缺少比较值`, - field: 'fields', - details: { fieldKey: field.key, operator: condition.operator }, - } - } - } - } - - const circularFields = detectCircularDependencies(formattedFields) - if (circularFields.length > 0) { - return { - valid: false, - error: `字段显示条件存在循环依赖:${circularFields.join(', ')}`, - field: 'fields', - details: { circularFields }, - } - } - - return { - valid: true, - fields: formattedFields, - } -} diff --git a/license-server/.env.example b/license-server/.env.example index 7c80946..12f6b73 100644 --- a/license-server/.env.example +++ b/license-server/.env.example @@ -31,18 +31,14 @@ LICENSE_REQUEST_SIGNING_SECRET="replace-with-32-byte-or-longer-request-signing-s # 付费模块短期拉包 token 签名密钥(生产环境建议独立配置,最少 32 字节) MODULE_PULL_TOKEN_SECRET="replace-with-32-byte-or-longer-module-pull-token-secret" -# 授权模块分发 -# 模块核心本体放在镜像站,验证服务器只下发授权清单和实例绑定凭证。 -# 免费版限额模块 license-limits-core 也走这条链路。 +# 付费增强包分发 +# paid-core 核心本体放在镜像站,验证服务器只下发授权清单、实例绑定凭证和短期拉包 token。 +# 免费版不拉 paid-core;免费版人数/素材数上限由基础镜像按本地签名许可证执行。 PREMIUM_MODULE_REGISTRY_BASE="registry.example.com/flnxi/premium" PREMIUM_MODULE_VERSION="1.0.0" -# 如需单独覆盖某个模块镜像,可使用: -# PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE="registry.example.com/flnxi/premium/license-limits-core:1.0.0" -# PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE="registry.example.com/flnxi/premium/custom-templates-core:1.0.0" -# PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE="registry.example.com/flnxi/premium/intranet-penetration-core:1.0.0" -# PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST="sha256:..." -# PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST="sha256:..." -# PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST="sha256:..." +# 如需单独覆盖 paid-core 镜像,可使用: +# PREMIUM_MODULE_PAID_CORE_IMAGE="registry.example.com/flnxi/premium/paid-core:1.0.0" +# PREMIUM_MODULE_PAID_CORE_DIGEST="sha256:..." # 管理员初始密码(只作为初始化入口,不要保留弱默认值) ADMIN_INITIAL_PASSWORD="replace-with-strong-initial-admin-password" diff --git a/license-server/app/api/registry/auth/route.ts b/license-server/app/api/registry/auth/route.ts index 20790af..459b02f 100644 --- a/license-server/app/api/registry/auth/route.ts +++ b/license-server/app/api/registry/auth/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getPublicProjectDb } from '@/lib/project-auth'; +import { getPublicProjectDb } from '../../../../lib/project-auth'; import { MODULE_PULL_TOKEN_USERNAME, hashModulePullToken, verifyModulePullToken, -} from '@/lib/module-pull-token'; -import { getAuthorizedModuleGrant } from '@/lib/module-grants'; +} from '../../../../lib/module-pull-token'; +import { getAuthorizedModuleGrant } from '../../../../lib/module-grants'; export const dynamic = 'force-dynamic'; diff --git a/license-server/docker-compose.yml b/license-server/docker-compose.yml index 23caef8..24c3167 100644 --- a/license-server/docker-compose.yml +++ b/license-server/docker-compose.yml @@ -42,15 +42,9 @@ services: - MODULE_PULL_TOKEN_SECRET=${MODULE_PULL_TOKEN_SECRET:?MODULE_PULL_TOKEN_SECRET is required} - PREMIUM_MODULE_REGISTRY_BASE=${PREMIUM_MODULE_REGISTRY_BASE:?PREMIUM_MODULE_REGISTRY_BASE is required} - PREMIUM_MODULE_VERSION=${PREMIUM_MODULE_VERSION:-1.0.0} - - PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE:-} - - PREMIUM_MODULE_LICENSE_LIMITS_CORE_TAG=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_TAG:-} - - PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST=${PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST:-} - - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE:-} - - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_TAG=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_TAG:-} - - PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST=${PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST:-} - - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE:-} - - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_TAG=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_TAG:-} - - PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=${PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST:-} + - PREMIUM_MODULE_PAID_CORE_IMAGE=${PREMIUM_MODULE_PAID_CORE_IMAGE:-} + - PREMIUM_MODULE_PAID_CORE_TAG=${PREMIUM_MODULE_PAID_CORE_TAG:-} + - PREMIUM_MODULE_PAID_CORE_DIGEST=${PREMIUM_MODULE_PAID_CORE_DIGEST:-} - ADMIN_INITIAL_PASSWORD=${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD is required} - NODE_ENV=production depends_on: diff --git a/license-server/lib/__tests__/env-example.test.ts b/license-server/lib/__tests__/env-example.test.ts index d0702fa..65d570d 100644 --- a/license-server/lib/__tests__/env-example.test.ts +++ b/license-server/lib/__tests__/env-example.test.ts @@ -11,7 +11,7 @@ describe('license-server 首装环境示例', () => { it('应该包含授权模块镜像站配置,避免验证服务器承担模块本体下载', () => { expect(envExample).toContain('PREMIUM_MODULE_REGISTRY_BASE='); expect(envExample).toContain('PREMIUM_MODULE_VERSION='); - expect(envExample).toContain('PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE'); + expect(envExample).toContain('PREMIUM_MODULE_PAID_CORE_IMAGE'); expect(envExample).toContain('MODULE_PULL_TOKEN_SECRET='); }); diff --git a/license-server/lib/__tests__/module-grants.test.ts b/license-server/lib/__tests__/module-grants.test.ts index 4462adc..93fba4f 100644 --- a/license-server/lib/__tests__/module-grants.test.ts +++ b/license-server/lib/__tests__/module-grants.test.ts @@ -68,11 +68,8 @@ describe('module-grants', () => { expiresAt: null, }); - expect(prisma.licenseModuleGrant.create).toHaveBeenCalledTimes(2); - expect(Array.from(prisma.grants.keys()).sort()).toEqual([ - 'license-1:custom-templates-core', - 'license-1:license-limits-core', - ]); + expect(prisma.licenseModuleGrant.create).toHaveBeenCalledTimes(1); + expect(Array.from(prisma.grants.keys()).sort()).toEqual(['license-1:paid-core']); }); it('已禁用的模块授权不应被默认初始化重新启用', async () => { @@ -80,9 +77,9 @@ describe('module-grants', () => { const prisma = createFakePrisma([ { licenseId: 'license-1', - moduleId: 'custom-templates-core', + moduleId: 'paid-core', status: 'disabled', - image: 'registry.test/flnxi/premium/custom-templates-core:1.0.0', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', version: '1.0.0', digest: null, expiresAt: null, @@ -93,8 +90,8 @@ describe('module-grants', () => { const license = { id: 'license-1', licenseType: 'STANDARD' as const, expiresAt: null }; await ensureDefaultModuleGrants(prisma, license); - expect(prisma.grants.get('license-1:custom-templates-core')?.status).toBe('disabled'); - await expect(getAuthorizedModuleGrant(prisma, license, 'custom-templates-core')).resolves.toBeNull(); + expect(prisma.grants.get('license-1:paid-core')?.status).toBe('disabled'); + await expect(getAuthorizedModuleGrant(prisma, license, 'paid-core')).resolves.toBeNull(); }); it('被吊销或过期的模块不应出现在授权清单里', async () => { @@ -102,21 +99,14 @@ describe('module-grants', () => { const prisma = createFakePrisma([ { licenseId: 'license-1', - moduleId: 'license-limits-core', - status: 'active', - expiresAt: null, - revokedAt: null, - }, - { - licenseId: 'license-1', - moduleId: 'custom-templates-core', + moduleId: 'paid-core', status: 'active', expiresAt: null, revokedAt: new Date('2026-05-01T00:00:00.000Z'), }, { licenseId: 'license-1', - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core-expired', status: 'active', expiresAt: new Date('2026-05-30T00:00:00.000Z'), revokedAt: null, @@ -129,6 +119,6 @@ describe('module-grants', () => { new Date('2026-06-02T00:00:00.000Z') ); - expect(grants.map((grant) => grant.moduleId)).toEqual(['license-limits-core']); + expect(grants.map((grant) => grant.moduleId)).toEqual([]); }); }); diff --git a/license-server/lib/__tests__/module-pull-token.test.ts b/license-server/lib/__tests__/module-pull-token.test.ts index 3d7c05e..e4fd599 100644 --- a/license-server/lib/__tests__/module-pull-token.test.ts +++ b/license-server/lib/__tests__/module-pull-token.test.ts @@ -19,21 +19,21 @@ describe('module-pull-token', () => { licenseId: 'license-1', activationId: 'activation-1', instanceId: 'instance-1', - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', registryHost: 'registry.test', - repository: 'flnxi/premium/intranet-penetration-core', + repository: 'flnxi/premium/paid-core', tag: '1.0.0', digest: 'sha256:abc', }); expect(credential.username).toBe('flnxi-module'); expect(credential.expiresAt).toBe('2026-06-02T00:15:00.000Z'); - expect(payload.repository).toBe('flnxi/premium/intranet-penetration-core'); + expect(payload.repository).toBe('flnxi/premium/paid-core'); expect(verifyModulePullToken(credential.pullToken)).toEqual( expect.objectContaining({ projectCode: 'flnxi', - moduleId: 'intranet-penetration-core', - repository: 'flnxi/premium/intranet-penetration-core', + moduleId: 'paid-core', + repository: 'flnxi/premium/paid-core', digest: 'sha256:abc', }) ); @@ -46,16 +46,16 @@ describe('module-pull-token', () => { licenseId: 'license-1', activationId: 'activation-1', instanceId: 'instance-1', - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', registryHost: 'registry.test', - repository: 'flnxi/premium/intranet-penetration-core', + repository: 'flnxi/premium/paid-core', tag: '1.0.0', digest: null, }); const [payload, signature] = credential.pullToken.split('.'); const decoded = JSON.parse(Buffer.from(payload + '==', 'base64url').toString('utf8')); - decoded.moduleId = 'custom-templates-core'; + decoded.moduleId = 'tampered-paid-core'; const tamperedPayload = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url'); expect(verifyModulePullToken(`${tamperedPayload}.${signature}`)).toBeNull(); @@ -68,9 +68,9 @@ describe('module-pull-token', () => { licenseId: 'license-1', activationId: 'activation-1', instanceId: 'instance-1', - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', registryHost: 'registry.test', - repository: 'flnxi/premium/intranet-penetration-core', + repository: 'flnxi/premium/paid-core', tag: '1.0.0', digest: null, }); diff --git a/license-server/lib/__tests__/premium-module-manifest.test.ts b/license-server/lib/__tests__/premium-module-manifest.test.ts index 8afc476..22383e8 100644 --- a/license-server/lib/__tests__/premium-module-manifest.test.ts +++ b/license-server/lib/__tests__/premium-module-manifest.test.ts @@ -18,47 +18,38 @@ describe('premium-module-manifest', () => { delete process.env.PREMIUM_MODULE_VERSION; }); - it('应为标准版生成自定义模板核心模块清单', async () => { + it('应为标准版生成统一付费核心模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); const result = buildSignedPremiumModuleManifest('STANDARD', new Date('2026-06-25T00:00:00.000Z'), 'instance-1'); expect(result.manifest.licenseType).toBe('STANDARD'); expect(result.manifest.instanceId).toBe('instance-1'); - expect(result.manifest.modules.map((module) => module.id)).toEqual([ - 'license-limits-core', - 'custom-templates-core', - ]); + expect(result.manifest.modules.map((module) => module.id)).toEqual(['paid-core']); expect(result.manifest.modules[0].artifact).toEqual({ type: 'registry-image', - image: 'registry.test/flnxi/premium/license-limits-core:1.0.0', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', digest: null, version: '1.0.0', }); - expect(result.manifest.modules[0].limits).toEqual({ maxUsers: -1, maxMaterials: -1 }); + expect(result.manifest.modules[0].limits).toBeUndefined(); expect(result.manifest.modules[0].sizeBytes).toBeGreaterThan(0); expect(result.manifest.modules[0].sha256).toMatch(/^[a-f0-9]{64}$/); expect(result.signature).toContain('signed:'); }); - it('应为免费版生成限额授权模块清单', async () => { + it('免费版不应下发付费模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); const result = buildSignedPremiumModuleManifest('FREE', new Date('2026-06-05T00:00:00.000Z'), 'instance-1'); expect(result.manifest.licenseType).toBe('FREE'); - expect(result.manifest.modules.map((module) => module.id)).toEqual(['license-limits-core']); - expect(result.manifest.modules[0].minLicenseType).toBe('FREE'); - expect(result.manifest.modules[0].limits).toEqual({ maxUsers: 10, maxMaterials: 100 }); + expect(result.manifest.modules).toEqual([]); }); - it('应为专业版生成全部授权模块清单', async () => { + it('专业版也应下发统一付费核心模块清单', async () => { const { buildSignedPremiumModuleManifest } = await import('../premium-module-manifest'); const result = buildSignedPremiumModuleManifest('PRO', new Date('2026-07-25T00:00:00.000Z'), 'instance-1'); - expect(result.manifest.modules.map((module) => module.id)).toEqual([ - 'license-limits-core', - 'custom-templates-core', - 'intranet-penetration-core', - ]); + expect(result.manifest.modules.map((module) => module.id)).toEqual(['paid-core']); }); it('应生成和清单 hash 匹配的实例绑定授权凭证', async () => { @@ -66,13 +57,13 @@ describe('premium-module-manifest', () => { const leaseExpiresAt = new Date('2026-07-25T00:00:00.000Z'); const manifest = buildSignedPremiumModuleManifest('PRO', leaseExpiresAt, 'instance-1').manifest; const entitlement = buildPremiumModuleEntitlement({ - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', licenseType: 'PRO', instanceId: 'instance-1', leaseExpiresAt, }); - const manifestModule = manifest.modules.find((module) => module.id === 'intranet-penetration-core'); + const manifestModule = manifest.modules.find((module) => module.id === 'paid-core'); expect(entitlement.sha256).toBe(manifestModule?.sha256); expect(entitlement.sizeBytes).toBe(manifestModule?.sizeBytes); expect(entitlement.artifact).toEqual(manifestModule?.artifact); diff --git a/license-server/lib/__tests__/registry-auth-route.test.ts b/license-server/lib/__tests__/registry-auth-route.test.ts index c9ed0c2..bcef166 100644 --- a/license-server/lib/__tests__/registry-auth-route.test.ts +++ b/license-server/lib/__tests__/registry-auth-route.test.ts @@ -1,22 +1,22 @@ import { NextRequest } from 'next/server'; -jest.mock('@/lib/project-auth', () => ({ +jest.mock('../project-auth', () => ({ getPublicProjectDb: jest.fn(), })); -jest.mock('@/lib/module-pull-token', () => ({ +jest.mock('../module-pull-token', () => ({ MODULE_PULL_TOKEN_USERNAME: 'flnxi-module', hashModulePullToken: jest.fn(() => 'token-hash'), verifyModulePullToken: jest.fn(), })); -jest.mock('@/lib/module-grants', () => ({ +jest.mock('../module-grants', () => ({ getAuthorizedModuleGrant: jest.fn(), })); -import { getPublicProjectDb } from '@/lib/project-auth'; -import { verifyModulePullToken } from '@/lib/module-pull-token'; -import { getAuthorizedModuleGrant } from '@/lib/module-grants'; +import { getPublicProjectDb } from '../project-auth'; +import { verifyModulePullToken } from '../module-pull-token'; +import { getAuthorizedModuleGrant } from '../module-grants'; const mockGetPublicProjectDb = getPublicProjectDb as jest.MockedFunction; const mockVerifyModulePullToken = verifyModulePullToken as jest.MockedFunction; @@ -36,9 +36,9 @@ describe('registry auth route', () => { licenseId: 'license-1', activationId: 'activation-1', instanceId: 'instance-1', - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', registryHost: 'registry.test', - repository: 'flnxi/premium/intranet-penetration-core', + repository: 'flnxi/premium/paid-core', tag: '1.0.0', digest: 'sha256:abc', iat: 1780272000, @@ -46,10 +46,10 @@ describe('registry auth route', () => { jti: 'jti-1', }); mockGetAuthorizedModuleGrant.mockResolvedValue({ - moduleId: 'intranet-penetration-core', + moduleId: 'paid-core', artifact: { type: 'registry-image', - image: 'registry.test/flnxi/premium/intranet-penetration-core:1.0.0', + image: 'registry.test/flnxi/premium/paid-core:1.0.0', version: '1.0.0', digest: 'sha256:abc', }, @@ -87,8 +87,8 @@ describe('registry auth route', () => { id: 'audit-1', licenseId: 'license-1', activationId: 'activation-1', - moduleId: 'intranet-penetration-core', - repository: 'flnxi/premium/intranet-penetration-core', + moduleId: 'paid-core', + repository: 'flnxi/premium/paid-core', digest: 'sha256:abc', expiresAt: new Date('2026-06-02T00:15:00.000Z'), lastValidatedAt: null, @@ -105,7 +105,7 @@ describe('registry auth route', () => { it('应允许 token 声明仓库和 digest 完全匹配的拉取请求', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:abc&referenceType=manifests&reference=sha256:abc', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&digest=sha256:abc&referenceType=manifests&reference=sha256:abc', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -121,8 +121,8 @@ describe('registry auth route', () => { expect(data).toEqual( expect.objectContaining({ success: true, - moduleId: 'intranet-penetration-core', - repository: 'flnxi/premium/intranet-penetration-core', + moduleId: 'paid-core', + repository: 'flnxi/premium/paid-core', }) ); expect(mockGetPublicProjectDb).toHaveBeenCalledWith('flnxi'); @@ -131,7 +131,7 @@ describe('registry auth route', () => { it('manifest tag 和 token 不匹配时应拒绝', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&referenceType=manifests&reference=2.0.0', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&referenceType=manifests&reference=2.0.0', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -149,7 +149,7 @@ describe('registry auth route', () => { it('manifest tag 和 token 匹配时应允许继续校验项目状态', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&referenceType=manifests&reference=1.0.0', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&referenceType=manifests&reference=1.0.0', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -167,7 +167,7 @@ describe('registry auth route', () => { it('已登记 token 的 blob 层文件请求不应拿镜像 digest 误判', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:layer&referenceType=blobs', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&digest=sha256:layer&referenceType=blobs', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -186,8 +186,8 @@ describe('registry auth route', () => { id: 'audit-1', licenseId: 'license-1', activationId: 'activation-1', - moduleId: 'intranet-penetration-core', - repository: 'flnxi/premium/intranet-penetration-core', + moduleId: 'paid-core', + repository: 'flnxi/premium/paid-core', digest: 'sha256:abc', expiresAt: new Date('2026-06-02T00:15:00.000Z'), lastValidatedAt: new Date(), @@ -195,7 +195,7 @@ describe('registry auth route', () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:child-manifest&referenceType=manifests&reference=sha256:child-manifest', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&digest=sha256:child-manifest&referenceType=manifests&reference=sha256:child-manifest', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -212,7 +212,7 @@ describe('registry auth route', () => { it('未先验证主清单时,陌生 manifest digest 仍应拒绝', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull&digest=sha256:other&referenceType=manifests&reference=sha256:other', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull&digest=sha256:other&referenceType=manifests&reference=sha256:other', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -231,7 +231,7 @@ describe('registry auth route', () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -250,7 +250,7 @@ describe('registry auth route', () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/intranet-penetration-core:pull', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/paid-core:pull', { headers: { Authorization: createBasicTokenHeader('pull-token'), @@ -267,7 +267,7 @@ describe('registry auth route', () => { it('请求仓库和 token 不匹配时应拒绝,不进入项目数据库', async () => { const { GET } = await import('../../app/api/registry/auth/route'); const request = new NextRequest( - 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/custom-templates-core:pull', + 'http://license.test/api/registry/auth?scope=repository:flnxi/premium/unknown-core:pull', { headers: { Authorization: createBasicTokenHeader('pull-token'), diff --git a/license-server/lib/premium-module-catalog.ts b/license-server/lib/premium-module-catalog.ts index 6f821ec..979f9ba 100644 --- a/license-server/lib/premium-module-catalog.ts +++ b/license-server/lib/premium-module-catalog.ts @@ -1,4 +1,4 @@ -import { LICENSE_CONFIGS, type LicenseType } from './types'; +import type { LicenseType } from './types'; export interface PremiumModuleArtifact { type: 'registry-image'; @@ -29,27 +29,23 @@ export interface ParsedRegistryImage { } export const MODULE_DEFINITIONS: Record = { - 'license-limits-core': { - id: 'license-limits-core', - minLicenseType: 'FREE', - capabilities: ['licenseLimits'], - }, - 'custom-templates-core': { - id: 'custom-templates-core', + 'paid-core': { + id: 'paid-core', minLicenseType: 'STANDARD', - capabilities: ['customTemplates'], - }, - 'intranet-penetration-core': { - id: 'intranet-penetration-core', - minLicenseType: 'PRO', - capabilities: ['intranetPenetration'], + capabilities: [ + 'customTemplates', + 'departmentIsolation', + 'systemSettings', + 'intranetPenetration', + 'apiAccess', + ], }, }; export const MODULE_IDS_BY_LICENSE: Record = { - FREE: ['license-limits-core'], - STANDARD: ['license-limits-core', 'custom-templates-core'], - PRO: ['license-limits-core', 'custom-templates-core', 'intranet-penetration-core'], + FREE: [], + STANDARD: ['paid-core'], + PRO: ['paid-core'], }; const LICENSE_TYPE_RANK: Record = { @@ -109,9 +105,9 @@ export function getModuleLimits( moduleId: string, licenseType: LicenseType ): PremiumModuleGrantSpec['limits'] | undefined { - return moduleId === 'license-limits-core' - ? LICENSE_CONFIGS[licenseType].limits - : undefined; + void moduleId; + void licenseType; + return undefined; } export function getDefaultModuleGrantSpecs(licenseType: LicenseType): PremiumModuleGrantSpec[] { diff --git a/premium-modules/custom-templates-core/README.md b/premium-modules/custom-templates-core/README.md deleted file mode 100644 index e9f3357..0000000 --- a/premium-modules/custom-templates-core/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# custom-templates-core - -这是 FLNXI 标准版及以上的自定义模板授权执行模块。 - -当前状态: - -- 提供 `/health` 健康检查。 -- 提供 `/status` 能力状态。 -- 提供 `/capabilities` 能力清单。 -- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `customTemplates:use/create/update/delete/migrate/read/list/validate/filter`。 -- 第一版先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路,避免一次性拆散模板、上传、筛选和迁移链路。 - -发布后需要把固定镜像和 digest 写入 `license-server`: - -```env -PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE=registry.example.com:5000/flnxi/premium/custom-templates-core:1.0.0 -PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_DIGEST=sha256:... -``` diff --git a/premium-modules/custom-templates-core/package.json b/premium-modules/custom-templates-core/package.json deleted file mode 100644 index 52aaaf7..0000000 --- a/premium-modules/custom-templates-core/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "flnxi-custom-templates-core", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "start": "node server.mjs" - } -} diff --git a/premium-modules/custom-templates-core/server.mjs b/premium-modules/custom-templates-core/server.mjs deleted file mode 100644 index 442c030..0000000 --- a/premium-modules/custom-templates-core/server.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import http from 'node:http'; - -const moduleId = 'custom-templates-core'; -const port = Number.parseInt(process.env.PORT || '8787', 10); -const capabilities = ['customTemplates']; -const allowedActions = new Set([ - 'customTemplates:use', - 'customTemplates:create', - 'customTemplates:update', - 'customTemplates:delete', - 'customTemplates:migrate', - 'customTemplates:read', - 'customTemplates:list', - 'customTemplates:validate', - 'customTemplates:filter', -]); - -function sendJson(res, statusCode, payload) { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': Buffer.byteLength(body), - }); - res.end(body); -} - -function readJsonBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - - req.on('data', (chunk) => { - body += chunk; - if (body.length > 1024 * 1024) { - req.destroy(); - reject(new Error('request_body_too_large')); - } - }); - - req.on('end', () => { - if (!body.trim()) { - resolve({}); - return; - } - - try { - resolve(JSON.parse(body)); - } catch { - reject(new Error('invalid_json')); - } - }); - - req.on('error', reject); - }); -} - -function sendAuthorization(res, action) { - const allowed = typeof action === 'string' && allowedActions.has(action); - sendJson(res, allowed ? 200 : 403, { - moduleId, - allowed, - action, - capabilities, - }); -} - -const server = http.createServer((req, res) => { - const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); - - if (req.method === 'GET' && url.pathname === '/health') { - sendJson(res, 200, { moduleId, status: 'healthy' }); - return; - } - - if (req.method === 'GET' && url.pathname === '/status') { - sendJson(res, 200, { - moduleId, - status: 'authorization_contract_ready', - capabilities, - note: '此模块先承接自定义模板授权执行层;后续再迁移 CRUD、筛选和数据处理链路', - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/capabilities') { - sendJson(res, 200, { - moduleId, - capabilities, - actions: Array.from(allowedActions), - }); - return; - } - - if (req.method === 'POST' && url.pathname === '/authorize') { - readJsonBody(req) - .then((body) => { - sendAuthorization(res, body.action); - }) - .catch((error) => { - sendJson(res, 400, { moduleId, allowed: false, error: error.message }); - }); - return; - } - - sendJson(res, 404, { moduleId, error: '接口不存在' }); -}); - -server.listen(port, '0.0.0.0', () => { - console.log(`${moduleId} listening on ${port}`); -}); diff --git a/premium-modules/intranet-penetration-core/Dockerfile b/premium-modules/intranet-penetration-core/Dockerfile deleted file mode 100644 index df1a0c5..0000000 --- a/premium-modules/intranet-penetration-core/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM docker.m.daocloud.io/node:20-alpine - -WORKDIR /app - -COPY package.json server.mjs ./ - -ENV NODE_ENV=production -ENV PORT=8787 - -EXPOSE 8787 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - -CMD ["node", "server.mjs"] diff --git a/premium-modules/intranet-penetration-core/README.md b/premium-modules/intranet-penetration-core/README.md deleted file mode 100644 index 60a71f0..0000000 --- a/premium-modules/intranet-penetration-core/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# intranet-penetration-core - -这是 FLNXI 第一批拆分的付费模块镜像,用来承接专业版内网穿透模块的运行合同。 - -当前状态: - -- 提供 `/health` 健康检查。 -- 提供 `/status`、`/start`、`/stop` 三个接口合同。 -- 提供 `/capabilities` 能力清单。 -- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `intranetPenetration:status/start/stop/read`。 -- 未配置真实穿透提供商时明确返回 `provider_not_configured`,不返回占位成功。 - -发布后需要把固定镜像和 digest 写入 `license-server`: - -```env -PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE=registry.example.com:5000/flnxi/premium/intranet-penetration-core:1.0.0 -PREMIUM_MODULE_INTRANET_PENETRATION_CORE_DIGEST=sha256:... -``` - -后续接入真实 provider 时,只在这个模块里扩展,不把核心实现重新塞回主应用基础镜像。 diff --git a/premium-modules/intranet-penetration-core/VERSION.txt b/premium-modules/intranet-penetration-core/VERSION.txt deleted file mode 100644 index 3eefcb9..0000000 --- a/premium-modules/intranet-penetration-core/VERSION.txt +++ /dev/null @@ -1 +0,0 @@ -1.0.0 diff --git a/premium-modules/intranet-penetration-core/package.json b/premium-modules/intranet-penetration-core/package.json deleted file mode 100644 index 26ffcc7..0000000 --- a/premium-modules/intranet-penetration-core/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "flnxi-intranet-penetration-core", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "start": "node server.mjs" - } -} diff --git a/premium-modules/intranet-penetration-core/server.mjs b/premium-modules/intranet-penetration-core/server.mjs deleted file mode 100644 index f08a668..0000000 --- a/premium-modules/intranet-penetration-core/server.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import http from 'node:http'; - -const moduleId = 'intranet-penetration-core'; -const port = Number.parseInt(process.env.PORT || '8787', 10); -const provider = (process.env.TUNNEL_PROVIDER || '').trim(); -const capabilities = ['intranetPenetration']; -const allowedActions = new Set([ - 'intranetPenetration:status', - 'intranetPenetration:start', - 'intranetPenetration:stop', - 'intranetPenetration:read', -]); - -function sendJson(res, statusCode, payload) { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': Buffer.byteLength(body), - }); - res.end(body); -} - -function readJsonBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - - req.on('data', (chunk) => { - body += chunk; - if (body.length > 1024 * 1024) { - req.destroy(); - reject(new Error('request_body_too_large')); - } - }); - - req.on('end', () => { - if (!body.trim()) { - resolve({}); - return; - } - - try { - resolve(JSON.parse(body)); - } catch { - reject(new Error('invalid_json')); - } - }); - - req.on('error', reject); - }); -} - -function providerNotConfigured(res, action) { - sendJson(res, 501, { - moduleId, - status: 'provider_not_configured', - action, - error: '内网穿透模块已加载,但尚未配置真实穿透提供商', - }); -} - -function sendAuthorization(res, action) { - const allowed = typeof action === 'string' && allowedActions.has(action); - sendJson(res, allowed ? 200 : 403, { - moduleId, - allowed, - action, - capabilities, - }); -} - -const server = http.createServer((req, res) => { - const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); - - if (req.method === 'GET' && url.pathname === '/health') { - sendJson(res, 200, { moduleId, status: 'healthy' }); - return; - } - - if (req.method === 'GET' && url.pathname === '/status') { - if (!provider) { - providerNotConfigured(res, 'status'); - return; - } - providerNotConfigured(res, 'status'); - return; - } - - if (req.method === 'GET' && url.pathname === '/capabilities') { - sendJson(res, 200, { - moduleId, - capabilities, - actions: Array.from(allowedActions), - }); - return; - } - - if (req.method === 'POST' && url.pathname === '/start') { - if (!provider) { - providerNotConfigured(res, 'start'); - return; - } - providerNotConfigured(res, 'start'); - return; - } - - if (req.method === 'POST' && url.pathname === '/stop') { - if (!provider) { - providerNotConfigured(res, 'stop'); - return; - } - providerNotConfigured(res, 'stop'); - return; - } - - if (req.method === 'POST' && url.pathname === '/authorize') { - readJsonBody(req) - .then((body) => { - sendAuthorization(res, body.action); - }) - .catch((error) => { - sendJson(res, 400, { moduleId, allowed: false, error: error.message }); - }); - return; - } - - sendJson(res, 404, { moduleId, error: '接口不存在' }); -}); - -server.listen(port, '0.0.0.0', () => { - console.log(`${moduleId} listening on ${port}`); -}); diff --git a/premium-modules/license-limits-core/Dockerfile b/premium-modules/license-limits-core/Dockerfile deleted file mode 100644 index df1a0c5..0000000 --- a/premium-modules/license-limits-core/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM docker.m.daocloud.io/node:20-alpine - -WORKDIR /app - -COPY package.json server.mjs ./ - -ENV NODE_ENV=production -ENV PORT=8787 - -EXPOSE 8787 - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD node -e "fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" - -CMD ["node", "server.mjs"] diff --git a/premium-modules/license-limits-core/README.md b/premium-modules/license-limits-core/README.md deleted file mode 100644 index bb4b5f4..0000000 --- a/premium-modules/license-limits-core/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# license-limits-core - -这是 FLNXI 授权模块链路中的基础限额模块。所有版本都需要通过验证系统拿到这个模块授权,用来承载“用户数上限、素材数上限”这类商业规则。 - -当前状态: - -- 提供 `/health` 健康检查。 -- 提供 `/status` 能力状态。 -- 提供 `/capabilities` 能力清单。 -- 提供 `/authorize` 授权决策入口,接收 `{ action, context }`,当前只允许 `licenseLimits:check/read/validate/enforce`。 -- 提供 `/limits` 合同入口,但具体限额必须来自验证系统签发的实例授权,不在镜像里写死。 -- 提供 `/limits/enforce` 限额执行入口,接收 `{ type, current, limits }`,由模块判断当前用户数/素材数是否还能新增。 - -执行边界:主应用负责从数据库统计当前用户数或素材数,并负责校验本地许可证、模块授权凭证和模块运行态;本模块负责根据验证系统签发的上限和当前数量给出最终允许/拒绝结果。它不要求客户每次操作都联网访问验证服务器,但本模块容器必须在客户本机运行并可被主应用访问。 - -发布后需要把固定镜像和 digest 写入 `license-server`: - -```env -PREMIUM_MODULE_LICENSE_LIMITS_CORE_IMAGE=registry.example.com:5000/flnxi/premium/license-limits-core:1.0.0 -PREMIUM_MODULE_LICENSE_LIMITS_CORE_DIGEST=sha256:... -``` diff --git a/premium-modules/license-limits-core/VERSION.txt b/premium-modules/license-limits-core/VERSION.txt deleted file mode 100644 index 3eefcb9..0000000 --- a/premium-modules/license-limits-core/VERSION.txt +++ /dev/null @@ -1 +0,0 @@ -1.0.0 diff --git a/premium-modules/license-limits-core/__tests__/server.test.ts b/premium-modules/license-limits-core/__tests__/server.test.ts deleted file mode 100644 index 81535ea..0000000 --- a/premium-modules/license-limits-core/__tests__/server.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index 7bf8478..0000000 --- a/premium-modules/license-limits-core/server.mjs +++ /dev/null @@ -1,166 +0,0 @@ -import http from 'node:http'; - -const moduleId = 'license-limits-core'; -const port = Number.parseInt(process.env.PORT || '8787', 10); -const capabilities = ['licenseLimits']; -const allowedActions = new Set([ - 'licenseLimits:check', - 'licenseLimits:read', - 'licenseLimits:validate', - 'licenseLimits:enforce', -]); - -function sendJson(res, statusCode, payload) { - const body = JSON.stringify(payload); - res.writeHead(statusCode, { - 'Content-Type': 'application/json; charset=utf-8', - 'Content-Length': Buffer.byteLength(body), - }); - res.end(body); -} - -function readJsonBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - - req.on('data', (chunk) => { - body += chunk; - if (body.length > 1024 * 1024) { - req.destroy(); - reject(new Error('request_body_too_large')); - } - }); - - req.on('end', () => { - if (!body.trim()) { - resolve({}); - return; - } - - try { - resolve(JSON.parse(body)); - } catch { - reject(new Error('invalid_json')); - } - }); - - req.on('error', reject); - }); -} - -function sendAuthorization(res, action) { - const allowed = typeof action === 'string' && allowedActions.has(action); - sendJson(res, allowed ? 200 : 403, { - moduleId, - allowed, - action, - capabilities, - }); -} - -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'}`); - - if (req.method === 'GET' && url.pathname === '/health') { - sendJson(res, 200, { moduleId, status: 'healthy' }); - return; - } - - if (req.method === 'GET' && url.pathname === '/status') { - sendJson(res, 200, { - moduleId, - status: 'ready', - capabilities, - note: '具体人数和素材数上限以验证系统下发的实例授权为准', - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/capabilities') { - sendJson(res, 200, { - moduleId, - capabilities, - actions: Array.from(allowedActions), - }); - return; - } - - if (req.method === 'GET' && url.pathname === '/limits') { - sendJson(res, 200, { - moduleId, - status: 'entitlement_required', - error: '此模块只提供限额能力合同,具体限额必须来自已签名的实例授权', - }); - 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) => { - sendAuthorization(res, body.action); - }) - .catch((error) => { - sendJson(res, 400, { moduleId, allowed: false, error: error.message }); - }); - return; - } - - sendJson(res, 404, { moduleId, error: '接口不存在' }); -}); - -server.listen(port, '0.0.0.0', () => { - console.log(`${moduleId} listening on ${port}`); -}); diff --git a/premium-modules/custom-templates-core/Dockerfile b/premium-modules/paid-core/Dockerfile similarity index 84% rename from premium-modules/custom-templates-core/Dockerfile rename to premium-modules/paid-core/Dockerfile index df1a0c5..07411f7 100644 --- a/premium-modules/custom-templates-core/Dockerfile +++ b/premium-modules/paid-core/Dockerfile @@ -2,7 +2,7 @@ FROM docker.m.daocloud.io/node:20-alpine WORKDIR /app -COPY package.json server.mjs ./ +COPY package.json server.mjs template-validation.mjs ./ ENV NODE_ENV=production ENV PORT=8787 diff --git a/premium-modules/paid-core/README.md b/premium-modules/paid-core/README.md new file mode 100644 index 0000000..cf8721b --- /dev/null +++ b/premium-modules/paid-core/README.md @@ -0,0 +1,43 @@ +# paid-core + +这是 FLNXI 付费增强包。商业交付方向是“免费基础镜像 + paid-core 付费包”,而不是为每个付费功能单独发布一个镜像。 + +当前状态: + +- 提供 `/health` 健康检查。 +- 提供 `/status` 能力状态。 +- 提供 `/capabilities` 能力清单。 +- 提供 `/authorize` 授权决策入口。 +- 提供 `/templates/validate-fields`,承接自定义模板字段保存前校验和格式化。 +- 提供 `/templates/normalize-save`,承接自定义模板创建/更新前的保存请求标准化。 +- 提供 `/templates/plan-delete`,承接自定义模板删除前的使用中拦截规划。 +- 提供 `/materials/validate-template-fields`,承接上传/编辑素材时的自定义字段内容校验。 +- 提供 `/materials/prepare-metadata`,承接上传/编辑素材时的自定义字段 metadata 准备和大小检查。 +- 提供 `/templates/detect-structure-changes`,承接已有素材模板的结构变更风险判断。 +- 提供 `/templates/prepare-migration-metadata`,承接模板迁移时的目标字段投影、必填字段兼容性判断、metadata 准备、旧字段备份计算和大小检查。 +- 提供 `/templates/plan-migration-batch`,承接模板迁移批次大小、安全确认、预览模式和分批执行规划。 +- 提供 `/templates/build-migration-preview`,承接模板迁移预览、备份字段影响统计和样例生成。 +- 提供 `/templates/project-public-options`,承接公开模板列表、上传页模板列表和素材类型 tag 的展示投影。 +- 提供 `/templates/calculate-field-stats`,承接后台模板详情里的字段填写率和低填写率字段统计。 +- 提供 `/materials/plan-list-query`,承接素材列表搜索字段、自定义字段搜索、首页筛选字段和 metadata 筛选的查询计划。 +- 提供 `/materials/plan-custom-search`,承接素材列表自定义字段搜索规划。 +- 提供 `/materials/plan-home-filter-fields`,承接首页筛选字段的标准列和 metadata 统计来源规划。 +- 保留 `/materials/plan-home-filter-counts` 兼容旧调用,当前主路由使用 `/materials/plan-home-filter-fields`。 +- 提供 `/departments/plan-scope`,承接部门隔离范围规划。 +- 提供 `/departments/plan-structure-change`,承接部门创建、重命名、删除时的部门和用户联动计划。 +- 承接自定义模板、部门隔离、系统设置、内网穿透、API 访问等付费功能的运行门禁。 +- 内网穿透真实 provider 尚未接入时,`/tunnel/status`、`/tunnel/start`、`/tunnel/stop` 会明确返回 `provider_not_configured`。 + +重要边界: + +- 当前版本先把付费功能入口收敛到一个包,并已迁入自定义模板字段保存前校验、保存请求标准化、模板删除前使用中拦截规划、素材字段提交校验、素材 metadata 准备、结构变更风险判断、公开模板展示投影、模板字段填写率统计、素材列表搜索/筛选查询计划、自定义字段搜索规划、首页自定义筛选字段统计规划、部门隔离范围规划、API 访问运行门禁,以及模板迁移目标字段投影、兼容性判断、metadata 准备、旧字段备份计算、迁移批次规划、迁移预览和备份字段影响统计。 +- 素材删除、恢复、精品设置和统计入口已通过统一素材管理范围或 `/departments/plan-scope` 使用 `paid-core` 的部门范围规划;主程序继续保留最终 Prisma 条件、数据库写入、删除和统计 SQL 执行。 +- 模板迁移最终数据库写入、素材列表分页查询、首页筛选字段统计 SQL 执行、部门结构最终写库等动作仍由主程序执行;`paid-core` 返回规则、计划、校验和展示投影,不直接连接客户数据库。 +- 因此当前准确口径是“付费能力的计划层和运行门禁已拆入 paid-core”,不能表述为“所有业务执行都从主程序移走”。 + +发布后需要把固定镜像和 digest 写入 `license-server`: + +```env +PREMIUM_MODULE_PAID_CORE_IMAGE=registry.example.com:5000/flnxi/premium/paid-core:1.0.0 +PREMIUM_MODULE_PAID_CORE_DIGEST=sha256:... +``` diff --git a/premium-modules/custom-templates-core/VERSION.txt b/premium-modules/paid-core/VERSION.txt similarity index 100% rename from premium-modules/custom-templates-core/VERSION.txt rename to premium-modules/paid-core/VERSION.txt diff --git a/premium-modules/license-limits-core/package.json b/premium-modules/paid-core/package.json similarity index 74% rename from premium-modules/license-limits-core/package.json rename to premium-modules/paid-core/package.json index b605067..dd05029 100644 --- a/premium-modules/license-limits-core/package.json +++ b/premium-modules/paid-core/package.json @@ -1,5 +1,5 @@ { - "name": "flnxi-license-limits-core", + "name": "flnxi-paid-core", "version": "1.0.0", "private": true, "type": "module", diff --git a/premium-modules/paid-core/server.mjs b/premium-modules/paid-core/server.mjs new file mode 100644 index 0000000..e81a50a --- /dev/null +++ b/premium-modules/paid-core/server.mjs @@ -0,0 +1,606 @@ +import http from 'node:http'; +import { + buildTemplateMigrationPreviewRequest, + calculateTemplateFieldStatsRequest, + detectDangerousTemplateChanges, + normalizeUploadTemplateSaveRequest, + planHomeFilterCustomFieldCountsRequest, + planDepartmentScopeRequest, + planMaterialListQueryRequest, + planMaterialCustomSearchRequest, + planHomeFilterFieldCountsRequest, + planTemplateMigrationBatchRequest, + planDepartmentStructureChangeRequest, + planUploadTemplateDeleteRequest, + planUploadTemplateUpdateRequest, + prepareMaterialMetadataRequest, + prepareTemplateMigrationMetadataRequest, + projectPublicTemplateOptionsRequest, + validateSubmittedTemplateFieldsRequest, + validateAndFormatUploadTemplateFields, +} from './template-validation.mjs'; + +const moduleId = 'paid-core'; +const port = Number.parseInt(process.env.PORT || '8787', 10); +const tunnelProvider = (process.env.TUNNEL_PROVIDER || '').trim(); + +const capabilities = [ + 'customTemplates', + 'departmentIsolation', + 'systemSettings', + 'intranetPenetration', + 'apiAccess', +]; + +const allowedActions = new Set([ + 'customTemplates:use', + 'customTemplates:create', + 'customTemplates:update', + 'customTemplates:delete', + 'customTemplates:migrate', + 'customTemplates:read', + 'customTemplates:list', + 'customTemplates:validate', + 'customTemplates:filter', + 'customTemplates:planDelete', + 'customTemplates:planUpdate', + 'customTemplates:planMigrationBatch', + 'customTemplates:planMaterialListQuery', + 'customTemplates:planHomeFilterFields', + 'departmentIsolation:use', + 'departmentIsolation:read', + 'departmentIsolation:manage', + 'departmentIsolation:planStructureChange', + 'systemSettings:use', + 'systemSettings:read', + 'systemSettings:update', + 'intranetPenetration:use', + 'intranetPenetration:status', + 'intranetPenetration:start', + 'intranetPenetration:stop', + 'intranetPenetration:read', + 'apiAccess:use', + 'apiAccess:read', +]); + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ''; + + req.on('data', (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + req.destroy(); + reject(new Error('request_body_too_large')); + } + }); + + req.on('end', () => { + if (!body.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('invalid_json')); + } + }); + + req.on('error', reject); + }); +} + +function sendAuthorization(res, action) { + const allowed = typeof action === 'string' && allowedActions.has(action); + sendJson(res, allowed ? 200 : 403, { + moduleId, + allowed, + action, + capabilities, + }); +} + +function providerNotConfigured(res, action) { + sendJson(res, 501, { + moduleId, + status: 'provider_not_configured', + action, + error: '付费增强包已加载,但尚未配置真实内网穿透提供商', + }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + + if (req.method === 'GET' && url.pathname === '/health') { + sendJson(res, 200, { moduleId, status: 'healthy' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/status') { + sendJson(res, 200, { + moduleId, + status: 'ready', + capabilities, + note: 'paid-core 是统一付费增强包;业务核心迁移会继续推进', + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/capabilities') { + sendJson(res, 200, { + moduleId, + capabilities, + actions: Array.from(allowedActions), + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/authorize') { + readJsonBody(req) + .then((body) => { + sendAuthorization(res, body.action); + }) + .catch((error) => { + sendJson(res, 400, { moduleId, allowed: false, error: error.message }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/validate-fields') { + readJsonBody(req) + .then((body) => { + const result = validateAndFormatUploadTemplateFields(body.fields); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:validate', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:validate', + valid: false, + error: error.message, + field: 'fields', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/normalize-save') { + readJsonBody(req) + .then((body) => { + const result = normalizeUploadTemplateSaveRequest(body.payload, body.mode); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:normalizeSave', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:normalizeSave', + valid: false, + error: error.message, + field: 'template', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/plan-delete') { + readJsonBody(req) + .then((body) => { + const result = planUploadTemplateDeleteRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:planDelete', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planDelete', + valid: false, + allowed: false, + error: error.message, + field: 'template', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/plan-update') { + readJsonBody(req) + .then((body) => { + const result = planUploadTemplateUpdateRequest(body); + sendJson(res, result.valid ? 200 : result.statusCode || 400, { + moduleId, + action: 'customTemplates:planUpdate', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planUpdate', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_UPDATE_PLAN_FAILED', + field: 'template', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/plan-migration-batch') { + readJsonBody(req) + .then((body) => { + const result = planTemplateMigrationBatchRequest(body); + sendJson(res, result.allowed ? 200 : result.statusCode || 400, { + moduleId, + action: 'customTemplates:planMigrationBatch', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planMigrationBatch', + valid: false, + allowed: false, + error: error.message, + errorCode: 'TEMPLATE_MIGRATION_BATCH_PLAN_FAILED', + field: 'template', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/detect-structure-changes') { + readJsonBody(req) + .then((body) => { + if (!body.existingTemplate || !Array.isArray(body.existingTemplate.fields)) { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:detectStructureChanges', + valid: false, + error: '缺少现有模板字段信息', + field: 'existingTemplate', + }); + return; + } + + const changes = detectDangerousTemplateChanges(body); + sendJson(res, 200, { + moduleId, + action: 'customTemplates:detectStructureChanges', + valid: true, + changes, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:detectStructureChanges', + valid: false, + error: error.message, + field: 'template', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/validate-template-fields') { + readJsonBody(req) + .then((body) => { + const result = validateSubmittedTemplateFieldsRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:validateSubmittedFields', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:validateSubmittedFields', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_FIELD_VALIDATION_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/prepare-metadata') { + readJsonBody(req) + .then((body) => { + const result = prepareMaterialMetadataRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:prepareMaterialMetadata', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:prepareMaterialMetadata', + valid: false, + error: error.message, + errorCode: 'METADATA_PREPARE_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/prepare-migration-metadata') { + readJsonBody(req) + .then((body) => { + const result = prepareTemplateMigrationMetadataRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:prepareMigrationMetadata', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:prepareMigrationMetadata', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/build-migration-preview') { + readJsonBody(req) + .then((body) => { + const result = buildTemplateMigrationPreviewRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:buildMigrationPreview', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:buildMigrationPreview', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/project-public-options') { + readJsonBody(req) + .then((body) => { + const result = projectPublicTemplateOptionsRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:projectPublicOptions', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:projectPublicOptions', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/templates/calculate-field-stats') { + readJsonBody(req) + .then((body) => { + const result = calculateTemplateFieldStatsRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:calculateFieldStats', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:calculateFieldStats', + valid: false, + error: error.message, + errorCode: 'TEMPLATE_FIELD_STATS_CALCULATE_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/plan-custom-search') { + readJsonBody(req) + .then((body) => { + const result = planMaterialCustomSearchRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:planMaterialCustomSearch', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planMaterialCustomSearch', + valid: false, + error: error.message, + errorCode: 'MATERIAL_CUSTOM_SEARCH_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/plan-home-filter-counts') { + readJsonBody(req) + .then((body) => { + const result = planHomeFilterCustomFieldCountsRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:planHomeFilterCounts', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planHomeFilterCounts', + valid: false, + error: error.message, + errorCode: 'HOME_FILTER_COUNTS_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/plan-list-query') { + readJsonBody(req) + .then((body) => { + const result = planMaterialListQueryRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:planMaterialListQuery', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planMaterialListQuery', + valid: false, + error: error.message, + errorCode: 'MATERIAL_LIST_QUERY_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/materials/plan-home-filter-fields') { + readJsonBody(req) + .then((body) => { + const result = planHomeFilterFieldCountsRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'customTemplates:planHomeFilterFields', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'customTemplates:planHomeFilterFields', + valid: false, + error: error.message, + errorCode: 'HOME_FILTER_FIELDS_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/departments/plan-scope') { + readJsonBody(req) + .then((body) => { + const result = planDepartmentScopeRequest(body); + sendJson(res, result.valid ? 200 : 400, { + moduleId, + action: 'departmentIsolation:planScope', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'departmentIsolation:planScope', + valid: false, + error: error.message, + errorCode: 'DEPARTMENT_SCOPE_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/departments/plan-structure-change') { + readJsonBody(req) + .then((body) => { + const result = planDepartmentStructureChangeRequest(body); + sendJson(res, result.allowed ? 200 : result.statusCode || 400, { + moduleId, + action: 'departmentIsolation:planStructureChange', + ...result, + }); + }) + .catch((error) => { + sendJson(res, 400, { + moduleId, + action: 'departmentIsolation:planStructureChange', + valid: false, + allowed: false, + error: error.message, + errorCode: 'DEPARTMENT_STRUCTURE_CHANGE_PLAN_FAILED', + }); + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/tunnel/status') { + if (!tunnelProvider) { + providerNotConfigured(res, 'status'); + return; + } + providerNotConfigured(res, 'status'); + return; + } + + if (req.method === 'POST' && url.pathname === '/tunnel/start') { + if (!tunnelProvider) { + providerNotConfigured(res, 'start'); + return; + } + providerNotConfigured(res, 'start'); + return; + } + + if (req.method === 'POST' && url.pathname === '/tunnel/stop') { + if (!tunnelProvider) { + providerNotConfigured(res, 'stop'); + return; + } + providerNotConfigured(res, 'stop'); + return; + } + + sendJson(res, 404, { moduleId, error: '接口不存在' }); +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`${moduleId} listening on ${port}`); +}); diff --git a/premium-modules/paid-core/template-validation.mjs b/premium-modules/paid-core/template-validation.mjs new file mode 100644 index 0000000..6960f64 --- /dev/null +++ b/premium-modules/paid-core/template-validation.mjs @@ -0,0 +1,2469 @@ +const UPLOAD_TEMPLATE_FIELD_LIMITS = { + maxFields: 80, + maxKeyLength: 64, + maxLabelLength: 80, + maxOptions: 200, + maxOptionLength: 80, +}; + +const DEFAULT_MIGRATION_SAFE_LIMIT = 200; +const MAX_MIGRATION_FORCE_LIMIT = 1000; +const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived']); +const ALLOWED_MATERIAL_KINDS = new Set(['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']); +const ALLOWED_FIELD_TYPES = new Set([ + 'text', + 'textarea', + 'number', + 'select', + 'checkbox', + 'radio', + 'date', + 'uppercase', +]); +const OPTION_FIELD_TYPES = new Set(['select', 'radio']); +const CONDITION_OPERATORS = new Set(['equals', 'notEquals', 'contains', 'notEmpty', 'isEmpty']); +const CONDITION_OPERATORS_REQUIRING_VALUE = new Set(['equals', 'notEquals', 'contains']); +const STANDARD_MATERIAL_FIELDS = new Set([ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'materialType', + 'materialKind', + 'renderer', + 'renderTime', + 'imageResolution', + 'colorMode', + 'authorName', + 'platformName', +]); +const MIGRATION_BACKUP_STANDARD_FIELDS = [ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'materialType', + 'materialKind', + 'renderer', + 'renderTime', + 'imageResolution', + 'colorMode', + 'authorName', + 'platformName', + 'audioDuration', + 'audioCategory', + 'audioScene', +]; +const CLEARABLE_NON_TARGET_FIELD_DEFAULTS = { + brandName: null, + seriesName: null, + skuCode: null, + materialTexture: null, + platformName: '', + renderTime: null, + imageResolution: null, + colorMode: null, + audioDuration: null, + audioCategory: null, + audioScene: null, +}; +const AUDIO_MATERIAL_FIELDS = ['audioDuration', 'audioCategory', 'audioScene']; +const STANDARD_FIELD_LABELS = { + productModel: '产品型号', + brandName: '品牌', + seriesName: '系列', + skuCode: 'SKU', + materialTexture: '材质纹理', + categoryName: '分类', + sceneName: '场景', + materialType: '素材类型', + materialKind: '素材大类', + renderer: '渲染器', + renderTime: '渲染时间', + imageResolution: '图片分辨率', + colorMode: '色彩模式', + authorName: '作者', + platformName: '平台', + audioDuration: '音频时长', + audioCategory: '音频分类', + audioScene: '音频场景', +}; +const MATERIAL_HOME_FILTER_COLUMN_FIELDS = new Set([ + 'categoryName', + 'sceneName', + 'platformName', + 'materialType', + 'renderer', + 'brandName', + 'colorMode', + 'renderTime', + 'imageResolution', + 'seriesName', + 'skuCode', + 'materialTexture', + 'authorName', +]); +const MATERIAL_LIST_SEARCH_COLUMN_FIELDS = [ + 'productModel', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'categoryName', + 'sceneName', + 'authorName', +]; +const MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS = new Set([ + 'renderer', + 'brandName', + 'seriesName', + 'skuCode', + 'materialTexture', + 'renderTime', + 'imageResolution', + 'colorMode', + 'productModel', + 'platformName', + 'categoryName', + 'sceneName', + 'authorName', +]); +const departmentCollator = new Intl.Collator('zh-Hans-CN-u-co-pinyin', { + numeric: true, + sensitivity: 'base', +}); + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function validationError(error, field, details) { + return { + valid: false, + error, + field, + details, + }; +} + +function parseFieldConditions(conditionsJson) { + if (!conditionsJson) return null; + if (typeof conditionsJson === 'string' && conditionsJson.trim() === '') return null; + + try { + const parsed = typeof conditionsJson === 'string' ? JSON.parse(conditionsJson) : conditionsJson; + if (!parsed || typeof parsed !== 'object') return null; + if (!Array.isArray(parsed.conditions)) return null; + + return { + logic: parsed.logic === 'OR' ? 'OR' : 'AND', + conditions: parsed.conditions.filter((condition) => ( + condition && + typeof condition === 'object' && + condition.fieldKey && + condition.operator + )), + }; + } catch { + return null; + } +} + +function detectCircularDependencies(fields) { + const dependencyMap = new Map(); + + for (const field of fields) { + const conditions = parseFieldConditions(field.conditions); + if (!conditions) continue; + dependencyMap.set(field.key, new Set(conditions.conditions.map((condition) => condition.fieldKey))); + } + + const visited = new Set(); + const recursionStack = new Set(); + const circularFields = []; + + function dfs(fieldKey) { + if (recursionStack.has(fieldKey)) return true; + if (visited.has(fieldKey)) return false; + + visited.add(fieldKey); + recursionStack.add(fieldKey); + + const deps = dependencyMap.get(fieldKey); + if (deps) { + for (const dep of deps) { + if (dfs(dep)) { + circularFields.push(fieldKey); + return true; + } + } + } + + recursionStack.delete(fieldKey); + return false; + } + + for (const field of fields) { + if (!visited.has(field.key)) dfs(field.key); + } + + return Array.from(new Set(circularFields)); +} + +function parseValidationRules(validationsJson) { + if (!validationsJson || String(validationsJson).trim() === '') return null; + + try { + const parsed = JSON.parse(validationsJson); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + return parsed; + } catch { + return null; + } +} + +function validateValidationRules(rules) { + if (typeof rules.minLength === 'number' && typeof rules.maxLength === 'number') { + if (rules.minLength > rules.maxLength) { + return { valid: false, error: '最小长度不能大于最大长度' }; + } + } + + if (typeof rules.min === 'number' && typeof rules.max === 'number') { + if (rules.min > rules.max) { + return { valid: false, error: '最小值不能大于最大值' }; + } + } + + if (rules.pattern) { + try { + new RegExp(rules.pattern); + } catch { + return { valid: false, error: '正则表达式格式无效' }; + } + } + + return { valid: true }; +} + +function getSupportedValidations(fieldType) { + const common = ['required']; + + switch (fieldType) { + case 'text': + case 'textarea': + return [...common, 'minLength', 'maxLength', 'pattern']; + case 'number': + return [...common, 'min', 'max']; + case 'select': + case 'checkbox': + case 'radio': + case 'date': + case 'file': + case 'image': + default: + return common; + } +} + +function normalizeStaticOptions(value) { + if (value === null || value === undefined) { + return { value: null, invalid: false }; + } + + if (Array.isArray(value)) { + const options = value.map((option) => String(option ?? '').trim()).filter(Boolean); + return { + value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null, + invalid: false, + }; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return { value: null, invalid: false }; + + try { + return normalizeStaticOptions(JSON.parse(trimmed)); + } catch { + const options = trimmed.split('\n').map((option) => option.trim()).filter(Boolean); + return { + value: options.length > 0 ? JSON.stringify(Array.from(new Set(options))) : null, + invalid: false, + }; + } + } + + return { value: null, invalid: true }; +} + +function parseNormalizedOptions(value) { + if (!value) return []; + + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.map((option) => String(option ?? '').trim()).filter(Boolean) + : []; + } catch { + return []; + } +} + +function normalizeJsonValue(value) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) return ''; + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + + return value ?? null; +} + +function stableStringify(value) { + const normalized = normalizeJsonValue(value); + + if (Array.isArray(normalized)) { + return JSON.stringify(normalized.map((item) => stableStringify(item))); + } + + if (normalized && typeof normalized === 'object') { + const entries = Object.entries(normalized) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, stableStringify(item)]); + return JSON.stringify(entries); + } + + return JSON.stringify(normalized); +} + +function parseStaticOptions(value) { + const normalized = normalizeJsonValue(value); + if (!Array.isArray(normalized)) return []; + + return normalized + .filter((item) => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); +} + +export function detectDangerousTemplateChanges(args) { + const existingTemplate = args?.existingTemplate; + const nextMaterialType = args?.nextMaterialType; + const nextMaterialKind = args?.nextMaterialKind; + const nextFields = args?.nextFields; + const changes = []; + + if (!existingTemplate || !Array.isArray(existingTemplate.fields)) { + return changes; + } + + if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) { + changes.push({ + type: 'materialTypeChanged', + before: existingTemplate.materialType, + after: nextMaterialType, + }); + } + + if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) { + changes.push({ + type: 'materialKindChanged', + before: existingTemplate.materialKind, + after: nextMaterialKind, + }); + } + + if (!Array.isArray(nextFields)) { + return changes; + } + + const incomingById = new Map( + nextFields + .filter((field) => field.id) + .map((field) => [field.id, field]) + ); + + for (const existingField of existingTemplate.fields) { + const incomingField = incomingById.get(existingField.id); + + if (!incomingField) { + changes.push({ + type: 'fieldDeleted', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + continue; + } + + if (incomingField.key !== existingField.key) { + changes.push({ + type: 'fieldKeyChanged', + fieldId: existingField.id, + before: existingField.key, + after: incomingField.key, + fieldLabel: existingField.label, + }); + } + + if (incomingField.type !== existingField.type) { + changes.push({ + type: 'fieldTypeChanged', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + before: existingField.type, + after: incomingField.type, + }); + } + + if (existingField.addToCategory && incomingField.addToCategory === false) { + changes.push({ + type: 'fieldCategoryDisabled', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + + if (existingField.isSearchable && incomingField.isSearchable === false) { + changes.push({ + type: 'fieldSearchDisabled', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + + if (!existingField.isReadOnly && incomingField.isReadOnly === true) { + changes.push({ + type: 'fieldMadeReadOnly', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + + if (!existingField.required && incomingField.required === true) { + changes.push({ + type: 'fieldMadeRequired', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + + if (stableStringify(incomingField.validations) !== stableStringify(existingField.validations)) { + changes.push({ + type: 'fieldValidationChanged', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + + const existingOptions = parseStaticOptions(existingField.optionsStatic); + if (existingOptions.length > 0) { + const incomingOptions = new Set(parseStaticOptions(incomingField.optionsStatic)); + const removedOptions = existingOptions.filter((option) => !incomingOptions.has(option)); + if (removedOptions.length > 0) { + changes.push({ + type: 'fieldOptionsRemoved', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + removedOptions, + }); + } + } + + if (stableStringify(incomingField.conditions) !== stableStringify(existingField.conditions)) { + changes.push({ + type: 'fieldConditionsChanged', + fieldId: existingField.id, + fieldKey: existingField.key, + fieldLabel: existingField.label, + }); + } + } + + return changes; +} + +function normalizeValidations(value, fieldType) { + if (value === null || value === undefined) return { value: null }; + if (typeof value === 'string' && value.trim() === '') return { value: null }; + + let raw = value; + if (typeof value === 'string') { + try { + raw = JSON.parse(value); + } catch { + return { value: null, invalid: '验证规则必须是合法 JSON 对象' }; + } + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { value: null, invalid: '验证规则必须是对象' }; + } + + const parsedRules = parseValidationRules(JSON.stringify(raw)); + if (!parsedRules) { + return { value: null, invalid: '验证规则必须是对象' }; + } + + const validation = validateValidationRules(parsedRules); + if (!validation.valid) { + return { value: null, invalid: validation.error || '验证规则配置无效' }; + } + + const supportedValidations = new Set(getSupportedValidations(fieldType)); + const unsupportedRules = [ + ['minLength', parsedRules.minLength], + ['maxLength', parsedRules.maxLength], + ['pattern', parsedRules.pattern], + ['min', parsedRules.min], + ['max', parsedRules.max], + ] + .filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey))) + .map(([ruleKey]) => String(ruleKey)); + + if (unsupportedRules.length > 0) { + return { + value: null, + invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`, + }; + } + + return { + value: JSON.stringify(parsedRules), + rules: parsedRules, + }; +} + +function normalizeConditions(value) { + if (value === null || value === undefined) return { value: null, invalid: false }; + if (typeof value === 'string' && value.trim() === '') return { value: null, invalid: false }; + + let raw; + try { + raw = typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return { value: null, invalid: true }; + } + + if (!raw || typeof raw !== 'object') return { value: null, invalid: true }; + if (raw.logic !== undefined && raw.logic !== 'AND' && raw.logic !== 'OR') { + return { value: null, invalid: true }; + } + if (!Array.isArray(raw.conditions)) return { value: null, invalid: true }; + if (raw.conditions.length === 0) return { value: null, invalid: false }; + + for (const condition of raw.conditions) { + if (!condition || typeof condition !== 'object') return { value: null, invalid: true }; + if (!condition.fieldKey || !condition.operator) return { value: null, invalid: true }; + } + + const parsed = parseFieldConditions(raw); + if (!parsed || parsed.conditions.length !== raw.conditions.length) { + return { value: null, invalid: true }; + } + + return { value: JSON.stringify(parsed), invalid: false, conditions: parsed }; +} + +export function validateAndFormatUploadTemplateFields(fields) { + if (!Array.isArray(fields)) { + return { + valid: false, + error: '字段列表必须是数组', + field: 'fields', + }; + } + + if (fields.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields) { + return { + valid: false, + error: `单个模板最多支持 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields} 个字段,请拆分模板或减少字段数量`, + field: 'fields', + details: { + maxFields: UPLOAD_TEMPLATE_FIELD_LIMITS.maxFields, + currentFields: fields.length, + }, + }; + } + + const fieldKeys = new Set(); + const duplicateKeys = []; + const formattedFields = []; + + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index] || {}; + const key = String(field.key || '').trim(); + + if (!key) { + return { + valid: false, + error: '字段key不能为空', + field: 'fields', + }; + } + + if (fieldKeys.has(key)) { + duplicateKeys.push(key); + } else { + fieldKeys.add(key); + } + + const label = String(field.label || '').trim() || `字段${index + 1}`; + const type = String(field.type || 'text').trim() || 'text'; + const addToCategory = !!field.addToCategory; + const normalizedOptions = normalizeStaticOptions(field.optionsStatic); + const optionsStatic = normalizedOptions.value; + const normalizedValidations = normalizeValidations(field.validations, type); + const normalizedConditions = normalizeConditions(field.conditions); + const conditions = normalizedConditions.value; + + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { + return { + valid: false, + error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`, + field: 'fields', + details: { fieldKey: key }, + }; + } + + if (key.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength) { + return { + valid: false, + error: `字段“${label}”的 key 不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength} 个字符`, + field: 'fields', + details: { + fieldKey: key, + maxKeyLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxKeyLength, + }, + }; + } + + if (label.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength) { + return { + valid: false, + error: `字段“${label.slice(0, 20)}...”的名称不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength} 个字符`, + field: 'fields', + details: { + fieldKey: key, + maxLabelLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxLabelLength, + }, + }; + } + + if (!ALLOWED_FIELD_TYPES.has(type)) { + return { + valid: false, + error: `字段“${label}”的类型不支持:${type}`, + field: 'fields', + details: { + fieldKey: key, + fieldType: type, + allowedTypes: Array.from(ALLOWED_FIELD_TYPES), + }, + }; + } + + if (OPTION_FIELD_TYPES.has(type)) { + if (normalizedOptions.invalid) { + return { + valid: false, + error: `字段“${label}”的选项必须是数组或每行一个选项`, + field: 'fields', + details: { fieldKey: key }, + }; + } + + const options = parseNormalizedOptions(optionsStatic); + if (options.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions) { + return { + valid: false, + error: `字段“${label}”的选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions} 个`, + field: 'fields', + details: { + fieldKey: key, + maxOptions: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptions, + currentOptions: options.length, + }, + }; + } + + const tooLongOption = options.find((option) => option.length > UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength); + if (tooLongOption) { + return { + valid: false, + error: `字段“${label}”的单个选项不能超过 ${UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength} 个字符`, + field: 'fields', + details: { + fieldKey: key, + maxOptionLength: UPLOAD_TEMPLATE_FIELD_LIMITS.maxOptionLength, + sample: tooLongOption.slice(0, 20), + }, + }; + } + + if (field.required && !addToCategory && options.length === 0) { + return { + valid: false, + error: `字段“${label}”是必填${type === 'radio' ? '单选' : '下拉'}字段,至少需要配置一个选项`, + field: 'fields', + details: { fieldKey: key }, + }; + } + } + + if (normalizedValidations.invalid) { + return { + valid: false, + error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`, + field: 'fields', + details: { fieldKey: key }, + }; + } + + if (normalizedConditions.invalid) { + return { + valid: false, + error: `字段“${label}”的显示条件格式不正确`, + field: 'fields', + details: { fieldKey: key }, + }; + } + + formattedFields.push({ + id: field.id ?? null, + key, + label, + type, + required: !!field.required, + orderIndex: field.orderIndex ?? index, + layoutWidth: field.layoutWidth || 'full', + validations: normalizedValidations.value, + optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource), + optionsStatic, + allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions), + visibleOnHomeFilter: !!field.visibleOnHomeFilter, + isSearchable: !!field.isSearchable, + isReadOnly: !!field.isReadOnly, + addToCategory, + conditions, + }); + } + + if (duplicateKeys.length > 0) { + return { + valid: false, + error: `字段key重复:${duplicateKeys.join(', ')}`, + field: 'fields', + details: { duplicateKeys }, + }; + } + + const availableKeys = new Set(formattedFields.map((field) => field.key)); + + for (const field of formattedFields) { + const conditions = parseFieldConditions(field.conditions); + if (!conditions) continue; + + for (const condition of conditions.conditions) { + if (!CONDITION_OPERATORS.has(condition.operator)) { + return { + valid: false, + error: `字段“${field.label}”的显示条件包含不支持的判断方式`, + field: 'fields', + details: { fieldKey: field.key, operator: condition.operator }, + }; + } + + if (!availableKeys.has(condition.fieldKey)) { + return { + valid: false, + error: `字段“${field.label}”的显示条件引用了不存在的字段:${condition.fieldKey}`, + field: 'fields', + details: { fieldKey: field.key, dependencyKey: condition.fieldKey }, + }; + } + + if (condition.fieldKey === field.key) { + return { + valid: false, + error: `字段“${field.label}”的显示条件不能引用自己`, + field: 'fields', + details: { fieldKey: field.key }, + }; + } + + if ( + CONDITION_OPERATORS_REQUIRING_VALUE.has(condition.operator) && + String(condition.value ?? '').trim() === '' + ) { + return { + valid: false, + error: `字段“${field.label}”的显示条件缺少比较值`, + field: 'fields', + details: { fieldKey: field.key, operator: condition.operator }, + }; + } + } + } + + const circularFields = detectCircularDependencies(formattedFields); + if (circularFields.length > 0) { + return { + valid: false, + error: `字段显示条件存在循环依赖:${circularFields.join(', ')}`, + field: 'fields', + details: { circularFields }, + }; + } + + return { + valid: true, + fields: formattedFields, + }; +} + +function evaluateSubmittedFieldCondition(condition, fieldValues) { + const fieldValue = fieldValues[condition.fieldKey]; + const stringValue = fieldValue === null || fieldValue === undefined + ? '' + : String(fieldValue).trim(); + + switch (condition.operator) { + case 'equals': + return stringValue === (condition.value || ''); + case 'notEquals': + return stringValue !== (condition.value || ''); + case 'contains': + return stringValue.includes(condition.value || ''); + case 'notEmpty': + return stringValue !== ''; + case 'isEmpty': + return stringValue === ''; + default: + return true; + } +} + +function isSubmittedFieldVisible(field, fieldValues) { + const conditions = parseFieldConditions(field.conditions); + if (!conditions || !conditions.conditions || conditions.conditions.length === 0) { + return true; + } + + const results = conditions.conditions.map((condition) => evaluateSubmittedFieldCondition(condition, fieldValues)); + return conditions.logic === 'OR' + ? results.some((result) => result) + : results.every((result) => result); +} + +function getVisibleSubmittedTemplateFields(fields, fieldValues) { + if (!fields.length) return []; + + let visibleKeys = new Set(); + for (let pass = 0; pass < fields.length; pass += 1) { + const activeValues = {}; + for (const field of fields) { + if (visibleKeys.has(field.key)) { + activeValues[field.key] = fieldValues[field.key]; + } + } + + const nextVisibleKeys = new Set(); + for (const field of fields) { + if (isSubmittedFieldVisible(field, activeValues)) { + nextVisibleKeys.add(field.key); + } + } + + const sameSize = nextVisibleKeys.size === visibleKeys.size; + const sameKeys = sameSize && Array.from(nextVisibleKeys).every((key) => visibleKeys.has(key)); + visibleKeys = nextVisibleKeys; + + if (sameKeys) break; + } + + return fields.filter((field) => visibleKeys.has(field.key)); +} + +function filterVisibleSubmittedFieldValues(fields, fieldValues) { + const visibleKeys = new Set( + getVisibleSubmittedTemplateFields(fields, fieldValues).map((field) => field.key) + ); + + return Object.fromEntries( + Object.entries(fieldValues).filter(([key]) => visibleKeys.has(key)) + ); +} + +function hasSubmittedFieldValue(value, type) { + if (type === 'checkbox') { + return value !== undefined && value !== null; + } + + if (value === undefined || value === null) { + return false; + } + + if (typeof value === 'string') { + return value.trim().length > 0; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return true; +} + +function validateSubmittedFieldValue(value, fieldType, rules, fieldLabel) { + const errors = []; + const label = fieldLabel || '此字段'; + + if (!rules) { + return { valid: true, errors: [] }; + } + + const stringValue = value === null || value === undefined ? '' : String(value).trim(); + const isEmpty = stringValue === ''; + + if (rules.required && isEmpty) { + errors.push(rules.requiredMessage || `${label}不能为空`); + return { valid: false, errors }; + } + + if (isEmpty) { + return { valid: true, errors: [] }; + } + + if (fieldType === 'text' || fieldType === 'textarea') { + if (typeof rules.minLength === 'number' && stringValue.length < rules.minLength) { + errors.push(rules.minLengthMessage || `${label}长度不能少于${rules.minLength}个字符`); + } + + if (typeof rules.maxLength === 'number' && stringValue.length > rules.maxLength) { + errors.push(rules.maxLengthMessage || `${label}长度不能超过${rules.maxLength}个字符`); + } + } + + if (fieldType === 'number') { + const numValue = Number.parseFloat(stringValue); + + if (Number.isNaN(numValue)) { + errors.push(`${label}必须是有效的数字`); + } else { + if (typeof rules.min === 'number' && numValue < rules.min) { + errors.push(rules.minMessage || `${label}不能小于${rules.min}`); + } + + if (typeof rules.max === 'number' && numValue > rules.max) { + errors.push(rules.maxMessage || `${label}不能大于${rules.max}`); + } + } + } + + if (rules.pattern) { + try { + const regex = new RegExp(rules.pattern); + if (!regex.test(stringValue)) { + errors.push(rules.patternMessage || `${label}格式不正确`); + } + } catch { + // 模板保存时已经校验过正则;运行期忽略坏规则,避免扩大故障面。 + } + } + + return { + valid: errors.length === 0, + errors, + }; +} + +export function validateSubmittedTemplateFieldsRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const templateId = typeof body.templateId === 'string' ? body.templateId : null; + const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields) + ? body.templateFields + : {}; + const allowUnknownKeys = body.allowUnknownKeys !== false; + const fields = Array.isArray(body.fields) ? body.fields : []; + const templateName = typeof body.templateName === 'string' ? body.templateName : undefined; + + if (!templateId) { + return { + valid: true, + templateFields, + visibleFieldKeys: Object.keys(templateFields), + }; + } + + const normalizedFields = fields + .filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key.trim()) + .map((field) => ({ + key: field.key.trim(), + label: typeof field.label === 'string' ? field.label : field.key.trim(), + type: typeof field.type === 'string' && field.type ? field.type : 'text', + required: field.required === true, + conditions: field.conditions ?? null, + validations: field.validations ?? null, + })); + + const fieldMap = new Map(normalizedFields.map((field) => [field.key, field])); + const visibleFields = getVisibleSubmittedTemplateFields(normalizedFields, templateFields); + const visibleTemplateFields = filterVisibleSubmittedFieldValues(normalizedFields, templateFields); + + if (!allowUnknownKeys) { + const unknownKeys = Object.keys(templateFields).filter((key) => !fieldMap.has(key)); + if (unknownKeys.length > 0) { + return { + valid: false, + error: `提交了模板中不存在的字段:${unknownKeys.join(', ')}`, + errorCode: 'UNKNOWN_TEMPLATE_FIELDS', + details: { + unknownKeys, + templateId, + }, + }; + } + } + + const missingRequiredFields = visibleFields + .filter((field) => field.required) + .filter((field) => !hasSubmittedFieldValue(visibleTemplateFields[field.key], field.type)) + .map((field) => ({ + key: field.key, + label: field.label || field.key, + type: field.type || 'text', + })); + + if (missingRequiredFields.length > 0) { + return { + valid: false, + error: `模板必填字段未填写:${missingRequiredFields.map((field) => field.label).join('、')}`, + errorCode: 'TEMPLATE_REQUIRED_FIELDS_MISSING', + details: { + templateId, + missingRequiredFields, + }, + }; + } + + const invalidFields = visibleFields + .map((field) => { + const result = validateSubmittedFieldValue( + visibleTemplateFields[field.key], + field.type || 'text', + parseValidationRules(field.validations), + field.label || field.key + ); + + return { + key: field.key, + label: field.label || field.key, + type: field.type || 'text', + errors: result.errors, + }; + }) + .filter((field) => field.errors.length > 0); + + if (invalidFields.length > 0) { + return { + valid: false, + error: `模板字段填写不符合规则:${invalidFields.map((field) => `${field.label}:${field.errors.join('、')}`).join(';')}`, + errorCode: 'TEMPLATE_FIELD_VALIDATION_FAILED', + details: { + templateId, + invalidFields, + }, + }; + } + + return { + valid: true, + templateName, + templateFields: visibleTemplateFields, + visibleFieldKeys: visibleFields.map((field) => field.key), + }; +} + +function separateSubmittedMaterialFields(templateFields) { + const standardData = {}; + const customFields = {}; + + for (const [key, value] of Object.entries(templateFields)) { + if (STANDARD_MATERIAL_FIELDS.has(key)) { + standardData[key] = value === null || value === undefined ? '' : String(value); + } else { + customFields[key] = value; + } + } + + return { standardData, customFields }; +} + +function parseExistingMetadata(existingMetadata) { + if (!existingMetadata) return {}; + try { + const parsed = JSON.parse(existingMetadata); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata) { + const metadata = parseExistingMetadata(existingMetadata); + + if (templateId) { + metadata.templateId = templateId; + } + + if (Object.keys(customFields).length > 0) { + metadata.templateFields = { + ...(metadata.templateFields || {}), + ...customFields, + }; + } + + if (Array.isArray(previewUrls) && previewUrls.length > 0) { + metadata.previewUrls = previewUrls; + } + + return Object.keys(metadata).length === 0 ? null : JSON.stringify(metadata); +} + +function compressSubmittedMaterialMetadata(metadata) { + if (!metadata) return null; + + try { + const parsed = JSON.parse(metadata); + + if (parsed.templateFields) { + const cleanedFields = {}; + for (const [key, value] of Object.entries(parsed.templateFields)) { + if (value !== null && value !== undefined && value !== '') { + cleanedFields[key] = value; + } + } + if (Object.keys(cleanedFields).length > 0) { + parsed.templateFields = cleanedFields; + } else { + delete parsed.templateFields; + } + } + + if (Array.isArray(parsed.previewUrls) && parsed.previewUrls.length === 0) { + delete parsed.previewUrls; + } + + return Object.keys(parsed).length === 0 ? null : JSON.stringify(parsed); + } catch { + return metadata; + } +} + +function getUtf8Size(value) { + return value ? Buffer.byteLength(value, 'utf8') : 0; +} + +function getRecordValue(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value; + } + return {}; +} + +function hasBackupValue(value) { + return value !== undefined && value !== null && value !== ''; +} + +function getExistingMigrationBackups(metadata) { + return Array.isArray(metadata.templateMigrationBackups) + ? metadata.templateMigrationBackups + : []; +} + +function buildDroppedStandardFields(standardFields, targetFieldKeys) { + const droppedFields = {}; + + for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) { + const value = standardFields[key]; + if (!targetFieldKeys.has(key) && hasBackupValue(value)) { + droppedFields[key] = value; + } + } + + return droppedFields; +} + +function normalizePreviewField(field) { + const item = field && typeof field === 'object' ? field : {}; + const key = typeof item.key === 'string' ? item.key.trim() : ''; + if (!key) return null; + + return { + key, + label: typeof item.label === 'string' && item.label.trim() ? item.label.trim() : key, + type: typeof item.type === 'string' && item.type.trim() ? item.type.trim() : undefined, + required: item.required === true, + }; +} + +function buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, source) { + const templateField = sourceTemplateFieldsByKey.get(key); + return { + key, + label: templateField?.label || STANDARD_FIELD_LABELS[key] || key, + type: templateField?.type, + source, + }; +} + +function addPreviewFieldImpact(impacts, field) { + const mapKey = `${field.source}:${field.key}`; + const existing = impacts.get(mapKey); + if (existing) { + existing.affectedCount += 1; + return; + } + + impacts.set(mapKey, { + ...field, + affectedCount: 1, + }); +} + +function normalizePreviewKeys(value) { + if (Array.isArray(value)) { + return Array.from(new Set(value.map((key) => String(key || '').trim()).filter(Boolean))); + } + + if (value && typeof value === 'object') { + return Object.keys(value).filter(Boolean); + } + + return []; +} + +function normalizePublicTemplate(template) { + const item = template && typeof template === 'object' ? template : {}; + const id = typeof item.id === 'string' ? item.id.trim() : ''; + if (!id) return null; + + return { + ...item, + id, + name: typeof item.name === 'string' ? item.name.trim() : '', + }; +} + +function isBuiltinTemplate(template) { + return typeof template?.id === 'string' && template.id.startsWith('builtin-'); +} + +function isActiveCustomTemplate(template) { + return ( + template && + !isBuiltinTemplate(template) && + template.status === 'active' + ); +} + +function normalizeBuiltinTemplates(templates) { + return (Array.isArray(templates) ? templates : []) + .map(normalizePublicTemplate) + .filter(Boolean) + .map((template) => ({ ...template, isBuiltin: true })); +} + +function normalizeCustomTemplates(templates) { + return (Array.isArray(templates) ? templates : []) + .map(normalizePublicTemplate) + .filter(isActiveCustomTemplate) + .map((template) => ({ ...template, isBuiltin: false })); +} + +function filterWritableTemplateFields(fields) { + return (Array.isArray(fields) ? fields : []).filter((field) => ( + field && + typeof field === 'object' && + field.isReadOnly !== true && + field.key !== 'materialType' + )); +} + +function attachMaterialCounts(templates, materialCounts) { + const counts = materialCounts && typeof materialCounts === 'object' && !Array.isArray(materialCounts) + ? materialCounts + : {}; + + return templates.map((template) => ({ + ...template, + materialCount: Number.isFinite(counts[template.id]) ? counts[template.id] : 0, + })); +} + +function stripTemplateMaterialSuffix(name) { + const stripped = String(name || '').trim().replace(/\s*素材模板\s*$/g, '').trim(); + return stripped || String(name || '').trim(); +} + +function buildCustomTemplateIdentifier(template) { + const rawName = typeof template.name === 'string' ? template.name.trim() : ''; + if (!rawName) return null; + + const templateName = stripTemplateMaterialSuffix(rawName) || `模板-${template.id.substring(0, 8)}`; + return `template:${template.id}:${templateName}`; +} + +function projectTemplateList(builtinTemplates, customTemplates) { + return [ + ...builtinTemplates.map((template) => ({ + id: template.id, + name: template.name, + isBuiltin: true, + })), + ...customTemplates.map((template) => ({ + id: template.id, + name: template.name, + isBuiltin: false, + })), + ]; +} + +function projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts) { + const writableCustomTemplates = customTemplates.map((template) => ({ + ...template, + fields: filterWritableTemplateFields(template.fields), + })); + + return attachMaterialCounts([ + ...builtinTemplates, + ...writableCustomTemplates, + ], materialCounts); +} + +function projectMaterialTypes(builtinMaterialTypes, customTemplates) { + const typeSet = new Set( + (Array.isArray(builtinMaterialTypes) ? builtinMaterialTypes : []) + .map((type) => String(type || '').trim()) + .filter(Boolean) + ); + + for (const template of customTemplates) { + const identifier = buildCustomTemplateIdentifier(template); + if (identifier) typeSet.add(identifier); + } + + return Array.from(typeSet).sort(); +} + +function hasFilledTemplateFieldValue(value) { + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.trim() !== ''; + if (Array.isArray(value)) return value.length > 0; + return true; +} + +function normalizeOptionalMaterialColumnValue(value) { + if (value === undefined || value === null || value === '') { + return null; + } + return String(value); +} + +export function prepareTemplateMigrationMetadataRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const targetTemplateId = typeof body.targetTemplateId === 'string' && body.targetTemplateId + ? body.targetTemplateId + : null; + const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId : ''; + const sourceTemplateName = typeof body.sourceTemplateName === 'string' ? body.sourceTemplateName : ''; + const targetTemplateName = typeof body.targetTemplateName === 'string' ? body.targetTemplateName : ''; + const migratedAt = typeof body.migratedAt === 'string' && body.migratedAt + ? body.migratedAt + : new Date().toISOString(); + const targetFieldKeys = new Set( + Array.isArray(body.targetFieldKeys) + ? body.targetFieldKeys.filter((key) => typeof key === 'string' && key) + : [] + ); + const targetFields = Array.isArray(body.targetFields) + ? body.targetFields + .filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key) + .map((field) => ({ + key: field.key, + label: typeof field.label === 'string' && field.label ? field.label : field.key, + type: typeof field.type === 'string' && field.type ? field.type : 'text', + required: field.required === true, + })) + : []; + const materialFieldValues = body.materialFieldValues && typeof body.materialFieldValues === 'object' && !Array.isArray(body.materialFieldValues) + ? body.materialFieldValues + : {}; + const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields) + ? body.templateFields + : Object.fromEntries( + targetFields + .filter((field) => materialFieldValues[field.key] !== undefined) + .map((field) => [field.key, materialFieldValues[field.key]]) + ); + const materialStandardFields = body.materialStandardFields && typeof body.materialStandardFields === 'object' && !Array.isArray(body.materialStandardFields) + ? body.materialStandardFields + : {}; + const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null; + const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : []; + const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0 + ? Math.floor(body.maxSizeBytes) + : 65535; + + if (!targetTemplateId) { + return { + valid: false, + error: '缺少目标模板 ID', + errorCode: 'MISSING_TARGET_TEMPLATE_ID', + details: { + sourceTemplateId, + }, + }; + } + + const missingRequiredFields = targetFields + .filter((field) => field.required) + .filter((field) => !hasSubmittedFieldValue(templateFields[field.key], field.type)) + .map((field) => field.label || field.key); + + if (missingRequiredFields.length > 0) { + return { + valid: false, + error: '目标模板存在无法从旧素材自动补齐的必填字段', + errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED', + details: { + missingRequiredFields, + targetTemplateId, + }, + }; + } + + const existingMetadataObject = parseExistingMetadata(existingMetadata); + const { standardData, customFields } = separateSubmittedMaterialFields(templateFields); + for (const key of AUDIO_MATERIAL_FIELDS) { + if (hasOwn(templateFields, key)) { + standardData[key] = normalizeOptionalMaterialColumnValue(templateFields[key]); + } + } + for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) { + if (!targetFieldKeys.has(key)) { + standardData[key] = emptyValue; + } + } + const targetMetadata = buildSubmittedMaterialMetadata( + customFields, + targetTemplateId, + previewUrls, + null + ); + const targetMetadataObject = parseExistingMetadata(targetMetadata); + const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields); + const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields); + const droppedTemplateFields = {}; + const droppedStandardFields = buildDroppedStandardFields(materialStandardFields, targetFieldKeys); + + for (const [key, value] of Object.entries(existingTemplateFields)) { + if (!targetFieldKeys.has(key) && hasBackupValue(value)) { + droppedTemplateFields[key] = value; + } + } + + const nextMetadata = { + ...existingMetadataObject, + ...targetMetadataObject, + templateId: targetTemplateId, + }; + + if (Object.keys(targetTemplateFields).length > 0) { + nextMetadata.templateFields = targetTemplateFields; + } else { + delete nextMetadata.templateFields; + } + + const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length; + const droppedStandardFieldCount = Object.keys(droppedStandardFields).length; + + if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) { + const backup = { + sourceTemplateId, + sourceTemplateName, + targetTemplateId, + targetTemplateName, + migratedAt, + }; + + if (droppedTemplateFieldCount > 0) { + backup.droppedTemplateFields = droppedTemplateFields; + } + + if (droppedStandardFieldCount > 0) { + backup.droppedStandardFields = droppedStandardFields; + } + + nextMetadata.templateMigrationBackups = [ + ...getExistingMigrationBackups(existingMetadataObject), + backup, + ]; + } + + const metadata = compressSubmittedMaterialMetadata(JSON.stringify(nextMetadata)); + const size = getUtf8Size(metadata); + + if (size > maxSizeBytes) { + return { + valid: false, + error: `模板迁移后 metadata 数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请先减少字段内容后再迁移。`, + errorCode: 'METADATA_TOO_LARGE', + details: { + size, + maxSize: maxSizeBytes, + }, + }; + } + + return { + valid: true, + standardData, + metadata, + metadataSize: { + ok: true, + size, + maxSize: maxSizeBytes, + }, + templateFields, + droppedTemplateFieldCount, + droppedStandardFieldCount, + droppedTemplateFields, + droppedStandardFields, + }; +} + +export function buildTemplateMigrationPreviewRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const totalMaterialCount = Number.isFinite(body.totalMaterialCount) + ? Math.max(0, Math.floor(body.totalMaterialCount)) + : 0; + const migratableCount = Number.isFinite(body.migratableCount) + ? Math.max(0, Math.floor(body.migratableCount)) + : 0; + const previewOnly = body.previewOnly === true; + const previewLimit = previewOnly && Number.isFinite(body.previewLimit) + ? Math.max(0, Math.floor(body.previewLimit)) + : undefined; + const targetFields = Array.isArray(body.targetFields) + ? body.targetFields.map(normalizePreviewField).filter(Boolean) + : []; + const sourceTemplateFieldsByKey = new Map( + (Array.isArray(body.sourceTemplateFields) ? body.sourceTemplateFields : []) + .map(normalizePreviewField) + .filter(Boolean) + .map((field) => [field.key, field]) + ); + const impacts = Array.isArray(body.impacts) ? body.impacts : []; + + let materialsWithDroppedFields = 0; + let droppedTemplateFieldCount = 0; + let droppedStandardFieldCount = 0; + const previewFieldImpacts = new Map(); + const samples = []; + + for (const rawImpact of impacts) { + const impact = rawImpact && typeof rawImpact === 'object' ? rawImpact : {}; + const droppedTemplateKeys = normalizePreviewKeys( + impact.droppedTemplateFieldKeys || impact.droppedTemplateFields + ); + const droppedStandardKeys = normalizePreviewKeys( + impact.droppedStandardFieldKeys || impact.droppedStandardFields + ); + + if (droppedTemplateKeys.length === 0 && droppedStandardKeys.length === 0) { + continue; + } + + materialsWithDroppedFields += 1; + droppedTemplateFieldCount += droppedTemplateKeys.length; + droppedStandardFieldCount += droppedStandardKeys.length; + + const droppedTemplateFields = droppedTemplateKeys.map((key) => + buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'template') + ); + const droppedStandardFields = droppedStandardKeys.map((key) => + buildPreviewFieldSummary(key, sourceTemplateFieldsByKey, 'standard') + ); + + droppedTemplateFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field)); + droppedStandardFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field)); + + if (samples.length < 10) { + samples.push({ + materialId: typeof impact.materialId === 'string' ? impact.materialId : '', + productModel: + impact.productModel === undefined || impact.productModel === null || impact.productModel === '' + ? null + : String(impact.productModel), + droppedTemplateFields, + droppedStandardFields, + }); + } + } + + return { + valid: true, + migrationBackup: { + materialsWithDroppedFields, + droppedTemplateFieldCount, + droppedStandardFieldCount, + }, + migrationPreview: { + totalMaterialCount, + totalMaterials: migratableCount, + previewLimited: previewOnly && totalMaterialCount > migratableCount, + previewLimit, + targetCurrentFields: targetFields.map((field) => ({ + key: field.key, + label: field.label || field.key, + type: field.type, + required: field.required, + })), + backupFields: { + templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'), + standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'), + }, + samples, + }, + }; +} + +export function projectPublicTemplateOptionsRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const builtinTemplates = normalizeBuiltinTemplates(body.builtinTemplates); + const customTemplates = normalizeCustomTemplates(body.customTemplates); + const materialCounts = body.materialCounts && typeof body.materialCounts === 'object' && !Array.isArray(body.materialCounts) + ? body.materialCounts + : {}; + + return { + valid: true, + templates: projectTemplateList(builtinTemplates, customTemplates), + uploadTemplates: projectUploadTemplateList(builtinTemplates, customTemplates, materialCounts), + materialTypes: projectMaterialTypes(body.builtinMaterialTypes, customTemplates), + }; +} + +function normalizeFieldKeyList(value) { + if (!Array.isArray(value)) return []; + const keys = []; + const seen = new Set(); + for (const item of value) { + const rawKey = typeof item === 'string' ? item : item?.key; + const key = typeof rawKey === 'string' ? rawKey.trim() : ''; + if (!key || seen.has(key)) continue; + seen.add(key); + keys.push(key); + } + return keys; +} + +export function planMaterialCustomSearchRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields); + const customFieldKeys = searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key)); + const search = typeof body.search === 'string' ? body.search.trim() : ''; + + return { + valid: true, + search, + customFieldKeys, + }; +} + +export function planHomeFilterCustomFieldCountsRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const fieldKeys = normalizeFieldKeyList(body.fields); + const customFieldKeys = fieldKeys.filter((key) => !MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key)); + + return { + valid: true, + customFieldKeys, + }; +} + +export function planHomeFilterFieldCountsRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const fieldKeys = normalizeFieldKeyList(body.fields); + + return { + valid: true, + fieldPlans: fieldKeys.map((key) => ({ + key, + source: MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key) ? 'column' : 'metadata', + })), + }; +} + +function normalizeFilterList(value) { + if (!Array.isArray(value)) return []; + const filters = []; + const seen = new Set(); + + for (const item of value) { + if (!item || typeof item !== 'object') continue; + const key = typeof item.key === 'string' ? item.key.trim() : ''; + const rawValue = item.value; + const filterValue = typeof rawValue === 'string' ? rawValue.trim() : ''; + if (!key || !filterValue) continue; + const dedupeKey = `${key}\u0000${filterValue}`; + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + filters.push({ key, value: filterValue }); + } + + return filters; +} + +export function planMaterialListQueryRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const search = typeof body.search === 'string' ? body.search.trim() : ''; + const searchableFieldKeys = normalizeFieldKeyList(body.searchableFields); + const enabledHomeFilterKeys = new Set(normalizeFieldKeyList(body.enabledHomeFilterFields)); + const homeFieldFilters = normalizeFilterList(body.homeFieldFilters); + + return { + valid: true, + search, + standardSearchFields: search ? MATERIAL_LIST_SEARCH_COLUMN_FIELDS : [], + customSearchFieldKeys: search + ? searchableFieldKeys.filter((key) => !STANDARD_MATERIAL_FIELDS.has(key)) + : [], + standardHomeFilters: homeFieldFilters.filter((filter) => + MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key) + ), + customHomeFilters: homeFieldFilters.filter((filter) => + !MATERIAL_LIST_HOME_FILTER_COLUMN_FIELDS.has(filter.key) && enabledHomeFilterKeys.has(filter.key) + ), + }; +} + +function normalizeDepartmentName(name) { + return typeof name === 'string' ? name.trim() : ''; +} + +function buildDepartmentDescendantSet(rawDepartments) { + const names = Array.isArray(rawDepartments) + ? rawDepartments + .map((department) => ( + typeof department === 'string' + ? normalizeDepartmentName(department) + : normalizeDepartmentName(department?.name) + )) + .filter(Boolean) + : []; + const childrenMap = new Map(); + + const ensureNode = (name) => { + if (!childrenMap.has(name)) { + childrenMap.set(name, []); + } + }; + + names.forEach((name) => ensureNode(name)); + names.forEach((name) => { + const lastDashIndex = name.lastIndexOf('-'); + if (lastDashIndex > 0) { + const parent = name.substring(0, lastDashIndex); + ensureNode(parent); + childrenMap.get(parent).push(name); + } + }); + childrenMap.forEach((children) => children.sort((a, b) => departmentCollator.compare(a, b))); + + const descendantMap = new Map(); + const visit = (name) => { + if (descendantMap.has(name)) return descendantMap.get(name); + const descendants = new Set([name]); + for (const child of childrenMap.get(name) || []) { + for (const descendant of visit(child)) { + descendants.add(descendant); + } + } + descendantMap.set(name, descendants); + return descendants; + }; + childrenMap.forEach((_children, name) => visit(name)); + return descendantMap; +} + +export function planDepartmentScopeRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const scopeDepartment = normalizeDepartmentName(body.scopeDepartment); + const targetDepartment = normalizeDepartmentName(body.targetDepartment); + const descendantMap = buildDepartmentDescendantSet(body.departments); + const scopedDepartments = scopeDepartment + ? Array.from(descendantMap.get(scopeDepartment) || new Set([scopeDepartment])) + : []; + scopedDepartments.sort((a, b) => departmentCollator.compare(a, b)); + + return { + valid: true, + scopedDepartments, + withinScope: Boolean( + scopeDepartment && + targetDepartment && + ( + targetDepartment === scopeDepartment || + scopedDepartments.includes(targetDepartment) || + targetDepartment.startsWith(`${scopeDepartment}-`) + ) + ), + }; +} + +function normalizeDepartmentRecords(rawDepartments) { + if (!Array.isArray(rawDepartments)) return []; + return rawDepartments + .map((department) => ({ + id: typeof department?.id === 'string' ? department.id : '', + name: normalizeDepartmentName(department?.name), + })) + .filter((department) => department.id && department.name); +} + +function normalizeDepartmentUsers(rawUsers) { + if (!Array.isArray(rawUsers)) return []; + return rawUsers + .map((user) => ({ + id: typeof user?.id === 'string' ? user.id : '', + department: typeof user?.department === 'string' ? user.department : '', + managedDepartment: + typeof user?.managedDepartment === 'string' && user.managedDepartment + ? user.managedDepartment + : null, + })) + .filter((user) => user.id); +} + +function denyDepartmentStructureChange(statusCode, error, errorCode, details) { + return { + valid: true, + allowed: false, + statusCode, + error, + errorCode, + details, + }; +} + +function renameDepartmentValue(value, oldName, nextName) { + if (!value) return value; + const oldPrefix = `${oldName}-`; + const nextPrefix = `${nextName}-`; + if (value === oldName) return nextName; + if (value.startsWith(oldPrefix)) { + return `${nextPrefix}${value.slice(oldPrefix.length)}`; + } + return value; +} + +export function planDepartmentStructureChangeRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const action = typeof body.action === 'string' ? body.action.trim() : ''; + const target = body.target && typeof body.target === 'object' ? body.target : {}; + const departments = normalizeDepartmentRecords(body.departments); + const users = normalizeDepartmentUsers(body.users); + const targetId = typeof target.id === 'string' ? target.id : ''; + const nextName = normalizeDepartmentName(target.name); + + if (!['create', 'rename', 'delete'].includes(action)) { + return denyDepartmentStructureChange(400, '部门操作类型无效', 'INVALID_DEPARTMENT_ACTION'); + } + + if (action === 'create') { + if (!nextName) { + return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED'); + } + if (departments.some((department) => department.name === nextName)) { + return denyDepartmentStructureChange(400, '部门已存在', 'DEPARTMENT_NAME_EXISTS', { + name: nextName, + }); + } + return { + valid: true, + allowed: true, + action, + departmentOps: [{ type: 'create', data: { name: nextName } }], + userOps: [], + summary: { createdName: nextName }, + }; + } + + if (!targetId) { + return denyDepartmentStructureChange(400, '缺少部门ID', 'DEPARTMENT_ID_REQUIRED'); + } + + const targetDepartment = departments.find((department) => department.id === targetId); + if (!targetDepartment) { + return denyDepartmentStructureChange(404, '未找到对应的部门', 'DEPARTMENT_NOT_FOUND', { + id: targetId, + }); + } + + if (action === 'rename') { + if (!nextName) { + return denyDepartmentStructureChange(400, '部门名称不能为空', 'DEPARTMENT_NAME_REQUIRED'); + } + if (targetDepartment.name === nextName) { + return { + valid: true, + allowed: false, + statusCode: 200, + response: targetDepartment, + }; + } + + const oldName = targetDepartment.name; + const oldPrefix = `${oldName}-`; + const nextPrefix = `${nextName}-`; + const renameItems = departments + .filter((department) => department.id === targetId || department.name.startsWith(oldPrefix)) + .map((department) => ({ + id: department.id, + oldName: department.name, + newName: department.id === targetId + ? nextName + : `${nextPrefix}${department.name.slice(oldPrefix.length)}`, + })); + const renameIds = new Set(renameItems.map((item) => item.id)); + const nextNames = new Set(renameItems.map((item) => item.newName)); + const conflictNames = departments + .filter((department) => nextNames.has(department.name) && !renameIds.has(department.id)) + .map((department) => department.name); + + if (conflictNames.length > 0) { + return denyDepartmentStructureChange(400, `存在同名分支:${conflictNames.join('、')}`, 'DEPARTMENT_NAME_CONFLICT', { + conflictNames, + }); + } + + const userOps = users + .map((user) => { + const nextDepartment = renameDepartmentValue(user.department, oldName, nextName) || ''; + const nextManagedDepartment = renameDepartmentValue(user.managedDepartment, oldName, nextName) || null; + const data = {}; + if (nextDepartment !== user.department) data.department = nextDepartment; + if (nextManagedDepartment !== user.managedDepartment) data.managedDepartment = nextManagedDepartment; + return Object.keys(data).length > 0 ? { type: 'update', id: user.id, data } : null; + }) + .filter(Boolean); + + return { + valid: true, + allowed: true, + action, + departmentOps: renameItems.map((item) => ({ + type: 'rename', + id: item.id, + oldName: item.oldName, + newName: item.newName, + })), + userOps, + summary: { + oldName, + newName: nextName, + renamedDepartmentCount: renameItems.length, + updatedUserCount: userOps.length, + }, + }; + } + + const deletePrefix = `${targetDepartment.name}-`; + const deleteItems = departments.filter((department) => + department.id === targetId || department.name.startsWith(deletePrefix) + ); + const targetIds = deleteItems.map((department) => department.id); + const targetNames = deleteItems.map((department) => department.name); + + return { + valid: true, + allowed: true, + action, + departmentOps: [{ type: 'deleteMany', ids: targetIds, names: targetNames }], + userOps: [ + { type: 'clearDepartmentIn', names: targetNames }, + { type: 'clearManagedDepartmentIn', names: targetNames }, + ], + summary: { + removedDepartmentCount: targetIds.length, + removedDepartments: targetNames, + }, + }; +} + +export function calculateTemplateFieldStatsRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const fields = Array.isArray(body.fields) + ? body.fields + .filter((field) => field && typeof field === 'object' && typeof field.key === 'string' && field.key) + .map((field) => ({ + key: field.key, + label: typeof field.label === 'string' && field.label ? field.label : field.key, + type: typeof field.type === 'string' && field.type ? field.type : 'text', + })) + : []; + const materials = Array.isArray(body.materials) ? body.materials : []; + + if (materials.length === 0) { + return { + valid: true, + fieldStats: null, + }; + } + + const totalMaterials = materials.length; + const stats = fields.map((field) => { + let filledCount = 0; + + for (const material of materials) { + const metadata = parseExistingMetadata(material?.metadata); + const templateFields = getRecordValue(metadata.templateFields); + if (hasFilledTemplateFieldValue(templateFields[field.key])) { + filledCount += 1; + } + } + + const fillRate = totalMaterials > 0 ? (filledCount / totalMaterials) * 100 : 0; + + return { + fieldKey: field.key, + fieldLabel: field.label, + fieldType: field.type, + filledCount, + totalCount: totalMaterials, + fillRate: Math.round(fillRate * 100) / 100, + isLowFillRate: fillRate < 30, + }; + }); + + stats.sort((a, b) => a.fillRate - b.fillRate); + + return { + valid: true, + fieldStats: { + totalMaterials, + fields: stats, + lowFillRateFields: stats.filter((field) => field.isLowFillRate), + }, + }; +} + +export function prepareMaterialMetadataRequest(args) { + const body = args && typeof args === 'object' ? args : {}; + const templateFields = body.templateFields && typeof body.templateFields === 'object' && !Array.isArray(body.templateFields) + ? body.templateFields + : {}; + const templateId = typeof body.templateId === 'string' && body.templateId ? body.templateId : null; + const previewUrls = Array.isArray(body.previewUrls) ? body.previewUrls.filter((url) => typeof url === 'string') : []; + const existingMetadata = typeof body.existingMetadata === 'string' ? body.existingMetadata : null; + const maxSizeBytes = Number.isFinite(body.maxSizeBytes) && body.maxSizeBytes > 0 + ? Math.floor(body.maxSizeBytes) + : 65535; + + const { standardData, customFields } = separateSubmittedMaterialFields(templateFields); + const rawMetadata = buildSubmittedMaterialMetadata(customFields, templateId, previewUrls, existingMetadata); + const metadata = compressSubmittedMaterialMetadata(rawMetadata); + const size = getUtf8Size(metadata); + + if (size > maxSizeBytes) { + return { + valid: false, + error: `自定义字段数据过大,当前约 ${size} 字节,最大允许 ${maxSizeBytes} 字节。请减少字段内容后重试。`, + errorCode: 'METADATA_TOO_LARGE', + details: { + size, + maxSize: maxSizeBytes, + }, + }; + } + + return { + valid: true, + standardData, + metadata, + metadataSize: { + ok: true, + size, + maxSize: maxSizeBytes, + }, + }; +} + +export function normalizeUploadTemplateSaveRequest(payload, mode = 'create') { + const body = payload && typeof payload === 'object' ? payload : {}; + const isCreate = mode !== 'update'; + const template = {}; + + if (isCreate || hasOwn(body, 'name')) { + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) { + return validationError('模板名称不能为空', 'name'); + } + template.name = name; + } + + if (hasOwn(body, 'description')) { + template.description = body.description ?? null; + } else if (isCreate) { + template.description = null; + } + + const rawStatus = hasOwn(body, 'status') ? body.status : 'draft'; + if (isCreate || hasOwn(body, 'status')) { + if (typeof rawStatus !== 'string' || !ALLOWED_TEMPLATE_STATUSES.has(rawStatus)) { + return validationError('模板状态必须是 draft、active 或 archived', 'status'); + } + template.status = rawStatus; + } + + if (isCreate || hasOwn(body, 'materialType')) { + const materialType = typeof body.materialType === 'string' ? body.materialType.trim() : ''; + if (!materialType) { + return validationError('素材类型不能为空', 'materialType'); + } + template.materialType = materialType; + } + + if (isCreate || hasOwn(body, 'materialKind')) { + const materialKind = typeof body.materialKind === 'string' ? body.materialKind.trim().toUpperCase() : ''; + if (!materialKind || !ALLOWED_MATERIAL_KINDS.has(materialKind)) { + return validationError( + `素材大类必须是以下之一:${Array.from(ALLOWED_MATERIAL_KINDS).join(', ')}`, + 'materialKind' + ); + } + template.materialKind = materialKind; + } + + if (hasOwn(body, 'fieldGroups')) { + template.fieldGroups = body.fieldGroups ?? null; + } else if (isCreate) { + template.fieldGroups = null; + } + + if (hasOwn(body, 'displayConfig')) { + template.displayConfig = body.displayConfig ?? null; + } else if (isCreate) { + template.displayConfig = null; + } + + const fieldsProvided = isCreate || hasOwn(body, 'fields'); + if (fieldsProvided) { + const formattedFieldsResult = validateAndFormatUploadTemplateFields( + hasOwn(body, 'fields') ? body.fields : [] + ); + if (!formattedFieldsResult.valid) { + return formattedFieldsResult; + } + template.fields = formattedFieldsResult.fields; + } + + return { + valid: true, + mode: isCreate ? 'create' : 'update', + fieldsProvided, + template, + }; +} + +export function planUploadTemplateDeleteRequest(payload) { + const body = payload && typeof payload === 'object' ? payload : {}; + const templateName = + typeof body.templateName === 'string' && body.templateName.trim() + ? body.templateName.trim() + : '未命名模板'; + const materialCount = Number.isFinite(Number(body.materialCount)) + ? Math.max(0, Math.trunc(Number(body.materialCount))) + : 0; + + if (materialCount > 0) { + return { + valid: true, + allowed: false, + error: `无法删除模板"${templateName}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`, + errorCode: 'TEMPLATE_IN_USE', + details: { + templateName, + materialCount, + }, + }; + } + + return { + valid: true, + allowed: true, + templateName, + materialCount, + }; +} + +function parsePositiveInteger(value) { + if (value === undefined || value === null || value === '') { + return null; + } + + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + + return parsed; +} + +export function planTemplateMigrationBatchRequest(payload) { + const body = payload && typeof payload === 'object' ? payload : {}; + const request = body.request && typeof body.request === 'object' ? body.request : {}; + const sourceTemplateId = typeof body.sourceTemplateId === 'string' ? body.sourceTemplateId.trim() : ''; + const targetTemplateId = typeof body.targetTemplateId === 'string' ? body.targetTemplateId.trim() : ''; + const sourceTemplateName = + typeof body.sourceTemplateName === 'string' && body.sourceTemplateName.trim() + ? body.sourceTemplateName.trim() + : '源模板'; + const targetTemplateName = + typeof body.targetTemplateName === 'string' && body.targetTemplateName.trim() + ? body.targetTemplateName.trim() + : '目标模板'; + const totalMaterialCount = Number.isFinite(Number(body.totalMaterialCount)) + ? Math.max(0, Math.trunc(Number(body.totalMaterialCount))) + : 0; + const previewOnly = request.previewOnly === true; + const forceLargeMigration = request.forceLargeMigration === true; + const updateMaterialType = request.updateMaterialType !== false; + const migrationLimit = Math.min( + parsePositiveInteger(request.migrationLimit) ?? DEFAULT_MIGRATION_SAFE_LIMIT, + MAX_MIGRATION_FORCE_LIMIT + ); + + if (!targetTemplateId) { + return { + valid: true, + allowed: false, + statusCode: 400, + error: '请选择目标模板', + errorCode: 'MISSING_TARGET', + }; + } + + if (sourceTemplateId && sourceTemplateId === targetTemplateId) { + return { + valid: true, + allowed: false, + statusCode: 400, + error: '源模板和目标模板不能相同', + errorCode: 'SAME_TEMPLATE', + }; + } + + if (totalMaterialCount === 0) { + return { + valid: true, + allowed: false, + statusCode: 200, + response: { + success: true, + migratedCount: 0, + message: '没有需要迁移的素材', + }, + }; + } + + if (!previewOnly && totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT && !forceLargeMigration) { + return { + valid: true, + allowed: false, + statusCode: 409, + error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`, + errorCode: 'MIGRATION_CONFIRM_REQUIRED', + details: { + totalMaterialCount, + safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT, + migrationLimit, + maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT, + sourceTemplateId, + targetTemplateId, + nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。', + }, + }; + } + + const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit); + + return { + valid: true, + allowed: true, + sourceTemplateId, + targetTemplateId, + sourceTemplateName, + targetTemplateName, + totalMaterialCount, + previewOnly, + forceLargeMigration, + updateMaterialType, + migrationLimit, + safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT, + maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT, + batchLimited, + materialTake: batchLimited ? migrationLimit : null, + migratedAt: new Date().toISOString(), + }; +} + +function buildUploadTemplateUpdateData(template, nextVersion) { + const updateData = {}; + + if (template.name !== undefined) updateData.name = template.name; + if (template.description !== undefined) updateData.description = template.description; + if (template.status !== undefined) updateData.status = template.status; + if (template.fieldGroups !== undefined) updateData.fieldGroups = template.fieldGroups; + if (template.displayConfig !== undefined) updateData.displayConfig = template.displayConfig; + if (template.materialType !== undefined) updateData.materialType = template.materialType; + if (template.materialKind !== undefined) updateData.materialKind = template.materialKind; + updateData.version = nextVersion; + + return updateData; +} + +export function planUploadTemplateUpdateRequest(payload) { + const body = payload && typeof payload === 'object' ? payload : {}; + const existing = body.existingTemplate && typeof body.existingTemplate === 'object' + ? body.existingTemplate + : {}; + const updatePayload = body.payload && typeof body.payload === 'object' ? body.payload : {}; + const normalizedResult = normalizeUploadTemplateSaveRequest(updatePayload, 'update'); + + if (!existing.id) { + return { + valid: false, + error: '缺少现有模板信息', + errorCode: 'EXISTING_TEMPLATE_REQUIRED', + field: 'existingTemplate', + statusCode: 400, + }; + } + + const existingVersion = existing.version || 1; + const clientVersion = hasOwn(updatePayload, 'version') ? updatePayload.version : undefined; + if (clientVersion !== undefined && clientVersion !== existing.version) { + return { + valid: false, + error: '模板已被其他人修改,请刷新后重试', + errorCode: 'VERSION_CONFLICT', + field: 'version', + statusCode: 409, + details: { + templateName: existing.name, + clientVersion, + serverVersion: existing.version, + lastUpdated: existing.updatedAt, + }, + }; + } + + if (!normalizedResult.valid) { + return { + valid: false, + error: normalizedResult.error, + errorCode: 'VALIDATION_ERROR', + field: normalizedResult.field, + statusCode: 400, + details: normalizedResult.details, + }; + } + + const nextVersion = existingVersion + 1; + const normalized = normalizedResult.template; + const updateData = buildUploadTemplateUpdateData(normalized, nextVersion); + const formattedFields = normalizedResult.fieldsProvided ? (normalized.fields || []) : null; + + if (formattedFields) { + const existingFieldIds = new Set( + (Array.isArray(existing.fields) ? existing.fields : []) + .map((field) => field && field.id) + .filter(Boolean) + ); + const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id)); + if (foreignField) { + return { + valid: false, + error: '字段不属于当前模板,请刷新后重试', + errorCode: 'FIELD_NOT_IN_TEMPLATE', + field: 'fields', + statusCode: 400, + details: { + fieldId: foreignField.id, + fieldKey: foreignField.key, + }, + }; + } + } + + const materialCount = Number.isFinite(Number(existing.materialCount)) + ? Math.max(0, Math.trunc(Number(existing.materialCount))) + : 0; + if (materialCount > 0) { + const dangerousChanges = detectDangerousTemplateChanges({ + existingTemplate: { + materialType: existing.materialType, + materialKind: existing.materialKind, + fields: Array.isArray(existing.fields) ? existing.fields : [], + }, + nextMaterialType: updateData.materialType, + nextMaterialKind: updateData.materialKind, + nextFields: formattedFields || undefined, + }); + + if (dangerousChanges.length > 0) { + return { + valid: false, + error: `模板“${existing.name}”已有 ${materialCount} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`, + errorCode: 'TEMPLATE_STRUCTURE_LOCKED', + field: 'template', + statusCode: 409, + details: { + templateName: existing.name, + materialCount, + dangerousChanges, + }, + }; + } + } + + return { + valid: true, + updateData, + fieldsProvided: normalizedResult.fieldsProvided, + fields: formattedFields, + nextVersion, + }; +} diff --git a/scripts/__tests__/deploy-registry-script.test.ts b/scripts/__tests__/deploy-registry-script.test.ts index f93c480..4bd1df2 100644 --- a/scripts/__tests__/deploy-registry-script.test.ts +++ b/scripts/__tests__/deploy-registry-script.test.ts @@ -78,7 +78,7 @@ describe('deploy-registry.ps1', () => { it('支持发布付费模块镜像并输出验证服务器 digest 配置', () => { expect(script).toContain('"PublishPremiumModule"') - expect(script).toContain('[string]$PremiumModuleId = "intranet-penetration-core"') + expect(script).toContain('[string]$PremiumModuleId = "paid-core"') expect(script).toContain('[string]$PremiumModuleRepositoryPrefix = "premium"') expect(script).toContain('[switch]$AllowMissingPremiumModuleDigest') expect(script).toContain('function Invoke-PublishPremiumModule') diff --git a/scripts/__tests__/premium-module-source.test.ts b/scripts/__tests__/premium-module-source.test.ts index 2f7da71..c699d26 100644 --- a/scripts/__tests__/premium-module-source.test.ts +++ b/scripts/__tests__/premium-module-source.test.ts @@ -2,11 +2,7 @@ import fs from 'fs' import path from 'path' describe('premium module source packages', () => { - const moduleIds = [ - 'license-limits-core', - 'custom-templates-core', - 'intranet-penetration-core', - ] + const moduleIds = ['paid-core'] it('所有授权模块都应可作为独立模块镜像构建', () => { for (const moduleId of moduleIds) { @@ -16,22 +12,281 @@ describe('premium module source packages', () => { expect(fs.existsSync(path.join(moduleDir, 'VERSION.txt'))).toBe(true) expect(fs.existsSync(path.join(moduleDir, 'package.json'))).toBe(true) + expect(fs.existsSync(path.join(moduleDir, 'template-validation.mjs'))).toBe(true) expect(dockerfile).toContain('HEALTHCHECK') expect(dockerfile).toContain('EXPOSE 8787') + expect(dockerfile).toContain('template-validation.mjs') expect(server).toContain(`const moduleId = '${moduleId}'`) expect(server).toContain("url.pathname === '/health'") expect(server).not.toContain("status: 'success'") } }) - it('内网穿透模块未接真实 provider 时必须明确返回未配置', () => { - const moduleDir = path.join(process.cwd(), 'premium-modules', 'intranet-penetration-core') + it('paid-core 未接真实穿透 provider 时必须明确返回未配置', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') expect(server).toContain("status: 'provider_not_configured'") - expect(server).toContain("url.pathname === '/start'") - expect(server).toContain("url.pathname === '/status'") - expect(server).toContain("url.pathname === '/stop'") + expect(server).toContain("url.pathname === '/tunnel/start'") + expect(server).toContain("url.pathname === '/tunnel/status'") + expect(server).toContain("url.pathname === '/tunnel/stop'") expect(server).not.toContain("status: 'success'") }) + + it('paid-core 应承接 API 访问功能门禁', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + + expect(server).toContain("'apiAccess'") + expect(server).toContain("'apiAccess:use'") + expect(server).toContain("'apiAccess:read'") + }) + + it('paid-core 应承接自定义模板字段保存前校验', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/validate-fields'") + expect(validation).toContain('validateAndFormatUploadTemplateFields') + expect(validation).toContain('maxFields: 80') + }) + + it('paid-core 应承接自定义模板保存请求标准化', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/normalize-save'") + expect(server).toContain('normalizeUploadTemplateSaveRequest') + expect(server).toContain("action: 'customTemplates:normalizeSave'") + expect(validation).toContain('normalizeUploadTemplateSaveRequest') + expect(validation).toContain('ALLOWED_TEMPLATE_STATUSES') + expect(validation).toContain('ALLOWED_MATERIAL_KINDS') + for (const field of [ + 'name', + 'description', + 'status', + 'materialType', + 'materialKind', + 'fieldGroups', + 'displayConfig', + 'fields', + ]) { + expect(validation).toContain(`'${field}'`) + } + }) + + it('paid-core 应承接自定义模板删除规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/plan-delete'") + expect(server).toContain("action: 'customTemplates:planDelete'") + expect(validation).toContain('planUploadTemplateDeleteRequest') + expect(validation).toContain('TEMPLATE_IN_USE') + }) + + it('paid-core 应承接自定义模板更新规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/plan-update'") + expect(server).toContain("action: 'customTemplates:planUpdate'") + expect(validation).toContain('planUploadTemplateUpdateRequest') + expect(validation).toContain('VERSION_CONFLICT') + expect(validation).toContain('FIELD_NOT_IN_TEMPLATE') + expect(validation).toContain('TEMPLATE_STRUCTURE_LOCKED') + }) + + it('paid-core 应承接自定义模板素材字段提交校验', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/validate-template-fields'") + expect(server).toContain("action: 'customTemplates:validateSubmittedFields'") + expect(validation).toContain('validateSubmittedTemplateFieldsRequest') + expect(validation).toContain('UNKNOWN_TEMPLATE_FIELDS') + expect(validation).toContain('TEMPLATE_REQUIRED_FIELDS_MISSING') + expect(validation).toContain('TEMPLATE_FIELD_VALIDATION_FAILED') + }) + + it('paid-core 应承接自定义模板素材 metadata 准备和大小检查', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/prepare-metadata'") + expect(server).toContain("action: 'customTemplates:prepareMaterialMetadata'") + expect(validation).toContain('prepareMaterialMetadataRequest') + expect(validation).toContain('STANDARD_MATERIAL_FIELDS') + expect(validation).toContain('METADATA_TOO_LARGE') + }) + + it('paid-core 应承接自定义模板迁移 metadata 准备和备份计算', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/prepare-migration-metadata'") + expect(server).toContain("action: 'customTemplates:prepareMigrationMetadata'") + expect(validation).toContain('prepareTemplateMigrationMetadataRequest') + expect(validation).toContain('materialFieldValues') + expect(validation).toContain('templateMigrationBackups') + expect(validation).toContain('droppedTemplateFields') + expect(validation).toContain('droppedStandardFields') + expect(validation).toContain('MIGRATION_BACKUP_STANDARD_FIELDS') + expect(validation).toContain('CLEARABLE_NON_TARGET_FIELD_DEFAULTS') + expect(validation).toContain('AUDIO_MATERIAL_FIELDS') + expect(validation).toContain('normalizeOptionalMaterialColumnValue') + expect(validation).toContain('TARGET_TEMPLATE_FIELDS_UNRESOLVED') + expect(validation).toContain('METADATA_TOO_LARGE') + }) + + it('paid-core 应承接自定义模板迁移预览和备份字段影响统计', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/build-migration-preview'") + expect(server).toContain("action: 'customTemplates:buildMigrationPreview'") + expect(validation).toContain('buildTemplateMigrationPreviewRequest') + expect(validation).toContain('STANDARD_FIELD_LABELS') + expect(validation).toContain('buildPreviewFieldSummary') + expect(validation).toContain('addPreviewFieldImpact') + expect(validation).toContain('migrationBackup') + expect(validation).toContain('migrationPreview') + }) + + it('paid-core 应承接自定义模板迁移批处理规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/plan-migration-batch'") + expect(server).toContain("action: 'customTemplates:planMigrationBatch'") + expect(validation).toContain('planTemplateMigrationBatchRequest') + expect(validation).toContain('DEFAULT_MIGRATION_SAFE_LIMIT') + expect(validation).toContain('MAX_MIGRATION_FORCE_LIMIT') + expect(validation).toContain('MIGRATION_CONFIRM_REQUIRED') + }) + + it('paid-core 应承接自定义模板公开列表和素材类型投影', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/project-public-options'") + expect(server).toContain("action: 'customTemplates:projectPublicOptions'") + expect(validation).toContain('projectPublicTemplateOptionsRequest') + expect(validation).toContain('projectTemplateList') + expect(validation).toContain('projectUploadTemplateList') + expect(validation).toContain('projectMaterialTypes') + expect(validation).toContain('filterWritableTemplateFields') + expect(validation).toContain('stripTemplateMaterialSuffix') + expect(validation).toContain("`template:${template.id}:${templateName}`") + }) + + it('paid-core 应承接自定义模板字段填写率统计', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/calculate-field-stats'") + expect(server).toContain("action: 'customTemplates:calculateFieldStats'") + expect(validation).toContain('calculateTemplateFieldStatsRequest') + expect(validation).toContain('hasFilledTemplateFieldValue') + expect(validation).toContain('parseExistingMetadata') + expect(validation).toContain('isLowFillRate: fillRate < 30') + expect(validation).toContain('lowFillRateFields') + }) + + it('paid-core 应承接素材列表自定义字段搜索规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/plan-custom-search'") + expect(server).toContain("action: 'customTemplates:planMaterialCustomSearch'") + expect(validation).toContain('planMaterialCustomSearchRequest') + expect(validation).toContain('normalizeFieldKeyList') + expect(validation).toContain('STANDARD_MATERIAL_FIELDS.has(key)') + }) + + it('paid-core 应承接素材列表搜索和筛选查询计划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/plan-list-query'") + expect(server).toContain("action: 'customTemplates:planMaterialListQuery'") + expect(validation).toContain('planMaterialListQueryRequest') + expect(validation).toContain('standardSearchFields') + expect(validation).toContain('customSearchFieldKeys') + expect(validation).toContain('standardHomeFilters') + expect(validation).toContain('customHomeFilters') + }) + + it('paid-core 应承接首页自定义筛选字段统计规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/plan-home-filter-counts'") + expect(server).toContain("action: 'customTemplates:planHomeFilterCounts'") + expect(validation).toContain('planHomeFilterCustomFieldCountsRequest') + expect(validation).toContain('MATERIAL_HOME_FILTER_COLUMN_FIELDS') + expect(validation).toContain('MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key)') + }) + + it('paid-core 应承接首页筛选字段统计计划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/materials/plan-home-filter-fields'") + expect(server).toContain("action: 'customTemplates:planHomeFilterFields'") + expect(validation).toContain('planHomeFilterFieldCountsRequest') + expect(validation).toContain('fieldPlans') + expect(validation).toContain("source: MATERIAL_HOME_FILTER_COLUMN_FIELDS.has(key) ? 'column' : 'metadata'") + }) + + it('paid-core 应承接部门隔离范围规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/departments/plan-scope'") + expect(server).toContain("action: 'departmentIsolation:planScope'") + expect(validation).toContain('planDepartmentScopeRequest') + expect(validation).toContain('buildDepartmentDescendantSet') + expect(validation).toContain('withinScope') + }) + + it('paid-core 应承接部门结构变更规划', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/departments/plan-structure-change'") + expect(server).toContain("action: 'departmentIsolation:planStructureChange'") + expect(validation).toContain('planDepartmentStructureChangeRequest') + expect(validation).toContain('departmentOps') + expect(validation).toContain('userOps') + expect(validation).toContain('clearManagedDepartmentIn') + }) + + it('paid-core 应承接自定义模板结构变更风险判断', () => { + const moduleDir = path.join(process.cwd(), 'premium-modules', 'paid-core') + const server = fs.readFileSync(path.join(moduleDir, 'server.mjs'), 'utf8') + const validation = fs.readFileSync(path.join(moduleDir, 'template-validation.mjs'), 'utf8') + + expect(server).toContain("url.pathname === '/templates/detect-structure-changes'") + expect(validation).toContain('detectDangerousTemplateChanges') + expect(validation).toContain('fieldOptionsRemoved') + expect(validation).toContain('fieldConditionsChanged') + }) })