Compare commits

..

11 Commits

Author SHA1 Message Date
WuYongJie
028ab2ad17 完成 paid-core 付费核心拆分 2026-06-03 18:41:52 +08:00
WuYongJie
fcd8a83e8d 把免费版限额判断下沉到授权模块 2026-06-03 12:13:42 +08:00
WuYongJie
9f355ded27 推进付费模块运行边界拆分 2026-06-03 11:37:55 +08:00
WuYongJie
504a1d93e3 加固生产调试入口和许可证审计 2026-06-02 22:37:52 +08:00
WuYongJie
73b367e75e 加强付费模块拉包凭证和部署链路 2026-06-02 19:25:48 +08:00
WuYongJie
f92109e34f 完善客户升级 runtime 预拉 2026-06-02 14:22:24 +08:00
WuYongJie
4696215999 加固许可证链路和客户交付安全边界 2026-06-02 13:53:38 +08:00
WuYongJie
3b5da1c2c2 支持飞牛在线升级场景 2026-06-02 11:48:39 +08:00
WuYongJie
97e3e6351c 发布安全加固版 1.0.7 2026-06-02 10:32:42 +08:00
WuYongJie
3f5085e740 清理已关停测试服务器记录 2026-06-02 10:26:20 +08:00
WuYongJie
ccc25629c2 加固登录与高危接口安全边界 2026-06-02 10:16:28 +08:00
146 changed files with 14790 additions and 2748 deletions

View File

@ -91,6 +91,8 @@ gongxiangp2-source-reference-old-style-20260513-132942
license-server
flnxi-agent
flnxi-agent-go
premium-modules
deploy/registry-proxy
# 数据库文件
*.sql

View File

@ -11,6 +11,11 @@ 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 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
# Versioned deployment images
# Customer delivery must pin the full application image tag. Do not rely on latest.
APP_IMAGE=registry.example.com/flnxi/material/flnxi-material:1.0.0
@ -29,7 +34,9 @@ POSTGRES_VOLUME_NAME=flnxi-postgres-data
MINIO_VOLUME_NAME=flnxi-minio-data
APP_NETWORK_NAME=flnxi-app-network
APP_PORT=4320
MINIO_API_BIND_HOST=0.0.0.0
MINIO_API_PORT=9000
MINIO_CONSOLE_BIND_HOST=127.0.0.1
MINIO_CONSOLE_PORT=9001
# Upload limits, in bytes. Defaults: single file 10GB, total request 10GB, text fields 2MB.
@ -37,6 +44,13 @@ MAX_UPLOAD_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_TOTAL_FILE_SIZE_BYTES=10737418240
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
# Login protection
# Defaults: same IP 5 password-login attempts per minute; same account locks for 15 minutes after 5 failures.
AUTH_LOGIN_IP_WINDOW_MS=60000
AUTH_LOGIN_IP_MAX_ATTEMPTS=5
AUTH_LOGIN_ACCOUNT_MAX_FAILURES=5
AUTH_LOGIN_ACCOUNT_LOCK_MS=900000
# Database settings
# Leave DATABASE_URL empty when using the PostgreSQL service from docker-compose.prod.yml.
# Fill it only when using an external PostgreSQL database, for example:
@ -94,6 +108,18 @@ SYSTEM_INIT_TOKEN=
INIT_ADMIN_EMAIL=admin@local.flnxi
INIT_ADMIN_PASSWORD=
# Legacy one-off data restore endpoint. Keep disabled in customer environments unless doing a controlled restore window.
RESTORE_DATA_ENABLED=0
RESTORE_DATA_TOKEN=
RESTORE_DATA_ALLOWED_IPS=127.0.0.1
# Only set to 1 when the app is behind a trusted reverse proxy that strips untrusted forwarding headers.
RESTORE_DATA_TRUST_PROXY=0
RESTORE_ADMIN_EMAIL=admin@local.flnxi
RESTORE_ADMIN_USERNAME=admin
RESTORE_ADMIN_PASSWORD=
RESTORE_S3_BUCKET_URL=
RESTORE_S3_PUBLIC_URL=
# AI model (optional)
GEMINI_API_KEY=
GEMINI_API_BASE_URL=https://api.huandutech.com/v1

18
.gitignore vendored
View File

@ -6,10 +6,9 @@ node_modules/
out/
# Env
.env
.env.production
.env.local
.env.*.local
.env*
!.env.example
!**/.env.example
server.env
env-backups/
@ -75,9 +74,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Vercel
.vercel
@ -97,6 +93,14 @@ next-env.d.ts
*.bak-*
*.ps1.bak-*
# Local deployment/security test artifacts
deploy-artifacts/
hack_*.js
hook.js
hack_*.sql
reset_pw.sql
update_pw.sql
# Docker images (large files, use Git LFS if needed)
/docker-images/**/*.tar
/docker-images/**/*.tar.gz

View File

@ -258,9 +258,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/prisma/migrations ./prisma/migrat
# 4. 版本文件(许可证系统版本检查)
COPY --from=builder --chown=nextjs:nodejs /app/VERSION.txt ./VERSION.txt
# 4. lib 目录(包含 configLoader 等运行时模块)
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
# 5. 补充关键模块(standalone 输出可能未完整包含)
# 复制 Prisma Client(必需)
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma

View File

@ -28,6 +28,7 @@
├── scripts/ # 构建、迁移、修复、部署辅助脚本
├── docs/ # 当前项目文档入口
├── license-server/ # 独立许可证服务
├── premium-modules/ # 付费模块镜像源码
├── flnxi-agent/ # Node 版安装与运维 Agent
└── flnxi-agent-go/ # Go 版安装与运维 Agent
```
@ -129,7 +130,11 @@ 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` 中的项目配置一致。
付费模块采用“镜像站放模块核心本体,验证服务器发授权清单和实例凭证”的分工;部署 `license-server` 时还要确认 `PREMIUM_MODULE_REGISTRY_BASE` 和模块版本指向当前可用镜像站。
授权模块采用“免费基础镜像 + 统一 `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/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 获取、模块镜像拉取和版本升级时;日常上传、创建用户、模板使用等动作优先依赖本地已验签许可证缓存、已缓存的模块授权凭证和本机正在运行的授权模块。也就是说,客户现场短时间断网不应影响已经授权且本地模块正常运行的日常使用,但许可证过期、授权被吊销、需要拉取新模块或升级时仍需要重新连验证服务器。
### 初始化 Prisma

View File

@ -1 +1 @@
1.0.6
1.0.13

View File

@ -19,4 +19,9 @@ describe('docker-compose.prod.yml', () => {
expect(compose).toContain('mc anonymous set download "myminio/$${S3_BUCKET}"')
expect(compose).toContain('- S3_SET_ACL=${S3_SET_ACL:-0}')
})
it('MinIO 控制台默认只绑定本机,素材访问端口仍可按客户入口开放', () => {
expect(compose).toContain('${MINIO_API_BIND_HOST:-0.0.0.0}:${MINIO_API_PORT:-9000}:9000')
expect(compose).toContain('${MINIO_CONSOLE_BIND_HOST:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001')
})
})

View File

@ -27,12 +27,23 @@ describe('production build hardening', () => {
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[\s\S]*\/app\/prisma\s+\.\/prisma/)
})
it('生产运行镜像不能额外复制未混淆源码目录', () => {
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/lib\s+\.\/lib/)
expect(dockerfile).not.toMatch(/COPY\s+--from=builder[^\n]*\/app\/app\s+\.\/app/)
})
it('混淆依赖缺失时不能静默跳过', () => {
expect(buildScript).toContain('生产混淆不能跳过')
expect(buildScript).toContain('process.exit(1)')
expect(buildScript).not.toContain('跳过混淆步骤')
})
it('混淆目标缺失或混淆失败时不能继续出包', () => {
expect(buildScript).toContain('failedFiles')
expect(buildScript).toContain('没有找到需要混淆的目标文件')
expect(buildScript).toContain('混淆失败的目标文件')
})
it('混淆配置保持保守,避免破坏 Next.js 服务端产物', () => {
const { obfuscatorOptions } = require('../scripts/obfuscate-config')

View File

@ -33,6 +33,10 @@ jest.mock('@/lib/system-integrity', () => ({
ensureBuiltinTemplates: jest.fn(),
}))
jest.mock('@/lib/license', () => ({
canUseSystemSettings: jest.fn(),
}))
jest.mock('fs/promises', () => ({
access: jest.fn(),
unlink: jest.fn(),
@ -41,6 +45,7 @@ jest.mock('fs/promises', () => ({
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { repairSystemIntegrity } = require('@/lib/system-integrity')
const { canUseSystemSettings } = require('@/lib/license')
import { POST as cleanupDatabase } from '../cleanup-database/route'
import { POST as migrateMaterialFields } from '../migrate-material-fields/route'
@ -51,6 +56,7 @@ import { POST as repairSystemIntegrityRoute } from '../../system/integrity/route
describe('admin destructive actions require super admin', () => {
beforeEach(() => {
jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
})
it('普通管理员不能清理数据库', async () => {
@ -105,6 +111,27 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$executeRawUnsafe).toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能清理数据库', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await cleanupDatabase(
new NextRequest('http://localhost/api/admin/cleanup-database', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '清理全部素材' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('最高权限账号清理数据库时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
@ -173,7 +200,10 @@ describe('admin destructive actions require super admin', () => {
prisma.$transaction.mockImplementation((callback: any) => callback(tx))
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' })
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
})
)
const data = await response.json()
@ -182,6 +212,46 @@ describe('admin destructive actions require super admin', () => {
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3)
})
it('最高权限账号重置排行榜数据时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('确认短语')
expect(data.requiredConfirmPhrase).toBe('重置排行榜数据')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能重置排行榜数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await resetMaterialRankings(
new NextRequest('http://localhost/api/admin/reset-material-rankings', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '重置排行榜数据' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('普通管理员不能批量移交素材归属', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
@ -262,7 +332,10 @@ describe('admin destructive actions require super admin', () => {
prisma.$queryRawUnsafe.mockResolvedValue(1)
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '执行字段迁移' }),
})
)
const data = await response.json()
@ -272,6 +345,45 @@ describe('admin destructive actions require super admin', () => {
expect(prisma.$queryRawUnsafe).toHaveBeenCalled()
})
it('最高权限账号执行素材字段迁移时也必须输入确认短语', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', {
method: 'POST',
body: JSON.stringify({ confirmPhrase: '迁移' }),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('确认短语')
expect(data.requiredConfirmPhrase).toBe('执行字段迁移')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('免费版即使是最高权限账号也不能执行素材字段迁移', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await migrateMaterialFields(
new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
})
it('普通管理员不能执行系统完整性修复', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },

View File

@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
import {
collectMaterialStorageUrls,
deleteMaterialStorageFiles,
@ -21,6 +22,13 @@ export async function POST(req: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== '清理全部素材') {
return NextResponse.json(

View File

@ -3,9 +3,12 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic'
const REQUIRED_CONFIRM_PHRASE = '执行字段迁移'
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -17,6 +20,24 @@ export async function POST(req: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
return NextResponse.json(
{
error: '素材字段迁移是高风险操作,请输入确认短语后再执行',
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
},
{ status: 400 }
)
}
// 执行 SQL 迁移 - 需要分开执行每个命令
const commands = [
`ALTER TABLE "materials" ADD COLUMN IF NOT EXISTS "brandName" TEXT`,

View File

@ -3,9 +3,12 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { canUseSystemSettings } from '@/lib/license'
export const dynamic = 'force-dynamic'
const REQUIRED_CONFIRM_PHRASE = '重置排行榜数据'
/**
* 重置素材排行榜相关数据:
* - 清空点赞记录表 material_likes
@ -25,6 +28,24 @@ export async function POST(req: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
const body = await req.json().catch(() => ({}))
if (body?.confirmPhrase !== REQUIRED_CONFIRM_PHRASE) {
return NextResponse.json(
{
error: '重置排行榜是高风险操作,请输入确认短语后再执行',
requiredConfirmPhrase: REQUIRED_CONFIRM_PHRASE,
},
{ status: 400 }
)
}
// 使用事务,确保一致性
const result = await prisma.$transaction(async (tx) => {
// 清空点赞记录

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: '没有需要迁移的素材',
})
}
const batchPlan = await planTemplateMigrationBatchWithPaidCore({
sourceTemplateId,
targetTemplateId,
sourceTemplateName: sourceTemplate.name,
targetTemplateName: targetTemplate.name,
totalMaterialCount,
request: {
updateMaterialType: body?.updateMaterialType,
previewOnly: body?.previewOnly,
forceLargeMigration: body?.forceLargeMigration,
migrationLimit: body?.migrationLimit,
},
})
if (
!previewOnly &&
totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT &&
!forceLargeMigration
) {
if (!batchPlan.valid) {
return NextResponse.json({
error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`,
errorCode: 'MIGRATION_CONFIRM_REQUIRED',
details: {
totalMaterialCount,
safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT,
migrationLimit,
maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT,
sourceTemplateId,
targetTemplateId,
nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。',
},
}, { status: 409 })
error: batchPlan.error,
errorCode: batchPlan.errorCode,
details: batchPlan.details,
}, { status: batchPlan.statusCode || 503 })
}
if (!batchPlan.allowed) {
if (batchPlan.response) {
return NextResponse.json(batchPlan.response, { status: batchPlan.statusCode })
}
return NextResponse.json({
error: batchPlan.error,
errorCode: batchPlan.errorCode,
details: batchPlan.details,
}, { status: batchPlan.statusCode })
}
const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit)
const materials = await prisma.material.findMany({
where: {
templateId: sourceTemplateId,
@ -499,7 +233,7 @@ export async function POST(
{ createdAt: 'asc' },
{ id: 'asc' },
],
take: batchLimited ? migrationLimit : undefined,
take: batchPlan.materialTake ?? undefined,
})
const materialCount = materials.length
@ -519,10 +253,10 @@ export async function POST(
targetTemplateName: targetTemplate.name,
materialCount,
totalMaterialCount,
migrationLimit,
previewOnly,
forceLargeMigration,
updateMaterialType,
migrationLimit: batchPlan.migrationLimit,
previewOnly: batchPlan.previewOnly,
forceLargeMigration: batchPlan.forceLargeMigration,
updateMaterialType: batchPlan.updateMaterialType,
})
const incompatibleMaterials: Array<{
@ -539,131 +273,92 @@ export async function POST(
const targetFieldKeys = new Set(
(targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key)
)
const sourceTemplateFieldsByKey = new Map(
(sourceTemplate.fields as TemplateFieldSummary[]).map((field) => [field.key, field])
)
const migratedAt = new Date().toISOString()
let materialsWithDroppedFields = 0
let droppedTemplateFieldCount = 0
let droppedStandardFieldCount = 0
const previewFieldImpacts = new Map<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 (migrationMetadata.errorCode === 'METADATA_TOO_LARGE') {
oversizedMetadataMaterials.push({
materialId: material.id,
productModel: material.productModel,
size: Number(migrationMetadata.details?.size || 0),
maxSize: Number(migrationMetadata.details?.maxSize || 0),
})
continue
}
return NextResponse.json({
error: migrationMetadata.error || '付费增强包无法准备模板迁移数据',
errorCode: migrationMetadata.errorCode || 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED',
details: migrationMetadata.details,
}, { status: 503 })
}
if (!metadataSize.ok) {
oversizedMetadataMaterials.push({
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
migrationPreviewImpacts.push({
materialId: material.id,
productModel: material.productModel,
size: metadataSize.size,
maxSize: metadataSize.maxSize,
droppedTemplateFieldKeys: Object.keys(migrationMetadata.droppedTemplateFields),
droppedStandardFieldKeys: Object.keys(migrationMetadata.droppedStandardFields),
})
}
const updateData: any = {
templateId: targetTemplateId,
materialKind: targetTemplate.materialKind,
metadata,
...standardData,
metadata: migrationMetadata.metadata,
...migrationMetadata.standardData,
}
clearNonTargetStandardFields(updateData, targetFieldKeys)
if (updateMaterialType && targetTemplate.materialType) {
if (batchPlan.updateMaterialType && targetTemplate.materialType) {
updateData.materialType = targetTemplate.materialType
}
if ('audioDuration' in nextTemplateFieldValues) {
updateData.audioDuration = normalizeOptionalString(nextTemplateFieldValues.audioDuration)
}
if ('audioCategory' in nextTemplateFieldValues) {
updateData.audioCategory = normalizeOptionalString(nextTemplateFieldValues.audioCategory)
}
if ('audioScene' in nextTemplateFieldValues) {
updateData.audioScene = normalizeOptionalString(nextTemplateFieldValues.audioScene)
}
return {
preparedUpdates.push({
id: material.id,
data: updateData,
}
})
})
}
if (incompatibleMaterials.length > 0) {
return NextResponse.json({
@ -689,25 +384,26 @@ export async function POST(
}, { status: 409 })
}
const migrationPreview = {
const migrationPreviewResult = await buildTemplateMigrationPreviewWithPaidCore({
totalMaterialCount,
totalMaterials: preparedUpdates.length,
previewLimited: previewOnly && totalMaterialCount > preparedUpdates.length,
previewLimit: previewOnly ? migrationLimit : undefined,
targetCurrentFields: (targetTemplate.fields as TemplateFieldSummary[]).map((field) => ({
key: field.key,
label: field.label || field.key,
type: field.type,
required: field.required,
})),
backupFields: {
templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'),
standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'),
},
samples: previewSamples,
}
migratableCount: preparedUpdates.length,
previewOnly: batchPlan.previewOnly,
previewLimit: batchPlan.previewOnly ? batchPlan.migrationLimit : undefined,
targetFields: targetTemplate.fields as TemplateFieldSummary[],
sourceTemplateFields: sourceTemplate.fields as TemplateFieldSummary[],
impacts: migrationPreviewImpacts,
})
if (previewOnly) {
if (!migrationPreviewResult.valid) {
return NextResponse.json({
error: migrationPreviewResult.error || '付费增强包无法生成模板迁移预览',
errorCode: migrationPreviewResult.errorCode || 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED',
details: migrationPreviewResult.details,
}, { status: 503 })
}
const { migrationBackup, migrationPreview } = migrationPreviewResult
if (batchPlan.previewOnly) {
return NextResponse.json({
success: true,
previewOnly: true,
@ -721,11 +417,7 @@ export async function POST(
id: targetTemplate.id,
name: targetTemplate.name,
},
migrationBackup: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationBackup,
migrationPreview,
})
}
@ -751,9 +443,7 @@ export async function POST(
sourceTemplateId,
targetTemplateId,
migratedCount: preparedUpdates.length,
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
...migrationBackup,
})
return NextResponse.json({
@ -761,7 +451,7 @@ export async function POST(
migratedCount: preparedUpdates.length,
totalMaterialCount,
remainingMaterialCount: Math.max(totalMaterialCount - preparedUpdates.length, 0),
batchLimited,
batchLimited: batchPlan.batchLimited,
message:
totalMaterialCount > preparedUpdates.length
? `已将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}",剩余 ${totalMaterialCount - preparedUpdates.length} 个将继续分批处理`
@ -774,11 +464,7 @@ export async function POST(
id: targetTemplate.id,
name: targetTemplate.name,
},
migrationBackup: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationBackup,
})
} catch (error: any) {
console.error('[TemplateMigrate] Migration error:', {

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,159 +219,35 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 版本冲突检测
if (version !== undefined && version !== existing.version) {
console.warn('[TemplateUpdate] Version conflict detected:', {
const updatePlan = await planUploadTemplateUpdateWithPaidCore({
payload: body || {},
existingTemplate: {
id: existing.id,
version: existing.version,
name: existing.name,
updatedAt: existing.updatedAt,
materialType: existing.materialType,
materialKind: existing.materialKind,
materialCount: existing._count.materials,
fields: existing.fields,
},
})
if (!updatePlan.valid) {
console.warn('[TemplateUpdate] Paid core update planning rejected:', {
templateId: id,
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
updatePlan,
})
return NextResponse.json({
error: '模板已被其他人修改,请刷新后重试',
errorCode: 'VERSION_CONFLICT',
details: {
templateName: existing.name,
clientVersion: version,
serverVersion: existing.version,
lastUpdated: existing.updatedAt,
}
}, { status: 409 })
}
console.log('[TemplateUpdate] Version check passed:', {
templateId: id,
templateName: existing.name,
currentVersion: existing.version,
newVersion: (existing.version || 1) + 1,
})
// 验证必填字段
if (name !== undefined && (typeof name !== 'string' || !name.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid name')
return NextResponse.json({
error: '模板名称不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'name',
}, { status: 400 })
}
// 验证materialType
if (materialType !== undefined && (typeof materialType !== 'string' || !materialType.trim())) {
console.warn('[TemplateUpdate] Validation failed: invalid materialType')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
if (materialKind !== undefined) {
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateUpdate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
}
if (status !== undefined && !isValidUploadTemplateStatus(status)) {
console.warn('[TemplateUpdate] Validation failed: invalid status:', status)
return NextResponse.json({
error: '模板状态必须是 draft、active 或 archived',
errorCode: 'VALIDATION_ERROR',
field: 'status',
}, { status: 400 })
error: updatePlan.error,
errorCode: updatePlan.errorCode,
field: updatePlan.field,
details: updatePlan.details,
}, { status: updatePlan.statusCode || 400 })
}
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
if (description !== undefined) updateData.description = description
if (status !== undefined) updateData.status = status
if (fieldGroups !== undefined) updateData.fieldGroups = fieldGroups
if (displayConfig !== undefined) updateData.displayConfig = displayConfig
if (materialType !== undefined) {
updateData.materialType = materialType.trim()
}
if (materialKind !== undefined) {
const rawKind = materialKind.trim().toUpperCase()
updateData.materialKind = rawKind
}
updateData.version = (existing.version || 1) + 1
let formattedFields = null
if (fields !== undefined) {
const formattedFieldsResult = validateAndFormatUploadTemplateFields(fields)
if (!formattedFieldsResult.valid) {
console.warn('[TemplateUpdate] Validation failed:', formattedFieldsResult)
return NextResponse.json({
error: formattedFieldsResult.error,
errorCode: 'VALIDATION_ERROR',
field: formattedFieldsResult.field,
details: formattedFieldsResult.details,
}, { status: 400 })
}
formattedFields = formattedFieldsResult.fields
const existingFieldIds = new Set(existing.fields.map((field) => field.id))
const foreignField = formattedFields.find((field) => field.id && !existingFieldIds.has(field.id))
if (foreignField) {
console.warn('[TemplateUpdate] Field id does not belong to template:', {
templateId: id,
fieldId: foreignField.id,
fieldKey: foreignField.key,
})
return NextResponse.json({
error: '字段不属于当前模板,请刷新后重试',
errorCode: 'FIELD_NOT_IN_TEMPLATE',
details: {
fieldId: foreignField.id,
fieldKey: foreignField.key,
},
}, { status: 400 })
}
}
if (existing._count.materials > 0) {
const dangerousChanges = detectDangerousTemplateChanges({
existingTemplate: {
materialType: existing.materialType,
materialKind: existing.materialKind,
fields: existing.fields,
},
nextMaterialType: updateData.materialType,
nextMaterialKind: updateData.materialKind,
nextFields: formattedFields || undefined,
})
if (dangerousChanges.length > 0) {
console.warn('[TemplateUpdate] Dangerous structure change blocked:', {
templateId: id,
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
})
return NextResponse.json({
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key、修改字段类型、取消已加入分类的字段、关闭已启用搜索的字段、把可填写字段改为只读、把字段改为必填、修改校验规则、删除选项或修改显示条件。请新建模板后迁移素材。`,
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
details: {
templateName: existing.name,
materialCount: existing._count.materials,
dangerousChanges,
},
}, { status: 409 })
}
}
const updateData: any = updatePlan.updateData
const formattedFields = updatePlan.fieldsProvided ? (updatePlan.fields || []) : null
const template = await prisma.$transaction(async (tx) => {
console.log('[TemplateUpdate] Updating template:', {
@ -887,21 +415,36 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
return NextResponse.json({ error: '模板不存在' }, { status: 404 })
}
// 检查是否有素材使用该模板
const materialCount = template._count.materials
if (materialCount > 0) {
const deletePlan = await planUploadTemplateDeleteWithPaidCore({
templateName: template.name,
materialCount,
})
if (!deletePlan.valid) {
console.warn('[TemplateDelete] Paid core delete planning failed:', {
templateId: id,
templateName: template.name,
materialCount,
deletePlan,
})
return NextResponse.json({
error: deletePlan.error,
errorCode: deletePlan.errorCode,
details: deletePlan.details,
}, { status: 403 })
}
if (!deletePlan.allowed) {
console.warn('[TemplateDelete] Cannot delete template with materials:', {
templateId: id,
templateName: template.name,
materialCount,
})
return NextResponse.json({
error: `无法删除模板"${template.name}",因为有 ${materialCount} 个素材正在使用该模板。请先删除或迁移这些素材。`,
errorCode: 'TEMPLATE_IN_USE',
details: {
templateName: template.name,
materialCount,
}
return NextResponse.json({
error: deletePlan.error,
errorCode: deletePlan.errorCode,
details: deletePlan.details,
}, { status: 400 })
}
@ -947,4 +490,3 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
}
}

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')
return NextResponse.json({
error: '模板名称不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'name',
}, { status: 400 })
}
// 验证materialType
if (!materialType || typeof materialType !== 'string' || !materialType.trim()) {
console.warn('[TemplateCreate] Validation failed: materialType is required')
return NextResponse.json({
error: '素材类型不能为空',
errorCode: 'VALIDATION_ERROR',
field: 'materialType',
}, { status: 400 })
}
// 验证materialKind
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : ''
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
console.warn('[TemplateCreate] Validation failed: invalid materialKind:', materialKind)
return NextResponse.json({
error: `素材大类必须是以下之一:${allowedKinds.join(', ')}`,
errorCode: 'VALIDATION_ERROR',
field: 'materialKind',
}, { status: 400 })
}
const finalMaterialType = materialType.trim()
const finalMaterialKind = rawKind
if (!isValidUploadTemplateStatus(status)) {
console.warn('[TemplateCreate] Validation failed: invalid status:', status)
const normalizedResult = await normalizeUploadTemplateSaveWithPaidCore(body || {}, 'create')
if (!normalizedResult.valid) {
console.warn('[TemplateCreate] Paid core validation failed:', normalizedResult)
return NextResponse.json({
error: '模板状态必须是 draft、active 或 archived',
error: normalizedResult.error,
errorCode: 'VALIDATION_ERROR',
field: 'status',
field: normalizedResult.field,
details: normalizedResult.details,
}, { status: 400 })
}
const formattedFieldsResult = validateAndFormatUploadTemplateFields(fields)
if (!formattedFieldsResult.valid) {
console.warn('[TemplateCreate] Validation failed:', formattedFieldsResult)
return NextResponse.json({
error: formattedFieldsResult.error,
errorCode: 'VALIDATION_ERROR',
field: formattedFieldsResult.field,
details: formattedFieldsResult.details,
}, { status: 400 })
}
const formattedFields = formattedFieldsResult.fields.map(({ id: _id, ...field }) => field)
const normalized = normalizedResult.template
const formattedFields = (normalized.fields || []).map(({ id: _id, ...field }) => field)
const createData = {
name,
description: description ?? null,
status,
materialType: finalMaterialType,
materialKind: finalMaterialKind,
fieldGroups: fieldGroups ?? null,
displayConfig: displayConfig ?? null,
name: normalized.name,
description: normalized.description ?? null,
status: normalized.status,
materialType: normalized.materialType,
materialKind: normalized.materialKind,
fieldGroups: normalized.fieldGroups ?? null,
displayConfig: normalized.displayConfig ?? null,
fields: formattedFields.length
? { createMany: { data: formattedFields } }
: undefined,

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

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 },
})
if (!targetDepartment) {
return NextResponse.json(
{ error: '未找到对应的部门' },
{ status: 404 }
)
}
const trimmedName = name.trim()
if (targetDepartment.name === trimmedName) {
return NextResponse.json(targetDepartment)
}
// 构建所有需要重命名的部门(自身 + 子级)
const oldName = targetDepartment.name
const oldPrefix = `${oldName}-`
const newPrefix = `${trimmedName}-`
const descendantDepartments = await prisma.department.findMany({
where: {
name: {
startsWith: oldPrefix,
const [departments, users] = await Promise.all([
loadDepartmentPlanSnapshot(),
prisma.user.findMany({
select: {
id: true,
department: true,
managedDepartment: true,
},
},
}),
])
const plan = await planDepartmentStructureChangeWithPaidCore({
action: 'rename',
target: { id, name },
departments,
users,
})
type RenameItem = { id: string; oldName: string; newName: string }
const renameItems: RenameItem[] = [
{ id: targetDepartment.id, oldName, newName: trimmedName },
...descendantDepartments.map((dept) => ({
id: dept.id,
oldName: dept.name,
newName: `${newPrefix}${dept.name.slice(oldPrefix.length)}`,
})),
]
// 检查是否会与其他部门重名
const newNameSet = Array.from(new Set(renameItems.map((item) => item.newName)))
const renameIds = renameItems.map((item) => item.id)
const conflicting = await prisma.department.findMany({
where: {
name: { in: newNameSet },
NOT: {
id: { in: renameIds },
},
},
select: { name: true },
})
if (conflicting.length > 0) {
return NextResponse.json(
{ error: `存在同名分支:${conflicting.map((item) => item.name).join('、')}` },
{ status: 400 }
)
}
const renameValue = (value?: string | null) => {
if (!value) return value
if (value === oldName) return trimmedName
if (value.startsWith(oldPrefix)) {
return `${newPrefix}${value.slice(oldPrefix.length)}`
}
return value
if (!plan.valid || !plan.allowed) {
return departmentPlanResponse(plan)
}
await prisma.$transaction(async (tx) => {
for (const item of renameItems) {
for (const item of plan.departmentOps.filter((op) => op.type === 'rename') as any[]) {
await tx.department.update({
where: { id: item.id },
data: { name: item.newName },
})
}
const affectedUsers = await tx.user.findMany({
where: {
OR: [
{ department: oldName },
{ department: { startsWith: oldPrefix } },
{ managedDepartment: oldName },
{ managedDepartment: { startsWith: oldPrefix } },
],
},
select: {
id: true,
department: true,
managedDepartment: true,
},
})
for (const user of affectedUsers) {
const nextDepartment = renameValue(user.department) ?? ''
const nextManaged = renameValue(user.managedDepartment)
const data: { department?: string; managedDepartment?: string | null } = {}
if (nextDepartment !== user.department) {
data.department = nextDepartment
}
if (nextManaged !== user.managedDepartment) {
data.managedDepartment = nextManaged ?? null
}
if (Object.keys(data).length > 0) {
for (const userOp of plan.userOps.filter((op) => op.type === 'update') as any[]) {
if (userOp.id && userOp.data && Object.keys(userOp.data).length > 0) {
await tx.user.update({
where: { id: user.id },
data,
where: { id: userOp.id },
data: userOp.data,
})
}
}
@ -265,50 +199,36 @@ export async function DELETE(req: NextRequest) {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: '缺少部门ID' },
{ status: 400 }
)
}
const department = await prisma.department.findUnique({
where: { id },
select: { id: true, name: true },
const departments = await loadDepartmentPlanSnapshot()
const plan = await planDepartmentStructureChangeWithPaidCore({
action: 'delete',
target: { id },
departments,
})
if (!department) {
return NextResponse.json(
{ error: '未找到对应的部门' },
{ status: 404 }
)
if (!plan.valid || !plan.allowed) {
return departmentPlanResponse(plan)
}
const prefix = `${department.name}-`
const relatedDepartments = await prisma.department.findMany({
where: {
OR: [
{ name: department.name },
{ name: { startsWith: prefix } },
],
},
select: { id: true, name: true },
})
const targetIds = relatedDepartments.map((dept) => dept.id)
const targetNames = relatedDepartments.map((dept) => dept.name)
const deleteOp = plan.departmentOps.find((op) => op.type === 'deleteMany') as any
const targetIds = Array.isArray(deleteOp?.ids) ? deleteOp.ids : []
const targetNames = Array.isArray(deleteOp?.names) ? deleteOp.names : []
await prisma.$transaction(async (tx) => {
if (targetNames.length > 0) {
await tx.user.updateMany({
where: { department: { in: targetNames } },
data: { department: '' },
})
for (const userOp of plan.userOps as any[]) {
if (userOp.type === 'clearDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) {
await tx.user.updateMany({
where: { department: { in: userOp.names } },
data: { department: '' },
})
}
await tx.user.updateMany({
where: { managedDepartment: { in: targetNames } },
data: { managedDepartment: null },
})
if (userOp.type === 'clearManagedDepartmentIn' && Array.isArray(userOp.names) && userOp.names.length > 0) {
await tx.user.updateMany({
where: { managedDepartment: { in: userOp.names } },
data: { managedDepartment: null },
})
}
}
if (targetIds.length > 0) {

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,7 +73,12 @@ describe('GET /api/home-filter-fields 权限边界', () => {
options: [{ label: '店铺A', value: '店铺A' }],
},
])
prisma.$queryRawUnsafe
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,11 @@ 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({
fields: [
{
@ -96,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) }])
@ -105,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,23 +4,29 @@ 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 表中存在对应列的字段才能统计
const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
categoryName: 'categoryName',
sceneName: 'sceneName',
platformName: 'platformName',
materialType: 'materialType',
renderer: 'renderer',
brandName: 'brandName',
colorMode: 'colorMode',
renderTime: 'renderTime',
imageResolution: 'imageResolution',
seriesName: 'seriesName',
skuCode: 'skuCode',
materialTexture: 'materialTexture',
authorName: 'authorName',
const MATERIAL_FIELD_COLUMN_MAP: Record<string, Prisma.Sql> = {
categoryName: Prisma.sql`"categoryName"`,
sceneName: Prisma.sql`"sceneName"`,
platformName: Prisma.sql`"platformName"`,
materialType: Prisma.sql`"materialType"`,
renderer: Prisma.sql`"renderer"`,
brandName: Prisma.sql`"brandName"`,
colorMode: Prisma.sql`"colorMode"`,
renderTime: Prisma.sql`"renderTime"`,
imageResolution: Prisma.sql`"imageResolution"`,
seriesName: Prisma.sql`"seriesName"`,
skuCode: Prisma.sql`"skuCode"`,
materialTexture: Prisma.sql`"materialTexture"`,
authorName: Prisma.sql`"authorName"`,
}
type HomeFilterCountPlan = {
key: string
source: 'column' | 'metadata'
}
function metadataTemplateFieldsJson() {
@ -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 column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
if (!column) {
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
@ -83,9 +89,14 @@ async function buildMaterialCounts(fieldKeys: string[]) {
}
// 获取每个选项的素材数量(排除已删除的素材)
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>(
`SELECT "${column}" as value, COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL GROUP BY "${column}"`
)
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
SELECT ${columnSql} AS value, COUNT(*)::bigint AS count
FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
GROUP BY ${columnSql}
`)
const map = new Map<string, number>()
rows.forEach((row) => {
if (!row.value) return
@ -95,9 +106,13 @@ async function buildMaterialCounts(fieldKeys: string[]) {
// 获取该字段有非空值的素材总数(不重复计算,排除已删除的素材)
// 这是"全部"按钮应该显示的数量
const totalResult = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
`SELECT COUNT(*)::bigint as count FROM "materials" WHERE "${column}" IS NOT NULL AND "${column}" != '' AND "deletedAt" IS NULL`
)
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
SELECT COUNT(*)::bigint AS count
FROM "materials"
WHERE ${columnSql} IS NOT NULL
AND ${columnSql} != ''
AND "deletedAt" IS NULL
`)
totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
})
)
@ -121,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

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
import { POST } from '../route';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
jest.mock('@/lib/license/fingerprint-service', () => ({
@ -15,6 +16,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/register-free'),
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
}));
jest.mock('@/lib/license/crypto-service', () => ({
@ -27,6 +29,10 @@ jest.mock('@/lib/license/license-service', () => ({
startHeartbeat: jest.fn(),
}));
jest.mock('@/lib/license/premium-module-service', () => ({
syncPremiumModules: jest.fn(),
}));
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}));
@ -35,10 +41,15 @@ const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>;
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>;
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>;
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>;
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>;
const signedFreeLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'FREE',
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
@ -97,6 +108,7 @@ describe('POST /api/license/activate-free', () => {
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
mockVerifyLicenseSignature.mockReturnValue(true);
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true });
mockSyncPremiumModules.mockResolvedValue({ synced: true });
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
@ -121,6 +133,7 @@ describe('POST /api/license/activate-free', () => {
signedFreeLicense.data,
signedFreeLicense.signature
);
expect(mockSyncPremiumModules).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
@ -131,6 +144,24 @@ describe('POST /api/license/activate-free', () => {
expect(fetchBody.contactInfo).toBe('13800000000');
});
it('免费版授权模块同步失败不应阻断首次激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
mockRegisterFreeSuccess();
mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' });
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSaveLicenseLocally).toHaveBeenCalled();
expect(mockInitializeSystem).toHaveBeenCalled();
expect(mockStartHeartbeat).toHaveBeenCalled();
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down');
consoleWarnSpy.mockRestore();
});
it('已有最高权限账号后,未授权请求不能改写免费许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
@ -146,6 +177,7 @@ describe('POST /api/license/activate-free', () => {
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录');
expect(global.fetch).not.toHaveBeenCalled();
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
@ -160,6 +192,7 @@ describe('POST /api/license/activate-free', () => {
expect(data.success).toBe(false);
expect(data.error).toContain('许可证服务返回数据异常');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
@ -175,6 +208,29 @@ describe('POST /api/license/activate-free', () => {
expect(data.success).toBe(false);
expect(data.error).toBe('许可证签名验证失败');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证绑定信息不匹配时拒绝激活', async () => {
mockRegisterFreeSuccess({
license: {
...signedFreeLicense,
data: {
...signedFreeLicense.data,
licenseKey: 'FLNXI-FREE-OTHER-1234-5678-ABCD',
},
},
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toContain('激活码不匹配');
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockSyncPremiumModules).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});

View File

@ -4,7 +4,9 @@ import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
function maskSecret(value: unknown): string {
if (typeof value !== 'string' || !value) return '****';
@ -138,10 +140,25 @@ export async function POST(request: NextRequest) {
);
}
const bindingResult = validateSignedLicenseBinding(signedLicense, {
licenseKey: activationCode,
instanceId: fingerprint.instanceId,
});
if (!bindingResult.valid) {
return NextResponse.json(
{ success: false, error: bindingResult.error },
{ status: 400 }
);
}
console.log('[License] 免费版激活码:', maskSecret(activationCode), data.data.isExisting ? '(已存在)' : '(新注册)');
// ✅ 在后端保存许可证
await saveLicenseLocally(activationCode, signedLicense);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
// 初始化系统(创建默认管理员、部门、店铺等)
const initResult = await initializeSystem();

View File

@ -16,6 +16,7 @@ jest.mock('@/lib/license/fingerprint-service', () => ({
jest.mock('@/lib/license/server-config', () => ({
buildLicenseServerLicenseApiUrl: jest.fn(() => 'https://license.example.com/api/license/flnxi/activate'),
getLicenseProjectCode: jest.fn(() => 'flnxi-sucai'),
}))
jest.mock('@/lib/license/crypto-service', () => ({
@ -45,6 +46,10 @@ const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetu
const signedLicense = {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'ABCD-1234-EFGH-5678',
instanceId: 'instance-1',
fingerprintHash: 'a'.repeat(64),
type: 'PRO',
limits: { maxUsers: 100, maxMaterials: 10000 },
features: {
@ -233,7 +238,27 @@ describe('POST /api/license/activate', () => {
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('付费模块清单同步失败不应阻断许可证激活', async () => {
test('许可证绑定信息不匹配时不保存本地许可证', async () => {
mockActivateSuccess({
license: {
...signedLicense,
data: {
...signedLicense.data,
instanceId: 'other-instance-id',
},
},
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toContain('不属于当前设备')
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockSyncPremiumModules).not.toHaveBeenCalled()
})
test('授权模块清单同步失败不应阻断许可证激活', async () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
mockActivateSuccess()
mockSyncPremiumModules.mockResolvedValueOnce({ synced: false, error: 'network down' })
@ -244,7 +269,7 @@ describe('POST /api/license/activate', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(mockSaveLicenseLocally).toHaveBeenCalledWith('ABCD-1234-EFGH-5678', signedLicense)
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 付费模块清单同步失败:', 'network down')
expect(consoleWarnSpy).toHaveBeenCalledWith('[License] 授权模块清单同步失败:', 'network down')
consoleWarnSpy.mockRestore()
})

View File

@ -6,6 +6,7 @@ import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
import { validateSignedLicenseBinding } from '@/lib/license/license-binding';
import type { ActivateResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
@ -61,11 +62,22 @@ export async function POST(request: NextRequest) {
);
}
const bindingResult = validateSignedLicenseBinding(data.license, {
licenseKey,
instanceId: fingerprint.instanceId,
});
if (!bindingResult.valid) {
return NextResponse.json(
{ success: false, error: bindingResult.error },
{ status: 400 }
);
}
// ✅ 在后端保存许可证
await saveLicenseLocally(licenseKey, data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
// 初始化系统(创建默认管理员、部门、店铺等)

View File

@ -1,11 +1,11 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/license/license-service', () => ({
getLocalLicenseReadOnly: jest.fn(),
getLocalLicense: jest.fn(),
isLicenseConfigExpired: jest.fn(),
}))
const { getLocalLicenseReadOnly, isLicenseConfigExpired } = require('@/lib/license/license-service')
const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service')
import { GET } from '../route'
@ -16,7 +16,7 @@ describe('GET /api/license/check', () => {
})
test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => {
getLocalLicenseReadOnly.mockResolvedValue({
getLocalLicense.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',
@ -46,7 +46,7 @@ describe('GET /api/license/check', () => {
})
test('本地许可证为空或验签失败时应返回未激活', async () => {
getLocalLicenseReadOnly.mockResolvedValue(null)
getLocalLicense.mockResolvedValue(null)
const response = await GET(new NextRequest('http://localhost/api/license/check'))
const data = await response.json()
@ -58,7 +58,7 @@ describe('GET /api/license/check', () => {
test('本地许可证在线租约过期时不应恢复激活 cookie', async () => {
isLicenseConfigExpired.mockReturnValue(true)
getLocalLicenseReadOnly.mockResolvedValue({
getLocalLicense.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',

View File

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLocalLicenseReadOnly, isLicenseConfigExpired } from '@/lib/license/license-service';
import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service';
export const dynamic = 'force-dynamic';
/**
* 检查数据库中是否有已激活的许可证
@ -7,7 +9,7 @@ import { getLocalLicenseReadOnly, isLicenseConfigExpired } from '@/lib/license/l
*/
export async function GET(request: NextRequest) {
try {
const license = await getLocalLicenseReadOnly();
const license = await getLocalLicense();
if (!license || isLicenseConfigExpired(license.cachedConfig)) {
return NextResponse.json({ activated: false });
}

View File

@ -0,0 +1,97 @@
import { NextRequest } from 'next/server';
import { POST } from '../route';
import { updatePremiumModuleRuntimeStatus } from '@/lib/license/premium-module-service';
jest.mock('@/lib/license/premium-module-service', () => ({
PREMIUM_MODULE_IDS: {
PAID_CORE: 'paid-core',
},
updatePremiumModuleRuntimeStatus: jest.fn(),
}));
const mockUpdatePremiumModuleRuntimeStatus =
updatePremiumModuleRuntimeStatus as jest.MockedFunction<typeof updatePremiumModuleRuntimeStatus>;
function createRequest(body: Record<string, unknown>, token = 'a'.repeat(32)) {
return new NextRequest('http://localhost/api/license/premium-modules/runtime-status', {
method: 'POST',
headers: {
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
}
describe('POST /api/license/premium-modules/runtime-status', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env.MODULE_RUNTIME_STATUS_TOKEN = 'a'.repeat(32);
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
afterEach(() => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
delete process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN;
});
it('需要回写 token,避免公开接口被任意写入模块运行态', async () => {
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}, 'wrong-token'));
expect(response.status).toBe(401);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('token 未配置时默认关闭回写入口', async () => {
delete process.env.MODULE_RUNTIME_STATUS_TOKEN;
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(503);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
it('接受 Agent 上报的合法模块运行状态并持久化', async () => {
const lastSyncAt = new Date().toISOString();
const response = await POST(createRequest({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
}));
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({ success: true, updated: 1 });
expect(mockUpdatePremiumModuleRuntimeStatus).toHaveBeenCalledWith({
moduleId: 'paid-core',
image: 'registry.test/flnxi/premium/paid-core:1.0.0',
digest: 'sha256:' + 'a'.repeat(64),
status: 'running',
lastSyncAt,
lastError: undefined,
});
});
it('拒绝未知模块或非法状态', async () => {
const response = await POST(createRequest({
moduleId: 'unknown-module',
image: 'registry.test/flnxi/premium/unknown:1.0.0',
status: 'running',
lastSyncAt: new Date().toISOString(),
}));
expect(response.status).toBe(400);
expect(mockUpdatePremiumModuleRuntimeStatus).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import {
PREMIUM_MODULE_IDS,
updatePremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatus,
type PremiumModuleRuntimeStatusValue,
} from '@/lib/license/premium-module-service';
const validModuleIds = new Set<string>(Object.values(PREMIUM_MODULE_IDS));
const validStatuses = new Set<PremiumModuleRuntimeStatusValue>([
'missing',
'pulling',
'running',
'unhealthy',
'expired',
'revoked',
]);
function getRuntimeStatusToken(): string {
return (
process.env.MODULE_RUNTIME_STATUS_TOKEN ||
process.env.FLNXI_MODULE_STATUS_CALLBACK_TOKEN ||
''
).trim();
}
function readBearerToken(request: NextRequest): string {
const authorization = request.headers.get('authorization') || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice('bearer '.length).trim();
}
return (request.headers.get('x-module-status-token') || '').trim();
}
function normalizeRuntimeStatus(input: unknown): PremiumModuleRuntimeStatus | null {
if (!input || typeof input !== 'object') return null;
const raw = input as Record<string, unknown>;
const moduleId = typeof raw.moduleId === 'string' ? raw.moduleId.trim() : '';
const image = typeof raw.image === 'string' ? raw.image.trim() : '';
const digest = typeof raw.digest === 'string' && raw.digest.trim() ? raw.digest.trim() : null;
const status = typeof raw.status === 'string' ? raw.status.trim() : '';
const lastSyncAt = typeof raw.lastSyncAt === 'string' ? raw.lastSyncAt.trim() : '';
const lastError = typeof raw.lastError === 'string' && raw.lastError.trim()
? raw.lastError.trim()
: undefined;
if (!validModuleIds.has(moduleId)) return null;
if (!image) return null;
if (!validStatuses.has(status as PremiumModuleRuntimeStatusValue)) return null;
const lastSyncAtTime = Date.parse(lastSyncAt);
if (!lastSyncAt || Number.isNaN(lastSyncAtTime)) return null;
return {
moduleId,
image,
digest,
status: status as PremiumModuleRuntimeStatusValue,
lastSyncAt: new Date(lastSyncAtTime).toISOString(),
lastError,
};
}
export async function POST(request: NextRequest) {
const configuredToken = getRuntimeStatusToken();
if (!configuredToken || configuredToken.length < 32) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写 token 未配置' },
{ status: 503 }
);
}
if (readBearerToken(request) !== configuredToken) {
return NextResponse.json(
{ success: false, error: '模块运行状态回写未授权' },
{ status: 401 }
);
}
const body = await request.json().catch(() => null);
const rawStatuses: unknown[] = Array.isArray(body?.statuses) ? body.statuses : [body];
const statuses = rawStatuses.map(normalizeRuntimeStatus);
if (statuses.length === 0 || statuses.some((status: PremiumModuleRuntimeStatus | null) => !status)) {
return NextResponse.json(
{ success: false, error: '模块运行状态参数无效' },
{ status: 400 }
);
}
for (const status of statuses as PremiumModuleRuntimeStatus[]) {
await updatePremiumModuleRuntimeStatus(status);
}
return NextResponse.json({
success: true,
updated: statuses.length,
});
}

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) {
@ -88,34 +86,27 @@ export async function GET() {
} else {
console.error('[material-types] Custom templates disabled by current license')
}
// 直接使用自定义模板的名称作为 tag,每个自定义模板都显示为独立的 tag
// 使用特殊前缀 "template:" 来标识这是自定义模板,避免与内置类型冲突
// 注意:不再使用 materialType,直接使用模板名称作为tag
// 去掉模板名称中的"素材模板"后缀,让tag按钮显示更简洁
let addedCount = 0
customTemplates.forEach((template) => {
let templateName = template.name?.trim()
if (!templateName) {
console.warn(`[material-types] Skipping template with empty name: ${template.id}`)
return
}
// 去掉"素材模板"后缀(包括可能的空格)
templateName = templateName.replace(/\s*素材模板\s*$/g, '').trim()
if (!templateName) {
// 如果去掉后缀后为空,使用原始名称
templateName = template.name?.trim() || `模板-${template.id.substring(0, 8)}`
}
// 使用 "template:模板ID:模板名称" 格式,前端可以解析出模板ID和名称
// 模板名称可能包含冒号,所以使用 split(':').slice(2).join(':') 来提取
const templateIdentifier = `template:${template.id}:${templateName}`
typeSet.add(templateIdentifier)
addedCount++
console.log(`[material-types] ✓ Added custom template: "${templateName}" (ID: ${template.id}, status: ${template.status})`)
if (!customTemplatesAllowed) {
const builtinOnly = Array.from(new Set(builtinMaterialTypes)).sort()
console.log(`[material-types] Final material types list (${builtinOnly.length} items):`, JSON.stringify(builtinOnly, null, 2))
return noCacheJson(builtinOnly)
}
const projected = await projectPublicTemplateOptionsWithPaidCore({
builtinMaterialTypes,
customTemplates,
})
console.log(`[material-types] ✓ Added ${addedCount} custom templates`)
const result = Array.from(typeSet).sort()
if (!projected.valid) {
return noCacheJson({
error: projected.error || '付费增强包无法生成素材类型列表',
errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED',
details: projected.details,
}, 503)
}
const result = projected.materialTypes
console.log(`[material-types] Final material types list (${result.length} items):`, JSON.stringify(result, null, 2))
console.log(`[material-types] Built-in types: ${result.filter(t => !t.startsWith('template:')).join(', ')}`)
const customTemplateNames = result.filter(t => t.startsWith('template:')).map(t => {
@ -126,14 +117,7 @@ export async function GET() {
console.log(`[material-types] Custom template identifiers: ${result.filter(t => t.startsWith('template:')).join(', ') || '(none)'}`)
console.log('[material-types] ====== END QUERYING CUSTOM TEMPLATES ======')
// 强制不缓存,确保每次请求都返回最新数据
return NextResponse.json(result, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
return noCacheJson(result)
} catch (error) {
console.error('[material-types] ========== ERROR IN API ==========')
console.error('[material-types] Error fetching material types:', error)
@ -141,13 +125,7 @@ export async function GET() {
// 返回内置类型作为fallback(与首页 FALLBACK_MATERIAL_TYPES 保持一致,但去掉"素材模板"后缀)
const fallbackTypes = Array.from(new Set(Object.values(BUILTIN_TEMPLATE_TYPES)))
console.error('[material-types] Returning fallback types:', fallbackTypes)
return NextResponse.json(fallbackTypes, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
})
return noCacheJson(fallbackTypes)
}
}

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,22 +308,42 @@ export async function PUT(
)
}
const { metadata: rawMetadata } = prepareMaterialData(
templateFieldsValidation.templateFields as Record<string, any>,
effectiveTemplateId,
undefined,
material.metadata
)
const metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
let metadata: string | null = null
if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId: effectiveTemplateId,
templateFields: templateFieldsValidation.templateFields as Record<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
)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
)
}
}
updateData.templateId = effectiveTemplateId

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,37 +54,11 @@ export async function POST(req: NextRequest) {
)
}
if (!operator.isSuperAdmin) {
const isOwnMaterial = material.userId === operatorId
if (!isOwnMaterial) {
const operatorScope = (operator.managedDepartment || '').trim()
const targetDepartment = (material.user?.department || '').trim()
if (!operatorScope) {
return NextResponse.json(
{ error: '您只能恢复自己上传的素材' },
{ status: 403 }
)
}
let hasAccess =
targetDepartment === operatorScope ||
targetDepartment.startsWith(`${operatorScope}-`)
if (!hasAccess) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap)
}
if (!hasAccess) {
return NextResponse.json(
{ error: '您只能恢复自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
if (!actor.isSuperAdmin && !(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您只能恢复自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
// Restore material: set deletedAt to null

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({
where: {
isSearchable: true,
},
select: {
key: true,
},
})
async function planMaterialListQuery(
search: string,
homeFieldFilters: Array<{ key: string; value: string }>
) {
const requestedHomeFieldKeys = Array.from(new Set(homeFieldFilters.map((filter) => filter.key)))
const [searchableFields, enabledHomeFilterFields] = await Promise.all([
prisma.uploadField.findMany({
where: {
isSearchable: true,
},
select: {
key: true,
},
}),
requestedHomeFieldKeys.length > 0
? prisma.fieldDefinition.findMany({
where: {
key: { in: requestedHomeFieldKeys },
isFilterEnabled: true,
},
select: {
key: true,
},
})
: Promise.resolve([]),
])
return Array.from(
new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key)))
)
return planMaterialListQueryWithPaidCore({
search,
searchableFields,
homeFieldFilters,
enabledHomeFilterFields,
})
}
async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise<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({
where: {
key: { in: customKeys },
isFilterEnabled: true,
},
select: {
key: true,
},
})
return new Set(enabledFields.map((field) => field.key))
}
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 })
}
}
})
const enabledCustomHomeFilterKeys = await getEnabledCustomHomeFilterKeys(requestedDynamicFieldKeys)
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
if (enabledCustomHomeFilterKeys.has(fieldKey)) {
dynamicFieldFilters.push({ key: fieldKey, value })
}
homeFieldFilters.push({ key: fieldKey, value })
}
})
const queryPlan = await planMaterialListQuery(search, homeFieldFilters)
if (!queryPlan.valid) {
return NextResponse.json(
{ error: queryPlan.error || '无法规划素材查询条件' },
{ status: 503 }
)
}
// Build where clause
const where: any = {}
@ -216,21 +193,21 @@ export async function GET(req: NextRequest) {
}
// Add search condition (search in SPU, shop, scene category, author)
if (search) {
const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(search)
if (queryPlan.search) {
const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(
queryPlan.search,
queryPlan.customSearchFieldKeys
)
const standardSearchConditions = queryPlan.standardSearchFields.map((field) => ({
[field]: { contains: queryPlan.search, mode: 'insensitive' },
}))
const customSearchConditions = customSearchMaterialIds.length > 0
? [{ id: { in: customSearchMaterialIds } }]
: []
andConditions.push({
OR: [
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
...(customSearchMaterialIds.length > 0
? [{ id: { in: customSearchMaterialIds } }]
: []),
...standardSearchConditions,
...customSearchConditions,
]
})
}
@ -313,13 +290,12 @@ export async function GET(req: NextRequest) {
}
}
for (const { key, value } of dynamicFieldFilters) {
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) {
andConditions.push({ [key]: value } as Record<string, string>)
} else {
const materialIds = await findMaterialIdsByCustomFieldValue(key, value)
andConditions.push({ id: { in: materialIds } })
}
for (const { key, value } of queryPlan.standardHomeFilters) {
andConditions.push({ [key]: value } as Record<string, string>)
}
for (const { key, value } of queryPlan.customHomeFilters) {
const materialIds = await findMaterialIdsByCustomFieldValue(key, value)
andConditions.push({ id: { in: materialIds } })
}
// 合并所有条件
@ -496,20 +472,14 @@ export async function DELETE(req: NextRequest) {
)
}
// 获取当前操作者信息
const operatorId = (session.user as any).id
// 获取操作者的部门管理信息
const operator = await prisma.user.findUnique({
where: { id: operatorId },
select: {
id: true,
department: true,
managedDepartment: true,
isSuperAdmin: true,
},
})
const isSuperAdmin = Boolean(operator?.isSuperAdmin)
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '当前用户不存在或已被禁用' },
{ status: 403 }
)
}
const isSuperAdmin = actor.isSuperAdmin
// Get material to delete files, including uploader's department info
const material = await prisma.material.findUnique({
@ -539,43 +509,14 @@ export async function DELETE(req: NextRequest) {
}
// 获取用户ID用于日志记录
const userId = operatorId
const userId = actor.id
// 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。
if (!isSuperAdmin) {
// 检查是否是自己上传的素材
const isOwnMaterial = material.userId === operatorId
if (!isOwnMaterial) {
// 检查是否有权限管理素材上传者所在的部门
const operatorScope = (operator?.managedDepartment || '').trim()
const targetDepartment = (material.user?.department || '').trim()
if (!operatorScope) {
return NextResponse.json(
{ error: '您只能删除自己上传的素材' },
{ status: 403 }
)
}
// 检查目标素材的上传者是否在管理范围内
let hasAccess = targetDepartment === operatorScope ||
targetDepartment.startsWith(`${operatorScope}-`)
if (!hasAccess) {
// 使用部门层级关系检查
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap)
}
if (!hasAccess) {
return NextResponse.json(
{ error: '您只能删除自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
if (!isSuperAdmin && !(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您只能删除自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
if (permanent) {

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,20 +26,24 @@ async function buildMonthlyStatsWhereSql(actor: MaterialPermissionActor) {
if (actor.role === 'ADMIN' && scopeDepartment) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendants = buildDepartmentDescendants(departments).get(scopeDepartment)
const scopedDepartments = Array.from(descendants || new Set([scopeDepartment]))
const scopePlan = await planDepartmentScopeWithPaidCore({
scopeDepartment,
departments,
})
visibleConditions.push(Prisma.sql`
EXISTS (
SELECT 1
FROM "users" u
WHERE u."id" = m."userId"
AND (
u."department" IN (${Prisma.join(scopedDepartments)})
OR u."department" LIKE ${`${scopeDepartment}-%`}
)
)
`)
if (scopePlan.valid && scopePlan.scopedDepartments.length > 0) {
visibleConditions.push(Prisma.sql`
EXISTS (
SELECT 1
FROM "users" u
WHERE u."id" = m."userId"
AND (
u."department" IN (${Prisma.join(scopePlan.scopedDepartments)})
OR u."department" LIKE ${`${scopeDepartment}-%`}
)
)
`)
}
}
conditions.push(Prisma.sql`(${Prisma.join(visibleConditions, ' OR ')})`)

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,22 +198,42 @@ export async function PUT(req: NextRequest) {
templateFields = templateFieldsValidation.templateFields
const { metadata: rawMetadata } = prepareMaterialData(
templateFields,
effectiveTemplateId,
undefined,
existingMaterial.metadata
)
const metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
let metadata: string | null = null
if (effectiveTemplateId && !isBuiltinTemplate(effectiveTemplateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId: effectiveTemplateId,
templateFields,
existingMetadata: existingMaterial.metadata,
})
if (!metadataResult.valid) {
return NextResponse.json(
{
error: metadataResult.error,
errorCode: metadataResult.errorCode,
details: metadataResult.details,
},
{ status: 400 }
)
}
metadata = metadataResult.metadata
} else {
const { metadata: rawMetadata } = prepareMaterialData(
templateFields,
effectiveTemplateId,
undefined,
existingMaterial.metadata
)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
)
}
}
updateData.templateId = effectiveTemplateId

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,17 +788,37 @@ export async function POST(req: NextRequest) {
}
// 标准字段直接落列,自定义字段与预览图数组进入 metadata
const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls)
const metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return await failWithCleanup(NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
))
let metadata: string | null = null
if (templateId && !isBuiltinTemplate(templateId)) {
const metadataResult = await prepareMaterialMetadataWithPaidCore({
templateId,
templateFields,
previewUrls,
})
if (!metadataResult.valid) {
return await failWithCleanup(NextResponse.json(
{
error: metadataResult.error,
errorCode: metadataResult.errorCode,
details: metadataResult.details,
},
{ status: 400 }
))
}
metadata = metadataResult.metadata
} else {
const { metadata: rawMetadata } = prepareMaterialData(templateFields, templateId, previewUrls)
metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return await failWithCleanup(NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
))
}
}
console.log('[Upload] Material data to create:', {

View File

@ -1,5 +1,7 @@
import { NextRequest } from 'next/server'
const VALID_RESTORE_TOKEN = 'restore-token-12345678901234567890'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
@ -18,10 +20,18 @@ jest.mock('@/lib/prisma', () => ({
},
}))
function createRequest(token?: string) {
function createRequest(
token?: string,
ip = '127.0.0.1',
confirmPhrase?: string
) {
return new NextRequest('http://localhost/api/restore-data', {
method: 'POST',
headers: token ? { 'x-restore-data-token': token } : undefined,
headers: {
...(token ? { 'x-restore-data-token': token } : {}),
...(confirmPhrase ? { 'x-restore-data-confirm': confirmPhrase } : {}),
'x-forwarded-for': ip,
},
})
}
@ -38,6 +48,8 @@ describe('POST /api/restore-data', () => {
process.env = { ...originalEnv }
delete process.env.RESTORE_DATA_ENABLED
delete process.env.RESTORE_DATA_TOKEN
delete process.env.RESTORE_DATA_ALLOWED_IPS
delete process.env.RESTORE_DATA_TRUST_PROXY
delete process.env.RESTORE_ADMIN_PASSWORD
})
@ -46,11 +58,11 @@ describe('POST /api/restore-data', () => {
})
it('默认未显式启用时拒绝执行', async () => {
process.env.RESTORE_DATA_TOKEN = 'restore-token'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest('restore-token'))
const response = await POST(createRequest(VALID_RESTORE_TOKEN))
const data = await response.json()
expect(response.status).toBe(503)
@ -71,9 +83,26 @@ describe('POST /api/restore-data', () => {
expect(data.message).toContain('RESTORE_DATA_TOKEN')
})
it('启用且配置令牌后会拒绝错误令牌', async () => {
it('启用后会拒绝过短恢复令牌', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = 'restore-token'
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest('restore-token'))
const data = await response.json()
expect(response.status).toBe(503)
expect(data.success).toBe(false)
expect(data.message).toContain('RESTORE_DATA_TOKEN 长度不足')
})
it('启用且配置令牌后会拒绝错误令牌', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_DATA_TRUST_PROXY = '1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
@ -84,4 +113,66 @@ describe('POST /api/restore-data', () => {
expect(data.success).toBe(false)
expect(data.message).toBe('恢复令牌无效')
})
it('启用且配置令牌后仍要求配置 IP 白名单', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest(VALID_RESTORE_TOKEN))
const data = await response.json()
expect(response.status).toBe(503)
expect(data.success).toBe(false)
expect(data.message).toContain('RESTORE_DATA_ALLOWED_IPS')
})
it('启用且配置令牌后会拒绝非白名单 IP', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_DATA_TRUST_PROXY = '1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '10.0.0.8'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.message).toBe('当前来源 IP 不允许执行恢复')
})
it('默认不信任可伪造的代理 IP 头', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '127.0.0.1'))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.message).toBe('无法确认可信来源 IP,恢复接口已拒绝')
})
it('通过令牌和白名单后仍要求恢复确认头', async () => {
process.env.RESTORE_DATA_ENABLED = '1'
process.env.RESTORE_DATA_TOKEN = VALID_RESTORE_TOKEN
process.env.RESTORE_DATA_ALLOWED_IPS = '127.0.0.1'
process.env.RESTORE_DATA_TRUST_PROXY = '1'
process.env.RESTORE_ADMIN_PASSWORD = 'configured-password'
const POST = await loadPost()
const response = await POST(createRequest(VALID_RESTORE_TOKEN, '127.0.0.1'))
const data = await response.json()
expect(response.status).toBe(400)
expect(data.success).toBe(false)
expect(data.message).toBe('恢复确认头无效')
expect(data.requiredConfirmHeader).toBe('x-restore-data-confirm')
})
})

View File

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import * as crypto from 'crypto';
import https from 'https';
import fs from 'fs';
import path from 'path';
@ -11,6 +12,13 @@ const S3_BUCKET_URL = process.env.RESTORE_S3_BUCKET_URL as string;
const S3_PUBLIC_URL = process.env.RESTORE_S3_PUBLIC_URL as string;
const RESTORE_DATA_ENABLED = process.env.RESTORE_DATA_ENABLED?.trim() === '1';
const RESTORE_DATA_TOKEN = process.env.RESTORE_DATA_TOKEN?.trim() || '';
const RESTORE_DATA_MIN_TOKEN_LENGTH = 32;
const RESTORE_DATA_CONFIRM_PHRASE = 'RESTORE_DATA';
const RESTORE_DATA_TRUST_PROXY = process.env.RESTORE_DATA_TRUST_PROXY?.trim() === '1';
const RESTORE_DATA_ALLOWED_IPS = (process.env.RESTORE_DATA_ALLOWED_IPS || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
// 默认配置
const ADMIN_EMAIL = process.env.RESTORE_ADMIN_EMAIL?.trim() || 'admin@local.flnxi';
@ -21,6 +29,34 @@ const DEFAULT_SCENE = '默认场景';
const DEFAULT_RENDERER = 'CR';
const DEFAULT_MATERIAL_TYPE = '3DMAX';
function getClientIp(req: NextRequest): string {
const directIp = (req as NextRequest & { ip?: string }).ip?.trim();
if (directIp) return directIp;
if (!RESTORE_DATA_TRUST_PROXY) {
return 'unknown';
}
const forwardedFor = req.headers.get('x-forwarded-for') || '';
const realIp = req.headers.get('x-real-ip') || '';
return forwardedFor.split(',')[0]?.trim() || realIp.trim() || 'unknown';
}
function constantTimeEqualText(actual: string, expected: string): boolean {
const actualHash = crypto.createHash('sha256').update(actual).digest();
const expectedHash = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(actualHash, expectedHash);
}
function auditRestoreData(message: string, req: NextRequest, extra?: Record<string, unknown>) {
console.warn('[RestoreData]', {
message,
ip: getClientIp(req),
userAgent: req.headers.get('user-agent') || '',
...extra,
});
}
// 解析S3 XML
function parseS3XML(xml: string): string[] {
const keys: string[] = [];
@ -108,7 +144,10 @@ function matchFiles(previewFiles: string[], materialFiles: string[]): Match[] {
export async function POST(req: NextRequest) {
try {
const clientIp = getClientIp(req);
if (!RESTORE_DATA_ENABLED) {
auditRestoreData('restore endpoint disabled', req);
return NextResponse.json({
success: false,
message: '未显式启用 RESTORE_DATA_ENABLED=1,恢复接口已禁用',
@ -116,21 +155,66 @@ export async function POST(req: NextRequest) {
}
if (!RESTORE_DATA_TOKEN) {
auditRestoreData('restore token missing in environment', req);
return NextResponse.json({
success: false,
message: '未配置 RESTORE_DATA_TOKEN,恢复接口已禁用',
}, { status: 503 });
}
if (RESTORE_DATA_TOKEN.length < RESTORE_DATA_MIN_TOKEN_LENGTH) {
auditRestoreData('restore token too short in environment', req);
return NextResponse.json({
success: false,
message: `RESTORE_DATA_TOKEN 长度不足 ${RESTORE_DATA_MIN_TOKEN_LENGTH} 位,恢复接口已禁用`,
}, { status: 503 });
}
if (RESTORE_DATA_ALLOWED_IPS.length === 0) {
auditRestoreData('restore allowed ip list missing', req);
return NextResponse.json({
success: false,
message: '未配置 RESTORE_DATA_ALLOWED_IPS,恢复接口已禁用',
}, { status: 503 });
}
if (clientIp === 'unknown') {
auditRestoreData('restore ip unknown', req, { trustProxy: RESTORE_DATA_TRUST_PROXY });
return NextResponse.json({
success: false,
message: '无法确认可信来源 IP,恢复接口已拒绝',
}, { status: 403 });
}
if (!RESTORE_DATA_ALLOWED_IPS.includes(clientIp)) {
auditRestoreData('restore ip rejected', req, { allowedIpCount: RESTORE_DATA_ALLOWED_IPS.length });
return NextResponse.json({
success: false,
message: '当前来源 IP 不允许执行恢复',
}, { status: 403 });
}
const requestToken = req.headers.get('x-restore-data-token')?.trim() || '';
if (requestToken !== RESTORE_DATA_TOKEN) {
if (!constantTimeEqualText(requestToken, RESTORE_DATA_TOKEN)) {
auditRestoreData('restore token rejected', req);
return NextResponse.json({
success: false,
message: '恢复令牌无效',
}, { status: 401 });
}
const confirmPhrase = req.headers.get('x-restore-data-confirm')?.trim() || '';
if (confirmPhrase !== RESTORE_DATA_CONFIRM_PHRASE) {
auditRestoreData('restore confirm phrase rejected', req);
return NextResponse.json({
success: false,
message: '恢复确认头无效',
requiredConfirmHeader: 'x-restore-data-confirm',
}, { status: 400 });
}
if (!ADMIN_PASSWORD) {
auditRestoreData('restore admin password missing in environment', req);
return NextResponse.json({
success: false,
message: '未配置 RESTORE_ADMIN_PASSWORD,无法创建恢复管理员',
@ -138,6 +222,7 @@ export async function POST(req: NextRequest) {
}
const output: string[] = [];
auditRestoreData('restore started', req);
output.push('🚀 开始数据恢复...\n');

View File

@ -410,6 +410,7 @@ describe('PUT /api/system-settings 权限边界', () => {
describe('POST /api/system-settings/upload-logo 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
processImage.mockResolvedValue(Buffer.from('processed-logo'))
uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png')
prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings })
@ -472,6 +473,23 @@ describe('POST /api/system-settings/upload-logo 权限边界', () => {
})
})
it('免费版即使是最高权限账号也不能上传系统 LOGO', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await uploadLogo(createLogoRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(processImage).not.toHaveBeenCalled()
expect(uploadToS3).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
it('拒绝 SVG 或非白名单图片格式', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },

View File

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { uploadToS3 } from '@/lib/s3'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { processImage } from '@/lib/image-processor'
import { canUseSystemSettings } from '@/lib/license'
// POST - 上传LOGO(最高权限)
export async function POST(request: NextRequest) {
@ -18,6 +19,13 @@ export async function POST(request: NextRequest) {
)
}
if (!(await canUseSystemSettings())) {
return NextResponse.json(
{ error: '当前许可证未包含系统设置功能,请联系管理员处理授权' },
{ status: 403 }
)
}
// 将NextRequest转换为Node.js IncomingMessage
const formData = await request.formData()
const logoFile = formData.get('logo') as File

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

@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { proxyIntranetModule } from '@/lib/tunnel/intranet-module'
async function ensureTunnelEnabled() {
if (featureFlags.tunnelEnabled) return true
@ -32,6 +33,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 })
}
const moduleResponse = await proxyIntranetModule('start')
if (moduleResponse) return moduleResponse
return NextResponse.json(
{ error: '内网穿透服务尚未接入真实提供商,不能启动占位隧道' },
{ status: 501 }

View File

@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { proxyIntranetModule } from '@/lib/tunnel/intranet-module'
export const dynamic = 'force-dynamic'
@ -38,6 +39,9 @@ export async function GET(req: NextRequest) {
}, { status: 403 })
}
const moduleResponse = await proxyIntranetModule('status')
if (moduleResponse) return moduleResponse
return NextResponse.json(
{
enabled: true,

View File

@ -5,6 +5,7 @@ import { featureFlags } from '@/lib/config'
import { canUseIntranetPenetration } from '@/lib/license'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { proxyIntranetModule } from '@/lib/tunnel/intranet-module'
async function ensureTunnelEnabled() {
if (featureFlags.tunnelEnabled) return true
@ -32,6 +33,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '当前许可证不包含内网穿透功能,请联系管理员处理许可证' }, { status: 403 })
}
const moduleResponse = await proxyIntranetModule('stop')
if (moduleResponse) return moduleResponse
return NextResponse.json(
{ error: '内网穿透服务尚未接入真实提供商,不能停止占位隧道' },
{ status: 501 }

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,9 +122,36 @@ export async function GET() {
})
)
const templatesWithCounts = allTemplates.map((template, index) => ({
const materialCounts = Object.fromEntries(
countTargets.map((template, index) => [template.id, templateCounts[index] || 0])
)
if (!customTemplatesAllowed) {
const templatesWithCounts = countTargets.map((template, index) => ({
...template,
materialCount: templateCounts[index],
}))
return NextResponse.json(templatesWithCounts)
}
const projected = await projectPublicTemplateOptionsWithPaidCore({
builtinTemplates,
customTemplates,
materialCounts,
})
if (!projected.valid) {
return NextResponse.json({
error: projected.error || '付费增强包无法生成上传模板列表',
errorCode: projected.errorCode || 'TEMPLATE_PUBLIC_OPTIONS_PROJECT_FAILED',
details: projected.details,
}, { status: 503 })
}
const templatesWithCounts = projected.uploadTemplates.map((template) => ({
...template,
materialCount: templateCounts[index],
materialCount: Number((template as any).materialCount || 0),
}))
return NextResponse.json(templatesWithCounts)

View File

@ -5,13 +5,15 @@
# ================================================================
param(
[ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "SyncRuntime", "Status", "Menu")]
[ValidateSet("DeployApp", "PublishRelease", "PublishAgent", "PublishPremiumModule", "SyncRuntime", "Status", "Menu")]
[string]$Action = "DeployApp",
[string]$Server = "",
[int]$SshPort = 22,
[string]$Username = "root",
[SecureString]$Password = $null,
[switch]$RemoteSudo,
[SecureString]$RemoteSudoPassword = $null,
[string]$KeyPath = "",
[string]$PlinkHostKey = "",
@ -29,7 +31,7 @@ param(
[string]$RegistryHost = "",
[ValidateSet("http", "https")]
[string]$RegistryScheme = "http",
[string]$RegistryUsername = "flnxi",
[string]$RegistryUsername = "flnxi-publish",
[SecureString]$RegistryPassword = $null,
[string]$RegistryNamespace = "flnxi",
[string]$ImageRepository = "material/flnxi-material",
@ -41,6 +43,10 @@ param(
[string]$Version = "",
[string]$AgentRepository = "agent",
[string]$AgentContext = "flnxi-agent-go",
[string]$PremiumModuleId = "paid-core",
[string]$PremiumModuleContextRoot = "premium-modules",
[string]$PremiumModuleRepositoryPrefix = "premium",
[switch]$AllowMissingPremiumModuleDigest,
[switch]$NoLatest,
[switch]$PushLatest,
[switch]$NoVersionFileUpdate,
@ -103,6 +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 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
@ -112,6 +119,7 @@ function Show-Help {
Write-Host " -Action DeployApp 构建、推送并部署素材库应用(默认)"
Write-Host " -Action PublishRelease 发布素材库正式版镜像,只推送不远程部署"
Write-Host " -Action PublishAgent 构建并推送 Go Agent 镜像"
Write-Host " -Action PublishPremiumModule 构建并推送付费模块镜像,输出 license-server 所需 digest"
Write-Host " -Action SyncRuntime 同步 Postgres / MinIO 等运行依赖镜像"
Write-Host " -Action Status 查看 Registry、本机 Docker、远程容器状态"
Write-Host " -Action Menu 打开交互菜单"
@ -119,8 +127,9 @@ function Show-Help {
Write-Host "主要参数:" -ForegroundColor White
Write-Host " -Server 远程服务器IP或域名;远程部署时必须显式传入"
Write-Host " -SshPort SSH端口,默认 22;内部测试机或客户自定义端口必须显式传入"
Write-Host " -RemoteSudo 远端 Docker 需要 sudo 时启用;常见于飞牛 / NAS 普通管理员账号"
Write-Host " -RegistryHost Registry地址;发布和部署时必须显式传入"
Write-Host " -RegistryUsername Registry账号,默认 flnxi"
Write-Host " -RegistryUsername Registry账号,默认 flnxi-publish"
Write-Host " -RegistryPassword Registry密码,建议用 Read-Host -AsSecureString 输入"
Write-Host " -ImageRepository 正式版镜像仓库路径,默认 material/flnxi-material"
Write-Host " -TestImageRepository 测试版镜像仓库路径,默认 material-dev/flnxi-material"
@ -129,6 +138,10 @@ function Show-Help {
Write-Host " -Version 正式版或 Agent 版本号"
Write-Host " -PushLatest 仅在需要兼容旧链路时,额外推送 latest(默认不推送)"
Write-Host " -AgentRepository Agent 镜像路径,默认 agent"
Write-Host " -PremiumModuleId 付费模块 ID,默认 paid-core"
Write-Host " -PremiumModuleContextRoot 付费模块源码根目录,默认 premium-modules"
Write-Host " -PremiumModuleRepositoryPrefix 付费模块镜像仓库前缀,默认 premium"
Write-Host " -AllowMissingPremiumModuleDigest 允许付费模块发布后暂时不输出 digest;正式商用不建议启用"
Write-Host " -RemotePath 远程部署目录,默认 /opt/flnxi;已有客户环境必须传入真实部署目录"
Write-Host " -FullComposeUp 远程执行完整 docker compose up -d;默认只更新 app 服务"
Write-Host " -UpdateRemoteEnv 兼容旧参数;DeployApp 现在默认会把 APP_IMAGE 等写入远程 .env"
@ -160,6 +173,14 @@ function ConvertTo-ShellSingleQuoted {
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Normalize-RemoteShellScript {
param([string]$Script)
if ($null -eq $Script) {
return ""
}
return (($Script -replace "`r`n", "`n") -replace "`r", "")
}
function Assert-Tool {
param([string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
@ -230,6 +251,48 @@ function Get-RegistryImageFor {
return "$RegistryHost/$RegistryNamespace/$($Repository.Trim('/'))`:$ImageTag"
}
function Get-PremiumModuleEnvKey {
param([string]$ModuleId)
return (($ModuleId -replace '[^a-zA-Z0-9]', '_').ToUpperInvariant())
}
function Get-PushedImageDigest {
param([string]$Image)
if ($DryRun) {
return "sha256:dryrun"
}
$repoDigestsRaw = ""
try {
$repoDigestsRaw = docker image inspect $Image --format "{{json .RepoDigests}}"
} catch {
$repoDigestsRaw = ""
}
if (-not [string]::IsNullOrWhiteSpace($repoDigestsRaw)) {
try {
$repoDigests = $repoDigestsRaw | ConvertFrom-Json
foreach ($repoDigest in $repoDigests) {
if ($repoDigest -match '@(sha256:[a-f0-9]+)$') {
return $Matches[1]
}
}
} catch {
Write-Warn "解析本地 RepoDigests 失败:$($_.Exception.Message)"
}
}
if (Get-Command docker -ErrorAction SilentlyContinue) {
$digest = docker buildx imagetools inspect $Image --format "{{.Manifest.Digest}}" 2>$null
if ($LASTEXITCODE -eq 0 -and $digest -match '^sha256:[a-f0-9]+$') {
return $digest.Trim()
}
}
return ""
}
function Get-DefaultTestTag {
return "test-$(Get-Date -Format 'yyyyMMdd-HHmm')"
}
@ -338,6 +401,9 @@ function Get-ComposeRuntimeEnv {
}
function Ensure-RegistryPassword {
if ($DryRun) {
return
}
if ($SkipPush -and $SkipRemote) {
return
}
@ -361,6 +427,20 @@ function Ensure-SshPasswordIfNeeded {
}
}
function Ensure-RemoteSudoPasswordIfNeeded {
if ($SkipRemote -or -not $RemoteSudo) {
return
}
if ($RemoteSudoPassword) {
return
}
if ($Password) {
$script:RemoteSudoPassword = $Password
return
}
$script:RemoteSudoPassword = Read-Host "请输入远程 sudo 密码" -AsSecureString
}
function Test-RegistryEndpoint {
Assert-RegistryHostConfigured
Write-RegistryHttpNotice
@ -388,10 +468,24 @@ function Test-RegistryEndpoint {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -TimeoutSec 8 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
Write-Success "Registry 接口可访问"
} elseif ($response.StatusCode -eq 401) {
Write-Success "Registry 接口可访问(需要认证)"
} else {
throw "Registry 返回状态码 $($response.StatusCode)"
}
} catch {
$statusCode = $null
try {
$statusCode = [int]$_.Exception.Response.StatusCode
} catch {
$statusCode = $null
}
if ($statusCode -eq 401) {
Write-Success "Registry 接口可访问(需要认证)"
return
}
throw "Registry 接口检查失败。若报 HTTPS/HTTP 相关错误,请先在 Docker Desktop 和目标机配置 insecure-registries:$RegistryHost。原始错误:$($_.Exception.Message)"
}
}
@ -404,15 +498,16 @@ function Invoke-DockerLogin {
}
$plain = ConvertTo-PlainText -SecureValue $RegistryPassword
if ($DryRun) {
Write-Info "登录 Registry:$RegistryHost"
Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray
return
}
if ([string]::IsNullOrWhiteSpace($plain)) {
throw "Registry 密码为空,无法登录"
}
Write-Info "登录 Registry:$RegistryHost"
if ($DryRun) {
Write-Host "[DRYRUN] docker login $RegistryHost -u $RegistryUsername --password-stdin" -ForegroundColor DarkGray
return
}
$plain | docker login $RegistryHost -u $RegistryUsername --password-stdin
if ($LASTEXITCODE -ne 0) {
@ -434,13 +529,26 @@ function Invoke-RemoteCommand {
Assert-RemoteServerConfigured
$target = "${Username}@${Server}"
$remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $Command)
$stdinPassword = ""
$normalizedCommand = Normalize-RemoteShellScript -Script $Command
$remoteCommand = "bash -lc " + (ConvertTo-ShellSingleQuoted -Value $normalizedCommand)
$stdinLines = New-Object System.Collections.Generic.List[string]
if ($RemoteSudo) {
$sudoPasswordPlain = ConvertTo-PlainText -SecureValue $RemoteSudoPassword
if ([string]::IsNullOrWhiteSpace($sudoPasswordPlain)) {
throw "远程 sudo 密码为空,无法执行需要 sudo 的 Docker 命令"
}
$stdinLines.Add($sudoPasswordPlain)
}
if ((-not $SkipRegistryLogin) -and (-not $NoRegistryPasswordInput)) {
$stdinPassword = ConvertTo-PlainText -SecureValue $RegistryPassword
if ([string]::IsNullOrWhiteSpace($stdinPassword)) {
$stdinRegistryPassword = ConvertTo-PlainText -SecureValue $RegistryPassword
if ([string]::IsNullOrWhiteSpace($stdinRegistryPassword)) {
throw "Registry 密码为空,无法远程登录 Registry"
}
$stdinLines.Add($stdinRegistryPassword)
}
$stdinPayload = ""
if ($stdinLines.Count -gt 0) {
$stdinPayload = ($stdinLines -join "`n") + "`n"
}
if ($KeyPath -and (Test-Path $KeyPath)) {
@ -461,10 +569,10 @@ function Invoke-RemoteCommand {
Write-Host "[DRYRUN] ssh -p $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return
}
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) {
if ($stdinLines.Count -eq 0) {
& ssh @args
} else {
$stdinPassword | ssh @args
$stdinPayload | ssh @args
}
if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE"
@ -488,10 +596,10 @@ function Invoke-RemoteCommand {
Write-Host "[DRYRUN] plink -P $SshPort $target bash -lc '<远程部署脚本,敏感信息已隐藏>'" -ForegroundColor DarkGray
return
}
if ($SkipRegistryLogin -or $NoRegistryPasswordInput) {
if ($stdinLines.Count -eq 0) {
& plink.exe @args
} else {
$stdinPassword | plink.exe @args
$stdinPayload | plink.exe @args
}
if ($LASTEXITCODE -ne 0) {
throw "远程部署命令失败,退出码:$LASTEXITCODE"
@ -522,9 +630,13 @@ function New-RemoteDeployScript {
$remoteObjectBackupEnabled = if ($NoRemoteObjectStorageBackup) { "0" } else { "1" }
$externalDataBackupConfirmed = if ($ExternalDataBackupConfirmed) { "1" } else { "0" }
$rollbackEnabled = if ($NoRollback) { "0" } else { "1" }
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$lines = New-Object System.Collections.Generic.List[string]
$lines.Add("set -Eeuo pipefail")
if ($RemoteSudo) {
$lines.Add("IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }")
}
if (-not $SkipRegistryLogin) {
$lines.Add("IFS= read -r REGISTRY_PASSWORD || { echo '未收到 Registry 密码'; exit 22; }")
}
@ -541,6 +653,7 @@ function New-RemoteDeployScript {
$lines.Add("external_data_backup_confirmed=$externalDataBackupConfirmed")
$lines.Add("remote_backup_helper_image=$quotedRemoteBackupHelperImage")
$lines.Add("rollback_enabled=$rollbackEnabled")
$lines.Add("remote_sudo_enabled=$remoteSudoEnabled")
$lines.Add("export APP_IMAGE=$quotedRegistryImage")
$lines.Add("export NEXTAUTH_URL=$quotedNextAuthUrl")
$lines.Add("export APP_PUBLIC_URL=$quotedAppPublicUrl")
@ -558,6 +671,21 @@ db_backup=""
object_backup=""
minio_volume_name=""
sudo_auth() {
if [ "${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "$@"
else
command docker "$@"
fi
}
upsert_env() {
key="$1"
value="$2"
@ -576,6 +704,7 @@ read_env_value() {
awk -F= -v k="$key" '
index($0, k "=") == 1 {
value=substr($0, length(k) + 2)
gsub(/\r$/, "", value)
gsub(/^"|"$/, "", value)
gsub(/^'\''|'\''$/, "", value)
print value
@ -887,6 +1016,10 @@ write_restore_plan
$lines.Add("docker pull $quotedRegistryImage")
if ($FullComposeUp) {
$lines.Add('echo "预拉 Runtime 镜像(完整 compose 升级)"')
$lines.Add("docker pull $quotedPostgresImage")
$lines.Add("docker pull $quotedMinioImage")
$lines.Add("docker pull $quotedMinioMcImage")
$lines.Add('docker compose -f "$compose_file" up -d --pull never')
} else {
$lines.Add('docker compose -f "$compose_file" stop "$compose_service" || true')
@ -908,9 +1041,26 @@ function New-RemoteRollbackScript {
$quotedRemotePath = ConvertTo-ShellSingleQuoted -Value $RemotePath
$quotedReason = ConvertTo-ShellSingleQuoted -Value $Reason
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" }
return @"
set -Eeuo pipefail
$sudoReadLine
remote_sudo_enabled=$remoteSudoEnabled
sudo_auth() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "`$@"
else
command docker "`$@"
fi
}
cd $quotedRemotePath
reason=$quotedReason
context_file="./backups/LAST_DEPLOY_CONTEXT.env"
@ -1072,6 +1222,7 @@ function Invoke-DeployApp {
Ensure-RegistryPassword
Ensure-SshPasswordIfNeeded
Ensure-RemoteSudoPasswordIfNeeded
Invoke-ProjectCheck
Test-RegistryEndpoint
$registryImage = Invoke-PublishAppImage -VersionTag $deployTag -Repository $deployRepository -PushLatest:$false
@ -1167,6 +1318,93 @@ function Invoke-PublishAgent {
Write-Info "镜像:$versionImage"
}
function Invoke-PublishPremiumModule {
Assert-RegistryHostConfigured
Write-Host "========================================" -ForegroundColor Magenta
Write-Host " FLNXI素材库 - 付费模块镜像发布" -ForegroundColor Magenta
Write-Host "========================================" -ForegroundColor Magenta
$moduleId = $PremiumModuleId.Trim()
if ([string]::IsNullOrWhiteSpace($moduleId)) {
throw "PremiumModuleId 不能为空"
}
$moduleContext = Join-Path $PremiumModuleContextRoot $moduleId
if (-not (Test-Path $moduleContext)) {
throw "找不到付费模块目录:$moduleContext"
}
$dockerfile = Join-Path $moduleContext "Dockerfile"
if (-not (Test-Path $dockerfile)) {
throw "付费模块缺少 Dockerfile:$dockerfile"
}
$versionFile = Join-Path $moduleContext "VERSION.txt"
$currentVersion = Read-VersionFile -Path $versionFile
$moduleVersion = if (-not [string]::IsNullOrWhiteSpace($Tag)) {
$Tag.Trim()
} else {
Get-VersionOrPrompt -CurrentVersion $currentVersion -Prompt "请输入付费模块 $moduleId 版本号"
}
Write-VersionFileIfNeeded -Path $versionFile -Value $moduleVersion
$moduleRepository = "$($PremiumModuleRepositoryPrefix.Trim('/'))/$moduleId"
$registryImage = Get-RegistryImageFor -Repository $moduleRepository -ImageTag $moduleVersion
$localImage = "flnxi-premium-$moduleId`:$moduleVersion"
Assert-Tool "docker"
if (-not $SkipPush) {
Ensure-RegistryPassword
Test-RegistryEndpoint
Invoke-DockerLogin
}
$env:DOCKER_BUILDKIT = "1"
$buildArgs = @(
"build",
"--progress=plain",
"-t", $localImage,
"-t", $registryImage,
"-f", $dockerfile
)
if ($NoCache) {
$buildArgs += "--no-cache"
}
$buildArgs += $moduleContext
Invoke-NativeCommand -FilePath "docker" -Arguments $buildArgs -Label "构建付费模块镜像:$registryImage"
if ($SkipPush) {
Write-Warn "已跳过推送付费模块镜像;未推送时无法作为客户拉取源使用"
} else {
Invoke-NativeCommand -FilePath "docker" -Arguments @("push", $registryImage) -Label "推送付费模块镜像"
}
$digest = if ($SkipPush) { "" } else { Get-PushedImageDigest -Image $registryImage }
if ([string]::IsNullOrWhiteSpace($digest) -and (-not $SkipPush) -and (-not $AllowMissingPremiumModuleDigest)) {
throw "未能自动读取付费模块镜像 digest。请确认 Registry 可通过 docker image inspect 或 docker buildx imagetools inspect 读取 digest;如只是临时内部调试,可显式传入 -AllowMissingPremiumModuleDigest。"
}
if ([string]::IsNullOrWhiteSpace($digest)) {
Write-Warn "未能自动读取镜像 digest;请在 Registry 管理界面或 docker buildx imagetools inspect 中确认"
}
$envKey = Get-PremiumModuleEnvKey -ModuleId $moduleId
Write-Success "付费模块发布完成"
Write-Info "模块:$moduleId"
Write-Info "镜像:$registryImage"
if (-not [string]::IsNullOrWhiteSpace($digest)) {
Write-Info "Digest:$digest"
}
Write-Host ""
Write-Host "请写入 license-server 环境变量:" -ForegroundColor Yellow
Write-Host "PREMIUM_MODULE_${envKey}_IMAGE=$registryImage" -ForegroundColor Yellow
Write-Host "PREMIUM_MODULE_${envKey}_TAG=$moduleVersion" -ForegroundColor Yellow
if (-not [string]::IsNullOrWhiteSpace($digest)) {
Write-Host "PREMIUM_MODULE_${envKey}_DIGEST=$digest" -ForegroundColor Yellow
}
}
function Invoke-SyncRuntimeImages {
Assert-RegistryHostConfigured
@ -1304,8 +1542,26 @@ function Invoke-ShowStatus {
if (-not $SkipRemote) {
Ensure-SshPasswordIfNeeded
Ensure-RemoteSudoPasswordIfNeeded
$remoteSudoEnabled = if ($RemoteSudo) { "1" } else { "0" }
$sudoReadLine = if ($RemoteSudo) { "IFS= read -r SUDO_PASSWORD || { echo '未收到远程 sudo 密码'; exit 23; }" } else { "" }
$remoteStatus = @"
set -e
$sudoReadLine
remote_sudo_enabled=$remoteSudoEnabled
sudo_auth() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
printf '%s\n' "`$SUDO_PASSWORD" | sudo -S -p '' true
fi
}
docker() {
if [ "`${remote_sudo_enabled:-0}" = "1" ]; then
sudo_auth
sudo -n docker "`$@"
else
command docker "`$@"
fi
}
echo '--- docker ps ---'
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' | grep -E 'flnxi|postgres|minio|license' || true
echo '--- compose ---'
@ -1321,8 +1577,9 @@ function Show-ActionMenu {
Write-Host "1. 应用发布部署(默认正式版通道,测试需显式 -DeployChannel test)"
Write-Host "2. 发布素材系统正式版镜像"
Write-Host "3. 发布 Agent 镜像"
Write-Host "4. 同步 Runtime 镜像"
Write-Host "5. 查看状态"
Write-Host "4. 发布付费模块镜像"
Write-Host "5. 同步 Runtime 镜像"
Write-Host "6. 查看状态"
Write-Host "0. 退出"
}
@ -1334,8 +1591,9 @@ function Invoke-Menu {
"1" { Invoke-DeployApp }
"2" { Invoke-PublishRelease }
"3" { Invoke-PublishAgent }
"4" { Invoke-SyncRuntimeImages }
"5" { Invoke-ShowStatus }
"4" { Invoke-PublishPremiumModule }
"5" { Invoke-SyncRuntimeImages }
"6" { Invoke-ShowStatus }
"0" { return }
default { Write-Warn "无效选择,请重新输入" }
}
@ -1354,6 +1612,7 @@ function Main {
"DeployApp" { Invoke-DeployApp }
"PublishRelease" { Invoke-PublishRelease }
"PublishAgent" { Invoke-PublishAgent }
"PublishPremiumModule" { Invoke-PublishPremiumModule }
"SyncRuntime" { Invoke-SyncRuntimeImages }
"Status" { Invoke-ShowStatus }
"Menu" { Invoke-Menu }

View File

@ -0,0 +1,71 @@
# FLNXI 付费模块 Registry 代理
这个目录只用于“客户拉取付费模块镜像”的 Registry 前置代理示例。
核心规则:
- 发布方推送镜像仍走发布账号,不把发布账号给客户。
- 客户拉取 `flnxi/premium/*` 付费模块时,用户名固定为 `flnxi-module`,密码是验证系统签发的 15 分钟短期 `pullToken`。
- Nginx 代理会把 `Authorization`、Registry 主机、模块仓库路径、请求类型、tag/reference 和 digest 转发给 `license-server` 的 `GET /api/registry/auth`。
- 验证系统确认 token、token 审计记录、许可证、激活实例和模块授权都有效后,代理才把请求放行到 Registry。
## 启动示例
先确认许可证服务和本代理在同一个 Docker 网络里,或者把 `LICENSE_SERVER_AUTH_URL` 改成代理能访问到的地址。
```bash
cd deploy/registry-proxy
cat > .env <<'EOF'
REGISTRY_PROXY_PORT=5000
LICENSE_NETWORK_NAME=license-network
LICENSE_SERVER_AUTH_URL=http://license-server:3000/api/registry/auth
EOF
docker compose up -d
```
如果许可证服务是通过 `license-server/nginx/nginx.conf` 里的外层 Nginx 暴露,可以把 `LICENSE_SERVER_AUTH_URL` 写成对应内网地址,例如:
```env
LICENSE_SERVER_AUTH_URL=http://license-nginx/api/registry/auth
```
如果镜像站已经存在,不想再启动一个新的 `registry:2`,使用接入现有 Registry 的编排:
```bash
cat > .env <<'EOF'
REGISTRY_PROXY_PORT=5002
REGISTRY_NETWORK_NAME=flnxi-registry-net
LICENSE_NETWORK_NAME=license-network
REGISTRY_BACKEND=http://flnxi-registry:5000
REGISTRY_BACKEND_AUTH=Basic <proxy-backend-user-basic-token>
LICENSE_SERVER_AUTH_URL=http://license-server:3000/api/registry/auth
EOF
docker compose -f docker-compose.existing-registry.yml up -d
```
这种方式只新增 Nginx 拉取代理,不会创建新的 Registry 数据卷,也不会覆盖已有镜像站。如果现有 Registry 后端本身启用了 Basic Auth,必须配置一个只给代理使用的后端账号,并把 `REGISTRY_BACKEND_AUTH` 写成 `Basic base64(username:password)`;客户仍然只拿验证系统签发的短期 `pullToken`,不会拿到这个后端账号。
## 客户拉取方式
客户侧 Go Agent 拿到验证系统返回的短期凭证后,等价于:
```bash
docker login registry.example.com:5000 -u flnxi-module -p '<pullToken>'
docker pull registry.example.com:5000/flnxi/premium/paid-core:1.0.0
```
没有短期 token、token 过期、仓库路径不匹配、tag 不匹配、镜像清单 digest 不匹配、许可证过期、激活失效或模块授权被吊销,都会被代理拒绝。Docker 拉取层文件时的 blob digest 不是整镜像 digest,最终整镜像 digest 由客户侧 Agent 拉取完成后再校验。
## 发布方注意
这个代理示例默认只放行客户拉取,不开放推送。发布付费模块镜像时应使用内部发布入口或临时直连 Registry 内网入口,再把固定 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:...
```
正式客户环境不要把发布账号、发布密码或 Registry 管理界面口令写进主应用 `.env`。Registry 原始后端也不要直接暴露给客户访问;客户入口应只到这个前置代理。

View File

@ -0,0 +1,24 @@
services:
module-registry-proxy:
image: ${REGISTRY_PROXY_NGINX_IMAGE:-nginx:1.27-alpine}
container_name: ${REGISTRY_PROXY_CONTAINER_NAME:-flnxi-module-registry-proxy}
restart: unless-stopped
ports:
- "${REGISTRY_PROXY_BIND_HOST:-0.0.0.0}:${REGISTRY_PROXY_PORT:-5002}:5000"
environment:
REGISTRY_BACKEND: ${REGISTRY_BACKEND:-http://flnxi-registry:5000}
REGISTRY_BACKEND_AUTH: ${REGISTRY_BACKEND_AUTH:-}
LICENSE_SERVER_AUTH_URL: ${LICENSE_SERVER_AUTH_URL:?LICENSE_SERVER_AUTH_URL is required, for example http://license-server:3000/api/registry/auth}
volumes:
- ./nginx.module-auth.conf.template:/etc/nginx/templates/default.conf.template:ro
networks:
- registry-network
- license-network
networks:
registry-network:
name: ${REGISTRY_NETWORK_NAME:-flnxi-registry-net}
external: true
license-network:
name: ${LICENSE_NETWORK_NAME:-license-network}
external: true

View File

@ -0,0 +1,41 @@
services:
registry:
image: ${REGISTRY_IMAGE:-registry:2}
container_name: ${REGISTRY_CONTAINER_NAME:-flnxi-registry}
restart: unless-stopped
environment:
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /var/lib/registry
REGISTRY_HTTP_ADDR: 0.0.0.0:5000
volumes:
- registry_data:/var/lib/registry
networks:
- registry-network
module-registry-proxy:
image: ${REGISTRY_PROXY_NGINX_IMAGE:-nginx:1.27-alpine}
container_name: ${REGISTRY_PROXY_CONTAINER_NAME:-flnxi-module-registry-proxy}
restart: unless-stopped
ports:
- "${REGISTRY_PROXY_BIND_HOST:-0.0.0.0}:${REGISTRY_PROXY_PORT:-5000}:5000"
environment:
REGISTRY_BACKEND: ${REGISTRY_BACKEND:-http://registry:5000}
REGISTRY_BACKEND_AUTH: ${REGISTRY_BACKEND_AUTH:-}
LICENSE_SERVER_AUTH_URL: ${LICENSE_SERVER_AUTH_URL:?LICENSE_SERVER_AUTH_URL is required, for example http://license-server:3000/api/registry/auth}
volumes:
- ./nginx.module-auth.conf.template:/etc/nginx/templates/default.conf.template:ro
depends_on:
- registry
networks:
- registry-network
- license-network
volumes:
registry_data:
name: ${REGISTRY_VOLUME_NAME:-flnxi-registry-data}
networks:
registry-network:
name: ${REGISTRY_NETWORK_NAME:-flnxi-registry-network}
license-network:
name: ${LICENSE_NETWORK_NAME:-license-network}
external: true

View File

@ -0,0 +1,81 @@
# FLNXI premium module Registry pull proxy.
# The Docker image push path should stay on the publisher-only Registry entry.
# Customer pulls for /v2/flnxi/premium/* are checked by license-server /api/registry/auth.
map $request_method $flnxi_is_pull_method {
default 0;
GET 1;
HEAD 1;
}
map $module_reference $module_digest_header {
default "";
~^sha256: $module_reference;
}
server {
listen 5000;
server_name _;
client_max_body_size 0;
proxy_read_timeout 900;
proxy_send_timeout 900;
proxy_request_buffering off;
error_page 401 = @flnxi_module_auth_required;
location = /v2/ {
if ($http_authorization = "") {
add_header Docker-Distribution-Api-Version registry/2.0 always;
add_header WWW-Authenticate 'Basic realm="flnxi premium modules"' always;
return 401;
}
add_header Docker-Distribution-Api-Version registry/2.0 always;
add_header Content-Type application/json;
return 200 '{}';
}
location ~ ^/v2/(?<module_repository>flnxi/premium/[^/]+)/(?<module_reference_type>manifests|blobs)/(?<module_reference>.+)$ {
if ($flnxi_is_pull_method = 0) {
return 403;
}
auth_request /_flnxi_module_auth;
auth_request_set $flnxi_auth_status $upstream_status;
proxy_pass ${REGISTRY_BACKEND};
proxy_set_header Authorization "${REGISTRY_BACKEND_AUTH}";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /_flnxi_module_auth {
internal;
proxy_pass ${LICENSE_SERVER_AUTH_URL};
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Authorization $http_authorization;
proxy_set_header X-Registry-Host $http_host;
proxy_set_header X-Registry-Repository $module_repository;
proxy_set_header X-Registry-Reference-Type $module_reference_type;
proxy_set_header X-Registry-Reference $module_reference;
proxy_set_header X-Registry-Digest $module_digest_header;
proxy_set_header X-Registry-Scope "repository:$module_repository:pull";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /v2/ {
return 403;
}
location @flnxi_module_auth_required {
add_header Docker-Distribution-Api-Version registry/2.0 always;
add_header WWW-Authenticate 'Basic realm="flnxi premium modules"' always;
return 401;
}
}

View File

@ -45,8 +45,8 @@ services:
volumes:
- minio_data:/data
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
- "${MINIO_API_BIND_HOST:-0.0.0.0}:${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_BIND_HOST:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001"
networks:
- app-network
healthcheck:
@ -135,6 +135,8 @@ 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}
- 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}
- INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-}

View File

@ -52,6 +52,8 @@
飞牛 fnOS 虚拟机上的首装验收记录,用于追溯 Linux/NAS 脚本和真实测试环境结论。
- `许可证链路安全评估_20260312.md`
历史安全评估快照,部分条目已被后续代码和部署配置修正;保留作溯源参考,不作为当前整改入口,引用前必须按当前代码重新复核。
- `许可证链路安全加固记录_20260602.md`
本轮针对本地伪造许可证、签名许可证缺少机器绑定、许可证服务构建上下文密钥风险的安全加固记录。
## 6. 使用原则

View File

@ -74,6 +74,8 @@ 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>
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
MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
@ -90,7 +92,10 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
- `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。
- `LICENSE_PUBLIC_KEY` 是许可证服务签发许可证时使用的 RSA 公钥,主应用靠它确认许可证没有被伪造。
- `LOCAL_ENCRYPTION_KEY` 用于主应用本地加密保存许可证缓存,至少 32 字符,不能使用示例值。
- 付费模块核心本体走镜像站,不走验证服务器大文件下载;`license-server` 需要配置 `PREMIUM_MODULE_REGISTRY_BASE` 和 `PREMIUM_MODULE_VERSION`,用于给客户实例下发签名模块清单。
- `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` 和 `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。
@ -103,6 +108,7 @@ MAX_UPLOAD_FIELD_SIZE_BYTES=2097152
对象存储也有两种模式:
- 内置 MinIO:`S3_ENDPOINT=http://minio:9000`,`S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` 可不填,Compose 会使用 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD`。`S3_PUBLIC_DOMAIN` 必须写成用户浏览器能访问的地址,例如 `http://192.168.1.20:9000/flnxi-materials`。
- 内置 MinIO 的 API 端口默认通过 `MINIO_API_BIND_HOST=0.0.0.0` 对浏览器可访问地址开放,用于素材预览和下载;管理控制台端口默认通过 `MINIO_CONSOLE_BIND_HOST=127.0.0.1` 只绑定本机。只有明确需要从局域网访问 MinIO 管理界面时,才把 `MINIO_CONSOLE_BIND_HOST` 改成服务器局域网 IP,并配合防火墙限制来源。
- 外部 S3 / 外部 MinIO:需要填写真实 `S3_ENDPOINT`、`S3_ACCESS_KEY_ID`、`S3_SECRET_ACCESS_KEY`、`S3_BUCKET`、`S3_PUBLIC_DOMAIN`,并确认 bucket 已存在或当前账号有创建和读写权限。
- 商业首版更推荐内置 MinIO,升级脚本可以自动备份素材对象。若使用外部 S3 / MinIO,升级前必须先完成客户侧 bucket / 对象备份;执行 `deploy-registry.ps1` 时同样需显式加 `-ExternalDataBackupConfirmed`。
@ -137,6 +143,39 @@ docker info | grep -A 5 "Insecure Registries"
Docker Desktop 需要在设置里的 Docker Engine 配置同样的 `insecure-registries`,保存并重启 Docker。
### 2.4 付费模块私有拉取
主应用基础镜像和付费模块镜像要分开理解:
- 基础镜像:客户通过 `APP_IMAGE` 拉取和升级,不能因为购买付费版就自动包含所有付费模块核心。
- 付费模块镜像:发布方推送到镜像站后,由验证系统确认客户授权,再签发 15 分钟短期拉包 token。
推荐链路:
```text
客户 Go Agent
-> license-server /api/license/{projectCode}/premium-modules/{moduleId}/pull-token
-> docker login registry.example.com -u flnxi-module -p <短期 pullToken>
-> Registry 前置代理
-> license-server /api/registry/auth
-> Registry 按模块仓库和短期 token 放行拉取
```
发布账号只用于内部推送镜像,不给客户安装现场长期保存。客户即使知道付费模块镜像地址,没有验证系统签发的短期 token,也不能拉取模块镜像。Registry 原始后端不要直接暴露给客户;客户入口应只到前置代理,否则会绕过这层鉴权。
模块发布时应尽量固定 digest,并在 `license-server` 配置中写入对应模块的 `_DIGEST` 变量。Docker 拉取时会先取镜像清单、再取多个层文件;代理会校验短期 token、模块仓库、tag、清单 digest、许可证、激活记录和模块授权状态,客户侧 Agent 拉取后还会比对整镜像 digest,不匹配就拒绝启动模块容器。
这条链路不是要求客户使用系统时一直在线。验证服务器负责“发证、续租、变更、发短期拉包权限”,客户现场负责“保存已验签缓存、运行模块、按模块返回结果执法”。所以离线客户要提前把基础镜像、模块镜像和当前许可证租约准备好;如果需要新增授权、续期、重新拉包或升级,就必须恢复到能访问验证服务器和镜像站的网络状态。
仓库内已提供可审查的代理示例:
```text
deploy/registry-proxy/docker-compose.yml
deploy/registry-proxy/nginx.module-auth.conf.template
```
这个代理默认只放行 `flnxi/premium/*` 模块拉取;非拉取动作会被拒绝。发布方推送镜像应走内部发布入口或 Registry 内网入口,不通过客户拉取代理。
## 3. 标准部署步骤
### 3.1 区分首装和升级
@ -237,6 +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 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
```
@ -244,6 +284,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- 客户升级和内部发布优先按版本 tag 走,不要把 `latest` 当成客户部署依据。
- 客户现场部署使用 `-DeployChannel release -Version x.x.x`;测试通道只用于内部验证,必须显式传 `-DeployChannel test`。
- 授权模块发布使用 `-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 迁移状态校验。
@ -553,6 +594,23 @@ docker exec -i flnxi-postgres pg_restore \
- `SERVER_IP` 和 `NEXTAUTH_URL` 是否填写成浏览器可访问地址
- 防火墙是否放通端口
### 旧恢复接口被拒绝
`/api/restore-data` 是历史一次性数据恢复入口,不属于客户日常后台功能。客户环境默认应保持:
```env
RESTORE_DATA_ENABLED=0
```
只有在受控恢复窗口才临时启用,并且必须同时满足:
- `RESTORE_DATA_TOKEN` 至少 32 字符,不能使用示例值或短口令。
- `RESTORE_DATA_ALLOWED_IPS` 只写维护机来源 IP。
- 通过可信反向代理传递来源 IP 时,才设置 `RESTORE_DATA_TRUST_PROXY=1`;否则接口默认不信任 `x-forwarded-for`。
- 调用时必须同时带 `x-restore-data-token` 和 `x-restore-data-confirm: RESTORE_DATA`。
恢复完成后必须立刻把 `RESTORE_DATA_ENABLED` 改回 `0`,并重启应用容器。
### MinIO 预览或下载异常
优先检查:

View File

@ -120,7 +120,11 @@ POST /api/license/{projectCode}/activate
POST /api/license/{projectCode}/verify
POST /api/license/{projectCode}/heartbeat
POST /api/license/{projectCode}/register-free
POST /api/license/{projectCode}/premium-modules
POST /api/license/{projectCode}/premium-modules/{moduleId}
POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token
POST /api/license/{projectCode}/generate
GET /api/registry/auth
GET /api/version/{projectCode}/check
GET /api/version/{projectCode}/agent
@ -128,7 +132,7 @@ GET /api/version/{projectCode}/agent
### 5.1.1 请求签名要求
`activate`、`verify`、`heartbeat`、`register-free`、`premium-modules` 和 `premium-modules/{moduleId}` 当前不是“只带项目编码就能访问”。项目侧可信执行层必须使用 `LICENSE_REQUEST_SIGNING_SECRET` 对请求体做 HMAC 签名,并把下面三个字段放进请求体:
`activate`、`verify`、`heartbeat`、`register-free`、`premium-modules`、`premium-modules/{moduleId}` 和 `premium-modules/{moduleId}/pull-token` 当前不是“只带项目编码就能访问”。项目侧可信执行层必须使用 `LICENSE_REQUEST_SIGNING_SECRET` 对请求体做 HMAC 签名,并把下面三个字段放进请求体:
```json
{
@ -142,6 +146,8 @@ GET /api/version/{projectCode}/agent
这和“验证许可证返回值的 RSA 签名”是两件事:请求签名用于防止外部伪造请求;许可证返回签名用于确认许可证内容没有被篡改。
如果项目需要拆分付费模块,还要额外接短期拉包凭证:验证系统先按 `LicenseModuleGrant` 判断该许可证是否允许使用某模块,再给 15 分钟有效的 `pullToken`。Registry 前置代理通过 `GET /api/registry/auth` 校验 token、仓库路径和 digest 后才放行拉取。发布账号只给发布方推镜像使用,不应发给客户现场。
### 5.2 禁止再使用的旧接口
以下旧接口已经废弃,当前统一返回 `410`:

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,17 +461,17 @@
- `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`:付费模块链路已调整为“镜像站放模块核心本体,验证服务器发签名清单和实例绑定授权凭证”。免费版拒绝,付费版按许可证类型返回带 Registry 镜像地址的签名 manifest;硬件指纹明显不匹配时拒绝并告警。
- `app/api/license/activate/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()`。
- `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`:人数/素材数上限、自定义模板、部门隔离、系统设置、内网穿透和 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 保持只读。
- `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。
- `lib/__tests__/license-service.test.ts`:新增测试覆盖旧本地许可证自动补时间锚点、小幅回拨不误伤、明显回拨拒绝功能和额度、本地时间状态被篡改后拒绝,以及网络失败宽限期前也先检查回拨状态。
- `lib/__tests__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。
- `lib/__tests__/premium-module-service.test.ts`、`license-server/lib/__tests__/premium-module-manifest.test.ts`、`license-server/lib/__tests__/free-registration-route-coverage.test.ts`:新增测试覆盖付费模块 manifest 同步/验签/降级失效、验证服务器 manifest 生成,以及免费注册入口的防误判和禁用/过期护栏。
- `lib/__tests__/premium-module-service.test.ts`、`license-server/lib/__tests__/premium-module-manifest.test.ts`、`license-server/lib/__tests__/free-registration-route-coverage.test.ts`:新增测试覆盖授权模块 manifest 同步/验签/降级失效、验证服务器 manifest 生成,以及免费注册入口的防误判和禁用/过期护栏。
验证:
@ -487,7 +493,7 @@
- 已完成:本地许可证读取已加入轻量时间回拨检测;免费版和租约能力不能再靠把系统时间往回调来继续走本地宽限期。
- 已完成:验证服务器默认入口接入 Nginx 限流,免费版重复注册/多设备复用会阻断,付费版异常多设备以告警为主。
- 已完成:主应用关键授权入口已有防漏口测试,后续改注册、上传、模板、部门、系统设置、内网穿透接口时会更早暴露许可证检查缺失。
- 已完成:付费模块 manifest 和实例绑定授权凭证链路已落地,模块核心本体位置由 Registry 镜像地址承载;自定义模板和内网穿透已经接入本地有效授权凭证门槛,验证服务器不承担大文件下载站职责。
- 已完成:授权模块 manifest 和实例绑定授权凭证链路已落地,模块核心本体位置由 Registry 镜像地址承载;免费版人数/素材数上限、自定义模板和内网穿透已经接入本地有效授权凭证门槛,验证服务器不承担大文件下载站职责。
- 后续增强:如果要做到更强的“核心业务实现完全不在主应用镜像里”,还需要继续把自定义模板/内网穿透内部服务端实现逐步移动到独立模块镜像或模块加载层;当前本轮完成的是清单、镜像地址、授权凭证、hash 校验、加密缓存和服务端入口强制依赖凭证的闭环。
### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复)
@ -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. 条件显示规则前后端都执行。(已完成第一轮)
第四轮,做权限和体验收口:
@ -1419,14 +1434,14 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器
证据:
- 浏览器验证时,`/api/admin/users?pageSize=1000` 和 `/api/departments` 曾返回 500,`/api/system-settings` 走了兜底或缓存。
- Node/Prisma 直接查询时复现 `Can't reach database server at 45.249.208.118:5432`。
- Node/Prisma 直接查询时曾复现“连接不到旧公网测试数据库”的错误,根因是本地环境文件还残留已关停测试服务器地址。
- 重启 dev 服务后,启动日志显示加载 `.env.development.local, .env.local, .env`,浏览器内重新请求 `/api/admin/users?pageSize=1000`、`/api/departments`、`/api/system-settings` 均返回 200。
- `docs/当前工作流.md` 已补充本地调试前检查实际环境文件和旧服务器地址的说明。
建议动作:
- 保持本地调试优先使用 `.env.development.local` 的本机数据库和本机对象存储。
- 旧测试服务器 `45.249.208.118:4320` 不再作为当前项目事实来源。
- 已关停的旧公网测试服务器不再作为当前项目事实来源。
- 如果后续仍需要保留 `.env.local` 或 `.env` 中的旧值,只能作为本机私有临时文件,不能写进 Git 或交付文档。
2026-05-28 继续推进:

View File

@ -62,7 +62,7 @@
证据:
- `license-server/docker-compose.yml` 强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,并要求配置 `PREMIUM_MODULE_REGISTRY_BASE`,用于把付费模块核心本体指向镜像站。
- `license-server/docker-compose.yml` 强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,并要求配置 `PREMIUM_MODULE_REGISTRY_BASE`,用于把授权模块核心本体指向镜像站。
- `license-server/.env.example:1` 到 `27` 没有这个变量。
- 本轮只读验证:`docker compose -f license-server/docker-compose.yml --env-file license-server/.env.example config` 直接报缺少 `LICENSE_REQUEST_SIGNING_SECRET`。

View File

@ -12,7 +12,7 @@
本地调试前要确认实际加载的环境文件。Next.js 开发模式通常会加载 `.env.development.local`、`.env.local` 和 `.env`;如果 `.env.local` 或 `.env` 里还残留旧服务器地址,后台用户、部门、系统设置等接口会连到错误数据库并返回 500。
当前已确认旧测试服务器 `45.249.208.118:4320` 不再作为项目事实来源。本地开发应优先使用本机测试数据库和对象存储,例如 `.env.development.local` 中的 `localhost:15432` 与 `localhost:19000`。如发现后台接口持续 500,先核对 dev 服务启动日志里的 `Environments:` 和当前 `DATABASE_URL` 指向,不要直接按旧远程环境排查。
当前已确认已关停的旧公网测试服务器不再作为项目事实来源。本地开发应优先使用本机测试数据库和对象存储,例如 `.env.development.local` 中的 `localhost:15432` 与 `localhost:19000`。如发现后台接口持续 500,先核对 dev 服务启动日志里的 `Environments:` 和当前 `DATABASE_URL` 指向,不要直接按旧远程环境排查。
Prisma 命令和 Next.js 开发服务的环境加载方式不同。直接运行 `npx prisma ...` 或 `npm run prisma:*` 时,Prisma 会优先读取根目录 `.env`;如果 `.env` 还是旧服务器地址,就会误连废弃环境。本机开发需要操作数据库时,优先使用下面的 `prisma:dev:*` 命令,它会显式读取 `.env.development.local`,并拒绝连接非本机数据库。

View File

@ -36,14 +36,15 @@ LICENSE_PROJECT_CODE=flnxi-sucai
Go Agent 目前走的是另一条现状链路,文档和脚本还没有和 Node Agent 完全统一;接入时不要默认它也已经接好了同一套 `LICENSE_SERVER_URL` / `LICENSE_PROJECT_CODE` 版本检查流程。
`license-server` 自身还需要配置付费模块的镜像站位置:
`license-server` 自身还需要配置授权模块的镜像站位置:
```env
MODULE_PULL_TOKEN_SECRET=replace-with-32-byte-or-longer-module-pull-token-secret
PREMIUM_MODULE_REGISTRY_BASE=registry.example.com/flnxi/premium
PREMIUM_MODULE_VERSION=1.0.0
```
如某个模块要单独指定镜像,可使用 `PREMIUM_MODULE_CUSTOM_TEMPLATES_CORE_IMAGE`、`PREMIUM_MODULE_INTRANET_PENETRATION_CORE_IMAGE` 这类变量覆盖。原则是:模块核心本体在镜像站,验证服务器只发授权清单和小型凭证。
如需要单独指定付费模块镜像,可使用 `PREMIUM_MODULE_PAID_CORE_IMAGE` 覆盖;固定 digest 可使用 `PREMIUM_MODULE_PAID_CORE_DIGEST`。原则是:模块核心本体在镜像站,验证服务器只发授权清单、实例绑定凭证和短期拉包 token。
## 2. 联调入口规则
@ -69,12 +70,24 @@ POST /api/license/{projectCode}/heartbeat
POST /api/license/{projectCode}/register-free
POST /api/license/{projectCode}/premium-modules
POST /api/license/{projectCode}/premium-modules/{moduleId}
POST /api/license/{projectCode}/premium-modules/{moduleId}/pull-token
POST /api/license/{projectCode}/generate
GET /api/registry/auth
GET /api/version/{projectCode}/check
GET /api/version/{projectCode}/agent
```
首次部署验证服务时,`docker compose up -d` 只会启动服务,不会自动创建数据库表、超管和项目登记。最短初始化顺序是:
```bash
docker compose exec -T license-server npm run db:push
docker compose exec -T license-server npm run init-admin -- admin
docker compose exec -T license-server npm run init-project -- flnxi-sucai
```
`init-project` 会把 `flnxi-sucai` 写入验证服务主库的项目表,默认指向同一个 Compose PostgreSQL 的 `public` schema。没有这一步,素材系统请求 `/api/license/flnxi-sucai/...` 时会找不到项目。
旧的无项目接口:
```text
@ -106,10 +119,17 @@ GET /api/version/{projectCode}/agent
- `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。
- `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。
- `activate`、`verify`、`heartbeat` 在付费许可证保存或续证成功后会尝试同步 `premium-modules` 付费模块授权清单和对应授权凭证;免费版会跳过,不访问远端模块接口。
- `activate`、`activate-free`、`verify`、`heartbeat` 返回的签名许可证必须包含 `projectCode`、`licenseKey`、`instanceId` 和 `fingerprintHash`;主应用保存和读取本地许可证时会核对这些绑定字段,缺少绑定字段的旧许可证需要重新激活。
- `activate`、`activate-free`、`verify`、`heartbeat` 在许可证保存或续证成功后会尝试同步 `premium-modules` 授权模块清单、对应授权凭证和短期镜像拉取凭证。FREE 清单为空,不拉额外模块;STANDARD/PRO 清单包含统一 `paid-core`。FREE 人数/素材数上限由基础镜像读取本地已验签许可证执行。
- `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。
- `info` 用于读取当前许可证类型、用量和功能开关。
联网边界按下面理解:
- 必须联网:首次激活、免费版注册、续期心跳、验证服务器授权变更同步、获取短期镜像拉包 token、拉取授权模块镜像、升级版本。
- 不要求每次实时联网:用户登录后的日常上传、创建用户、素材浏览、模板使用等操作,优先使用本地已验签许可证缓存、已缓存模块授权凭证和 Agent 回写的本机模块运行状态。
- 断网期间如果本地许可证仍在有效租约内,且需要的模块容器仍为 `running` 并通过 `/health` 和 `/authorize`,对应受控能力可以继续使用;一旦许可证过期、模块被吊销、需要重新拉包或本地模块不可用,就必须重新连验证服务器恢复。
主应用内部已经不会再直接访问无项目的 `license-server` 路径,而是会根据 `LICENSE_PROJECT_CODE` 转发到:
```text
@ -121,14 +141,35 @@ ${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 请求签名;免费版、禁用、过期或硬件指纹明显不匹配会被拒绝。
- 远端 `POST /api/license/{projectCode}/premium-modules/{moduleId}` 是单个付费模块授权凭证接口,要求 `premium-module-download` HMAC 请求签名,并复查许可证状态、硬件指纹和模块权限。
- `premium-modules` 返回签名 manifest(模块清单),每个模块会带镜像站上的模块本体地址、实例绑定信息、授权凭证大小和 SHA256;主应用随后只获取实例绑定的小型授权凭证,校验大小和 SHA256 后再本地加密缓存。
- 付费模块核心本体应放在镜像站或等价的私有制品仓库,验证服务器只负责授权判断、签名清单和授权凭证,不承担大文件下载站职责。
- 主应用的自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应授权凭证;许可证功能开关开启但没有有效授权凭证时,服务端仍会拒绝。
- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId}` 当前会校验请求 HMAC 签名;返回的许可证、付费模块清单和授权凭证必须通过签名、hash、大小、租约、实例和许可证类型检查后才能缓存或放行。
- 远端 `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、许可证状态、激活实例和模块授权状态。它不是普通前端接口。
- `premium-modules` 返回签名 manifest(模块清单),每个模块会带镜像站上的模块本体地址、实例绑定信息、授权凭证大小和 SHA256;主应用随后获取实例绑定的小型授权凭证和短期拉包凭证,校验大小、SHA256、镜像地址和过期时间后再本地加密缓存。
- 授权模块核心本体应放在镜像站或等价的私有制品仓库,验证服务器只负责授权判断、签名清单和授权凭证,不承担大文件下载站职责。
- 主应用的人数/素材数上限、自定义模板和内网穿透服务端入口已经依赖本地已验签 manifest 和对应授权凭证;许可证功能开关或本地数量字段被篡改但没有有效授权凭证时,服务端仍会拒绝。
- `activate / verify / heartbeat / register-free / premium-modules / premium-modules/{moduleId} / premium-modules/{moduleId}/pull-token` 当前会校验请求 HMAC 签名;返回的许可证、授权模块清单、授权凭证和短期拉包凭证必须通过签名、hash、大小、租约、实例、许可证类型、镜像地址和过期时间检查后才能缓存或放行。
## 5. 后台管理入口
## 5. 付费模块拉取规则
当前采用“两层凭证”:
- 模块授权凭证:证明这个客户、这台机器、这张许可证允许使用某个模块。
- 镜像拉取凭证:15 分钟短期 token,只允许拉取 token 内声明的模块仓库和 tag;镜像清单 digest 和客户侧 Agent 的整镜像 digest 校验用于防止模块版本被替换。
镜像站推荐由 Nginx / Registry 代理做私有拉取鉴权:发布账号仍只用于内部推送镜像;客户侧拉付费模块时使用用户名 `flnxi-module`,密码使用验证系统签发的短期 `pullToken`。代理把 Registry 主机、仓库、请求类型、digest 和 token 转发给 `GET /api/registry/auth` 校验,通过后才放行拉取。Registry 原始后端不要直接对客户开放。
主应用不会直接挂 Docker Socket,也不会自己启动模块容器。当前 Go Agent 已提供 `POST /api/modules/sync` 和 `GET /api/modules/status` 入口,用来接收主应用准备好的短期拉取凭证,负责拉取模块镜像、校验 digest、启动模块容器、健康检查和失败回滚。模块运行状态变化后,Agent 可通过 `POST /api/license/premium-modules/runtime-status` 回写主应用;主应用会把回写状态持久化,并把运行态纳入付费能力判断。
当前模块源码入口是:
```text
premium-modules/paid-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. 后台管理入口
`license-server` 后台现在按项目维度工作:
@ -150,17 +191,19 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
旧的无项目后台页现在只做跳转或废弃提示,不再承载真实管理逻辑。
## 6. 接入前检查
## 7. 接入前检查
上线前至少确认这 5 项:
1. `license-server` 主库里已存在目标项目,且 `projects.code = LICENSE_PROJECT_CODE`
2. 该项目对应的项目库连接配置正确
3. 主应用 `.env` 已配置 `LICENSE_SERVER_URL`、`LICENSE_PROJECT_CODE`、`LICENSE_REQUEST_SIGNING_SECRET`、`LICENSE_PUBLIC_KEY` 和 `LOCAL_ENCRYPTION_KEY`
4. Node Agent 如启用版本检查,也已配置相同的远端通信两项
5. 不再调用任何旧的无项目公开接口
4. `license-server` 已配置 `MODULE_PULL_TOKEN_SECRET`、`PREMIUM_MODULE_REGISTRY_BASE`、模块版本和需要固定的模块 digest
5. Registry 前置代理已能把客户拉取请求转发到 `GET /api/registry/auth`
6. Node Agent 如启用版本检查,也已配置相同的远端通信两项
7. 不再调用任何旧的无项目公开接口
## 7. 推荐联调方式
## 8. 推荐联调方式
可以按这个顺序联调:

View File

@ -0,0 +1,93 @@
# 许可证链路安全加固记录
更新时间:2026-06-02
## 1. 本轮结论
本轮重点修复的是“本地数据库被直接写入后,伪造许可证变成专业版”的风险。
修复后,主应用不再只判断“许可证签名是否正确”,还会额外核对签名许可证里的绑定信息:
- 项目编码必须匹配当前 `LICENSE_PROJECT_CODE`
- 激活码必须匹配本地保存的 `licenseKey`
- 实例 ID 必须匹配当前系统持久化的 `data/.instance-id`
- 签名许可证必须包含 `fingerprintHash`
缺少这些绑定字段的旧许可证缓存会被视为无效,需要重新激活。
## 2. 已完成的代码加固
- `license-server` 激活、免费注册、在线验证、心跳续租返回的许可证,都会把 `projectCode`、`licenseKey`、`instanceId`、`fingerprintHash`、`activationId` 写入 RSA 签名数据。
- 主应用保存许可证前,会校验签名数据里的项目、激活码和实例 ID 是否一致。
- 主应用每次从 `system_configs.key = license` 读取本地许可证时,也会重新做上述绑定校验。
- 旧的无绑定签名许可证、本地激活码不匹配、实例 ID 不匹配,都会被拒绝。
- `license-server/.dockerignore` 已补充 `.env*`、私钥和密钥目录排除,避免单独构建许可证服务镜像时把本地密钥带入镜像上下文。
## 3. P1/P2 继续加固状态
本轮继续处理了渗透模拟里 P1/P2 中属于产品侧能落地的部分。NAS 管理员密码、Docker 管理权限和客户主机 root/admin 权限不作为应用代码可完全防守边界,但默认交付口子继续收紧:
- 后台高风险维护接口继续要求最高权限账号、专业版系统设置能力,并增加人工确认短语:
- `/api/admin/cleanup-database`:`清理全部素材`
- `/api/admin/reset-material-rankings`:`重置排行榜数据`
- `/api/admin/migrate-material-fields`:`执行字段迁移`
- `upload-logo`、清库、重置排行榜、字段迁移都已纳入许可证覆盖测试,避免以后误删服务端许可证检查。
- `/api/restore-data` 继续保持默认关闭,并追加维护窗口保护:恢复令牌至少 32 字符、令牌固定时间比较、默认不信任可伪造的 `x-forwarded-for`、必须配置来源 IP 白名单、必须带 `x-restore-data-confirm: RESTORE_DATA`。
- `docker-compose.prod.yml` 中 MinIO 管理控制台默认只绑定 `127.0.0.1`,素材 API 端口仍按 `S3_PUBLIC_DOMAIN` 需要对用户浏览器开放。
- `home-filter-fields` 已经使用白名单字段和安全查询路径,并由测试确认不再调用 `$queryRawUnsafe`。
## 4. 代码混淆和客户镜像边界
当前不是“完全没做混淆”。生产镜像构建会执行 `npm run build:prod`,在 Next.js 构建后对服务端 chunks 和许可证 API 产物做保守混淆;浏览器 source map 默认关闭。
本轮继续收紧了两个交付边界:
- 运行镜像不再额外复制 `/app/lib` 源码目录,避免“构建产物已混淆,但客户镜像里又带一份未混淆源码”。
- 混淆脚本如果找不到目标文件,或任何目标文件混淆失败,会直接阻断生产出包。
注意:代码混淆只能提高逆向成本,不能当成许可证安全边界。真正的安全边界仍然是验证服务器签名、实例绑定、本地缓存校验、服务端权限和许可证检查。
## 5. 仍然不能靠应用代码完全解决的边界
如果攻击者已经拥有客户 NAS 的管理员权限、Docker 管理权限或数据库管理权限,他就能修改容器、替换镜像、直接改数据库,应用自身无法完全防住这种本机最高权限攻击。
商业化防护的正确边界是:
- 客户侧本地缓存只作为离线短期租约和防误改机制
- 许可证最终可信来源是验证服务器
- RSA 私钥只能存在于验证服务器运行环境
- 客户侧只保留 RSA 公钥和本地缓存加密密钥
- 本地缓存必须绑定项目、激活码和机器实例
- 付费能力模块和免费版限额模块继续依赖验证服务器签发的实例绑定清单和授权凭证
## 6. 运维必须处理的事项
本轮代码已经阻断低成本本地伪造,但如果本地或历史环境里曾经暴露过真实密钥,仍必须轮换:
1. 重新生成许可证服务 RSA 密钥对。
2. 更新 `license-server` 运行环境里的 `RSA_PRIVATE_KEY`。
3. 更新主应用运行环境里的 `LICENSE_PUBLIC_KEY`。
4. 轮换 `LICENSE_REQUEST_SIGNING_SECRET`、`LOCAL_ENCRYPTION_KEY`、数据库密码、对象存储密钥、`NEXTAUTH_SECRET`。
5. 要求已激活环境重新激活,生成带绑定字段的新许可证缓存。
6. 客户 NAS 禁用弱密码 SSH,避免暴露公网 SSH,必要时改成密钥登录。
注意:不要把真实 `.env`、私钥、数据库连接串或服务器密码写入 Git、文档、工单或截图。
## 7. 验证记录
本轮已验证:
- `npm run type-check`
- `npx tsc --noEmit`,在 `license-server/` 目录执行
- `npm test -- --runInBand lib/__tests__/license-service.test.ts app/api/license/activate/__tests__/route.test.ts app/api/license/activate-free/__tests__/route.test.ts`
- `npm test -- --runInBand app/api/admin/__tests__/destructive-actions-superadmin.test.ts app/api/restore-data/__tests__/route.test.ts lib/__tests__/license-route-coverage.test.ts __tests__/production-build-hardening.test.ts __tests__/deployment-compose.test.ts`
验证重点:
- 无绑定字段的旧许可证会被拒绝
- 绑定到其他实例的许可证会被拒绝
- 激活接口收到绑定信息不匹配的许可证时不会保存到本地
- 免费激活接口收到绑定信息不匹配的许可证时不会保存到本地
- 高风险后台接口必须保留确认短语
- 恢复接口默认不信任可伪造代理 IP 头,且必须带确认头
- 生产镜像不能额外复制未混淆源码目录

View File

@ -78,8 +78,12 @@
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、启动模块容器并在健康检查失败时尝试恢复旧模块容器。
## 3. 链路概览
当前大致链路如下:
@ -409,7 +413,7 @@
- `middleware.ts` 已明确不再承担许可证快速检查,当前主要依赖各个 API 自己做许可证判断
- 已确认的部门隔离漏口已补齐:`POST / PATCH / DELETE` 现在都要求同级许可证
- 已确认的自定义模板漏口已补齐:模板编辑、删除、迁移现在都要求同级许可证
- `apiAccess` 和 `intranetPenetration` 已定义为许可证能力,但当前仓库里未见明确的生产执法点
- `apiAccess` 当前仍是预留能力,标准版和专业版默认不出售;若未来开启,已要求走 `paid-core` 的 `apiAccess:use` 门禁
- `/api/license/check` 已改为先做本地验签,再恢复激活状态
直接影响:

View File

@ -10,6 +10,7 @@
- Go 版 Agent 内部安装和更新素材系统已改为读取完整版本镜像,不再默认拉取旧 Harbor / `latest`。
- 安装素材系统时必须提供完整主应用版本镜像,不能使用无版本 tag 或 `latest`。
- 安装素材系统时必须提供许可证服务参数;缺少关键参数时会在拉镜像前失败,避免客户装出一个启动不了的半成品。
- 已提供付费模块最小运行入口:`POST /api/modules/sync` 会使用验证系统签发的短期拉包凭证拉取模块镜像、校验 digest、启动模块容器并在健康检查失败时尝试恢复旧模块容器。
- 版本列表需要通过 `FLNXI_VERSION_LIST_URL` 显式配置来源;未配置时不会伪造 `latest` 或默认版本。
- 当前仍建议先作为内部验证入口,等管理界面和客户安装流程联调完成后,再提升为客户正式安装主入口。
@ -17,6 +18,7 @@
- 安装素材系统运行环境
- 执行更新
- 拉取和运行已授权的付费模块容器
- 管理相关 Docker 容器
- 提供内置 Web 界面和 API
@ -63,6 +65,9 @@ docker run -d \
| `FLNXI_POSTGRES_IMAGE` | 安装时使用的 PostgreSQL 镜像;也兼容读取 `POSTGRES_IMAGE` | `postgres:15-alpine` |
| `FLNXI_MINIO_IMAGE` | 安装时使用的 MinIO 镜像;也兼容读取 `MINIO_IMAGE` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` |
| `FLNXI_VERSION_LIST_URL` | Agent 管理界面可选版本列表来源,返回 Registry tags 结构 | 未设置 |
| `FLNXI_MODULE_NETWORK` | 付费模块容器默认加入的 Docker 网络 | `flnxi-network` |
| `FLNXI_MODULE_STATUS_CALLBACK_URL` | 付费模块运行状态变化时回调主应用的接口地址;未配置时不回调 | 未设置 |
| `FLNXI_MODULE_STATUS_CALLBACK_TOKEN` | 付费模块状态回调使用的 Bearer token;需和回调地址同时配置,不会写入日志 | 未设置 |
| `LICENSE_SERVER_URL` | 主应用许可证服务地址,安装素材系统时会写入主应用容器 | 未设置 |
| `LICENSE_PROJECT_CODE` | 主应用许可证项目编码,安装素材系统时会写入主应用容器 | 未设置 |
| `LICENSE_REQUEST_SIGNING_SECRET` | 主应用请求许可证服务的签名密钥,至少 32 字符 | 未设置 |
@ -74,6 +79,51 @@ docker run -d \
- 当前 Go 版 Agent 的版本列表不再写死 Harbor;后续正式交付前,需要决定版本列表来自 Registry tags、许可证服务,还是发布管理接口。
- Go 版 Agent 目前会把主应用启动必需的许可证配置写入容器,但还没有把“许可证服务可安装版本、灰度版本、客户授权版本范围”做成最终客户安装向导。
## 付费模块运行接口
主应用负责从许可证服务同步模块授权清单、模块授权凭证和短期拉包 token。Go Agent 不判断客户是否付费,只接收已经由主应用/验证系统准备好的模块运行请求:
```text
POST /api/modules/sync
GET /api/modules/status
```
`POST /api/modules/sync` 至少需要:
```json
{
"moduleId": "paid-core",
"image": "registry.example.com/flnxi/premium/paid-core:1.0.0",
"digest": "sha256:...",
"pullCredential": {
"registryHost": "registry.example.com",
"repository": "flnxi/premium/paid-core",
"tag": "1.0.0",
"digest": "sha256:...",
"username": "flnxi-module",
"pullToken": "<15分钟短期token>",
"expiresAt": "2026-06-02T00:15:00Z"
}
}
```
Agent 会拒绝过期 token、镜像地址不匹配、digest 不匹配或缺少固定版本 tag 的请求。模块容器默认加入 `flnxi-network`,也可以用 `FLNXI_MODULE_NETWORK` 或请求里的 `network` 覆盖;默认容器名为 `flnxi-module-{moduleId}`。如健康检查失败,会尝试恢复旧模块容器。
模块状态变化时,Agent 会先更新本地 `GET /api/modules/status` 可见的状态。如果同时配置了 `FLNXI_MODULE_STATUS_CALLBACK_URL` 和 `FLNXI_MODULE_STATUS_CALLBACK_TOKEN`,Agent 会向主应用回调地址发送 `POST` 请求,并在请求头里带上 `Authorization: Bearer <token>`。通过 Agent 安装主应用时,如果没有显式配置回调地址,安装流程会为本次安装生成回写 token,并按主应用访问地址生成默认回调地址。请求体为:
```json
{
"moduleId": "paid-core",
"image": "registry.example.com/flnxi/premium/paid-core:1.0.0",
"digest": "sha256:...",
"status": "running",
"lastSyncAt": "2026-06-02T00:20:00Z",
"lastError": ""
}
```
未同时配置回调地址和 token 时,Agent 保持现有行为,只在本地保存模块状态,不主动通知主应用。回调失败不会改变模块本地运行状态,主应用仍可通过 `GET /api/modules/status` 主动查询。
安装素材系统前,至少要给 Agent 容器配置:
```bash

View File

@ -21,6 +21,12 @@ describe('flnxi-agent-go/install.sh', () => {
expect(script).toContain('未完整配置许可证服务参数')
})
it('会把付费模块运行网络和状态回写配置透传给 Agent 容器', () => {
expect(script).toContain('FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}')
expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}')
expect(script).toContain('FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}')
})
it('文案明确这是 Agent 安装脚本,不冒充客户最终首装入口', () => {
expect(script).toContain('FLNXI Agent 安装脚本')
expect(script).toContain('Agent 安装脚本')

View File

@ -233,6 +233,9 @@ run_agent() {
-e "LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:-}"
-e "LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY:-}"
-e "LOCAL_ENCRYPTION_KEY=${LOCAL_ENCRYPTION_KEY:-}"
-e "FLNXI_MODULE_NETWORK=${FLNXI_MODULE_NETWORK:-flnxi-network}"
-e "FLNXI_MODULE_STATUS_CALLBACK_URL=${FLNXI_MODULE_STATUS_CALLBACK_URL:-}"
-e "FLNXI_MODULE_STATUS_CALLBACK_TOKEN=${FLNXI_MODULE_STATUS_CALLBACK_TOKEN:-}"
)
if [ -z "${FLNXI_MATERIAL_IMAGE:-${APP_IMAGE:-}}" ]; then

View File

@ -1,6 +1,7 @@
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
@ -161,7 +162,12 @@ func resolveInstallLicenseConfig(req InstallRequest, secrets InstallSecrets) (In
return config, nil
}
func resolveModuleStatusToken(secrets InstallSecrets) string {
return firstNonEmpty(os.Getenv(envModuleStatusCallbackToken), secrets.ModuleStatusToken)
}
func materialAppEnv(req InstallRequest, secrets InstallSecrets, licenseConfig InstallLicenseConfig, localIP string, webPort int) []string {
moduleStatusToken := resolveModuleStatusToken(secrets)
return []string{
"LICENSE_KEY=" + req.LicenseKey,
"LICENSE_SERVER_URL=" + licenseConfig.ServerURL,
@ -185,6 +191,8 @@ 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),
"PAID_CORE_MODULE_URL=http://flnxi-module-paid-core:8787",
"MODULE_RUNTIME_STATUS_TOKEN=" + moduleStatusToken,
}
}
@ -256,12 +264,22 @@ const (
envLicenseRequestSigningSecret = "LICENSE_REQUEST_SIGNING_SECRET"
envLicensePublicKey = "LICENSE_PUBLIC_KEY"
envLocalEncryptionKey = "LOCAL_ENCRYPTION_KEY"
envModuleStatusCallbackURL = "FLNXI_MODULE_STATUS_CALLBACK_URL"
envModuleStatusCallbackToken = "FLNXI_MODULE_STATUS_CALLBACK_TOKEN"
)
var (
installStatus = &InstallStatus{Status: "idle"}
updateStatus = &UpdateStatus{Status: "idle"}
statusMutex sync.RWMutex
moduleStatuses = map[string]ModuleRuntimeStatus{}
moduleStatusesMutex sync.RWMutex
moduleSyncLocks = map[string]bool{}
moduleSyncLocksMutex sync.Mutex
moduleCallbackURL string
moduleCallbackToken string
moduleCallbackMutex sync.RWMutex
)
type InstallStatus struct {
@ -280,6 +298,36 @@ type UpdateStatus struct {
Error string `json:"error,omitempty"`
}
type ModulePullCredential struct {
RegistryHost string `json:"registryHost"`
Repository string `json:"repository"`
Tag string `json:"tag"`
Digest string `json:"digest,omitempty"`
Username string `json:"username"`
PullToken string `json:"pullToken"`
ExpiresAt string `json:"expiresAt"`
}
type ModuleSyncRequest struct {
ModuleID string `json:"moduleId"`
Image string `json:"image"`
Digest string `json:"digest,omitempty"`
ContainerName string `json:"containerName,omitempty"`
Network string `json:"network,omitempty"`
Env map[string]string `json:"env,omitempty"`
HealthTimeoutSeconds int `json:"healthTimeoutSeconds,omitempty"`
PullCredential ModulePullCredential `json:"pullCredential"`
}
type ModuleRuntimeStatus struct {
ModuleID string `json:"moduleId"`
Image string `json:"image"`
Digest string `json:"digest,omitempty"`
Status string `json:"status"`
LastSyncAt string `json:"lastSyncAt"`
LastError string `json:"lastError,omitempty"`
}
type InstallRequest struct {
LicenseKey string `json:"licenseKey"`
DataPath string `json:"dataPath"`
@ -510,6 +558,11 @@ func runInstall(dc installDockerClient, req InstallRequest) {
// 获取宿主机 IP 用于配置公开访问地址(从用户请求中获取,确保是宿主机 IP 而非容器 IP)
localIP := getHostIP()
moduleStatusCallbackURL := firstNonEmpty(
os.Getenv(envModuleStatusCallbackURL),
"http://"+localIP+":"+fmt.Sprintf("%d", webPort)+"/api/license/premium-modules/runtime-status",
)
setModuleStatusCallbackConfig(moduleStatusCallbackURL, resolveModuleStatusToken(secrets))
// 启动素材系统
setStep("启动服务", 90, "启动素材系统...")
@ -562,6 +615,401 @@ func GetUpdateStatus(dc *docker.Client) gin.HandlerFunc {
}
}
func GetModuleStatuses() gin.HandlerFunc {
return func(c *gin.Context) {
moduleStatusesMutex.RLock()
defer moduleStatusesMutex.RUnlock()
result := make([]ModuleRuntimeStatus, 0, len(moduleStatuses))
for _, status := range moduleStatuses {
result = append(result, status)
}
c.JSON(http.StatusOK, result)
}
}
func SyncModule(dc *docker.Client) gin.HandlerFunc {
return func(c *gin.Context) {
var req ModuleSyncRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateModuleSyncRequest(req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
syncKey := moduleContainerName(req)
if !tryBeginModuleSync(syncKey) {
c.JSON(http.StatusConflict, gin.H{"error": "该模块正在同步中,请稍后再试", "moduleId": req.ModuleID})
return
}
go func() {
defer finishModuleSync(syncKey)
runModuleSync(dc, req)
}()
c.JSON(http.StatusOK, gin.H{"message": "模块同步已开始", "moduleId": req.ModuleID})
}
}
type moduleDockerClient interface {
GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error)
PullImage(ctx context.Context, image string, authStr string) error
GetImageRepoDigests(ctx context.Context, image string) ([]string, error)
StopContainer(ctx context.Context, containerID string) error
RemoveContainer(ctx context.Context, containerID string) error
StartContainer(ctx context.Context, containerID string) error
CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error)
WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error
}
func setModuleStatus(status ModuleRuntimeStatus) {
moduleStatusesMutex.Lock()
moduleStatuses[status.ModuleID] = status
moduleStatusesMutex.Unlock()
go reportModuleStatus(status)
}
func setModuleStatusCallbackConfig(callbackURL string, callbackToken string) {
moduleCallbackMutex.Lock()
moduleCallbackURL = strings.TrimSpace(callbackURL)
moduleCallbackToken = strings.TrimSpace(callbackToken)
moduleCallbackMutex.Unlock()
}
func getModuleStatusCallbackConfig() (string, string) {
callbackURL := strings.TrimSpace(os.Getenv(envModuleStatusCallbackURL))
callbackToken := strings.TrimSpace(os.Getenv(envModuleStatusCallbackToken))
if callbackURL != "" && callbackToken != "" {
return callbackURL, callbackToken
}
moduleCallbackMutex.RLock()
defer moduleCallbackMutex.RUnlock()
return moduleCallbackURL, moduleCallbackToken
}
func reportModuleStatus(status ModuleRuntimeStatus) {
callbackURL, callbackToken := getModuleStatusCallbackConfig()
if callbackURL == "" || callbackToken == "" {
return
}
body, err := json.Marshal(status)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+callbackToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
}
func tryBeginModuleSync(syncKey string) bool {
moduleSyncLocksMutex.Lock()
defer moduleSyncLocksMutex.Unlock()
if moduleSyncLocks[syncKey] {
return false
}
moduleSyncLocks[syncKey] = true
return true
}
func finishModuleSync(syncKey string) {
moduleSyncLocksMutex.Lock()
delete(moduleSyncLocks, syncKey)
moduleSyncLocksMutex.Unlock()
}
func moduleStatus(req ModuleSyncRequest, status string, err error) ModuleRuntimeStatus {
lastError := ""
if err != nil {
lastError = err.Error()
}
return ModuleRuntimeStatus{
ModuleID: req.ModuleID,
Image: req.Image,
Digest: firstNonEmpty(req.Digest, req.PullCredential.Digest),
Status: status,
LastSyncAt: time.Now().UTC().Format(time.RFC3339),
LastError: lastError,
}
}
func moduleContainerName(req ModuleSyncRequest) string {
if name := strings.TrimSpace(req.ContainerName); name != "" {
return name
}
builder := strings.Builder{}
for _, r := range strings.ToLower(req.ModuleID) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
builder.WriteRune(r)
}
}
suffix := builder.String()
if suffix == "" {
suffix = "unknown"
}
return "flnxi-module-" + suffix
}
func moduleEnv(req ModuleSyncRequest) []string {
env := []string{"FLNXI_MODULE_ID=" + req.ModuleID}
for key, value := range req.Env {
key = strings.TrimSpace(key)
if key == "" || strings.Contains(key, "=") {
continue
}
env = append(env, key+"="+value)
}
return env
}
func moduleRegistryAuth(credential ModulePullCredential) string {
payload := map[string]string{
"username": credential.Username,
"password": credential.PullToken,
"serveraddress": credential.RegistryHost,
}
b, _ := json.Marshal(payload)
return base64.URLEncoding.EncodeToString(b)
}
func parseRegistryImage(image string) (registryHost string, repository string, tag string, err error) {
image = strings.TrimSpace(image)
imageName := image
if at := strings.Index(imageName, "@"); at >= 0 {
imageName = imageName[:at]
}
parts := strings.Split(imageName, "/")
if len(parts) < 2 || strings.TrimSpace(parts[0]) == "" {
err = fmt.Errorf("模块镜像地址无效")
return
}
registryHost = parts[0]
imagePath := strings.Join(parts[1:], "/")
lastSlash := strings.LastIndex(imagePath, "/")
lastColon := strings.LastIndex(imagePath, ":")
if lastColon <= lastSlash || lastColon == len(imagePath)-1 {
err = fmt.Errorf("模块镜像缺少固定版本 tag")
return
}
repository = imagePath[:lastColon]
tag = imagePath[lastColon+1:]
return
}
func validateModuleSyncRequest(req ModuleSyncRequest) error {
if strings.TrimSpace(req.ModuleID) == "" {
return fmt.Errorf("moduleId is required")
}
if err := validateFixedImageTag(req.Image); err != nil {
return fmt.Errorf("module image: %w", err)
}
registryHost, repository, tag, err := parseRegistryImage(req.Image)
if err != nil {
return err
}
credential := req.PullCredential
if credential.Username != "flnxi-module" {
return fmt.Errorf("模块拉取用户名无效")
}
if strings.TrimSpace(credential.PullToken) == "" {
return fmt.Errorf("模块拉取 token 不能为空")
}
if credential.RegistryHost != registryHost || credential.Repository != repository || credential.Tag != tag {
return fmt.Errorf("模块拉取凭证和镜像地址不匹配")
}
expectedDigest := firstNonEmpty(req.Digest, credential.Digest)
if expectedDigest == "" {
return fmt.Errorf("模块 digest 不能为空")
}
if !isValidSha256Digest(expectedDigest) {
return fmt.Errorf("模块 digest 必须是 sha256 格式")
}
if req.Digest != "" && !isValidSha256Digest(req.Digest) {
return fmt.Errorf("模块 digest 必须是 sha256 格式")
}
if credential.Digest != "" && !isValidSha256Digest(credential.Digest) {
return fmt.Errorf("模块拉取凭证 digest 必须是 sha256 格式")
}
if req.Digest != "" && credential.Digest != "" && req.Digest != credential.Digest {
return fmt.Errorf("模块 digest 和拉取凭证不匹配")
}
if credential.ExpiresAt == "" {
return fmt.Errorf("模块拉取凭证缺少过期时间")
}
expiresAt, err := time.Parse(time.RFC3339, credential.ExpiresAt)
if err != nil {
return fmt.Errorf("模块拉取凭证过期时间无效")
}
if !expiresAt.After(time.Now()) {
return fmt.Errorf("模块拉取凭证已过期")
}
return nil
}
func isValidSha256Digest(digest string) bool {
if len(digest) != len("sha256:")+64 || !strings.HasPrefix(digest, "sha256:") {
return false
}
for _, ch := range digest[len("sha256:"):] {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
continue
}
return false
}
return true
}
func repoDigestsContain(repoDigests []string, digest string) bool {
if digest == "" {
return false
}
for _, value := range repoDigests {
if value == digest || strings.HasSuffix(value, "@"+digest) {
return true
}
}
return false
}
func moduleContainerConfig(req ModuleSyncRequest) (*container.Config, *container.HostConfig) {
network := firstNonEmpty(req.Network, os.Getenv("FLNXI_MODULE_NETWORK"), "flnxi-network")
return &container.Config{
Image: req.Image,
Env: moduleEnv(req),
}, &container.HostConfig{
NetworkMode: container.NetworkMode(network),
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
ReadonlyRootfs: true,
CapDrop: []string{"ALL"},
SecurityOpt: []string{"no-new-privileges:true"},
Tmpfs: map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"},
Resources: container.Resources{
Memory: 256 * 1024 * 1024,
NanoCPUs: 500000000,
},
}
}
func restoreModuleContainer(ctx context.Context, dc moduleDockerClient, old *docker.ContainerInfo, name string) error {
if old == nil {
return fmt.Errorf("旧模块容器不存在")
}
current, err := dc.GetContainer(ctx, name)
if err != nil {
return err
}
if current != nil && current.ID == old.ID {
if current.State != "running" {
return dc.StartContainer(ctx, current.ID)
}
return nil
}
if current != nil {
if current.State == "running" {
_ = dc.StopContainer(ctx, current.ID)
}
if err := dc.RemoveContainer(ctx, current.ID); err != nil {
return err
}
}
image := oldMaterialImage(old)
if image == "" {
return fmt.Errorf("旧模块镜像信息不存在")
}
cfg, hcfg, err := materialContainerConfig(old, image)
if err != nil {
return err
}
_, err = dc.CreateAndStartContainer(ctx, cfg, hcfg, name)
return err
}
func runModuleSync(dc moduleDockerClient, req ModuleSyncRequest) {
ctx := context.Background()
name := moduleContainerName(req)
setModuleStatus(moduleStatus(req, "pulling", nil))
old, err := dc.GetContainer(ctx, name)
if err != nil {
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
authStr := moduleRegistryAuth(req.PullCredential)
if err := dc.PullImage(ctx, req.Image, authStr); err != nil {
setModuleStatus(moduleStatus(req, "missing", err))
return
}
expectedDigest := firstNonEmpty(req.Digest, req.PullCredential.Digest)
repoDigests, err := dc.GetImageRepoDigests(ctx, req.Image)
if err != nil {
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
if !repoDigestsContain(repoDigests, expectedDigest) {
err := fmt.Errorf("模块镜像 digest 不匹配")
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
if old != nil {
if old.State == "running" {
_ = dc.StopContainer(ctx, old.ID)
}
if err := dc.RemoveContainer(ctx, old.ID); err != nil {
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
}
cfg, hcfg := moduleContainerConfig(req)
newID, err := dc.CreateAndStartContainer(ctx, cfg, hcfg, name)
if err != nil {
if old != nil {
_ = restoreModuleContainer(ctx, dc, old, name)
}
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
timeout := time.Duration(req.HealthTimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
if err := dc.WaitForHealthy(ctx, newID, timeout); err != nil {
if old != nil {
_ = restoreModuleContainer(ctx, dc, old, name)
}
setModuleStatus(moduleStatus(req, "unhealthy", err))
return
}
setModuleStatus(moduleStatus(req, "running", nil))
}
type updateDockerClient interface {
GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error)
PullImage(ctx context.Context, image string, authStr string) error

View File

@ -2,7 +2,10 @@ package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@ -115,6 +118,7 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
S3Bucket: "materials",
NextAuthSecret: "next-secret",
LocalEncryptionKey: strings.Repeat("b", 32),
ModuleStatusToken: strings.Repeat("c", 32),
}
licenseConfig := InstallLicenseConfig{
ServerURL: "https://license.example.test",
@ -135,6 +139,8 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
"S3_PUBLIC_DOMAIN",
"NEXTAUTH_URL",
"APP_PUBLIC_URL",
"PAID_CORE_MODULE_URL",
"MODULE_RUNTIME_STATUS_TOKEN",
} {
if env[want] == "" {
t.Fatalf("materialAppEnv() missing %s in %v", want, env)
@ -143,6 +149,9 @@ func TestMaterialAppEnvIncludesLicenseRuntimeConfig(t *testing.T) {
if env["APP_PUBLIC_URL"] != "http://192.168.1.20:4320" {
t.Fatalf("APP_PUBLIC_URL = %q, want host URL", env["APP_PUBLIC_URL"])
}
if env["MODULE_RUNTIME_STATUS_TOKEN"] != strings.Repeat("c", 32) {
t.Fatalf("MODULE_RUNTIME_STATUS_TOKEN not propagated to app")
}
}
func TestRunInstallStopsBeforePullWhenLicenseConfigMissing(t *testing.T) {
@ -225,6 +234,273 @@ func TestRunUpdateRestoresOldContainerAfterHealthCheckFailure(t *testing.T) {
}
}
func TestValidateModuleSyncRequestRejectsExpiredPullToken(t *testing.T) {
req := ModuleSyncRequest{
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/paid-core",
Tag: "1.0.0",
Digest: testModuleDigest,
Username: "flnxi-module",
PullToken: "token",
ExpiresAt: time.Now().Add(-time.Minute).Format(time.RFC3339),
},
}
err := validateModuleSyncRequest(req)
if err == nil || !strings.Contains(err.Error(), "已过期") {
t.Fatalf("validateModuleSyncRequest() error = %v, want expired token rejection", err)
}
}
func TestValidateModuleSyncRequestRequiresDigest(t *testing.T) {
req := validModuleSyncRequest()
req.Digest = ""
req.PullCredential.Digest = ""
err := validateModuleSyncRequest(req)
if err == nil || !strings.Contains(err.Error(), "digest 不能为空") {
t.Fatalf("validateModuleSyncRequest() error = %v, want missing digest rejection", err)
}
}
func TestValidateModuleSyncRequestRejectsInvalidDigest(t *testing.T) {
req := validModuleSyncRequest()
req.Digest = "sha256:abc"
req.PullCredential.Digest = "sha256:abc"
err := validateModuleSyncRequest(req)
if err == nil || !strings.Contains(err.Error(), "sha256 格式") {
t.Fatalf("validateModuleSyncRequest() error = %v, want invalid digest rejection", err)
}
}
func TestModuleSyncLockRejectsConcurrentSameModule(t *testing.T) {
resetModuleStatusesForTest()
key := moduleContainerName(validModuleSyncRequest())
if !tryBeginModuleSync(key) {
t.Fatal("first module sync lock acquisition should succeed")
}
if tryBeginModuleSync(key) {
t.Fatal("second module sync lock acquisition should fail")
}
finishModuleSync(key)
if !tryBeginModuleSync(key) {
t.Fatal("module sync lock should be released")
}
finishModuleSync(key)
}
func TestRunModuleSyncStartsContainerAfterDigestMatch(t *testing.T) {
resetModuleStatusesForTest()
req := validModuleSyncRequest()
fake := &fakeModuleDocker{
repoDigests: []string{"registry.local:5000/flnxi/premium/paid-core@" + testModuleDigest},
}
runModuleSync(fake, req)
moduleStatusesMutex.RLock()
status := moduleStatuses[req.ModuleID]
moduleStatusesMutex.RUnlock()
if status.Status != "running" {
t.Fatalf("module status = %q, want running: %+v", status.Status, status)
}
if len(fake.pulledImages) != 1 || fake.pulledImages[0] != req.Image {
t.Fatalf("pulled images = %v, want %q", fake.pulledImages, req.Image)
}
if fake.lastAuth == "" {
t.Fatal("expected registry auth for module pull")
}
if len(fake.createdImages) != 1 || fake.createdImages[0] != req.Image {
t.Fatalf("created images = %v, want module image", fake.createdImages)
}
if fake.lastHostConfig == nil || !fake.lastHostConfig.ReadonlyRootfs {
t.Fatal("module container should use a read-only root filesystem by default")
}
if !contains(fake.lastHostConfig.CapDrop, "ALL") {
t.Fatalf("module container CapDrop = %v, want ALL", fake.lastHostConfig.CapDrop)
}
if !contains(fake.lastHostConfig.SecurityOpt, "no-new-privileges:true") {
t.Fatalf("module container SecurityOpt = %v, want no-new-privileges", fake.lastHostConfig.SecurityOpt)
}
}
func TestRunModuleSyncRestoresOldContainerAfterHealthFailure(t *testing.T) {
resetModuleStatusesForTest()
req := validModuleSyncRequest()
old := &docker.ContainerInfo{
ID: "old-module",
ImageID: "sha256:old-module",
State: "running",
Config: &container.Config{
Image: "registry.local:5000/flnxi/premium/paid-core:0.9.0",
Env: []string{"OLD=1"},
},
HostConfig: &container.HostConfig{
NetworkMode: "flnxi-network",
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
},
}
fake := &fakeModuleDocker{
container: old,
repoDigests: []string{"registry.local:5000/flnxi/premium/paid-core@" + testModuleDigest},
healthErr: errors.New("not healthy"),
}
runModuleSync(fake, req)
moduleStatusesMutex.RLock()
status := moduleStatuses[req.ModuleID]
moduleStatusesMutex.RUnlock()
if status.Status != "unhealthy" {
t.Fatalf("module status = %q, want unhealthy", status.Status)
}
if fake.container == nil || fake.container.Config.Image != old.Config.Image {
t.Fatalf("restored container = %+v, want old image %q", fake.container, old.Config.Image)
}
if !contains(fake.removedIDs, "old-module") || !contains(fake.removedIDs, "new-module") {
t.Fatalf("removed IDs = %v, want old and failed new module", fake.removedIDs)
}
}
func TestSetModuleStatusSkipsCallbackWhenConfigMissing(t *testing.T) {
resetModuleStatusesForTest()
setModuleStatusCallbackConfig("", "")
t.Setenv(envModuleStatusCallbackURL, "")
t.Setenv(envModuleStatusCallbackToken, "callback-token")
called := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil))
select {
case <-called:
t.Fatal("module status callback should not be sent without callback URL")
case <-time.After(100 * time.Millisecond):
}
}
func TestSetModuleStatusReportsCallbackWhenConfigured(t *testing.T) {
resetModuleStatusesForTest()
setModuleStatusCallbackConfig("", "")
received := make(chan struct {
auth string
status ModuleRuntimeStatus
}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var status ModuleRuntimeStatus
if err := json.NewDecoder(r.Body).Decode(&status); err != nil {
t.Errorf("decode callback body: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
received <- struct {
auth string
status ModuleRuntimeStatus
}{
auth: r.Header.Get("Authorization"),
status: status,
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
t.Setenv(envModuleStatusCallbackURL, server.URL)
t.Setenv(envModuleStatusCallbackToken, "callback-token")
req := validModuleSyncRequest()
setModuleStatus(moduleStatus(req, "unhealthy", errors.New("not healthy")))
select {
case got := <-received:
if got.auth != "Bearer callback-token" {
t.Fatalf("Authorization = %q, want bearer callback token", got.auth)
}
if got.status.ModuleID != req.ModuleID {
t.Fatalf("moduleId = %q, want %q", got.status.ModuleID, req.ModuleID)
}
if got.status.Image != req.Image {
t.Fatalf("image = %q, want %q", got.status.Image, req.Image)
}
if got.status.Digest != req.Digest {
t.Fatalf("digest = %q, want %q", got.status.Digest, req.Digest)
}
if got.status.Status != "unhealthy" {
t.Fatalf("status = %q, want unhealthy", got.status.Status)
}
if got.status.LastSyncAt == "" {
t.Fatal("lastSyncAt should be set")
}
if !strings.Contains(got.status.LastError, "not healthy") {
t.Fatalf("lastError = %q, want health error", got.status.LastError)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for module status callback")
}
}
func TestSetModuleStatusReportsInstallFallbackCallback(t *testing.T) {
resetModuleStatusesForTest()
received := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received <- r.Header.Get("Authorization")
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
t.Setenv(envModuleStatusCallbackURL, "")
t.Setenv(envModuleStatusCallbackToken, "")
setModuleStatusCallbackConfig(server.URL, "fallback-token")
setModuleStatus(moduleStatus(validModuleSyncRequest(), "running", nil))
select {
case got := <-received:
if got != "Bearer fallback-token" {
t.Fatalf("Authorization = %q, want fallback bearer token", got)
}
case <-time.After(time.Second):
t.Fatal("module status callback not received")
}
}
const testModuleDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func validModuleSyncRequest() ModuleSyncRequest {
return ModuleSyncRequest{
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/paid-core",
Tag: "1.0.0",
Digest: testModuleDigest,
Username: "flnxi-module",
PullToken: "token",
ExpiresAt: time.Now().Add(time.Hour).Format(time.RFC3339),
},
}
}
func resetUpdateStatusForTest() {
statusMutex.Lock()
updateStatus = &UpdateStatus{Status: "running", Step: "初始化", Progress: 0}
@ -237,6 +513,18 @@ func resetInstallStatusForTest() {
statusMutex.Unlock()
}
func resetModuleStatusesForTest() {
moduleStatusesMutex.Lock()
moduleStatuses = map[string]ModuleRuntimeStatus{}
moduleStatusesMutex.Unlock()
moduleSyncLocksMutex.Lock()
moduleSyncLocks = map[string]bool{}
moduleSyncLocksMutex.Unlock()
setModuleStatusCallbackConfig("", "")
}
func envSliceToMap(values []string) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
@ -285,6 +573,78 @@ type fakeUpdateDocker struct {
startedIDs []string
}
type fakeModuleDocker struct {
container *docker.ContainerInfo
healthErr error
repoDigests []string
pulledImages []string
createdImages []string
removedIDs []string
stoppedIDs []string
startedIDs []string
lastAuth string
lastHostConfig *container.HostConfig
}
func (f *fakeModuleDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) {
return f.container, nil
}
func (f *fakeModuleDocker) PullImage(ctx context.Context, image string, authStr string) error {
f.pulledImages = append(f.pulledImages, image)
f.lastAuth = authStr
return nil
}
func (f *fakeModuleDocker) GetImageRepoDigests(ctx context.Context, image string) ([]string, error) {
return f.repoDigests, nil
}
func (f *fakeModuleDocker) StopContainer(ctx context.Context, containerID string) error {
f.stoppedIDs = append(f.stoppedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container.State = "exited"
}
return nil
}
func (f *fakeModuleDocker) RemoveContainer(ctx context.Context, containerID string) error {
f.removedIDs = append(f.removedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container = nil
}
return nil
}
func (f *fakeModuleDocker) StartContainer(ctx context.Context, containerID string) error {
f.startedIDs = append(f.startedIDs, containerID)
if f.container != nil && f.container.ID == containerID {
f.container.State = "running"
}
return nil
}
func (f *fakeModuleDocker) CreateAndStartContainer(ctx context.Context, config *container.Config, hostConfig *container.HostConfig, name string) (string, error) {
f.createdImages = append(f.createdImages, config.Image)
f.lastHostConfig = hostConfig
id := "new-module"
if strings.Contains(config.Image, ":0.9.0") {
id = "restored-module"
}
f.container = &docker.ContainerInfo{
ID: id,
ImageID: "sha256:" + id,
State: "running",
Config: config,
HostConfig: hostConfig,
}
return id, nil
}
func (f *fakeModuleDocker) WaitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error {
return f.healthErr
}
func (f *fakeUpdateDocker) GetContainer(ctx context.Context, name string) (*docker.ContainerInfo, error) {
return f.container, nil
}
@ -357,3 +717,4 @@ func contains(values []string, want string) bool {
var _ updateDockerClient = (*fakeUpdateDocker)(nil)
var _ installDockerClient = (*fakeInstallDocker)(nil)
var _ moduleDockerClient = (*fakeModuleDocker)(nil)

View File

@ -15,6 +15,7 @@ type InstallSecrets struct {
S3Bucket string
NextAuthSecret string
LocalEncryptionKey string
ModuleStatusToken string
}
func generateInstallSecrets() (InstallSecrets, error) {
@ -38,6 +39,11 @@ func generateInstallSecrets() (InstallSecrets, error) {
return InstallSecrets{}, err
}
moduleStatusToken, err := randomHex(32)
if err != nil {
return InstallSecrets{}, err
}
return InstallSecrets{
DbUser: "flnxi",
DbPassword: dbPassword,
@ -47,6 +53,7 @@ func generateInstallSecrets() (InstallSecrets, error) {
S3Bucket: "flnxi",
NextAuthSecret: nextAuthSecret,
LocalEncryptionKey: localEncryptionKey,
ModuleStatusToken: moduleStatusToken,
}, nil
}

View File

@ -35,6 +35,15 @@ func (c *Client) PullImage(ctx context.Context, image string, authStr string) er
return c.PullImageWithProgress(ctx, image, authStr, nil)
}
// GetImageRepoDigests 读取本地镜像的 RepoDigest,用于校验拉取结果是否匹配许可证服务器声明的 digest。
func (c *Client) GetImageRepoDigests(ctx context.Context, image string) ([]string, error) {
inspect, _, err := c.cli.ImageInspectWithRaw(ctx, image)
if err != nil {
return nil, err
}
return inspect.RepoDigests, nil
}
// PullImageWithProgress 拉取镜像并报告进度
func (c *Client) PullImageWithProgress(ctx context.Context, image string, authStr string, onProgress func(current, total int64, status string)) error {
opts := types.ImagePullOptions{}

View File

@ -56,6 +56,10 @@ func main() {
apiGroup.GET("/update/status", api.GetUpdateStatus(dockerClient))
apiGroup.GET("/versions", api.GetVersions(dockerClient))
// 付费模块运行
apiGroup.POST("/modules/sync", api.SyncModule(dockerClient))
apiGroup.GET("/modules/status", api.GetModuleStatuses())
// 容器管理
apiGroup.GET("/containers", api.ListContainers(dockerClient))
apiGroup.POST("/containers/:name/restart", api.RestartContainer(dockerClient))

View File

@ -22,6 +22,7 @@ jest.mock('bcryptjs', () => ({
}))
import { authOptions } from '../auth'
import { resetAuthRateLimitForTests } from '../auth-rate-limit'
import { consumeDingtalkLoginToken } from '../dingtalk-login-token'
import { isInitialAdminPasswordChangeRequired } from '../system-init'
import { prisma } from '../prisma'
@ -41,6 +42,11 @@ function getDingtalkProvider() {
describe('auth dingtalk-token provider', () => {
beforeEach(() => {
jest.clearAllMocks()
resetAuthRateLimitForTests()
delete process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS
delete process.env.AUTH_LOGIN_IP_WINDOW_MS
delete process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES
delete process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
mockIsInitialAdminPasswordChangeRequired.mockResolvedValue(false)
})
@ -94,6 +100,84 @@ describe('auth dingtalk-token provider', () => {
.toThrow('账号未激活或已被禁用')
})
it('账号密码登录失败时不暴露账号是否存在', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(
getDingtalkProvider().options.authorize({
email: 'missing@example.com',
password: 'plain-password',
}, {
headers: new Headers({ 'x-forwarded-for': '127.0.0.1' }),
})
)
.rejects
.toThrow('邮箱或密码错误')
})
it('同一账号连续失败后会临时锁定', async () => {
process.env.AUTH_LOGIN_ACCOUNT_MAX_FAILURES = '2'
process.env.AUTH_LOGIN_ACCOUNT_LOCK_MS = '60000'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'user-locked',
email: 'locked@example.com',
username: '锁定用户',
password: 'hashed-password',
role: 'USER',
isSuperAdmin: false,
avatar: null,
isActive: true,
})
;(bcrypt.compare as jest.Mock).mockResolvedValue(false)
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'locked@example.com',
password: 'right-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.2' }) }))
.rejects
.toThrow('该账号登录失败次数过多')
})
it('同一 IP 短时间登录尝试过多会被限制', async () => {
process.env.AUTH_LOGIN_IP_MAX_ATTEMPTS = '2'
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
await expect(getDingtalkProvider().options.authorize({
email: 'a@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'b@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('邮箱或密码错误')
await expect(getDingtalkProvider().options.authorize({
email: 'c@example.com',
password: 'wrong-password',
}, { headers: new Headers({ 'x-forwarded-for': '127.0.0.3' }) }))
.rejects
.toThrow('登录尝试过于频繁')
})
it('拒绝已禁用用户使用账号密码登录,即使该用户是普通管理员', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 'admin-disabled',

View File

@ -1,61 +1,69 @@
/**
* 许可证限制检查工具测试
* 测试 lib/license/check-limits.ts 中的限制检查函数
* 许可证限制检查工具测试。
*
* 当前商业化口径:
* - 免费版人数/素材数限制由基础镜像读取本地签名许可证执行。
* - 付费能力统一要求 paid-core 授权模块运行并放行。
*/
import {
canCreateUser,
canUploadMaterial,
canUseApiAccess,
canUseCustomTemplates,
canUseDepartmentIsolation,
canUseIntranetPenetration,
canUseSystemSettings,
canUseApiAccess,
LimitCheckResult,
} from '../license/check-limits';
// Mock license-service 模块
jest.mock('../license/license-service', () => ({
checkLimit: jest.fn(),
isFeatureEnabled: jest.fn(),
getLicenseType: jest.fn(),
isFeatureEnabled: jest.fn(),
}));
jest.mock('../license/premium-module-service', () => ({
hasPremiumModule: jest.fn(),
authorizePremiumModuleAction: jest.fn(),
PREMIUM_MODULE_IDS: {
CUSTOM_TEMPLATES_CORE: 'custom-templates-core',
INTRANET_PENETRATION_CORE: 'intranet-penetration-core',
PAID_CORE: 'paid-core',
},
}));
import { checkLimit, isFeatureEnabled, getLicenseType } from '../license/license-service';
import { hasPremiumModule } from '../license/premium-module-service';
import { checkLimit, getLicenseType, isFeatureEnabled } from '../license/license-service';
import { authorizePremiumModuleAction } from '../license/premium-module-service';
const mockCheckLimit = checkLimit as jest.MockedFunction<typeof checkLimit>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
const mockGetLicenseType = getLicenseType as jest.MockedFunction<typeof getLicenseType>;
const mockHasPremiumModule = hasPremiumModule as jest.MockedFunction<typeof hasPremiumModule>;
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>;
const mockAuthorizePremiumModuleAction =
authorizePremiumModuleAction as jest.MockedFunction<typeof authorizePremiumModuleAction>;
describe('canCreateUser', () => {
describe('基础镜像限额检查', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('当未达到限制时应该返回 allowed: true', async () => {
mockCheckLimit.mockResolvedValue({ allowed: true, current: 5, max: 10 });
it('未达到用户限制时允许创建用户', async () => {
mockCheckLimit.mockResolvedValue({
allowed: true,
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();
await expect(canCreateUser()).resolves.toEqual({
allowed: true,
current: 5,
max: 10,
});
expect(mockCheckLimit).toHaveBeenCalledWith('users');
});
it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => {
mockCheckLimit.mockResolvedValue({ allowed: false, current: 10, max: 10 });
it('FREE 达到用户限制时返回授权处理提示', async () => {
mockCheckLimit.mockResolvedValue({
allowed: false,
current: 10,
max: 10,
});
mockGetLicenseType.mockResolvedValue('FREE');
const result = await canCreateUser();
@ -66,36 +74,27 @@ describe('canCreateUser', () => {
expect(result.message).toContain('联系管理员');
});
it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => {
mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 });
mockGetLicenseType.mockResolvedValue('STANDARD');
it('未达到素材限制时允许上传素材', async () => {
mockCheckLimit.mockResolvedValue({
allowed: true,
current: 50,
max: 100,
});
const result = await canCreateUser();
expect(result.allowed).toBe(false);
expect(result.message).toContain('联系管理员');
expect(result.message).toContain('100');
});
});
describe('canUploadMaterial', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('当未达到限制时应该返回 allowed: true', async () => {
mockCheckLimit.mockResolvedValue({ allowed: true, current: 50, max: 100 });
const result = await canUploadMaterial();
expect(result.allowed).toBe(true);
expect(result.current).toBe(50);
expect(result.max).toBe(100);
await expect(canUploadMaterial()).resolves.toEqual({
allowed: true,
current: 50,
max: 100,
});
expect(mockCheckLimit).toHaveBeenCalledWith('materials');
});
it('当达到限制时(FREE 版本)应该返回授权处理提示', async () => {
mockCheckLimit.mockResolvedValue({ allowed: false, current: 100, max: 100 });
it('FREE 达到素材限制时返回授权处理提示', async () => {
mockCheckLimit.mockResolvedValue({
allowed: false,
current: 100,
max: 100,
});
mockGetLicenseType.mockResolvedValue('FREE');
const result = await canUploadMaterial();
@ -106,113 +105,112 @@ describe('canUploadMaterial', () => {
expect(result.message).toContain('联系管理员');
});
it('当达到限制时(非 FREE 版本)应该返回联系管理员提示', async () => {
mockCheckLimit.mockResolvedValue({ allowed: false, current: 1000, max: 1000 });
mockGetLicenseType.mockResolvedValue('PRO');
it('本地许可证限制已有错误信息时直接返回', async () => {
mockCheckLimit.mockResolvedValue({
allowed: false,
current: 0,
max: 0,
message: '许可证不可用',
} as any);
const result = await canUploadMaterial();
expect(result.allowed).toBe(false);
expect(result.message).toContain('联系管理员');
await expect(canCreateUser()).resolves.toEqual({
allowed: false,
current: 0,
max: 0,
message: '许可证不可用',
});
expect(mockGetLicenseType).not.toHaveBeenCalled();
});
});
describe('功能开关检查', () => {
describe('paid-core 付费功能门禁', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('canUseCustomTemplates', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(true);
expect(await canUseCustomTemplates()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
expect(mockHasPremiumModule).toHaveBeenCalledWith('custom-templates-core');
it('自定义模板必须同时满足许可证功能开关和 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'customTemplates:use',
status: 'running',
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseCustomTemplates()).toBe(false);
expect(mockHasPremiumModule).not.toHaveBeenCalled();
});
it('当缺少付费模块凭证时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(false);
expect(await canUseCustomTemplates()).toBe(false);
});
await expect(canUseCustomTemplates()).resolves.toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('customTemplates');
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'customTemplates:use');
});
describe('canUseDepartmentIsolation', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseDepartmentIsolation()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('departmentIsolation');
});
it('功能开关关闭时不访问 paid-core', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseDepartmentIsolation()).toBe(false);
});
await expect(canUseCustomTemplates()).resolves.toBe(false);
expect(mockAuthorizePremiumModuleAction).not.toHaveBeenCalled();
});
describe('canUseIntranetPenetration', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(true);
expect(await canUseIntranetPenetration()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('intranetPenetration');
expect(mockHasPremiumModule).toHaveBeenCalledWith('intranet-penetration-core');
it('部门隔离必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'departmentIsolation:use',
status: 'running',
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseIntranetPenetration()).toBe(false);
expect(mockHasPremiumModule).not.toHaveBeenCalled();
});
it('当缺少付费模块凭证时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockHasPremiumModule.mockResolvedValue(false);
expect(await canUseIntranetPenetration()).toBe(false);
});
await expect(canUseDepartmentIsolation()).resolves.toBe(true);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'departmentIsolation:use');
});
describe('canUseSystemSettings', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseSystemSettings()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('systemSettings');
it('内网穿透必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'intranetPenetration:use',
status: 'running',
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseSystemSettings()).toBe(false);
});
await expect(canUseIntranetPenetration()).resolves.toBe(true);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'intranetPenetration:use');
});
describe('canUseApiAccess', () => {
it('当功能启用时应该返回 true', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
expect(await canUseApiAccess()).toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess');
it('系统设置必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'systemSettings:use',
status: 'running',
});
it('当功能禁用时应该返回 false', async () => {
mockIsFeatureEnabled.mockResolvedValue(false);
expect(await canUseApiAccess()).toBe(false);
await expect(canUseSystemSettings()).resolves.toBe(true);
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'systemSettings:use');
});
it('paid-core 拒绝时付费能力不可用', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: false,
moduleId: 'paid-core',
action: 'customTemplates:use',
status: 'missing',
});
});
});
describe('LimitCheckResult 接口', () => {
it('应该正确处理无限制情况 (max = -1)', async () => {
mockCheckLimit.mockResolvedValue({ allowed: true, current: 0, max: -1 });
const result = await canCreateUser();
expect(result.allowed).toBe(true);
expect(result.max).toBe(-1);
await expect(canUseCustomTemplates()).resolves.toBe(false);
});
it('API 访问必须走 paid-core 授权', async () => {
mockIsFeatureEnabled.mockResolvedValue(true);
mockAuthorizePremiumModuleAction.mockResolvedValue({
available: true,
moduleId: 'paid-core',
action: 'apiAccess:use',
status: 'running',
});
await expect(canUseApiAccess()).resolves.toBe(true);
expect(mockIsFeatureEnabled).toHaveBeenCalledWith('apiAccess');
expect(mockAuthorizePremiumModuleAction).toHaveBeenCalledWith('paid-core', 'apiAccess:use');
});
});

View File

@ -63,6 +63,26 @@ describe('许可证业务入口覆盖', () => {
helper: 'canUseSystemSettings',
reason: '系统设置写操作必须走许可证功能开关',
},
{
file: 'app/api/system-settings/upload-logo/route.ts',
helper: 'canUseSystemSettings',
reason: '系统 LOGO 上传属于系统设置写操作,必须走许可证功能开关',
},
{
file: 'app/api/admin/cleanup-database/route.ts',
helper: 'canUseSystemSettings',
reason: '数据库清理属于高风险维护操作,必须走许可证功能开关',
},
{
file: 'app/api/admin/reset-material-rankings/route.ts',
helper: 'canUseSystemSettings',
reason: '排行榜重置属于高风险维护操作,必须走许可证功能开关',
},
{
file: 'app/api/admin/migrate-material-fields/route.ts',
helper: 'canUseSystemSettings',
reason: '素材字段迁移属于高风险维护操作,必须走许可证功能开关',
},
{
file: 'app/api/tunnel/start/route.ts',
helper: 'canUseIntranetPenetration',
@ -92,8 +112,270 @@ describe('许可证业务入口覆盖', () => {
it('高价值付费能力必须同时检查签名模块凭证', () => {
const source = readProjectFile('lib/license/check-limits.ts');
expect(source).toContain('hasPremiumModule');
expect(source).toContain('PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE');
expect(source).toContain('PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE');
expect(source).toContain('authorizePremiumModuleAction');
expect(source).toContain('PREMIUM_MODULE_IDS.PAID_CORE');
expect(source).toContain('customTemplates:use');
expect(source).toContain('intranetPenetration:use');
expect(source).toContain('apiAccess:use');
});
it('内网穿透入口通过许可证后必须代理到模块服务', () => {
for (const file of [
'app/api/tunnel/start/route.ts',
'app/api/tunnel/status/route.ts',
'app/api/tunnel/stop/route.ts',
]) {
const source = readProjectFile(file);
expect(source).toContain('proxyIntranetModule');
}
});
it('用户数和素材数限制必须来自基础镜像本地签名许可证', () => {
const source = readProjectFile('lib/license/check-limits.ts');
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('本地许可证变更必须写入审计日志', () => {
const source = readProjectFile('lib/license/license-service.ts');
expect(source).toContain('writeLicenseAuditLog');
expect(source).toContain('license_saved');
expect(source).toContain('license_updated');
expect(source).toContain('license_cleared');
expect(source).toContain("nextType === 'PRO'");
});
it('许可证异常必须写入审计日志', () => {
const source = readProjectFile('lib/license/license-service.ts');
expect(source).toContain('license_validation_failed');
expect(source).toContain('license_clock_integrity_failed');
expect(source).toContain('license_clock_rollback_detected');
expect(source).toContain('license_server_signature_failed');
expect(source).toContain('license_server_verification_failed');
expect(source).toContain('license_heartbeat_failed');
});
it('高风险后台维护入口必须要求确认短语', () => {
const destructiveRoutes = [
{
file: 'app/api/admin/cleanup-database/route.ts',
phrase: '清理全部素材',
},
{
file: 'app/api/admin/reset-material-rankings/route.ts',
phrase: '重置排行榜数据',
},
{
file: 'app/api/admin/migrate-material-fields/route.ts',
phrase: '执行字段迁移',
},
];
for (const { file, phrase } of destructiveRoutes) {
const source = readProjectFile(file);
expect(source).toContain('confirmPhrase');
expect(source).toContain(phrase);
}
});
});

View File

@ -91,6 +91,10 @@ function createSignedLicense(overrides: Record<string, any> = {}): SignedLicense
return {
data: {
projectCode: 'flnxi-sucai',
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
instanceId: 'test-instance-id',
fingerprintHash: 'a'.repeat(64),
type: 'FREE' as const,
limits: { maxUsers: 10, maxMaterials: 100 },
features: {
@ -601,6 +605,41 @@ describe('license-service', () => {
expect(localLicense).toBeNull();
});
it('缺少签名绑定字段的旧许可证应视为无许可证', async () => {
const unboundLicense = createSignedLicense();
delete (unboundLicense.data as unknown as Record<string, unknown>).projectCode;
delete (unboundLicense.data as unknown as Record<string, unknown>).licenseKey;
delete (unboundLicense.data as unknown as Record<string, unknown>).instanceId;
delete (unboundLicense.data as unknown as Record<string, unknown>).fingerprintHash;
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(createStoredLocalLicense({
signedLicense: unboundLicense,
})),
});
const { getLocalLicense, isFeatureEnabled } = await import('../license/license-service');
await expect(getLocalLicense()).resolves.toBeNull();
await expect(isFeatureEnabled('customTemplates')).resolves.toBe(false);
});
it('本地许可证绑定到其他实例时应视为无许可证', async () => {
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(createStoredLocalLicense({
signedLicenseData: {
instanceId: 'other-instance-id',
},
})),
});
const { getLocalLicense } = await import('../license/license-service');
await expect(getLocalLicense()).resolves.toBeNull();
});
});
describe('本地时间回拨检测', () => {

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

117
lib/auth-rate-limit.ts Normal file
View File

@ -0,0 +1,117 @@
type HeaderSource =
| Headers
| Record<string, string | string[] | undefined>
| undefined
type AuthRequestLike = {
headers?: HeaderSource
}
type RateState = {
count: number
firstAttemptAt: number
lockedUntil?: number
}
const ipAttempts = new Map<string, RateState>()
const accountFailures = new Map<string, RateState>()
const DEFAULT_IP_WINDOW_MS = 60 * 1000
const DEFAULT_IP_MAX_ATTEMPTS = 5
const DEFAULT_ACCOUNT_MAX_FAILURES = 5
const DEFAULT_ACCOUNT_LOCK_MS = 15 * 60 * 1000
function readPositiveIntEnv(name: string, fallback: number) {
const value = Number(process.env[name])
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
}
function getConfig() {
return {
ipWindowMs: readPositiveIntEnv('AUTH_LOGIN_IP_WINDOW_MS', DEFAULT_IP_WINDOW_MS),
ipMaxAttempts: readPositiveIntEnv('AUTH_LOGIN_IP_MAX_ATTEMPTS', DEFAULT_IP_MAX_ATTEMPTS),
accountMaxFailures: readPositiveIntEnv(
'AUTH_LOGIN_ACCOUNT_MAX_FAILURES',
DEFAULT_ACCOUNT_MAX_FAILURES
),
accountLockMs: readPositiveIntEnv('AUTH_LOGIN_ACCOUNT_LOCK_MS', DEFAULT_ACCOUNT_LOCK_MS),
}
}
function normalizeEmail(email: string) {
return email.trim().toLowerCase()
}
function readHeader(headers: HeaderSource, name: string) {
if (!headers) return ''
if (typeof (headers as Headers).get === 'function') {
return (headers as Headers).get(name) || ''
}
const record = headers as Record<string, string | string[] | undefined>
const lowerName = name.toLowerCase()
const value = record[name] ?? record[lowerName]
if (Array.isArray(value)) return value[0] || ''
return value || ''
}
function getClientIp(request?: AuthRequestLike) {
const forwardedFor = readHeader(request?.headers, 'x-forwarded-for')
const realIp = readHeader(request?.headers, 'x-real-ip')
const candidate = forwardedFor.split(',')[0]?.trim() || realIp.trim()
return candidate || 'unknown'
}
function getOrCreateWindowState(store: Map<string, RateState>, key: string, now: number, windowMs: number) {
const existing = store.get(key)
if (!existing || now - existing.firstAttemptAt >= windowMs) {
const fresh: RateState = { count: 0, firstAttemptAt: now }
store.set(key, fresh)
return fresh
}
return existing
}
export function assertCanAttemptPasswordLogin(email: string, request?: AuthRequestLike) {
const now = Date.now()
const config = getConfig()
const ipKey = getClientIp(request)
const accountKey = normalizeEmail(email)
const ipState = getOrCreateWindowState(ipAttempts, ipKey, now, config.ipWindowMs)
if (ipState.count >= config.ipMaxAttempts) {
throw new Error('登录尝试过于频繁,请稍后再试')
}
ipState.count += 1
const accountState = accountFailures.get(accountKey)
if (accountState?.lockedUntil && accountState.lockedUntil > now) {
throw new Error('该账号登录失败次数过多,请稍后再试')
}
if (accountState?.lockedUntil && accountState.lockedUntil <= now) {
accountFailures.delete(accountKey)
}
}
export function recordPasswordLoginFailure(email: string) {
const now = Date.now()
const config = getConfig()
const accountKey = normalizeEmail(email)
const state = getOrCreateWindowState(accountFailures, accountKey, now, config.accountLockMs)
state.count += 1
if (state.count >= config.accountMaxFailures) {
state.lockedUntil = now + config.accountLockMs
}
}
export function recordPasswordLoginSuccess(email: string) {
accountFailures.delete(normalizeEmail(email))
}
export function resetAuthRateLimitForTests() {
ipAttempts.clear()
accountFailures.clear()
}

View File

@ -3,6 +3,11 @@ import CredentialsProvider from 'next-auth/providers/credentials'
import { prisma } from '@/lib/prisma'
import { consumeDingtalkLoginToken } from '@/lib/dingtalk-login-token'
import { isInitialAdminPasswordChangeRequired } from '@/lib/system-init'
import {
assertCanAttemptPasswordLogin,
recordPasswordLoginFailure,
recordPasswordLoginSuccess,
} from '@/lib/auth-rate-limit'
import bcrypt from 'bcryptjs'
type AccountState = {
@ -30,7 +35,7 @@ export const authOptions: NextAuthOptions = {
password: { label: 'Password', type: 'password' },
dingtalkToken: { label: 'Dingtalk Token', type: 'text' },
},
async authorize(credentials) {
async authorize(credentials, request) {
const dingtalkToken = credentials?.dingtalkToken?.trim()
if (dingtalkToken) {
const user = await consumeDingtalkLoginToken(dingtalkToken, 'login')
@ -52,28 +57,36 @@ export const authOptions: NextAuthOptions = {
}
}
if (!credentials?.email || !credentials?.password) {
const email = credentials?.email?.trim()
const password = credentials?.password
if (!email || !password) {
throw new Error('请输入邮箱和密码')
}
assertCanAttemptPasswordLogin(email, request)
const user = await prisma.user.findUnique({
where: { email: credentials.email },
where: { email },
})
if (!user) {
throw new Error('用户不存在')
recordPasswordLoginFailure(email)
throw new Error('邮箱或密码错误')
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
password,
user.password
)
if (!isPasswordValid) {
throw new Error('密码错误')
recordPasswordLoginFailure(email)
throw new Error('邮箱或密码错误')
}
assertAccountCanUseSession(user)
recordPasswordLoginSuccess(email)
const mustChangePassword = await isInitialAdminPasswordChangeRequired(user.id)
return {

View File

@ -4,7 +4,10 @@
*/
import { checkLimit, isFeatureEnabled, getLicenseType } from './license-service';
import { hasPremiumModule, PREMIUM_MODULE_IDS } from './premium-module-service';
import {
authorizePremiumModuleAction,
PREMIUM_MODULE_IDS,
} from './premium-module-service';
export interface LimitCheckResult {
allowed: boolean;
@ -13,13 +16,19 @@ export interface LimitCheckResult {
message?: string;
}
async function checkAuthorizedLimit(type: 'users' | 'materials'): Promise<LimitCheckResult> {
return checkLimit(type);
}
/**
* 检查是否可以创建新用户
*/
export async function canCreateUser(): Promise<LimitCheckResult> {
const result = await checkLimit('users');
const result = await checkAuthorizedLimit('users');
if (!result.allowed) {
if (result.message) return result;
const licenseType = await getLicenseType();
return {
...result,
@ -37,9 +46,11 @@ export async function canCreateUser(): Promise<LimitCheckResult> {
* 检查是否可以上传新素材
*/
export async function canUploadMaterial(): Promise<LimitCheckResult> {
const result = await checkLimit('materials');
const result = await checkAuthorizedLimit('materials');
if (!result.allowed) {
if (result.message) return result;
const licenseType = await getLicenseType();
return {
...result,
@ -58,14 +69,25 @@ export async function canUploadMaterial(): Promise<LimitCheckResult> {
*/
export async function canUseCustomTemplates(): Promise<boolean> {
if (!(await isFeatureEnabled('customTemplates'))) return false;
return await hasPremiumModule(PREMIUM_MODULE_IDS.CUSTOM_TEMPLATES_CORE);
return (
await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.PAID_CORE,
'customTemplates:use'
)
).available;
}
/**
* 检查部门隔离功能是否可用
*/
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;
}
/**
@ -73,19 +95,36 @@ export async function canUseDepartmentIsolation(): Promise<boolean> {
*/
export async function canUseIntranetPenetration(): Promise<boolean> {
if (!(await isFeatureEnabled('intranetPenetration'))) return false;
return await hasPremiumModule(PREMIUM_MODULE_IDS.INTRANET_PENETRATION_CORE);
return (
await authorizePremiumModuleAction(
PREMIUM_MODULE_IDS.PAID_CORE,
'intranetPenetration:use'
)
).available;
}
/**
* 检查系统设置功能是否可用
*/
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

@ -32,8 +32,54 @@ export {
export {
getCachedPremiumModuleManifest,
getCachedPremiumModuleRuntimeStatus,
getCachedModulePullCredential,
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';
// 导出指纹服务

View File

@ -0,0 +1,63 @@
import { getLicenseProjectCode } from './server-config';
import type { SignedLicense } from './types';
interface ExpectedLicenseBinding {
licenseKey: string;
instanceId: string;
projectCode?: string;
fingerprintHash?: string;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
export function validateSignedLicenseBinding(
license: SignedLicense,
expected: ExpectedLicenseBinding
): { valid: true } | { valid: false; error: string } {
const data = license.data as unknown as Record<string, unknown>;
const projectCode = expected.projectCode ?? getLicenseProjectCode();
if (
!isNonEmptyString(data.projectCode) ||
!isNonEmptyString(data.licenseKey) ||
!isNonEmptyString(data.instanceId) ||
!isNonEmptyString(data.fingerprintHash)
) {
return {
valid: false,
error: '许可证缺少机器绑定信息,请重新激活',
};
}
if (data.projectCode !== projectCode) {
return {
valid: false,
error: '许可证项目不匹配,请重新激活',
};
}
if (data.licenseKey !== expected.licenseKey) {
return {
valid: false,
error: '许可证激活码不匹配,请重新激活',
};
}
if (data.instanceId !== expected.instanceId) {
return {
valid: false,
error: '许可证不属于当前设备,请重新激活',
};
}
if (expected.fingerprintHash && data.fingerprintHash !== expected.fingerprintHash) {
return {
valid: false,
error: '许可证硬件指纹不匹配,请重新激活',
};
}
return { valid: true };
}

View File

@ -6,6 +6,7 @@ import {
decryptLocal,
signLocalIntegrity,
verifyLocalIntegrity,
hashData,
} from './crypto-service';
import { initializeSystem as initializeSystemCore } from '../system-init';
import type {
@ -21,6 +22,7 @@ import { LICENSE_CONFIGS } from './types';
import { buildLicenseServerLicenseApiUrl } from './server-config';
import { buildSignedLicenseRequest } from './request-signature';
import { syncPremiumModules } from './premium-module-service';
import { validateSignedLicenseBinding } from './license-binding';
// 宽限期(7天)
const GRACE_PERIOD_DAYS = 7;
@ -37,6 +39,49 @@ let cachedLicense: LicenseConfig | null = null;
let lastVerified: Date | null = null;
let heartbeatTimer: NodeJS.Timeout | null = null;
type LicenseAuditSeverity = 'info' | 'warning' | 'high';
function getLicenseTransitionSeverity(
action: string,
previousType: string | null | undefined,
nextType: string | null | undefined
): LicenseAuditSeverity {
if (action === 'license_cleared') return 'warning';
if (!previousType || !nextType || previousType === nextType) return 'info';
if (nextType === 'PRO') return 'high';
return 'warning';
}
async function writeLicenseAuditLog(params: {
action: string;
previousConfig?: LicenseConfig | null;
nextConfig?: LicenseConfig | null;
licenseKey?: string | null;
instanceId?: string | null;
severity?: LicenseAuditSeverity;
source: string;
details?: Record<string, unknown>;
}): Promise<void> {
try {
const previousType = params.previousConfig?.type || null;
const nextType = params.nextConfig?.type || null;
await (prisma as any).licenseAuditLog.create({
data: {
action: params.action,
severity: params.severity || getLicenseTransitionSeverity(params.action, previousType, nextType),
previousType,
nextType,
licenseKeyHash: params.licenseKey ? hashData(params.licenseKey) : null,
instanceId: params.instanceId || null,
source: params.source,
details: params.details || undefined,
},
});
} catch (error) {
console.warn('[License] 许可证审计日志写入失败:', error);
}
}
function isDateStringExpired(value: string | null | undefined): boolean {
if (!value) return false;
@ -54,7 +99,15 @@ export function isLicenseConfigExpired(license: LicenseConfig | null): boolean {
}
function extractLicenseConfig(license: SignedLicense): LicenseConfig {
const { issuedAt: _issuedAt, ...config } = license.data;
const {
issuedAt: _issuedAt,
projectCode: _projectCode,
licenseKey: _licenseKey,
instanceId: _instanceId,
fingerprintHash: _fingerprintHash,
activationId: _activationId,
...config
} = license.data;
return {
...config,
leaseExpiresAt: config.leaseExpiresAt ?? null,
@ -123,6 +176,14 @@ async function persistLocalLicenseData(localData: LocalLicenseData): Promise<voi
async function enforceLocalClockState(localData: LocalLicenseData): Promise<LocalLicenseData | null> {
if (!isClockStateIntact(localData)) {
await writeLicenseAuditLog({
action: 'license_clock_integrity_failed',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
});
console.warn('[License] 本地许可证时间状态校验失败,已拒绝使用');
return null;
}
@ -132,6 +193,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
const lastVerifiedMs = new Date(localData.lastVerified).getTime();
if (Number.isNaN(lastVerifiedMs)) {
await writeLicenseAuditLog({
action: 'license_clock_state_invalid',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastVerified' },
});
console.warn('[License] 本地许可证验证时间格式异常,已拒绝使用');
return null;
}
@ -143,6 +213,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
await writeLicenseAuditLog({
action: 'license_clock_rollback_detected',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastVerified' },
});
console.warn('[License] 本地许可证验证时间晚于当前系统时间,已拒绝使用');
return null;
}
@ -159,6 +238,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
const lastSeenMs = new Date(localData.lastSeenAt).getTime();
if (Number.isNaN(lastSeenMs)) {
await writeLicenseAuditLog({
action: 'license_clock_state_invalid',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastSeenAt' },
});
console.warn('[License] 本地许可证时间状态格式异常,已拒绝使用');
return null;
}
@ -169,6 +257,15 @@ async function enforceLocalClockState(localData: LocalLicenseData): Promise<Loca
clockRollbackDetectedAt: localData.clockRollbackDetectedAt ?? nowIso,
});
await persistLocalLicenseData(rollbackState);
await writeLicenseAuditLog({
action: 'license_clock_rollback_detected',
previousConfig: localData.cachedConfig || null,
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
severity: 'high',
source: 'local_license_load',
details: { field: 'lastSeenAt' },
});
console.warn('[License] 检测到系统时间明显回拨,已拒绝使用本地许可证');
return null;
}
@ -213,6 +310,15 @@ function normalizeLocalLicenseData(localData: LocalLicenseData): LocalLicenseDat
return null;
}
const bindingResult = validateSignedLicenseBinding(signedLicense, {
licenseKey: localData.licenseKey,
instanceId: localData.instanceId,
});
if (!bindingResult.valid) {
console.warn('[License] 本地许可证绑定校验失败:', bindingResult.error);
return null;
}
return {
...localData,
cachedConfig: extractLicenseConfig(signedLicense),
@ -441,6 +547,19 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
};
}
await writeLicenseAuditLog({
action: 'license_server_verification_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_verification',
details: {
status: response.status,
error: data.error || null,
},
});
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
return { valid: true };
}
@ -449,13 +568,21 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
}
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
await writeLicenseAuditLog({
action: 'license_server_signature_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'high',
source: 'license_server_verification',
});
return { valid: false, error: '许可证签名验证失败' };
}
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
return { valid: true };
@ -463,6 +590,18 @@ export async function runLicenseVerification(): Promise<{ valid: boolean; error?
console.error('验证失败:', error);
const localLicense = await getLocalLicense();
if (localLicense) {
await writeLicenseAuditLog({
action: 'license_server_verification_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_verification',
details: { error: 'network_error' },
});
}
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
@ -562,6 +701,14 @@ export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; er
if (data.acknowledged && data.license) {
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
await writeLicenseAuditLog({
action: 'license_server_signature_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'high',
source: 'license_server_heartbeat',
});
return {
acknowledged: false,
error: '许可证签名验证失败',
@ -571,15 +718,39 @@ export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; er
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
console.warn('[License] 授权模块清单同步失败:', moduleSyncResult.error);
}
}
if (!data.acknowledged) {
await writeLicenseAuditLog({
action: 'license_heartbeat_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_heartbeat',
details: { error: 'error' in data ? data.error || null : null },
});
}
return {
acknowledged: data.acknowledged || false,
};
} catch (error) {
console.error('心跳发送失败:', error);
const localLicense = await getLocalLicense();
if (localLicense) {
await writeLicenseAuditLog({
action: 'license_heartbeat_failed',
previousConfig: localLicense.cachedConfig,
licenseKey: localLicense.licenseKey,
instanceId: localLicense.instanceId,
severity: 'warning',
source: 'license_server_heartbeat',
details: { error: 'network_error' },
});
}
return {
acknowledged: false,
error: '网络错误',
@ -689,6 +860,15 @@ export async function saveLicenseLocally(licenseKey: string, license: SignedLice
}
const instanceId = await getInstanceId();
const bindingResult = validateSignedLicenseBinding(license, {
licenseKey,
instanceId,
});
if (!bindingResult.valid) {
throw new Error(bindingResult.error);
}
const previous = await getLocalLicenseReadOnly();
const currentConfig = extractLicenseConfig(license);
const nowIso = new Date().toISOString();
@ -709,6 +889,15 @@ export async function saveLicenseLocally(licenseKey: string, license: SignedLice
create: { key: 'license', value: JSON.stringify(signedLocalData) },
});
await writeLicenseAuditLog({
action: previous ? 'license_replaced' : 'license_saved',
previousConfig: previous?.cachedConfig || null,
nextConfig: currentConfig,
licenseKey,
instanceId,
source: 'local_activation',
});
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
lastVerified = new Date(signedLocalData.lastVerified);
}
@ -721,6 +910,15 @@ export async function updateLocalLicense(license: SignedLicense): Promise<void>
throw new Error('许可证签名验证失败');
}
const bindingResult = validateSignedLicenseBinding(license, {
licenseKey: existing.licenseKey,
instanceId: existing.instanceId,
});
if (!bindingResult.valid) {
throw new Error(bindingResult.error);
}
const previousConfig = existing.cachedConfig;
const currentConfig = extractLicenseConfig(license);
const nowIso = new Date().toISOString();
@ -739,6 +937,15 @@ export async function updateLocalLicense(license: SignedLicense): Promise<void>
data: { value: JSON.stringify(signedLocalData) },
});
await writeLicenseAuditLog({
action: 'license_updated',
previousConfig,
nextConfig: currentConfig,
licenseKey: existing.licenseKey,
instanceId: existing.instanceId,
source: 'license_server_verification',
});
cachedLicense = isLicenseConfigExpired(currentConfig) ? null : currentConfig;
lastVerified = new Date(signedLocalData.lastVerified);
}
@ -767,10 +974,25 @@ export async function getLocalLicense(): Promise<LocalLicenseData | null> {
const parsed = JSON.parse(config.value) as LocalLicenseData;
const normalized = normalizeLocalLicenseData(parsed);
if (!normalized) return null;
if (!normalized) {
await writeLicenseAuditLog({
action: 'license_validation_failed',
licenseKey: typeof parsed?.licenseKey === 'string' ? parsed.licenseKey : null,
instanceId: typeof parsed?.instanceId === 'string' ? parsed.instanceId : null,
severity: 'high',
source: 'local_license_load',
});
return null;
}
return await enforceLocalClockState(normalized);
} catch {
} catch (error) {
await writeLicenseAuditLog({
action: 'license_validation_failed',
severity: 'high',
source: 'local_license_load',
details: { error: error instanceof Error ? error.name : 'unknown_error' },
});
return null;
}
}
@ -798,9 +1020,18 @@ export async function getLocalLicenseReadOnly(): Promise<LocalLicenseData | null
*/
export async function clearLocalLicense(): Promise<void> {
try {
const existing = await getLocalLicenseReadOnly();
await prisma.systemConfig.delete({
where: { key: 'license' },
});
await writeLicenseAuditLog({
action: 'license_cleared',
previousConfig: existing?.cachedConfig || null,
nextConfig: null,
licenseKey: existing?.licenseKey || null,
instanceId: existing?.instanceId || null,
source: 'license_revocation',
});
console.log('[License] 本地许可证缓存已清除');
} catch (error) {
console.error('[License] 清除本地许可证缓存失败:', error);

File diff suppressed because it is too large Load Diff

View File

@ -19,9 +19,20 @@ export interface LicenseConfig {
leaseExpiresAt: string | null; // 在线租约到期时间,旧许可证可为 null
}
// 签名许可证的绑定信息:用于证明这张许可证只属于指定项目、激活码和本机实例。
export interface LicenseBinding {
projectCode: string;
licenseKey: string;
instanceId: string;
fingerprintHash: string;
activationId?: string;
}
export type SignedLicenseData = LicenseConfig & LicenseBinding & { issuedAt: string };
// 签名后的许可证
export interface SignedLicense {
data: LicenseConfig & { issuedAt: string };
data: SignedLicenseData;
signature: string;
issuedAt: string;
}

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

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
type TunnelAction = 'status' | 'start' | 'stop';
function getModuleBaseUrl(): string {
return (process.env.PAID_CORE_MODULE_URL || '').trim().replace(/\/+$/, '');
}
export async function proxyIntranetModule(action: TunnelAction): Promise<NextResponse | null> {
const baseUrl = getModuleBaseUrl();
if (!baseUrl) return null;
const method = action === 'status' ? 'GET' : 'POST';
const response = await fetch(`${baseUrl}/tunnel/${action}`, { method });
const data = await response.json().catch(() => ({
error: '内网穿透模块返回数据异常',
}));
return NextResponse.json(data, { status: response.status });
}

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

@ -9,3 +9,12 @@ pnpm-debug.log*
tsconfig.tsbuildinfo
.git
.gitignore
# Local secrets must never enter the license-server image build context.
.env
.env.*
!.env.example
*.pem
*.key
keys
env-backups

View File

@ -28,13 +28,17 @@ JWT_SECRET="your-jwt-secret-key-at-least-32-bytes-long"
# 许可证请求签名密钥(生产环境必须配置,最少 32 字节;素材系统需使用同一值)
LICENSE_REQUEST_SIGNING_SECRET="replace-with-32-byte-or-longer-request-signing-secret"
# 付费模块分发
# 模块核心本体放在镜像站,验证服务器只下发授权清单和实例绑定凭证。
# 付费模块短期拉包 token 签名密钥(生产环境建议独立配置,最少 32 字节)
MODULE_PULL_TOKEN_SECRET="replace-with-32-byte-or-longer-module-pull-token-secret"
# 付费增强包分发
# paid-core 核心本体放在镜像站,验证服务器只下发授权清单、实例绑定凭证和短期拉包 token。
# 免费版不拉 paid-core;免费版人数/素材数上限由基础镜像按本地签名许可证执行。
PREMIUM_MODULE_REGISTRY_BASE="registry.example.com/flnxi/premium"
PREMIUM_MODULE_VERSION="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"
# 如需单独覆盖 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

@ -23,8 +23,8 @@ yarn-debug.log*
yarn-error.log*
# local env files
.env
.env*.local
.env*
!.env.example
# vercel
.vercel

View File

@ -40,6 +40,11 @@ COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/tsconfig.json ./
COPY --from=builder /app/tsconfig.scripts.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000

Some files were not shown because too many files have changed in this diff Show More