收口商业功能许可证边界
This commit is contained in:
parent
d359a411d2
commit
88303e21db
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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:" 来标识这是自定义模板,避免与内置类型冲突
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 为空数组,表示全部启用
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 为空数组,表示全部启用
|
||||
|
||||
@ -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. 链路概览
|
||||
|
||||
当前大致链路如下:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,13 +51,13 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, FeatureDescription> = {
|
||||
],
|
||||
},
|
||||
apiAccess: {
|
||||
name: 'API 访问',
|
||||
description: '通过 API 接口访问系统功能,实现自动化集成',
|
||||
name: 'API 访问(预留)',
|
||||
description: '当前商业首版不开放外部系统对接,避免把未交付能力作为售卖项',
|
||||
benefits: [
|
||||
'RESTful API 接口',
|
||||
'自动化工作流',
|
||||
'第三方系统对接',
|
||||
'批量数据操作',
|
||||
'当前版本不作为客户交付能力',
|
||||
'不在标准版或专业版默认启用',
|
||||
'后续如重新规划,需要补齐服务端权限、审计和文档',
|
||||
'避免客户误以为已支持外部系统对接',
|
||||
],
|
||||
},
|
||||
intranetPenetration: {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user