收口商业功能许可证边界

This commit is contained in:
WuYongJie 2026-06-01 17:26:04 +08:00
parent d359a411d2
commit 88303e21db
12 changed files with 160 additions and 61 deletions

View File

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

View File

@ -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,8 +56,10 @@ export async function GET() {
}
})
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)...')
@ -82,6 +85,9 @@ export async function GET() {
console.error('[material-types] Error details:', JSON.stringify(error, null, 2))
// 继续执行,即使查询失败也返回内置类型
}
} else {
console.error('[material-types] Custom templates disabled by current license')
}
// 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag
// 使用特殊前缀 "template:" 来标识这是自定义模板,避免与内置类型冲突

View File

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

View File

@ -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,8 +46,9 @@ export async function GET() {
console.error('Failed to parse enabledBuiltinTemplates:', error)
}
// 获取所有启用的自定义模板(排除内置模板,内置模板由代码生成)
const customTemplates = await prisma.uploadTemplate.findMany({
const customTemplatesAllowed = await canUseCustomTemplates()
const customTemplates = customTemplatesAllowed
? await prisma.uploadTemplate.findMany({
where: {
status: 'active',
// 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示)
@ -60,6 +62,7 @@ export async function GET() {
},
orderBy: { updatedAt: 'desc' },
})
: []
// 生成所有预制模板,只包含启用的
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用

View File

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

View File

@ -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,8 +86,9 @@ export async function GET() {
console.error('Failed to parse enabledBuiltinTemplates:', error)
}
// 获取所有启用的自定义模板(排除内置模板,内置模板由代码生成)
const customTemplates = await prisma.uploadTemplate.findMany({
const customTemplatesAllowed = await canUseCustomTemplates()
const customTemplates = customTemplatesAllowed
? await prisma.uploadTemplate.findMany({
where: {
status: 'active',
// 排除内置模板(builtin-开头的模板由系统自动创建,不应重复显示)
@ -97,6 +99,7 @@ export async function GET() {
orderBy: { updatedAt: 'desc' },
select: publicSelect,
})
: []
// 生成所有预制模板,只包含启用的
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用

View File

@ -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. 链路概览
当前大致链路如下:

View File

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

View File

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

View File

@ -51,13 +51,13 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, FeatureDescription> = {
],
},
apiAccess: {
name: 'API 访问',
description: '通过 API 接口访问系统功能,实现自动化集成',
name: 'API 访问(预留)',
description: '当前商业首版不开放外部系统对接,避免把未交付能力作为售卖项',
benefits: [
'RESTful API 接口',
'自动化工作流',
'第三方系统对接',
'批量数据操作',
'当前版本不作为客户交付能力',
'不在标准版或专业版默认启用',
'后续如重新规划,需要补齐服务端权限、审计和文档',
'避免客户误以为已支持外部系统对接',
],
},
intranetPenetration: {

View File

@ -134,7 +134,7 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
apiAccess: false,
intranetPenetration: false,
},
},
@ -148,7 +148,7 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
apiAccess: false,
intranetPenetration: true,
},
},

View File

@ -130,7 +130,7 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
apiAccess: false,
intranetPenetration: false,
},
},
@ -144,7 +144,7 @@ export const LICENSE_CONFIGS: Record<LicenseType, Omit<LicenseConfig, 'expiresAt
customTemplates: true,
systemSettings: true,
departmentIsolation: true,
apiAccess: true,
apiAccess: false,
intranetPenetration: true,
},
},