完成 paid-core 付费核心拆分

This commit is contained in:
WuYongJie 2026-06-03 18:41:52 +08:00
parent fcd8a83e8d
commit 028ab2ad17
82 changed files with 9141 additions and 3978 deletions

View File

@ -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

View File

@ -130,9 +130,9 @@ LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-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 获取、模块镜像拉取和版本升级时;日常上传、创建用户、模板使用等动作优先依赖本地已验签许可证缓存、已缓存的模块授权凭证和本机正在运行的授权模块。也就是说,客户现场短时间断网不应影响已经授权且本地模块正常运行的日常使用,但许可证过期、授权被吊销、需要拉取新模块或升级时仍需要重新连验证服务器。

View File

@ -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<string, any> = {}) {
}))
}
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<string, string | null> = {
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<string, any> {
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<string, any>) {
const existingMetadata = parseMetadataObject(params.existingMetadata)
const targetFieldKeys = new Set<string>(params.targetFieldKeys || [])
const standardData: Record<string, string> = {}
const targetTemplateFields: Record<string, unknown> = {}
const incomingTemplateFields =
params.templateFields ||
(Array.isArray(params.targetFields)
? Object.fromEntries(
params.targetFields
.filter((field: Record<string, any>) => params.materialFieldValues?.[field.key] !== undefined)
.map((field: Record<string, any>) => [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<string, any>) => field.required)
.filter((field: Record<string, any>) => !hasSubmittedFieldValue(incomingTemplateFields?.[field.key], field.type))
.map((field: Record<string, any>) => 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<string, unknown> = {}
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<string, unknown> = {}
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<string, any> = {
...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<string, unknown> = {
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<string, string> = {
productModel: '产品型号',
brandName: '品牌',
seriesName: '系列',
skuCode: 'SKU',
materialTexture: '材质纹理',
categoryName: '分类',
sceneName: '场景',
materialType: '素材类型',
materialKind: '素材大类',
renderer: '渲染器',
renderTime: '渲染时间',
imageResolution: '图片分辨率',
colorMode: '色彩模式',
authorName: '作者',
platformName: '平台',
audioDuration: '音频时长',
audioCategory: '音频分类',
audioScene: '音频场景',
}
function mockBuildMigrationPreview(params: Record<string, any>) {
const sourceFieldMap = new Map(
(params.sourceTemplateFields || []).map((field: Record<string, any>) => [field.key, field])
)
const backupImpacts = new Map<string, Record<string, any>>()
const samples: Array<Record<string, any>> = []
let materialsWithDroppedFields = 0
let droppedTemplateFieldCount = 0
let droppedStandardFieldCount = 0
function summarize(key: string, source: 'template' | 'standard') {
const templateField = sourceFieldMap.get(key) as Record<string, any> | undefined
return {
key,
label: templateField?.label || STANDARD_FIELD_LABELS[key] || key,
type: templateField?.type,
source,
}
}
function addImpact(field: Record<string, any>) {
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<string, any>) => ({
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<string, any>) {
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<string, any>) =>
mockPlanMigrationBatch(params)
)
prepareTemplateMigrationMetadataWithPaidCore.mockImplementation(async (params: Record<string, any>) =>
mockPrepareMigrationMetadata(params)
)
buildTemplateMigrationPreviewWithPaidCore.mockImplementation(async (params: Record<string, any>) =>
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',

View File

@ -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<string, unknown>
droppedStandardFields?: Record<string, unknown>
}
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<string, string | null> = {
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<string, string> = {
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<string, unknown> {
if (!metadata) {
return {}
}
try {
const parsed = JSON.parse(metadata)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
// 保持迁移可继续,坏 metadata 不再扩大影响面。
}
return {}
}
function getRecordValue(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return {}
}
function hasBackupValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== ''
}
function getExistingMigrationBackups(metadata: Record<string, unknown>): unknown[] {
return Array.isArray(metadata.templateMigrationBackups)
? metadata.templateMigrationBackups
: []
}
function buildFieldSummary(
key: string,
sourceTemplateFieldsByKey: Map<string, TemplateFieldSummary>,
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<string, MigrationPreviewFieldImpact>,
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<string, unknown>
targetFieldKeys: Set<string>
sourceTemplateId: string
sourceTemplateName: string
targetTemplateId: string
targetTemplateName: string
migratedAt: string
}): {
metadata: string | null
droppedTemplateFieldCount: number
droppedStandardFieldCount: number
droppedTemplateFields: Record<string, unknown>
droppedStandardFields: Record<string, unknown>
} {
const existingMetadataObject = parseMetadataObject(params.existingMetadata)
const targetMetadataObject = parseMetadataObject(params.targetMetadata)
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields)
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields)
const droppedTemplateFields: Record<string, unknown> = {}
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<string, unknown> = {
...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<string, unknown>,
targetFieldKeys: Set<string>
): Record<string, unknown> {
const droppedFields: Record<string, unknown> = {}
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<string, unknown>, targetFieldKeys: Set<string>) {
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
if (!targetFieldKeys.has(key)) {
updateData[key] = emptyValue
}
}
}
function buildMaterialFieldValues(material: Record<string, unknown>) {
const values = readAllFieldValues(material)
@ -284,26 +40,6 @@ function buildMaterialFieldValues(material: Record<string, unknown>) {
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: '没有需要迁移的素材',
})
}
if (
!previewOnly &&
totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT &&
!forceLargeMigration
) {
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,
const batchPlan = await planTemplateMigrationBatchWithPaidCore({
sourceTemplateId,
targetTemplateId,
nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。',
sourceTemplateName: sourceTemplate.name,
targetTemplateName: targetTemplate.name,
totalMaterialCount,
request: {
updateMaterialType: body?.updateMaterialType,
previewOnly: body?.previewOnly,
forceLargeMigration: body?.forceLargeMigration,
migrationLimit: body?.migrationLimit,
},
}, { status: 409 })
})
if (!batchPlan.valid) {
return NextResponse.json({
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<string, MigrationPreviewFieldImpact>()
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<string, unknown>
}> = []
for (const material of materials) {
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce<Record<string, unknown>>(
(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<string, any>,
targetTemplateId,
parsedMetadata.previewUrls
)
const droppedStandardFields = getDroppedStandardFields(
material as Record<string, unknown>,
targetFieldKeys
)
const migrationMetadata = buildMigrationMetadata({
const migrationMetadata = await prepareTemplateMigrationMetadataWithPaidCore({
existingMetadata: material.metadata,
targetMetadata: rawMetadata,
droppedStandardFields,
targetFieldKeys,
materialFieldValues: allValues,
materialStandardFields: material as Record<string, unknown>,
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 (!metadataSize.ok) {
if (migrationMetadata.errorCode === 'METADATA_TOO_LARGE') {
oversizedMetadataMaterials.push({
materialId: material.id,
productModel: material.productModel,
size: metadataSize.size,
maxSize: metadataSize.maxSize,
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 (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
migrationPreviewImpacts.push({
materialId: material.id,
productModel: material.productModel,
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:', {

View File

@ -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<string, unknown>)
.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,160 +219,36 @@ 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:', {
templateId: id,
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
})
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 })
}
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({
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,
},
nextMaterialType: updateData.materialType,
nextMaterialKind: updateData.materialKind,
nextFields: formattedFields || undefined,
})
if (dangerousChanges.length > 0) {
console.warn('[TemplateUpdate] Dangerous structure change blocked:', {
if (!updatePlan.valid) {
console.warn('[TemplateUpdate] Paid core update planning rejected:', {
templateId: id,
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
updatePlan,
})
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 })
}
error: updatePlan.error,
errorCode: updatePlan.errorCode,
field: updatePlan.field,
details: updatePlan.details,
}, { status: updatePlan.statusCode || 400 })
}
const updateData: any = updatePlan.updateData
const formattedFields = updatePlan.fieldsProvided ? (updatePlan.fields || []) : null
const template = await prisma.$transaction(async (tx) => {
console.log('[TemplateUpdate] Updating template:', {
templateId: id,
@ -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,
}
error: deletePlan.error,
errorCode: deletePlan.errorCode,
details: deletePlan.details,
}, { status: 400 })
}
@ -947,4 +490,3 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
}
}

View File

@ -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<string>()
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<string, string[]>()
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<string, any>(
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<any>) =>
@ -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()
})

View File

@ -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')
const normalizedResult = await normalizeUploadTemplateSaveWithPaidCore(body || {}, 'create')
if (!normalizedResult.valid) {
console.warn('[TemplateCreate] Paid core validation failed:', normalizedResult)
return NextResponse.json({
error: '模板名称不能为空',
error: normalizedResult.error,
errorCode: 'VALIDATION_ERROR',
field: 'name',
field: normalizedResult.field,
details: normalizedResult.details,
}, { 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)
return NextResponse.json({
error: '模板状态必须是 draft、active 或 archived',
errorCode: 'VALIDATION_ERROR',
field: 'status',
}, { 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,

View File

@ -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' },

View File

@ -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 = {
scopeWhere = scopePlan.valid && scopePlan.scopedDepartments.length > 0
? {
OR: [
{ id: currentUser.id },
{ department: { in: scopedDepartments } },
{ department: { in: scopePlan.scopedDepartments } },
{ department: { startsWith: `${scopeDepartment}-` } },
],
}
: { id: currentUser.id }
} else {
scopeWhere = { id: currentUser.id }
}

View File

@ -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<string, Set<string>> | 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 }

View File

@ -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: '设计部' } })
})

View File

@ -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<string, unknown>
details?: Record<string, unknown>
}) {
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 },
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,
})
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,
},
},
})
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,51 +199,37 @@ 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) {
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: targetNames } },
where: { department: { in: userOp.names } },
data: { department: '' },
})
}
if (userOp.type === 'clearManagedDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) {
await tx.user.updateMany({
where: { managedDepartment: { in: targetNames } },
where: { managedDepartment: { in: userOp.names } },
data: { managedDepartment: null },
})
}
}
if (targetIds.length > 0) {
await tx.department.deleteMany({

View File

@ -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])

View File

@ -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<string, Prisma.Sql> = {
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<string, Map<string, number>> = {}
const totals: Record<string, number> = {}
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<Array<{ value: string | null; count: bigint }>>(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,

View File

@ -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,

View File

@ -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()
})
})

View File

@ -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<string>()
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) {
@ -89,33 +87,26 @@ export async function GET() {
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
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)
}
// 去掉"素材模板"后缀(包括可能的空格)
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})`)
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)
}
}

View File

@ -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,13 +308,32 @@ export async function PUT(
)
}
let metadata: string | null = null
if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId: effectiveTemplateId,
templateFields: templateFieldsValidation.templateFields as Record<string, unknown>,
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<string, any>,
effectiveTemplateId,
undefined,
material.metadata
)
const metadata = compressMetadata(rawMetadata)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
@ -323,6 +344,7 @@ export async function PUT(
{ status: 400 }
)
}
}
updateData.templateId = effectiveTemplateId
updateData.metadata = metadata

View File

@ -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([])

View File

@ -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,

View File

@ -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 }

View File

@ -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,38 +54,12 @@ 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) {
if (!actor.isSuperAdmin && !(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您只能恢复自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
}
// Restore material: set deletedAt to null
await prisma.material.update({

View File

@ -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<string[]> {
const fields = await prisma.uploadField.findMany({
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,
},
})
return Array.from(
new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key)))
)
}
async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise<Set<string>> {
const customKeys = fieldKeys.filter((key) => !ALLOWED_DYNAMIC_FILTER_FIELDS.has(key))
if (customKeys.length === 0) {
return new Set()
}
const enabledFields = await prisma.fieldDefinition.findMany({
}),
requestedHomeFieldKeys.length > 0
? prisma.fieldDefinition.findMany({
where: {
key: { in: customKeys },
key: { in: requestedHomeFieldKeys },
isFilterEnabled: true,
},
select: {
key: true,
},
})
: Promise.resolve([]),
])
return new Set(enabledFields.map((field) => field.key))
return planMaterialListQueryWithPaidCore({
search,
searchableFields,
homeFieldFilters,
enabledHomeFilterFields,
})
}
async function findMaterialIdsByCustomFieldSearch(search: string): Promise<string[]> {
const fieldKeys = await getSearchableCustomFieldKeys()
async function findMaterialIdsByCustomFieldSearch(search: string, fieldKeys: string[]): Promise<string[]> {
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 })
}
homeFieldFilters.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 })
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,14 +290,13 @@ export async function GET(req: NextRequest) {
}
}
for (const { key, value } of dynamicFieldFilters) {
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) {
for (const { key, value } of queryPlan.standardHomeFilters) {
andConditions.push({ [key]: value } as Record<string, string>)
} else {
}
for (const { key, value } of queryPlan.customHomeFilters) {
const materialIds = await findMaterialIdsByCustomFieldValue(key, value)
andConditions.push({ id: { in: materialIds } })
}
}
// 合并所有条件
if (andConditions.length > 0) {
@ -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,44 +509,15 @@ 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) {
if (!isSuperAdmin && !(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您只能删除自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
}
if (permanent) {
const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), {

View File

@ -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 () => {

View File

@ -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,21 +26,25 @@ 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,
})
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(scopedDepartments)})
u."department" IN (${Prisma.join(scopePlan.scopedDepartments)})
OR u."department" LIKE ${`${scopeDepartment}-%`}
)
)
`)
}
}
conditions.push(Prisma.sql`(${Prisma.join(visibleConditions, ' OR ')})`)
}

View File

@ -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,13 +198,32 @@ export async function PUT(req: NextRequest) {
templateFields = templateFieldsValidation.templateFields
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
)
const metadata = compressMetadata(rawMetadata)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
@ -213,6 +234,7 @@ export async function PUT(req: NextRequest) {
{ status: 400 }
)
}
}
updateData.templateId = effectiveTemplateId
updateData.metadata = metadata

View File

@ -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,8 +788,27 @@ export async function POST(req: NextRequest) {
}
// 标准字段直接落列,自定义字段与预览图数组进入 metadata
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)
const metadata = compressMetadata(rawMetadata)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return await failWithCleanup(NextResponse.json(
@ -800,6 +819,7 @@ export async function POST(req: NextRequest) {
{ status: 400 }
))
}
}
console.log('[Upload] Material data to create:', {
previewUrl: previewUrl ? `${previewUrl.substring(0, 50)}...` : 'EMPTY',

View File

@ -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()
})
})

View File

@ -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<Array<{ enabledBuiltinTemplates?: string | null }>>`
@ -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 })

View File

@ -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()
})
})

View File

@ -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,11 +122,38 @@ 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: Number((template as any).materialCount || 0),
}))
return NextResponse.json(templatesWithCounts)
} catch (error) {
console.error('Get active upload templates error:', error)

View File

@ -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;正式商用不建议启用"

View File

@ -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 '<pullToken>'
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 原始后端也不要直接暴露给客户访问;客户入口应只到这个前置代理。

View File

@ -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}

View File

@ -74,9 +74,7 @@ LICENSE_PROJECT_CODE=flnxi-sucai
LICENSE_REQUEST_SIGNING_SECRET=<generate-a-shared-license-request-secret>
LICENSE_PUBLIC_KEY=<license-server-rsa-public-key>
LOCAL_ENCRYPTION_KEY=<generate-a-32-char-or-longer-local-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=<generate-a-32-char-or-longer-module-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 迁移状态校验。

View File

@ -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:<id>:<name>` 标识和“素材模板”后缀处理由 `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. 条件显示规则前后端都执行。(已完成第一轮)
第四轮,做权限和体验收口:

View File

@ -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. 后台管理入口

View File

@ -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` 已改为先做本地验签,再恢复激活状态
直接影响:

View File

@ -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",

View File

@ -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,
}
}

View File

@ -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",

View File

@ -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<typeof isFeatureEnabled>;
const mockCheckLimit = checkLimit as jest.MockedFunction<typeof checkLimit>;
const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
const mockGetAuthorizedUsageLimits = getAuthorizedUsageLimits as jest.MockedFunction<typeof getAuthorizedUsageLimits>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
const mockAuthorizePremiumModuleAction =
authorizePremiumModuleAction as jest.MockedFunction<typeof authorizePremiumModuleAction>;
const mockEnforceAuthorizedUsageLimit =
enforceAuthorizedUsageLimit as jest.MockedFunction<typeof enforceAuthorizedUsageLimit>;
const mockUserCount = prisma.user.count as jest.MockedFunction<typeof prisma.user.count>;
const mockMaterialCount = prisma.material.count as jest.MockedFunction<typeof prisma.material.count>;
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 () => {
it('自定义模板必须同时满足许可证功能开关和 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'custom-templates-core',
moduleId: 'paid-core',
action: 'customTemplates:use',
status: 'running',
});
expect(await canUseCustomTemplates()).toBe(true);
await expect(canUseCustomTemplates()).resolves.toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
'custom-templates-core',
'customTemplates:use'
);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'customTemplates:use');
});
it('当功能禁用时应该返回 false', async () => {
it('功能开关关闭时不访问 paid-core', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseCustomTemplates()).toBe(false);
await expect(canUseCustomTemplates()).resolves.toBe(false);
expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
});
it('当付费能力模块未运行或拒绝授权时应该返回 false', async () => {
it('部门隔离必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'departmentIsolation:use',
status: 'running',
});
await expect(canUseDepartmentIsolation()).resolves.toBe(true);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'departmentIsolation:use');
});
it('内网穿透必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'intranetPenetration:use',
status: 'running',
});
await expect(canUseIntranetPenetration()).resolves.toBe(true);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'intranetPenetration:use');
});
it('系统设置必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'systemSettings:use',
status: 'running',
});
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: 'custom-templates-core',
moduleId: 'paid-core',
action: 'customTemplates:use',
status: 'missing',
});
expect(await canUseCustomTemplates()).toBe(false);
});
await expect(canUseCustomTemplates()).resolves.toBe(false);
});
describe('canUseDepartmentIsolation', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseDepartmentIsolation()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('departmentIsolation');
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseDepartmentIsolation()).toBe(false);
});
});
describe('canUseIntranetPenetration', () => {
it('当功能启用时应该返回 true', async () => {
it('API 访问必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'intranet-penetration-core',
action: 'intranetPenetration:use',
moduleId: 'paid-core',
action: 'apiAccess:use',
status: 'running',
});
expect(await canUseIntranetPenetration()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration');
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith(
'intranet-penetration-core',
'intranetPenetration:use'
);
});
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);
});
});
describe('canUseSystemSettings', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseSystemSettings()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('systemSettings');
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseSystemSettings()).toBe(false);
});
});
describe('canUseApiAccess', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseApiAccess()).toBe(true);
await expect(canUseApiAccess()).resolves.toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess');
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseApiAccess()).toBe(false);
});
});
});
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);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'apiAccess:use');
});
});

View File

@ -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('本地许可证变更必须写入审计日志', () => {

File diff suppressed because it is too large Load Diff

View File

@ -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,
})
})
})

View File

@ -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<LimitCheckResult> {
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<boolean> {
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<boolean> {
* 检查部门隔离功能是否可用
*/
export async function canUseDepartmentIsolation(): Promise<boolean> {
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<boolean> {
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<boolean> {
* 检查系统设置功能是否可用
*/
export async function canUseSystemSettings(): Promise<boolean> {
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<boolean> {
return await isFeatureEnabled('apiAccess');
if (!(await isFeatureEnabled('apiAccess'))) return false;
return (
await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.PAID_CORE,
'apiAccess:use'
)
).available;
}

View File

@ -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,

File diff suppressed because it is too large Load Diff

View File

@ -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}-` } } },
],
}

View File

@ -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<string, unknown>
allowUnknownKeys?: boolean
}): Promise<TemplateFieldValidationResult> {
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<string, unknown>
allowUnknownKeys?: boolean
}): Promise<TemplateFieldValidationResult> {
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,
})
}

View File

@ -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<NextResponse | null> {
@ -11,7 +11,7 @@ export async function proxyIntranetModule(action: TunnelAction): Promise<NextRes
if (!baseUrl) return null;
const method = action === 'status' ? 'GET' : 'POST';
const response = await fetch(`${baseUrl}/${action}`, { method });
const response = await fetch(`${baseUrl}/tunnel/${action}`, { method });
const data = await response.json().catch(() => ({
error: '内网穿透模块返回数据异常',
}));

View File

@ -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<string, unknown>
}
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<string>()
const duplicateKeys: string[] = []
const formattedFields: FormattedUploadTemplateField[] = []
for (let index = 0; index < fields.length; index += 1) {
const rawField = fields[index]
const field = (rawField || {}) as Record<string, any>
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,
}
}

View File

@ -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"

View File

@ -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';

View File

@ -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:

View File

@ -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=');
});

View File

@ -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([]);
});
});

View File

@ -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,
});

View File

@ -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);

View File

@ -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<typeof getPublicProjectDb>;
const mockVerifyModulePullToken = verifyModulePullToken as jest.MockedFunction<typeof verifyModulePullToken>;
@ -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'),

View File

@ -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<string, PremiumModuleDefinition> = {
'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<LicenseType, string[]> = {
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<LicenseType, number> = {
@ -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[] {

View File

@ -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:...
```

View File

@ -1,9 +0,0 @@
{
"name": "flnxi-custom-templates-core",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@ -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}`);
});

View File

@ -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"]

View File

@ -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 时,只在这个模块里扩展,不把核心实现重新塞回主应用基础镜像。

View File

@ -1,9 +0,0 @@
{
"name": "flnxi-intranet-penetration-core",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
}
}

View File

@ -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}`);
});

View File

@ -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"]

View File

@ -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:...
```

View File

@ -1 +0,0 @@
1.0.0

View File

@ -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<number> {
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<void>((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',
})
);
});
});

View File

@ -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}`);
});

View File

@ -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

View File

@ -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:...
```

View File

@ -1,5 +1,5 @@
{
"name": "flnxi-license-limits-core",
"name": "flnxi-paid-core",
"version": "1.0.0",
"private": true,
"type": "module",

View File

@ -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}`);
});

File diff suppressed because it is too large Load Diff

View File

@ -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')

View File

@ -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')
})
})