diff --git a/app/api/material-types/__tests__/material-types-auth.test.ts b/app/api/material-types/__tests__/material-types-auth.test.ts index a9a778e..6b82768 100644 --- a/app/api/material-types/__tests__/material-types-auth.test.ts +++ b/app/api/material-types/__tests__/material-types-auth.test.ts @@ -19,14 +19,20 @@ jest.mock('@/lib/prisma', () => ({ }, })) +jest.mock('@/lib/license', () => ({ + canUseCustomTemplates: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { canUseCustomTemplates } = require('@/lib/license') describe('GET /api/material-types 权限与模板过滤', () => { beforeEach(() => { jest.resetAllMocks() prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: null }]) prisma.uploadTemplate.findMany.mockResolvedValue([]) + canUseCustomTemplates.mockResolvedValue(true) }) it('未登录时拒绝读取素材类型', async () => { @@ -56,4 +62,17 @@ describe('GET /api/material-types 权限与模板过滤', () => { where: expect.objectContaining({ status: 'active' }), })) }) + + it('许可证不包含自定义模板时,不再把历史自定义模板作为素材类型返回', async () => { + getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } }) + canUseCustomTemplates.mockResolvedValue(false) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toContain('3DMAX') + expect(data.every((item: string) => !item.startsWith('template:'))).toBe(true) + expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + }) }) diff --git a/app/api/material-types/route.ts b/app/api/material-types/route.ts index da9637d..4a84582 100644 --- a/app/api/material-types/route.ts +++ b/app/api/material-types/route.ts @@ -8,6 +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 { BUILTIN_TEMPLATE_KEYS, BUILTIN_TEMPLATE_TYPES, @@ -55,32 +56,37 @@ export async function GET() { } }) + const customTemplatesAllowed = await canUseCustomTemplates() // 查询所有自定义模板(排除内置模板,避免重复显示) let customTemplates: Array<{ id: string; name: string | null; materialType: string | null; status: string }> = [] - try { - console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======') - console.error('[material-types] Querying active custom templates (excluding builtin)...') - customTemplates = await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板,内置模板的ID以 'builtin-' 开头 - NOT: { - id: { - startsWith: 'builtin-' + if (customTemplatesAllowed) { + try { + console.error('[material-types] ====== START QUERYING CUSTOM TEMPLATES ======') + console.error('[material-types] Querying active custom templates (excluding builtin)...') + 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`) - 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)) + }, + select: { id: true, name: true, materialType: true, status: true }, + }) + console.error(`[material-types] ✓ Found ${customTemplates.length} active custom templates`) + 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)) + } + + } catch (error) { + console.error('[material-types] Error querying custom templates:', error) + console.error('[material-types] Error details:', JSON.stringify(error, null, 2)) + // 继续执行,即使查询失败也返回内置类型 } - - } catch (error) { - console.error('[material-types] Error querying custom templates:', error) - console.error('[material-types] Error details:', JSON.stringify(error, null, 2)) - // 继续执行,即使查询失败也返回内置类型 + } else { + console.error('[material-types] Custom templates disabled by current license') } // 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag diff --git a/app/api/templates/__tests__/templates-route-auth.test.ts b/app/api/templates/__tests__/templates-route-auth.test.ts index a40d5df..11b05d5 100644 --- a/app/api/templates/__tests__/templates-route-auth.test.ts +++ b/app/api/templates/__tests__/templates-route-auth.test.ts @@ -17,12 +17,18 @@ jest.mock('@/lib/prisma', () => ({ }, })) +jest.mock('@/lib/license', () => ({ + canUseCustomTemplates: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { canUseCustomTemplates } = require('@/lib/license') describe('GET /api/templates 权限边界', () => { beforeEach(() => { jest.resetAllMocks() + canUseCustomTemplates.mockResolvedValue(true) }) it('未登录时拒绝读取模板列表', async () => { @@ -55,4 +61,21 @@ describe('GET /api/templates 权限边界', () => { { id: 'custom-template', name: '自定义模板', isBuiltin: false }, ]) }) + + it('许可证不包含自定义模板时,只返回内置模板且不读取历史自定义模板', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + canUseCustomTemplates.mockResolvedValue(false) + prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["3dmax-material"]' }]) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual([ + { id: 'builtin-3dmax-material', name: '3DMAX 素材模板', isBuiltin: true }, + ]) + expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + }) }) diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts index 1d18c9b..ff64de4 100644 --- a/app/api/templates/route.ts +++ b/app/api/templates/route.ts @@ -9,6 +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 { BUILTIN_TEMPLATE_KEYS, createBuiltinTemplate, @@ -45,21 +46,23 @@ export async function GET() { console.error('Failed to parse enabledBuiltinTemplates:', error) } - // 获取所有启用的自定义模板(排除内置模板,内置模板由代码生成) - const customTemplates = await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) - NOT: { - id: { startsWith: 'builtin-' }, + const customTemplatesAllowed = await canUseCustomTemplates() + const customTemplates = customTemplatesAllowed + ? await prisma.uploadTemplate.findMany({ + where: { + status: 'active', + // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) + NOT: { + id: { startsWith: 'builtin-' }, + }, }, - }, - select: { - id: true, - name: true, - }, - orderBy: { updatedAt: 'desc' }, - }) + select: { + id: true, + name: true, + }, + orderBy: { updatedAt: 'desc' }, + }) + : [] // 生成所有预制模板,只包含启用的 // 如果 enabledBuiltinTemplates 为空数组,表示全部启用 diff --git a/app/api/upload-templates/__tests__/route-auth.test.ts b/app/api/upload-templates/__tests__/route-auth.test.ts index b7b73dc..331ae17 100644 --- a/app/api/upload-templates/__tests__/route-auth.test.ts +++ b/app/api/upload-templates/__tests__/route-auth.test.ts @@ -20,12 +20,18 @@ jest.mock('@/lib/prisma', () => ({ }, })) +jest.mock('@/lib/license', () => ({ + canUseCustomTemplates: jest.fn(), +})) + const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') +const { canUseCustomTemplates } = require('@/lib/license') describe('GET /api/upload-templates 权限边界', () => { beforeEach(() => { jest.resetAllMocks() + canUseCustomTemplates.mockResolvedValue(true) }) it('未登录时拒绝读取上传模板', async () => { @@ -60,4 +66,24 @@ describe('GET /api/upload-templates 权限边界', () => { materialCount: 0, }) }) + + it('许可证不包含自定义模板时,上传页只拿到内置模板', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + canUseCustomTemplates.mockResolvedValue(false) + prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["other-material"]' }]) + prisma.material.count.mockResolvedValue(0) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toHaveLength(1) + expect(data[0]).toMatchObject({ + id: 'builtin-other-material', + isBuiltin: true, + }) + expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled() + }) }) diff --git a/app/api/upload-templates/route.ts b/app/api/upload-templates/route.ts index 3386bea..970d618 100644 --- a/app/api/upload-templates/route.ts +++ b/app/api/upload-templates/route.ts @@ -10,6 +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 { BUILTIN_TEMPLATE_KEYS, BUILTIN_TEMPLATE_TYPES, @@ -85,18 +86,20 @@ export async function GET() { console.error('Failed to parse enabledBuiltinTemplates:', error) } - // 获取所有启用的自定义模板(排除内置模板,内置模板由代码生成) - const customTemplates = await prisma.uploadTemplate.findMany({ - where: { - status: 'active', - // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) - NOT: { - id: { startsWith: 'builtin-' }, + const customTemplatesAllowed = await canUseCustomTemplates() + const customTemplates = customTemplatesAllowed + ? await prisma.uploadTemplate.findMany({ + where: { + status: 'active', + // 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示) + NOT: { + id: { startsWith: 'builtin-' }, + }, }, - }, - orderBy: { updatedAt: 'desc' }, - select: publicSelect, - }) + orderBy: { updatedAt: 'desc' }, + select: publicSelect, + }) + : [] // 生成所有预制模板,只包含启用的 // 如果 enabledBuiltinTemplates 为空数组,表示全部启用 diff --git a/docs/许可证链路安全评估_20260312.md b/docs/许可证链路安全评估_20260312.md index a17c5c2..6fb1c80 100644 --- a/docs/许可证链路安全评估_20260312.md +++ b/docs/许可证链路安全评估_20260312.md @@ -76,6 +76,10 @@ 2026-05-30 补充:主应用到 `license-server` 的 `activate / verify / heartbeat / register-free / premium-modules` 主链路已要求 `LICENSE_REQUEST_SIGNING_SECRET`,请求体携带 `timestamp`、`nonce` 和 HMAC `signature`。本文早期关于“主应用请求缺少签名/重放防护”的旧结论已过期,只能作为历史问题来源,不再作为当前事实。 +2026-06-01 补充:`intranetPenetration` 已在内网穿透启动、状态、停止入口做服务端许可证拦截,并且要求付费模块凭证;`apiAccess` 当前没有作为素材系统客户功能交付,标准版和专业版默认配置已改为 `false`,对应展示文案调整为预留能力。此前“apiAccess 已定义但未见生产执法点”的判断,对当前代码应理解为“暂不交付、不可售卖”,而不是“已售卖但缺拦截”。 + +2026-06-01 补充:自定义模板不只在管理维护入口做许可证拦截,普通读取和使用入口也已按许可证收口。`/api/templates`、`/api/upload-templates`、`/api/material-types` 在当前许可证不包含 `customTemplates` 时只返回内置模板,不继续暴露历史自定义模板,避免客户降级后仍使用付费模板能力。 + ## 3. 链路概览 当前大致链路如下: diff --git a/lib/__tests__/license-route-coverage.test.ts b/lib/__tests__/license-route-coverage.test.ts index e8caa98..8583d1b 100644 --- a/lib/__tests__/license-route-coverage.test.ts +++ b/lib/__tests__/license-route-coverage.test.ts @@ -38,6 +38,21 @@ describe('许可证业务入口覆盖', () => { helper: 'canUseCustomTemplates', reason: '模板迁移必须走许可证功能开关', }, + { + file: 'app/api/templates/route.ts', + helper: 'canUseCustomTemplates', + reason: '首页模板筛选不能在降级后继续暴露历史自定义模板', + }, + { + file: 'app/api/upload-templates/route.ts', + helper: 'canUseCustomTemplates', + reason: '上传页模板选择不能在降级后继续使用历史自定义模板', + }, + { + file: 'app/api/material-types/route.ts', + helper: 'canUseCustomTemplates', + reason: '素材类型筛选不能在降级后继续暴露历史自定义模板', + }, { file: 'app/api/departments/route.ts', helper: 'canUseDepartmentIsolation', diff --git a/lib/__tests__/license-types.test.ts b/lib/__tests__/license-types.test.ts index 4990d66..fa1dd77 100644 --- a/lib/__tests__/license-types.test.ts +++ b/lib/__tests__/license-types.test.ts @@ -52,11 +52,11 @@ describe('LICENSE_CONFIGS', () => { expect(standardConfig.limits.maxMaterials).toBe(-1); }); - it('应该启用大部分高级功能', () => { + it('应该启用已交付的高级功能,不启用预留 API 访问', () => { expect(standardConfig.features.customTemplates).toBe(true); expect(standardConfig.features.systemSettings).toBe(true); expect(standardConfig.features.departmentIsolation).toBe(true); - expect(standardConfig.features.apiAccess).toBe(true); + expect(standardConfig.features.apiAccess).toBe(false); }); it('应该禁用内网穿透功能', () => { @@ -79,11 +79,11 @@ describe('LICENSE_CONFIGS', () => { expect(proConfig.limits.maxMaterials).toBe(-1); }); - it('应该启用所有高级功能', () => { + it('应该启用专业版已交付功能,不启用预留 API 访问', () => { expect(proConfig.features.customTemplates).toBe(true); expect(proConfig.features.systemSettings).toBe(true); expect(proConfig.features.departmentIsolation).toBe(true); - expect(proConfig.features.apiAccess).toBe(true); + expect(proConfig.features.apiAccess).toBe(false); expect(proConfig.features.intranetPenetration).toBe(true); }); }); diff --git a/lib/license/feature-descriptions.ts b/lib/license/feature-descriptions.ts index 5d6ac21..2caa4eb 100644 --- a/lib/license/feature-descriptions.ts +++ b/lib/license/feature-descriptions.ts @@ -51,13 +51,13 @@ export const FEATURE_DESCRIPTIONS: Record = { ], }, apiAccess: { - name: 'API 访问', - description: '通过 API 接口访问系统功能,实现自动化集成', + name: 'API 访问(预留)', + description: '当前商业首版不开放外部系统对接,避免把未交付能力作为售卖项', benefits: [ - 'RESTful API 接口', - '自动化工作流', - '第三方系统对接', - '批量数据操作', + '当前版本不作为客户交付能力', + '不在标准版或专业版默认启用', + '后续如重新规划,需要补齐服务端权限、审计和文档', + '避免客户误以为已支持外部系统对接', ], }, intranetPenetration: { diff --git a/lib/license/types.ts b/lib/license/types.ts index 9c86b05..c66ec02 100644 --- a/lib/license/types.ts +++ b/lib/license/types.ts @@ -134,7 +134,7 @@ export const LICENSE_CONFIGS: Record