feat: add super-admin product detail generator

This commit is contained in:
WuYongJie 2026-10-12 19:29:03 +08:00
parent 5b07f5ca54
commit c53fa0e292
77 changed files with 6498 additions and 52 deletions

View File

@ -215,3 +215,14 @@ temp_*.sql
!prisma/migrations/
!prisma/migrations/**
!data/team-function-initial-content.json
# Private generated data never belongs to a build context.
/detail-generator-private
/detail-generator-private/**
/.private
/.private/**
/workers/detail-renderer/node_modules
/workers/detail-renderer/node_modules/**
!workers/detail-renderer/package.json
!workers/detail-renderer/package-lock.json
!public/detail-generator/fonts/OFL.txt

4
.gitignore vendored
View File

@ -179,3 +179,7 @@ Relay私密接管清单*
/scripts/migrate-standard-model-white-bg-webp.js
/scripts/recover-selected-video-preview*.ts
/scripts/verify-p1-1-fix.ts
# Private detail-generator projects and disposable acceptance output
/detail-generator-private/
/output/detail-generator/

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-19T00:00:00+08:00",
"generatedAt": "2026-10-12T10:15:30Z",
"title": "Design System: 品牌中心部门看板",
"extensions": {
"colorMeta": {
@ -348,6 +348,21 @@
"#176fc1",
"#0d4d8c"
]
},
"detail-field-border": {
"role": "neutral",
"displayName": "详情页字段与次要按钮边框",
"canonical": "#cfd8e3"
},
"detail-control-ink": {
"role": "neutral",
"displayName": "详情页次要操作文字",
"canonical": "#344054"
},
"detail-primary-hover": {
"role": "primary",
"displayName": "详情页主要操作悬停蓝",
"canonical": "#0964de"
}
},
"typographyMeta": {
@ -472,6 +487,30 @@
"type": "navigation",
"description": "品牌中心范围外普通用户使用的中性素材库顶栏;只保留页面定位和退出登录,不提供品牌中心入口。",
"projection": "<header class=\"ds-material-standalone\" aria-label=\"素材资产库顶栏\"><strong><svg aria-hidden=\"true\" viewBox=\"0 0 24 24\"><path d=\"M4 5h6l2 2h8v12H4z\"/></svg>素材资产库</strong><button type=\"button\">退出登录</button></header><style>.ds-material-standalone{display:flex;min-height:64px;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid #e2e8f0;background:#fff;padding:0 24px;color:#101828;font-family:ui-sans-serif,system-ui}.ds-material-standalone strong{display:flex;min-width:0;align-items:center;gap:12px;font-size:16px}.ds-material-standalone svg{width:20px;height:20px;fill:none;stroke:#176fc1;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.ds-material-standalone button{min-height:40px;border:0;border-radius:8px;background:transparent;padding:0 12px;color:#4f6077;font:600 14px/1 ui-sans-serif,system-ui}.ds-material-standalone button:hover{background:#eef2f6;color:#101828}.ds-material-standalone button:focus-visible{outline:2px solid #5a9ce6;outline-offset:2px}</style>"
},
{
"name": "Detail Generator Field",
"kind": "input",
"refersTo": "detail-field",
"description": "仅记录详情页资料、大纲与文案编辑复用的 detailFieldClass;44px 最小高度、8px 圆角与明确的蓝色聚焦环。",
"html": "<label class=\"ds-detail-field-label\">产品名称<input class=\"ds-detail-field\" placeholder=\"输入已确认的名称\"></label>",
"css": ".ds-detail-field-label{display:grid;gap:6px;color:#101828;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif}.ds-detail-field{min-height:44px;width:100%;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:8px 12px;color:#101828;font-family:inherit;font-size:14px;line-height:20px;font-weight:inherit}.ds-detail-field::placeholder{color:#66758b}.ds-detail-field:focus-visible{outline:none;box-shadow:0 0 0 2px #1677ff}.ds-detail-field:disabled{background:#f8fafc;color:#66758b}"
},
{
"name": "Detail Generator Secondary Button",
"kind": "button",
"refersTo": "detail-button-secondary",
"description": "详情页复用的 detailButtonClass,承载保存、上传、返回和图片替换等次要操作;不替换其他页面按钮。",
"html": "<button class=\"ds-detail-button-secondary\" type=\"button\">保存修改</button>",
"css": ".ds-detail-button-secondary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:1px solid #cfd8e3;border-radius:8px;background:#fff;padding:0 12px;color:#344054;font:500 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-secondary:hover{background:#f8fafc}.ds-detail-button-secondary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-secondary:disabled{cursor:not-allowed;opacity:.5}"
},
{
"name": "Detail Generator Primary Button",
"kind": "button",
"refersTo": "detail-button-primary",
"description": "详情页复用的 detailPrimaryClass,用于分析、确认生成、继续生成与导出;保持既有 Action Blue,悬停变体仅限该控件。",
"html": "<button class=\"ds-detail-button-primary\" type=\"button\">确认大纲并生成</button>",
"css": ".ds-detail-button-primary{display:inline-flex;min-height:44px;flex-shrink:0;align-items:center;justify-content:center;gap:8px;white-space:nowrap;border:0;border-radius:8px;background:#1677ff;padding:0 16px;color:#fff;font:600 14px/20px 'Space Grotesk','Noto Sans SC',ui-sans-serif,system-ui,sans-serif;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}.ds-detail-button-primary:hover{background:#0964de}.ds-detail-button-primary:focus-visible{outline:none;box-shadow:0 0 0 2px #fff,0 0 0 4px #1677ff}.ds-detail-button-primary:disabled{cursor:not-allowed;opacity:.5}"
}
],
"narrative": {

View File

@ -25,6 +25,9 @@ colors:
workflow-inactive-description: "#667085"
workflow-hover: "#eef2f6"
workflow-focus: "#5a9ce6"
detail-field-border: "#cfd8e3"
detail-control-ink: "#344054"
detail-primary-hover: "#0964de"
typography:
display:
fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif"
@ -109,6 +112,26 @@ components:
rounded: "{rounded.md}"
padding: "10px 12px"
width: "232px"
detail-field:
backgroundColor: "{colors.surface}"
textColor: "{colors.text-primary}"
rounded: "{rounded.md}"
padding: "8px 12px"
detail-button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.detail-control-ink}"
rounded: "{rounded.md}"
padding: "0 12px"
detail-button-primary:
backgroundColor: "{colors.action-blue}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "0 16px"
detail-button-primary-hover:
backgroundColor: "{colors.detail-primary-hover}"
textColor: "{colors.surface}"
rounded: "{rounded.md}"
padding: "0 16px"
---
# Design System: 品牌中心部门看板
@ -319,6 +342,14 @@ components:
- **Responsive:** 1280px 以下转为两列;640px 以下加组与人物身份同一行,部门组和个人组在下方换行。长名称与多组允许行高自然增加。
- **Interaction:** 个人组提供具备人员/组名的移除按钮,独立加组选择器保留原提交行为;部门继承来源仅展示。完整部门路径保留在悬停提示及辅助语义中,错误与成功状态使用 alert/status。
### Detail Generator Controls
- **Scope:** `components/detail-generator/DetailProductForm.tsx` 导出的 `detailFieldClass`、`detailButtonClass` 和 `detailPrimaryClass`,在资料、大纲与单屏编辑器中复用;`detail-*` token 只记录这组已实现控件,不替换其他页面的字段或按钮。
- **Fields:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框,14px 文字与 `8px 12px` 内边距;placeholder 使用 `#66758b`。聚焦显示 2px `#1677ff` 环;禁用时为 `#f8fafc` 底与 `#66758b` 文字。长文案使用可纵向调整的 textarea。
- **Secondary Button:** 最小高度 44px、8px 圆角、白底、`#cfd8e3` 边框、`#344054` 文字,左右 12px 内边距;hover 为 `#f8fafc`。
- **Primary Button:** 最小高度 44px、8px 圆角、`#1677ff` 底与白字,左右 16px 内边距;hover 为 `#0964de`。两种按钮的 focus-visible 均为 2px 蓝环与 2px 白色间隔,disabled 降为 50% 不透明度并显示不可用指针。
- **Surface Contract:** 详情页的 Operate 模式、分阶段构图、750px 画布、编辑范围、响应式与验收边界记录在 `.impeccable/surfaces/app-assets-detail-generator-page-tsx.md`,不提升为全项目规则。
## Do's and Don'ts
### Do:

View File

@ -7,8 +7,21 @@ ARG ALPINE_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/alpine
# Local/CI builds remain possible without Git metadata. Production deployment
# supplies the exact SHA and rejects this sentinel before any container starts.
ARG VCS_REF=0000000000000000000000000000000000000000
ARG DETAIL_RENDERER_IMAGE=node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402
FROM ${BASE_IMAGE} AS base
# Fabric's Node renderer has a separate dependency tree and Node runtime.
# The platform's existing Canvas 2 installation remains independent.
FROM ${DETAIL_RENDERER_IMAGE} AS detail-renderer-deps
ARG ALPINE_MIRROR
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|${ALPINE_MIRROR}|g" /etc/apk/repositories && \
apk add --no-cache python3 make g++ cairo-dev jpeg-dev pango-dev giflib-dev
WORKDIR /opt/detail-renderer
COPY workers/detail-renderer/package.json workers/detail-renderer/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm CXXFLAGS="-include cstdint" npm_config_build_from_source=canvas npm ci --no-audit --no-fund
COPY workers/detail-renderer/ ./
RUN node -e "require('canvas').createCanvas(1,1); console.log('isolated Canvas smoke passed')"
# 安装依赖阶段
FROM base AS deps
ARG ALPINE_MIRROR
@ -200,6 +213,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/app ./app
COPY --from=builder --chown=root:root /app/public ./public
COPY --from=builder --chown=root:root /app/DEPLOYMENT_COMMIT /app/DEPLOYMENT_COMMIT
COPY --from=detail-renderer-deps --chown=root:root /opt/detail-renderer /opt/detail-renderer
COPY --from=detail-renderer-deps --chown=root:root /usr/local/bin/node /opt/detail-renderer/bin/node
ENV DETAIL_RENDERER_NODE=/opt/detail-renderer/bin/node
ENV DETAIL_RENDERER_ENTRY=/opt/detail-renderer/cli.mjs
# 只复制运行时必需的 node_modules
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
@ -243,6 +260,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260915_add_brand_
COPY --from=builder --chown=root:root /app/prisma/migrations/20260918_add_performance_conversion_sync ./prisma/migrations/20260918_add_performance_conversion_sync
COPY --from=builder --chown=root:root /app/prisma/migrations/20260923_add_certificate_report_metadata ./prisma/migrations/20260923_add_certificate_report_metadata
COPY --from=builder --chown=root:root /app/prisma/migrations/20260924_add_certificate_tested_on ./prisma/migrations/20260924_add_certificate_tested_on
COPY --from=builder --chown=root:root /app/prisma/migrations/20261012_detail_generator ./prisma/migrations/20261012_detail_generator
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
@ -251,6 +269,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-surv
# 应用代码归 root 所有,运行账号只能写上传目录和 Next.js 运行时缓存;
# 即使上层文件路径校验再次出现缺陷,也不能覆盖 server.js 或业务源码。
RUN mkdir -p /app/public /app/uploads && \
mkdir -p /app/detail-generator-private && \
mkdir -p /app/scripts && \
mkdir -p /app/.next/cache && \
mkdir -p /app/uploads/temp && \
@ -260,6 +279,8 @@ RUN mkdir -p /app/public /app/uploads && \
chown -R root:root /app && \
chmod -R a+rX /app/public /app/prisma/migrations /app/scripts && \
chown -R nextjs:nodejs /app/.next/cache /app/uploads && \
chown nextjs:nodejs /app/detail-generator-private && \
chmod 0700 /app/detail-generator-private && \
chmod 0750 /app/uploads /app/uploads/temp /app/uploads/assembled /app/uploads/materials /app/uploads/cleanup && \
test "$(cat /app/DEPLOYMENT_COMMIT)" = "$VCS_REF" && \
chmod 0444 /app/DEPLOYMENT_COMMIT && \

View File

@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { findDetailAsset } from '@/lib/detail-generator/repository'
import { readDetailFile } from '@/lib/detail-generator/storage'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { assetId: string } }) {
try {
const ownerId = await requireDetailGeneratorUser()
const asset = await findDetailAsset(assertDetailId(params.assetId), ownerId)
const buffer = await readDetailFile(asset.storageKey)
return new NextResponse(new Uint8Array(buffer), { headers: {
'Content-Type': asset.mimeType, 'Content-Length': String(buffer.length), 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff',
'Content-Disposition': `${asset.mimeType.startsWith('image/') && asset.kind !== 'export' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(asset.name)}`,
} })
} catch (error) { return detailGeneratorErrorResponse(error) }
}

View File

@ -0,0 +1,12 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { listDetailBrandProfiles, saveDetailBrandProfile } from '@/lib/detail-generator/brand-profiles'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export function GET() { return detailGeneratorRoute(async () => ({ profiles: await listDetailBrandProfiles() })) }
export function PUT(request: Request) {
return detailGeneratorRoute(async () => {
const input = await parseDetailJson(request)
return { profile: await saveDetailBrandProfile(input.profile, input.revision) }
})
}

View File

@ -0,0 +1,9 @@
import { NextResponse } from 'next/server'
import { requireDetailGeneratorUser } from '@/lib/detail-generator/access'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() {
let canUse = false
try { await requireDetailGeneratorUser(); canUse = true } catch { canUse = false }
return NextResponse.json({ canUse }, { headers: { 'Cache-Control': 'private, no-store' } })
}

View File

@ -0,0 +1,6 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { detailPublicJob, findDetailJob } from '@/lib/detail-generator/repository'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: detailPublicJob(await findDetailJob(assertDetailId(params.id), userId)) })) }

View File

@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import sharp from 'sharp'
import { detailGeneratorErrorResponse, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { readDetailMaterialImage } from '@/lib/detail-generator/materials'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(_request: Request, { params }: { params: { materialId: string; source: string; sourceId: string } }) {
try {
const ownerId = await requireDetailGeneratorUser()
const image = await readDetailMaterialImage(ownerId, assertDetailId(params.materialId), params.source, assertDetailId(params.sourceId))
const buffer = await sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
return new NextResponse(new Uint8Array(buffer), { headers: { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' } })
} catch (error) { return detailGeneratorErrorResponse(error) }
}

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { findDetailMaterialMatches } from '@/lib/detail-generator/materials'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: Request) { return detailGeneratorRoute(async userId => { const query = new URL(request.url).searchParams; return { matches: await findDetailMaterialMatches(userId, query.get('identifierType'), query.get('identifier')) } }) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'analyze', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { approveDetailAnalysis } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ project: await approveDetailAnalysis(userId, assertDetailId(params.id), await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { uploadDetailAsset } from '@/lib/detail-generator/service'
import { assertDetailId } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(userId => uploadDetailAsset(userId, assertDetailId(params.id), request)) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'export', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'generate', await parseDetailJson(request)) })) }

View File

@ -0,0 +1,5 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { importDetailMaterial } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export async function POST(request: Request, { params }: { params: { id: string } }) { return detailGeneratorRoute(async userId => importDetailMaterial(userId, assertDetailId(params.id), await parseDetailJson(request))) }

View File

@ -0,0 +1,10 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { editDetailProject, getDetailProject } from '@/lib/detail-generator/service'
import { deleteDetailProject } from '@/lib/detail-generator/repository'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
type Context = { params: { id: string } }
export async function GET(_request: Request, { params }: Context) { return detailGeneratorRoute(userId => getDetailProject(userId, assertDetailId(params.id))) }
export async function PATCH(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ project: await editDetailProject(userId, assertDetailId(params.id), await parseDetailJson(request)) })) }
export async function DELETE(_request: Request, { params }: Context) { return detailGeneratorRoute(async userId => { await deleteDetailProject(assertDetailId(params.id), userId); return { deleted: true } }) }

View File

@ -0,0 +1,7 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { createDetailAction, editDetailScreen } from '@/lib/detail-generator/service'
import { assertDetailId, parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
type Context = { params: { id: string; screenId: string } }
export async function PATCH(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ project: await editDetailScreen(userId, assertDetailId(params.id), assertDetailId(params.screenId), await parseDetailJson(request)) })) }
export async function POST(request: Request, { params }: Context) { return detailGeneratorRoute(async userId => ({ job: await createDetailAction(userId, assertDetailId(params.id), 'regenerate', await parseDetailJson(request), assertDetailId(params.screenId)) })) }

View File

@ -0,0 +1,7 @@
import { detailGeneratorRoute } from '@/lib/detail-generator/access'
import { getDetailHome, newDetailProject } from '@/lib/detail-generator/service'
import { parseDetailJson } from '@/lib/detail-generator/validation'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() { return detailGeneratorRoute(userId => getDetailHome(userId)) }
export async function POST(request: Request) { return detailGeneratorRoute(async userId => ({ project: await newDetailProject(userId, (await parseDetailJson(request)).document) })) }

View File

@ -0,0 +1,23 @@
import { getServerSession } from 'next-auth'
import { notFound, redirect } from 'next/navigation'
import { authOptions } from '@/lib/auth'
import { assertDetailGeneratorAccess, DetailGeneratorError } from '@/lib/detail-generator/access'
import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell'
import DetailGeneratorWorkspace from '@/components/detail-generator/DetailGeneratorWorkspace'
export const dynamic = 'force-dynamic'
export const metadata = { title: '详情页生成 | 品牌中心' }
export default async function DetailGeneratorPage() {
const session = await getServerSession(authOptions)
if (!session?.user?.id) redirect('/login')
try { await assertDetailGeneratorAccess(session) } catch (error) {
if (error instanceof DetailGeneratorError && error.status === 403) notFound()
throw error
}
return (
<MaterialLibraryAccessShell canAccessBrandCenter={session.user.canAccessBrandCenter === true}>
<DetailGeneratorWorkspace />
</MaterialLibraryAccessShell>
)
}

View File

@ -0,0 +1,37 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DepartmentShell from './DepartmentShell'
jest.mock('./DepartmentAccountMenu', () => ({ __esModule: true, default: () => null }))
jest.mock('./DepartmentHeader', () => {
const React = require('react')
return { __esModule: true, default: React.forwardRef(function Header(_props: unknown, ref: unknown) { return <button ref={ref as React.Ref<HTMLButtonElement>}>导航</button> }) }
})
beforeEach(() => {
window.history.replaceState(null, '', '/workspaces/dashboard/core-metrics')
window.matchMedia = jest.fn().mockReturnValue({ matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() })
})
test.each([false, undefined])('服务端能力为 %s 时不显示新功能入口', async canUse => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ canUse }) })
render(<DepartmentShell><p>内容</p></DepartmentShell>)
await waitFor(() => expect(global.fetch).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' }))
expect(screen.queryByRole('link', { name: /详情页生成/ })).not.toBeInTheDocument()
})
test('只有数据库能力响应明确为 true 才显示详情页生成入口', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ canUse: true }) })
render(<DepartmentShell><p>内容</p></DepartmentShell>)
fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' }))
expect(await screen.findByRole('link', { name: /详情页生成/ })).toHaveAttribute('href', '/assets/detail-generator')
expect(global.fetch).toHaveBeenCalledWith('/api/detail-generator/capabilities', expect.objectContaining({ cache: 'no-store' }))
})
test('权限请求失败保持入口隐藏', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('offline'))
render(<DepartmentShell><p>内容</p></DepartmentShell>)
await waitFor(() => expect(global.fetch).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: '资产与AI自动化' }))
expect(screen.queryByRole('link', { name: /详情页生成/ })).not.toBeInTheDocument()
})

View File

@ -22,6 +22,7 @@ import {
TrendingUp,
UsersRound,
Workflow,
WandSparkles,
X,
type LucideIcon,
} from 'lucide-react'
@ -60,6 +61,7 @@ function readPathname() {
}
function getPageTitle(pathname: string) {
if (pathname.startsWith('/assets/detail-generator')) return '详情页生成'
if (pathname === '/') return '部门总览'
for (const section of WORKSPACE_SECTIONS) {
const item = section.items.find((candidate) => pathname.startsWith(candidate.href))
@ -73,16 +75,29 @@ export default function DepartmentShell({ children }: { children: ReactNode }) {
const [navigationCollapsed, setNavigationCollapsed] = useState(false)
const [mobileNavigationOpen, setMobileNavigationOpen] = useState(false)
const [isMobileViewport, setIsMobileViewport] = useState(false)
const [canUseDetailGenerator, setCanUseDetailGenerator] = useState(false)
const navigationTriggerRef = useRef<HTMLButtonElement>(null)
const mobileDialogRef = useRef<HTMLDivElement>(null)
const contentRootRef = useRef<HTMLDivElement>(null)
const activeSection = WORKSPACE_SECTIONS.find((section) => section.items.some((item) => pathname.startsWith(item.href)))
const navigationSections = WORKSPACE_SECTIONS.map((section) => section.key === 'assets' && canUseDetailGenerator
? { ...section, items: [...section.items, { key: 'detail-generator', title: '详情页生成', href: '/assets/detail-generator', status: 'connected' as const }] }
: section)
const activeSection = navigationSections.find((section) => section.items.some((item) => pathname.startsWith(item.href)))
const [expandedSections, setExpandedSections] = useState<WorkspaceSectionKey[]>(activeSection ? [activeSection.key] : ['dashboard'])
useEffect(() => {
setPathname(readPathname())
}, [])
useEffect(() => {
const controller = new AbortController()
fetch('/api/detail-generator/capabilities', { cache: 'no-store', signal: controller.signal })
.then((response) => response.ok ? response.json() : null)
.then((capability) => setCanUseDetailGenerator(capability?.canUse === true))
.catch(() => setCanUseDetailGenerator(false))
return () => controller.abort()
}, [])
useEffect(() => {
if (typeof window.matchMedia !== 'function') return
const media = window.matchMedia('(max-width: 1023px)')
@ -175,7 +190,7 @@ export default function DepartmentShell({ children }: { children: ReactNode }) {
<nav aria-label="部门工作区" className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
<div className="space-y-1">
{WORKSPACE_SECTIONS.map((section) => {
{navigationSections.map((section) => {
const Icon = SECTION_ICONS[section.key]
const expanded = expandedSections.includes(section.key)
const sectionActive = activeSection?.key === section.key
@ -203,7 +218,7 @@ export default function DepartmentShell({ children }: { children: ReactNode }) {
<div id={`department-nav-${section.key}`} className="ml-5 mt-1 space-y-1 border-l border-white/10 pl-3">
{section.items.map((item) => {
const active = pathname.startsWith(item.href)
const ItemIcon = ITEM_ICONS[item.key] || Icon
const ItemIcon = item.key === 'detail-generator' ? WandSparkles : ITEM_ICONS[item.key] || Icon
return (
<Link
key={item.key}

View File

@ -0,0 +1,150 @@
import { render, waitFor } from '@testing-library/react'
import DetailCanvas from './DetailCanvas'
import { DETAIL_FONT_FAMILY, type DetailScreen } from '@/lib/detail-generator/types'
import { ensureDetailFontsLoaded } from '@/lib/detail-generator/fonts'
const mockTextboxes: { text: string; isEditing: boolean; hiddenTextarea: HTMLTextAreaElement | null; events: Record<string, () => void> }[] = []
const mockCanvases: unknown[] = []
let mockDisposePromise: Promise<unknown> = Promise.resolve(true)
jest.mock('@/lib/detail-generator/fonts', () => ({
...jest.requireActual('@/lib/detail-generator/fonts'),
ensureDetailFontsLoaded: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('fabric', () => {
class Textbox {
type = 'textbox'
text: string
isEditing = false
hiddenTextarea: HTMLTextAreaElement | null = null
events: Record<string, () => void> = {}
constructor(text: string, options: object) { this.text = text; Object.assign(this, options); mockTextboxes.push(this) }
on(name: string, callback: () => void) { this.events[name] = callback }
set(options: object) { Object.assign(this, options) }
exitEditing() { this.isEditing = false; this.events['editing:exited']?.() }
}
class Canvas {
objects: Textbox[] = []
element: HTMLCanvasElement
wrapper: HTMLDivElement
constructor(element: HTMLCanvasElement) {
this.element = element
this.wrapper = document.createElement('div')
this.wrapper.className = 'canvas-container'
element.parentNode!.insertBefore(this.wrapper, element)
this.wrapper.appendChild(element)
const upper = document.createElement('canvas')
upper.className = 'upper-canvas'
this.wrapper.appendChild(upper)
mockCanvases.push(this)
}
add(object: Textbox) { this.objects.push(object) }
getObjects() { return this.objects }
setDimensions() {}
setZoom() {}
requestRenderAll() {}
dispose() {
this.objects.forEach((object) => object.events['editing:exited']?.())
this.wrapper.replaceWith(this.element)
return mockDisposePromise
}
}
return { Canvas, Textbox }
})
const detailScreen: DetailScreen = {
id: 'screen-a', title: '原标题', body: '', imagePrompt: '', width: 750, height: 1200, backgroundColor: '#ffffff',
texts: [{ id: 'screen-a-title', text: '原标题', x: 40, y: 58, width: 670, fontSize: 48, lineHeight: 1.25, color: '#101828', fontFamily: DETAIL_FONT_FAMILY, align: 'left' }],
}
beforeEach(() => {
mockCanvases.length = 0; mockTextboxes.length = 0; mockDisposePromise = Promise.resolve(true)
global.ResizeObserver = class { observe() {}; disconnect() {}; unobserve() {} } as typeof ResizeObserver
jest.mocked(ensureDetailFontsLoaded).mockResolvedValue(undefined)
})
test('初始化错误保留可恢复提示和阶段诊断,不记录私有资源 URL', async () => {
const diagnostics = jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.mocked(ensureDetailFontsLoaded).mockRejectedValueOnce(new Error('Unable to load https://private.example.invalid/font?token=synthetic-token'))
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(diagnostics).toHaveBeenCalledWith('[DetailCanvas] preview failed', {
stage: 'load-fonts', name: 'Error', message: 'Unable to load [resource]',
}))
diagnostics.mockRestore()
})
test('结束未修改的文字编辑不会创建未保存草稿', async () => {
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].events['editing:exited']()
expect(onTextChange).not.toHaveBeenCalled()
mockTextboxes[0].text = '真实修改'
mockTextboxes[0].events['editing:exited']()
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '真实修改')
})
test('正在画布中输入但尚未退出编辑时,已经启用未保存保护', async () => {
const onUnsavedEdit = jest.fn()
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} onUnsavedEdit={onUnsavedEdit} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].events.changed()
expect(onUnsavedEdit).not.toHaveBeenCalled()
mockTextboxes[0].text = '尚未退出画布编辑的修改'
mockTextboxes[0].events.changed()
expect(onUnsavedEdit).toHaveBeenCalledTimes(1)
expect(onTextChange).not.toHaveBeenCalled()
})
test('点击画布外部操作前提交当前文案,避免保存旧文字', async () => {
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
const textbox = mockTextboxes[0]
textbox.hiddenTextarea = document.createElement('textarea')
textbox.events['editing:entered']()
textbox.text = '即将保存的真实文案'
textbox.hiddenTextarea.dispatchEvent(new FocusEvent('blur'))
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '即将保存的真实文案')
})
test('没有 blur 的外部 click 也显式提交并退出原生文字编辑', async () => {
const onTextChange = jest.fn()
const { container } = render(<><DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} /><button type="button">外部保存</button></>)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].text = '点击外部前的实际文本'
mockTextboxes[0].isEditing = true
container.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '点击外部前的实际文本')
expect(mockTextboxes[0].isEditing).toBe(false)
})
test('画布重建清理不会回写文字,且等待旧画布释放后才初始化', async () => {
const onTextChange = jest.fn()
let finishDisposal!: () => void
mockDisposePromise = new Promise<void>((resolve) => { finishDisposal = resolve })
const { rerender } = render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].text = '清理中的临时状态'
rerender(<DetailCanvas screen={{ ...detailScreen, height: 1300 }} disabled={false} onTextChange={onTextChange} />)
expect(onTextChange).not.toHaveBeenCalled()
expect(mockCanvases).toHaveLength(1)
finishDisposal()
await waitFor(() => expect(mockCanvases).toHaveLength(2))
expect(onTextChange).not.toHaveBeenCalled()
})
test('Fabric 真实移动 Canvas DOM 后多次切屏不会与 React insertBefore 冲突', async () => {
const diagnostics = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const { container, rerender } = render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(container.querySelectorAll('.canvas-container')).toHaveLength(1))
for (const id of ['screen-b', 'screen-c', 'screen-a']) {
rerender(<DetailCanvas screen={{ ...detailScreen, id }} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(container.querySelectorAll('.upper-canvas')).toHaveLength(1))
await waitFor(() => expect(container.querySelectorAll('[role="status"]')).toHaveLength(0))
expect(container.querySelectorAll('canvas')).toHaveLength(2)
}
expect(diagnostics).not.toHaveBeenCalled()
diagnostics.mockRestore()
})

View File

@ -0,0 +1,173 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { Canvas, Textbox } from 'fabric'
import type { DetailScreen } from '@/lib/detail-generator/types'
import { ensureDetailFontsLoaded, getDetailImageContainGeometry, getDetailImageCoverGeometry } from '@/lib/detail-generator/fonts'
import { assetUrl } from './client'
/** Editing is deliberately limited to text. The saved template owns geometry. */
export default function DetailCanvas({ screen, disabled, onTextChange, onUnsavedEdit, onPendingTextChange }: {
screen: DetailScreen
disabled: boolean
onTextChange: (layerId: string, text: string) => void
onUnsavedEdit?: () => void
onPendingTextChange?: (layerId: string, text: string) => void
}) {
const hostRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<Canvas | null>(null)
const disposalRef = useRef<Promise<unknown>>(Promise.resolve())
const changeRef = useRef(onTextChange)
const screenRef = useRef(screen)
const unsavedRef = useRef(onUnsavedEdit)
const pendingTextRef = useRef(onPendingTextChange)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
changeRef.current = onTextChange
screenRef.current = screen
unsavedRef.current = onUnsavedEdit
pendingTextRef.current = onPendingTextChange
useEffect(() => {
let cancelled = false
let observer: ResizeObserver | undefined
let canvas: Canvas | undefined
let ownedCanvas: HTMLCanvasElement | undefined
let ownedContainer: HTMLElement | undefined
let initialHost: HTMLDivElement | undefined
let outsideClick: ((event: Event) => void) | undefined
let stage = 'wait-disposal'
setLoading(true)
setError('')
async function render() {
await disposalRef.current
stage = 'load-library'
const fabric = await import('fabric')
stage = 'load-fonts'
await ensureDetailFontsLoaded()
if (cancelled || !hostRef.current || !wrapperRef.current) return
stage = 'create-canvas'
initialHost = hostRef.current
ownedCanvas = document.createElement('canvas')
initialHost.appendChild(ownedCanvas)
canvas = new fabric.Canvas(ownedCanvas, { width: screen.width, height: screen.height, backgroundColor: screen.backgroundColor, selection: false, preserveObjectStacking: true })
ownedContainer = ownedCanvas.parentElement || undefined
canvasRef.current = canvas
const resize = () => {
if (!canvas || cancelled || !wrapperRef.current) return
const scale = Math.min(1, Math.max(1, wrapperRef.current.clientWidth) / screen.width)
canvas.setDimensions({ width: screen.width * scale, height: screen.height * scale })
canvas.setZoom(scale)
}
resize()
observer = new ResizeObserver(resize)
observer.observe(wrapperRef.current)
async function addImage(image: NonNullable<DetailScreen['image']>, contain = false) {
stage = contain ? 'load-logo' : 'load-image'
const bitmap = await fabric.FabricImage.fromURL(assetUrl(image.assetId))
if (cancelled) return
const sourceWidth = bitmap.width || 1
const sourceHeight = bitmap.height || 1
const { offsetX = 0, offsetY = 0, ...geometry } = contain
? getDetailImageContainGeometry(sourceWidth, sourceHeight, image.width, image.height)
: { ...getDetailImageCoverGeometry(sourceWidth, sourceHeight, image.width, image.height), offsetX: 0, offsetY: 0 }
bitmap.set({ left: image.x + offsetX, top: image.y + offsetY, originX: 'left', originY: 'top', ...geometry, selectable: false, evented: false })
canvas?.add(bitmap)
}
if (screen.image) await addImage(screen.image)
if (cancelled || !canvas) return
for (const layer of screen.texts) {
stage = 'create-text'
const textbox = new fabric.Textbox(layer.text, {
left: layer.x, top: layer.y, width: layer.width, fontFamily: layer.fontFamily,
originX: 'left', originY: 'top',
fontSize: layer.fontSize, lineHeight: layer.lineHeight, fill: layer.color,
textAlign: layer.align, splitByGrapheme: true, padding: 0, strokeWidth: 0,
lockMovementX: true, lockMovementY: true, lockScalingX: true, lockScalingY: true,
lockRotation: true, hasControls: false, hasBorders: false,
selectable: !disabled, editable: !disabled,
selectionColor: 'rgba(22,119,255,0.18)', editingBorderColor: '#1677ff', cursorColor: layer.color,
})
textbox.on('editing:exited', () => {
if (cancelled) return
const text = textbox.text || ''
if (screenRef.current.texts.find((current) => current.id === layer.id)?.text === text) return
changeRef.current(layer.id, text)
})
textbox.on('changed', () => {
if (!cancelled && screenRef.current.texts.find((current) => current.id === layer.id)?.text !== (textbox.text || '')) {
pendingTextRef.current?.(layer.id, textbox.text || '')
unsavedRef.current?.()
}
})
textbox.on('editing:entered', () => {
// Fabric only stops its cursor on blur; commit before an outside save/link click.
textbox.hiddenTextarea?.addEventListener('blur', () => { if (!cancelled) textbox.exitEditing() }, { once: true })
})
canvas.add(textbox)
}
if (screen.logo) await addImage(screen.logo, true)
if (cancelled || !canvas) return
outsideClick = (event) => {
if (cancelled || !canvas || (event.target instanceof Node && wrapperRef.current?.contains(event.target))) return
for (const object of canvas.getObjects()) {
if (object.type !== 'textbox') continue
const textbox = object as Textbox
if (!textbox.isEditing) continue
const index = canvas.getObjects().filter((item) => item.type === 'textbox').indexOf(textbox)
const layer = screenRef.current.texts[index]
if (layer && layer.text !== (textbox.text || '')) changeRef.current(layer.id, textbox.text || '')
textbox.exitEditing()
}
}
document.addEventListener('pointerdown', outsideClick, true)
document.addEventListener('click', outsideClick, true)
canvas.requestRenderAll()
if (!cancelled) setLoading(false)
}
render().catch((cause: unknown) => {
if (!cancelled) {
const name = cause instanceof Error ? cause.name : 'UnknownError'
const raw = cause instanceof Error ? cause.message : 'Unknown failure'
const message = raw.replace(/https?:\/\/\S+/g, '[resource]').slice(0, 240)
console.error('[DetailCanvas] preview failed', { stage, name, message })
setError('预览加载失败。可重新打开此屏,或使用右侧文案编辑。'); setLoading(false)
}
})
return () => {
cancelled = true
observer?.disconnect()
if (outsideClick) {
document.removeEventListener('pointerdown', outsideClick, true)
document.removeEventListener('click', outsideClick, true)
}
canvasRef.current = null
const disposal = canvas ? canvas.dispose().catch(() => undefined) : Promise.resolve()
disposalRef.current = disposal.then(() => {
ownedCanvas?.remove()
if (ownedContainer && ownedContainer !== initialHost) ownedContainer.remove()
})
}
// Keep local Fabric text edits alive until the template or image actually changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screen.id, screen.image?.assetId, screen.image?.x, screen.image?.y, screen.image?.width, screen.image?.height, screen.logo?.assetId, screen.logo?.x, screen.logo?.y, screen.logo?.width, screen.logo?.height, screen.width, screen.height, screen.backgroundColor])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const textboxes = canvas.getObjects().filter((object) => object.type === 'textbox') as Textbox[]
screen.texts.forEach((layer, index) => {
const object = textboxes[index]
if (!object || object.isEditing) return
object.set({ text: layer.text, left: layer.x, top: layer.y, width: layer.width, fill: layer.color, fontSize: layer.fontSize, lineHeight: layer.lineHeight, textAlign: layer.align, selectable: !disabled, editable: !disabled })
})
canvas.requestRenderAll()
}, [screen.texts, disabled, loading])
return <div ref={wrapperRef} className="mx-auto w-full max-w-[750px]" aria-label={`${screen.title || '详情页'}预览`}>
{loading && <p role="status" className="mb-3 text-center text-sm text-[#66758b]">正在载入预览…</p>}
{error && <p role="alert" className="mb-3 text-sm text-[#b42318]">{error}</p>}
<div ref={hostRef} role="img" aria-label={`${screen.title}。文案可通过旁边的编辑框修改。`} className="max-w-full" />
</div>
}

View File

@ -0,0 +1,185 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import DetailGeneratorWorkspace from './DetailGeneratorWorkspace'
import { newDetailDocument } from './client'
import { DETAIL_MODEL_DEFAULTS, type DetailConfig, type DetailJob, type DetailProject } from '@/lib/detail-generator/types'
jest.mock('./DetailCanvas', () => ({ __esModule: true, default: ({ screen: preview, onPendingTextChange, onUnsavedEdit }: any) => <div data-testid="detail-canvas">预览<button type="button" onClick={() => { onPendingTextChange?.(`${preview.id}-body`, '画布新文案\n保存后保持换行。'); onUnsavedEdit?.() }}>模拟原生画布输入</button></div> }))
jest.mock('next/navigation', () => {
const router = { push: jest.fn() }
return { useRouter: () => router }
})
const config: DetailConfig = {
access: true, configured: true, brands: ['品牌甲'], brandProfiles: [],
defaults: DETAIL_MODEL_DEFAULTS,
models: { analysis: ['gemini-2.5-flash'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2'] },
}
const createProject = (): DetailProject => ({
id: 'project-a', ownerId: 'superadmin-a', revision: 3,
createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z',
document: { ...newDetailDocument(), name: '座椅项目', product: { ...newDetailDocument().product, name: '橡木座椅', facts: '材质:橡木', assetIds: ['product-image'] }, brand: { ...newDetailDocument().brand, name: '品牌甲' } },
})
const createJob = (status: DetailJob['status']): DetailJob => ({ id: 'job-a', projectId: 'project-a', ownerId: 'superadmin-a', kind: 'generate', status, payload: {}, checkpoint: {}, progress: 0, error: null, requestId: null, createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' })
function response(body: unknown, status = 200) { return { ok: status >= 200 && status < 300, status, json: async () => body } as Response }
beforeEach(() => {
window.history.replaceState(null, '', '/assets/detail-generator'); jest.clearAllMocks()
jest.spyOn(window.history, 'back').mockImplementation(() => undefined)
jest.spyOn(window.history, 'forward').mockImplementation(() => undefined)
})
afterEach(() => jest.restoreAllMocks())
async function openProject(project: DetailProject, jobs: DetailJob[] = [], additional?: (url: string, init: RequestInit) => Response | undefined, overrideConfig = config) {
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
const overridden = additional?.(String(url), init)
if (overridden) return overridden
if (url === '/api/detail-generator') return response({ config: overrideConfig, projects: [project] })
if (url === '/api/detail-generator/projects/project-a') return response({ project, assets: [], jobs })
throw new Error(`Unexpected request ${url}`)
})
global.fetch = fetchMock as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
await waitFor(() => expect(screen.getByLabelText('项目名称')).toHaveValue('座椅项目'))
return fetchMock
}
test('模型未配置时仍可准备资料,不能发起模型调用', async () => {
const fetchMock = await openProject(createProject(), [], undefined, { ...config, configured: false })
expect(screen.getByText('模型服务尚未配置。可先准备项目资料。')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '分析卖点与大纲' })).toBeDisabled()
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
})
test('保存遇到版本冲突时保留用户未保存的产品信息', async () => {
const fetchMock = await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '项目已被更新,请重新打开后合并修改' }, 409) : undefined)
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '新增真实参数:座高 45cm' } })
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await screen.findByText('项目已被更新,请重新打开后合并修改')
expect(screen.getByLabelText('已确认的产品信息')).toHaveValue('新增真实参数:座高 45cm')
expect(JSON.parse(String(fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')?.[1]?.body)).revision).toBe(3)
})
test('上传携带版本号并采用服务端返回项目,不再重复覆盖文档', async () => {
const project = createProject()
const asset = { id: 'new-image', name: '产品.png', mimeType: 'image/png', kind: 'product' }
const next = { ...project, revision: 4, document: { ...project.document, product: { ...project.document.product, assetIds: [...project.document.product.assetIds, asset.id] } } }
const fetchMock = await openProject(project, [], (url, init) => url.endsWith('/assets') && init.method === 'POST' ? response({ asset, project: next }) : undefined)
fireEvent.change(screen.getByLabelText('上传产品图'), { target: { files: [new File(['pixels'], '产品.png', { type: 'image/png' })] } })
await screen.findByAltText('产品.png')
const uploadCall = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/assets'))
expect((uploadCall?.[1]?.body as FormData).get('revision')).toBe('3')
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false)
})
test('选择已保存品牌草案加载调性并保留本项目私有标志', async () => {
const project = createProject()
project.document.brand.logoAssetId = 'own-logo'
await openProject(project, [], undefined, { ...config, brandProfiles: [{ id: 'brand-b', revision: 2, profile: { ...newDetailDocument().brand, name: '品牌乙', tone: '自然、温暖', primaryColor: '#335533' } }] })
fireEvent.change(screen.getByLabelText('品牌', { exact: true }), { target: { value: '品牌乙' } })
expect(screen.getByLabelText('调性')).toHaveValue('自然、温暖')
expect(screen.getByLabelText('品牌色')).toHaveValue('#335533')
expect(within(screen.getByRole('region', { name: '品牌标志' })).getByRole('button', { name: '移除素材' })).toBeInTheDocument()
})
test('分析完成后必须确认大纲才能生成,确认后分别调用审批与生成接口', async () => {
const project = createProject()
project.document.analysis = { sellingPoints: ['橡木材质'], warnings: [], screens: [{ id: 'screen-a', title: '橡木座椅', body: '自然质感', imagePrompt: '浅色环境中的橡木座椅,不生成文字' }] }
const approved = { ...project, revision: 4, document: { ...project.document, approvedAt: '2026-10-12T01:00:00Z' } }
const fetchMock = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/detail-generator') return response({ config, projects: [project] })
if (url.endsWith('/approve')) return response({ project: approved })
if (url.endsWith('/generate')) return response({ job: createJob('queued') })
return response({ project, assets: [], jobs: [] })
})
global.fetch = fetchMock as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
const confirm = await screen.findByRole('button', { name: '确认大纲并生成' })
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
fireEvent.click(confirm)
await waitFor(() => expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/generate'))).toBe(true))
const approval = fetchMock.mock.calls.find(([url]) => url.endsWith('/approve'))
expect(JSON.parse(String(approval?.[1]?.body)).revision).toBe(3)
const generation = fetchMock.mock.calls.find(([url]) => url.endsWith('/generate'))
expect(JSON.parse(String(generation?.[1]?.body)).idempotencyKey).toEqual(expect.any(String))
})
test('结果未知时停止生成与导出操作,不自动重新请求模型', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' }] }
project.document.screens = [{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [], image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } }]
const unknown = { ...createJob('unknown'), requestId: 'provider-request-a' }
const fetchMock = jest.fn(async (url: string, _init?: RequestInit) => url === '/api/detail-generator' ? response({ config, projects: [project] }) : response({ project, assets: [], jobs: [unknown] }))
global.fetch = fetchMock as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
await screen.findByText('模型结果待确认')
expect(screen.getByRole('button', { name: '重新生成此屏' })).toBeDisabled()
expect(screen.getByRole('button', { name: '导出' })).toBeDisabled()
expect(screen.getByText('请求编号:provider-request-a')).toBeInTheDocument()
expect(fetchMock.mock.calls.every(([, init]) => !init || (init as RequestInit).method !== 'POST')).toBe(true)
})
test('生成部分失败时可明确继续剩余屏,完整导出保持禁用', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [
{ id: 'screen-a', title: '橡木', body: '', imagePrompt: '橡木座椅' },
{ id: 'screen-b', title: '结构', body: '', imagePrompt: '座椅结构' },
] }
const template = { title: '橡木', body: '', imagePrompt: '橡木座椅', width: 750, height: 1000, backgroundColor: '#ffffff', texts: [] }
project.document.screens = [
{ ...template, id: 'screen-a', image: { assetId: 'product-image', x: 0, y: 0, width: 750, height: 1000 } },
{ ...template, id: 'screen-b' },
]
global.fetch = jest.fn(async (url: string) => url === '/api/detail-generator' ? response({ config, projects: [project] }) : response({ project, assets: [], jobs: [{ ...createJob('failed'), error: '第二屏生成失败' }] })) as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
expect(await screen.findByRole('button', { name: '继续生成剩余屏' })).toBeEnabled()
expect(screen.getByRole('button', { name: '导出' })).toBeDisabled()
})
test('编辑中权限失效后,返回素材库仍显示可取消的未保存确认', async () => {
await openProject(createProject(), [], (_url, init) => init.method === 'PATCH' ? response({ error: '此功能暂仅向超级管理员开放' }, 403) : undefined)
fireEvent.change(screen.getByLabelText('已确认的产品信息'), { target: { value: '我未保存的真实资料' } })
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await screen.findByRole('heading', { name: '详情页生成暂不可用' })
fireEvent.click(screen.getByRole('link', { name: '素材资产库' }))
expect(await screen.findByRole('alertdialog', { name: '离开此页面?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByRole('link', { name: '素材资产库' })).toBeInTheDocument()
expect(window.location.pathname).toBe('/assets/detail-generator')
})
test('保存前合并尚未退出原生画布编辑的最新文字,不能保存旧 React 文案', async () => {
const project = createProject()
project.document.approvedAt = '2026-10-12T01:00:00Z'
project.document.analysis = { sellingPoints: ['橡木'], warnings: [], screens: [{ id: 'screen-a', title: '橡木', body: '旧文案', imagePrompt: '座椅' }] }
project.document.screens = [{
id: 'screen-a', title: '橡木', body: '旧文案', imagePrompt: '座椅', width: 750, height: 1200, backgroundColor: '#ffffff', image: { assetId: 'product-image', x: 0, y: 350, width: 750, height: 750 },
texts: [
{ id: 'screen-a-title', text: '橡木', x: 40, y: 58, width: 670, fontSize: 48, lineHeight: 1.25, color: '#101828', fontFamily: 'Noto Sans SC', align: 'left' },
{ id: 'screen-a-body', text: '旧文案', x: 40, y: 160, width: 670, fontSize: 28, lineHeight: 1.5, color: '#101828', fontFamily: 'Noto Sans SC', align: 'left' },
],
}]
let savedBody = ''
global.fetch = jest.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/detail-generator') return response({ config, projects: [project] })
if (url.endsWith('/screens/screen-a')) {
const body = JSON.parse(String(init.body))
savedBody = body.screen.body
return response({ project: { ...project, revision: 4, document: { ...project.document, screens: [body.screen] } } })
}
return response({ project, assets: [], jobs: [] })
}) as typeof fetch
render(<DetailGeneratorWorkspace />)
fireEvent.click(await screen.findByRole('button', { name: /座椅项目/ }))
fireEvent.click(await screen.findByRole('button', { name: '模拟原生画布输入' }))
expect(screen.getByLabelText('正文')).toHaveValue('旧文案')
fireEvent.click(screen.getByRole('button', { name: '保存修改' }))
await waitFor(() => expect(savedBody).toBe('画布新文案\n保存后保持换行。'))
await waitFor(() => expect(screen.getByLabelText('正文')).toHaveValue('画布新文案\n保存后保持换行。'))
})

View File

@ -0,0 +1,309 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ArrowLeft, Check, Download, Loader2, Plus, Save, WandSparkles } from 'lucide-react'
import Link from 'next/link'
import type { DetailAsset, DetailAssetKind, DetailBrandProfileRecord, DetailConfig, DetailDocument, DetailExportFormat, DetailJob, DetailProject, DetailScreen } from '@/lib/detail-generator/types'
import { reflowDetailScreen } from '@/lib/detail-generator/layout'
import DetailProductForm, { detailButtonClass, detailPrimaryClass } from './DetailProductForm'
import DetailOutlineEditor from './DetailOutlineEditor'
import DetailScreenEditor from './DetailScreenEditor'
import { actionKey, assetUrl, DetailClientError, detailRequest, newDetailDocument } from './client'
import { useUnsavedDetailGuard } from './useUnsavedDetailGuard'
interface ProjectDetail { project: DetailProject; assets: DetailAsset[]; jobs: DetailJob[] }
interface MaterialMatch { materialId: string; productModel: string; skuCode: string; variant: string; assets: { id: string; source: 'asset' | 'sku'; name: string; previewUrl: string }[] }
const activeStatuses = new Set(['queued', 'running'])
const jobLabel = { analyze: '分析资料', generate: '生成详情页', regenerate: '重新生成此屏', export: '导出' }
const exportLabel = { png: '分屏 PNG', jpg: '分屏 JPG', 'long-png': '完整长图', psd: '分层 PSD' }
export default function DetailGeneratorWorkspace() {
const [config, setConfig] = useState<DetailConfig | null>(null)
const [projects, setProjects] = useState<DetailProject[]>([])
const [project, setProject] = useState<DetailProject | null>(null)
const [draft, setDraft] = useState<DetailDocument>(newDetailDocument)
const [assets, setAssets] = useState<DetailAsset[]>([])
const [jobs, setJobs] = useState<DetailJob[]>([])
const [matches, setMatches] = useState<MaterialMatch[] | null>(null)
const [dirty, setDirty] = useState(false)
const departureDialog = useUnsavedDetailGuard(dirty)
const [busy, setBusy] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const [locked, setLocked] = useState(false)
const selectedRef = useRef<string | null>(null)
const busyRef = useRef(false)
const pendingCanvasRef = useRef(new Map<string, Map<string, string>>())
const idempotencyRef = useRef(new Map<string, string>())
const activeJob = jobs.find((job) => activeStatuses.has(job.status))
const activeJobId = activeJob?.id
const currentProjectId = project?.id
const unknownJob = jobs.find((job) => job.status === 'unknown')
const unavailable = busy || !!activeJob || locked
const updateProject = useCallback((next: DetailProject) => {
pendingCanvasRef.current.clear()
setProject(next)
selectedRef.current = next.id
setDraft(next.document)
setDirty(false)
setProjects((current) => [next, ...current.filter((item) => item.id !== next.id)])
}, [])
const reportError = useCallback((cause: unknown) => {
if (cause instanceof DetailClientError && [401, 403].includes(cause.status)) setLocked(true)
setError(cause instanceof Error ? cause.message : '操作未完成,请稍后重试')
}, [])
const selectProject = useCallback(async (id: string) => {
setBusy(true)
setError('')
setNotice('')
setMatches(null)
try {
const detail = await detailRequest<ProjectDetail>(`/projects/${encodeURIComponent(id)}`)
updateProject(detail.project)
setAssets(detail.assets)
setJobs(detail.jobs)
const url = new URL(window.location.href)
url.searchParams.set('project', id)
window.history.replaceState(null, '', url)
} catch (cause) { reportError(cause) } finally { setBusy(false) }
}, [reportError, updateProject])
useEffect(() => {
let cancelled = false
async function load() {
try {
const result = await detailRequest<{ config: DetailConfig; projects: DetailProject[] }>('')
if (cancelled) return
setConfig(result.config)
setProjects(result.projects)
const requested = new URLSearchParams(window.location.search).get('project')
if (requested && result.projects.some((item) => item.id === requested)) await selectProject(requested)
} catch (cause) { if (!cancelled) reportError(cause) } finally { if (!cancelled) setLoading(false) }
}
void load()
return () => { cancelled = true }
}, [reportError, selectProject])
useEffect(() => {
if (!activeJobId || !currentProjectId || locked) return
let cancelled = false
let timer: ReturnType<typeof setTimeout>
const projectId = currentProjectId
async function poll() {
try {
const detail = await detailRequest<ProjectDetail>(`/projects/${encodeURIComponent(projectId)}`)
if (cancelled || selectedRef.current !== projectId) return
updateProject(detail.project)
setAssets(detail.assets)
setJobs(detail.jobs)
if (detail.jobs.some((job) => activeStatuses.has(job.status))) timer = setTimeout(poll, 2500)
} catch (cause) {
if (cancelled) return
reportError(cause)
if (!(cause instanceof DetailClientError && [401, 403].includes(cause.status))) timer = setTimeout(poll, 5000)
}
}
timer = setTimeout(poll, 1000)
return () => { cancelled = true; clearTimeout(timer) }
}, [activeJobId, currentProjectId, locked, reportError, updateProject])
function changeDocument(next: DetailDocument) {
if (JSON.stringify(next) === JSON.stringify(draft)) return
setDraft(next); setDirty(true); setNotice(''); setError('')
}
async function run(action: () => Promise<void>) {
if (unavailable || busyRef.current) return
busyRef.current = true
setBusy(true)
setError('')
setNotice('')
try { await action() } catch (cause) { reportError(cause) } finally { busyRef.current = false; setBusy(false) }
}
async function saveDocument(document = draft): Promise<DetailProject> {
const normalized = { ...document, screens: document.screens.map(flushCanvasScreen), name: document.name.trim() || document.product.name.trim() || document.product.identifier.trim() || '未命名项目' }
const result = project
? await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}`, { method: 'PATCH', body: JSON.stringify({ document: normalized, revision: project.revision }) })
: await detailRequest<{ project: DetailProject }>('', { method: 'POST', body: JSON.stringify({ document: normalized }) })
updateProject(result.project)
return result.project
}
async function queueJob(saved: DetailProject, path: string, identity: string, extra: Record<string, unknown> = {}) {
const key = `${saved.id}:${identity}`
const idempotencyKey = idempotencyRef.current.get(key) || actionKey()
idempotencyRef.current.set(key, idempotencyKey)
const result = await detailRequest<{ job: DetailJob }>(`/projects/${encodeURIComponent(saved.id)}${path}`, { method: 'POST', body: JSON.stringify({ ...extra, idempotencyKey }) })
idempotencyRef.current.delete(key)
setJobs((current) => [result.job, ...current.filter((item) => item.id !== result.job.id)])
}
function analyze() {
void run(async () => {
if (!draft.product.name.trim()) throw new Error('请填写产品名称')
if (!draft.product.facts.trim() && !draft.product.documentAssetIds.length) throw new Error('请填写真实产品信息或上传产品资料')
if (!draft.product.assetIds.length) throw new Error('请上传产品图,或查找并选用已有素材')
if (!draft.brand.name.trim()) throw new Error('请选择或填写品牌')
const saved = await saveDocument({ ...draft, analysis: null, approvedAt: null, screens: [] })
await queueJob(saved, '/analyze', 'analyze')
})
}
function approveAndGenerate() {
void run(async () => {
if (!draft.analysis?.screens.length) throw new Error('请至少保留一屏大纲')
if (draft.analysis.screens.some((screen) => !screen.title.trim() || !screen.imagePrompt.trim())) throw new Error('请补全各屏标题与画面要求')
const saved = project || await saveDocument()
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/approve`, { method: 'POST', body: JSON.stringify({ analysis: draft.analysis, revision: saved.revision }) })
updateProject(result.project)
await queueJob(result.project, '/generate', 'generate')
})
}
function upload(files: File[], kind: DetailAssetKind) {
void run(async () => {
let saved = dirty || !project ? await saveDocument() : project
for (const file of files) {
const form = new FormData()
form.set('file', file)
form.set('kind', kind)
form.set('revision', String(saved.revision))
const result = await detailRequest<{ asset: DetailAsset; project: DetailProject }>(`/projects/${encodeURIComponent(saved.id)}/assets`, { method: 'POST', body: form })
saved = result.project
updateProject(saved)
setAssets((current) => [...current, result.asset])
}
})
}
function removeAsset(id: string) {
const document = structuredClone(draft)
document.product.assetIds = document.product.assetIds.filter((item) => item !== id)
document.product.referenceAssetIds = document.product.referenceAssetIds.filter((item) => item !== id)
document.product.documentAssetIds = document.product.documentAssetIds.filter((item) => item !== id)
if (document.brand.logoAssetId === id) delete document.brand.logoAssetId
changeDocument(document)
}
function findMaterials() {
void run(async () => {
const query = new URLSearchParams({ identifierType: draft.product.identifierType, identifier: draft.product.identifier })
const result = await detailRequest<{ matches: MaterialMatch[] }>(`/materials?${query}`)
setMatches(result.matches)
})
}
function importMaterial(match: MaterialMatch, asset: MaterialMatch['assets'][number]) {
void run(async () => {
const saved = dirty || !project ? await saveDocument() : project
const result = await detailRequest<{ project: DetailProject; asset: DetailAsset }>(`/projects/${encodeURIComponent(saved.id)}/materials`, { method: 'POST', body: JSON.stringify({ materialId: match.materialId, source: asset.source, sourceId: asset.id, revision: saved.revision }) })
updateProject(result.project)
setAssets((current) => [...current, result.asset])
setNotice('已添加产品图')
})
}
function changeScreen(screen: DetailScreen) {
const previous = draft.screens.find((current) => current.id === screen.id)
const pending = pendingCanvasRef.current.get(screen.id)
// Retain native edits through a save click until the server accepts them.
// A subsequent inspector edit intentionally supersedes that layer instead.
for (const layer of screen.texts) {
if (layer.text !== previous?.texts.find((current) => current.id === layer.id)?.text && pending?.has(layer.id) && pending.get(layer.id) !== layer.text) pending.delete(layer.id)
}
if (JSON.stringify(screen) === JSON.stringify(draft.screens.find((current) => current.id === screen.id))) return
try {
const reflowed = reflowDetailScreen(screen)
changeDocument({ ...draft, screens: draft.screens.map((item) => item.id === screen.id ? reflowed : item) })
} catch (cause) {
setDraft({ ...draft, screens: draft.screens.map((item) => item.id === screen.id ? screen : item) })
setDirty(true)
reportError(cause)
}
}
function saveScreen(screen: DetailScreen) {
void run(async () => {
if (!project) return
const latestScreen = flushCanvasScreen(screen)
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/screens/${encodeURIComponent(screen.id)}`, { method: 'PATCH', body: JSON.stringify({ screen: latestScreen, revision: project.revision }) })
updateProject(result.project)
setNotice('修改已保存')
})
}
function replaceImage(screen: DetailScreen, file: File) {
void run(async () => {
if (!project) return
const form = new FormData()
form.set('file', file)
form.set('kind', 'product')
form.set('purpose', 'screen-replacement')
form.set('revision', String(project.revision))
const uploaded = await detailRequest<{ asset: DetailAsset; project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/assets`, { method: 'POST', body: form })
setAssets((current) => [...current, uploaded.asset])
const image = screen.image ? { ...screen.image, assetId: uploaded.asset.id } : { assetId: uploaded.asset.id, x: 0, y: 300, width: screen.width, height: screen.height - 300 }
// Keep edited text in the draft if attaching the uploaded image conflicts.
setProject(uploaded.project)
setProjects((current) => [uploaded.project, ...current.filter((item) => item.id !== uploaded.project.id)])
const result = await detailRequest<{ project: DetailProject }>(`/projects/${encodeURIComponent(project.id)}/screens/${encodeURIComponent(screen.id)}`, { method: 'PATCH', body: JSON.stringify({ screen: { ...screen, image }, revision: uploaded.project.revision }) })
updateProject(result.project)
setNotice('图片已替换')
})
}
function regenerate(screenId: string) { void run(async () => { if (project) await queueJob(project, `/screens/${encodeURIComponent(screenId)}`, `regenerate:${screenId}`) }) }
function recordPendingText(screenId: string, layerId: string, text: string) {
const pending = pendingCanvasRef.current.get(screenId) || new Map<string, string>()
pending.set(layerId, text)
pendingCanvasRef.current.set(screenId, pending)
}
function flushCanvasScreen(screen: DetailScreen): DetailScreen {
const pending = pendingCanvasRef.current.get(screen.id)
if (!pending) return screen
const texts = screen.texts.map((layer) => pending.has(layer.id) ? { ...layer, text: pending.get(layer.id)! } : layer)
return reflowDetailScreen({ ...screen, texts, title: texts[0]?.text ?? screen.title, body: texts[1]?.text ?? screen.body })
}
function saveBrand() {
void run(async () => {
const { logoAssetId: _logo, ...profile } = draft.brand
const saved = config?.brandProfiles.find((item) => item.profile.name === profile.name)
const result = await detailRequest<{ profile: DetailBrandProfileRecord }>('/brand-profiles', { method: 'PUT', body: JSON.stringify({ profile, ...(saved ? { revision: saved.revision } : {}) }) })
setConfig((current) => current ? { ...current, brandProfiles: [result.profile, ...current.brandProfiles.filter((item) => item.id !== result.profile.id)] } : current)
setNotice('品牌草案已保存')
})
}
function exportProject(format: DetailExportFormat) { void run(async () => { if (project) await queueJob(project, '/exports', `export:${format}`, { format }) }) }
function newProject() {
pendingCanvasRef.current.clear()
setProject(null); selectedRef.current = null; setDraft(newDetailDocument()); setAssets([]); setJobs([]); setDirty(false); setMatches(null); setError(''); setNotice('')
window.history.replaceState(null, '', '/assets/detail-generator')
}
if (loading) return <main className="px-4 py-6 sm:px-6"><p role="status" className="text-sm text-[#66758b]">正在载入详情页工作台…</p></main>
if (locked) return <main className="mx-auto max-w-3xl px-4 py-8">{departureDialog}<h1 className="text-xl font-semibold">详情页生成暂不可用</h1><p role="alert" className="mt-3 text-sm text-[#66758b]">{error || '此功能暂仅向超级管理员开放'}</p><Link href="/assets/material-library" className={`${detailButtonClass} mt-5`}><ArrowLeft className="h-4 w-4" aria-hidden="true" />素材资产库</Link></main>
const latestJob = jobs.reduce<DetailJob | undefined>((latest, job) => !latest || job.createdAt > latest.createdAt ? job : latest, undefined)
const latestFailure = latestJob?.status === 'failed' ? latestJob : undefined
const exportJobs = jobs.filter((job) => job.kind === 'export' && job.status === 'succeeded' && typeof job.checkpoint.exportAssetId === 'string').slice(0, 3)
return <main className="detail-generator-workspace mx-auto max-w-[1600px] px-4 py-5 selection:bg-[#dbeafe] selection:text-[#101828] sm:px-6">
{departureDialog}
<div className="mb-5 flex flex-wrap items-center justify-between gap-3"><div className="flex items-center gap-3"><h1 className="text-xl font-bold tracking-[-0.02em]">详情页生成</h1><span className="rounded-md bg-[#edf5ff] px-2 py-1 text-xs font-medium text-[#176fc1]">内测</span></div><div className="flex items-center gap-2">{dirty && <span role="status" className="hidden text-xs text-[#66758b] sm:inline">有未保存修改</span>}<button type="button" className={detailButtonClass} disabled={unavailable || !dirty} onClick={() => void run(async () => { await saveDocument(); setNotice('项目已保存') })}><Save className="h-4 w-4" aria-hidden="true" />保存</button><button type="button" className={detailButtonClass} disabled={unavailable || dirty} onClick={newProject}><Plus className="h-4 w-4" aria-hidden="true" />新建项目</button></div></div>
{(error || notice) && <div role={error ? 'alert' : 'status'} className={`mb-4 flex flex-wrap items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${error ? 'bg-[#fff0ee] text-[#b42318]' : 'bg-[#e9f8f2] text-[#087052]'}`}><span className="min-w-0 flex-1 break-words">{error || notice}</span>{error && <button type="button" onClick={() => setError('')} className="min-h-9 rounded-md px-2 font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">关闭</button>}</div>}
{!config?.configured && <p role="status" className="mb-4 text-sm text-[#854a0e]">模型服务尚未配置。可先准备项目资料。</p>}
<div className="grid min-w-0 gap-5 lg:grid-cols-[200px_minmax(0,1fr)]">
<aside aria-label="我的详情页项目" className="min-w-0"><h2 className="mb-2 text-sm font-semibold text-[#4f6077]">我的项目</h2><div className="flex gap-2 overflow-x-auto pb-2 lg:max-h-[calc(100vh-210px)] lg:flex-col lg:overflow-y-auto lg:pb-0">{!projects.length && <p className="py-3 text-sm text-[#66758b]">还没有项目</p>}{projects.map((item) => <button type="button" key={item.id} disabled={unavailable || dirty} aria-current={project?.id === item.id ? 'page' : undefined} onClick={() => void selectProject(item.id)} className={`min-h-14 w-40 shrink-0 rounded-lg px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-60 lg:w-full ${project?.id === item.id ? 'bg-[#e6f4ff] text-[#0050cb]' : 'text-[#344054] hover:bg-[#eef2f6]'}`}><span className="block truncate text-sm font-medium" title={item.document.name}>{item.document.name}</span><span className="mt-1 block truncate text-xs text-[#66758b]">{item.document.brand.name || '品牌未填写'}{item.document.screens.length ? ` · ${item.document.screens.length} 屏` : ' · 草稿'}</span></button>)}</div></aside>
<div className="min-w-0 rounded-xl border border-[#e2e8f0] bg-white p-4 sm:p-5">
{activeJob && <div role="status" aria-live="polite" className="mb-5 flex items-center gap-2 border-b border-[#e2e8f0] pb-4 text-sm text-[#176fc1]"><Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" />{jobLabel[activeJob.kind]}{activeJob.status === 'queued' ? ' · 等待执行' : ' · 进行中'}{activeJob.kind === 'generate' && draft.analysis ? ` · ${draft.screens.filter((screen) => screen.image).length}/${draft.analysis.screens.length} 屏` : ''}</div>}
{unknownJob && <div role="alert" className="mb-5 rounded-lg bg-[#fffaeb] p-3 text-sm text-[#854a0e]"><p className="font-semibold">模型结果待确认</p><p className="mt-1">{unknownJob.error || '请求已经发出,结果尚未确认。请勿重复生成。'}</p>{unknownJob.requestId && <p className="mt-2 break-all text-xs">请求编号:{unknownJob.requestId}</p>}</div>}
{latestFailure && !activeJob && !unknownJob && <p role="alert" className="mb-4 rounded-lg bg-[#fff0ee] p-3 text-sm text-[#b42318]">{jobLabel[latestFailure.kind]}未完成:{latestFailure.error || '请检查项目资料后重试'}</p>}
{!draft.analysis && !draft.approvedAt ? <><DetailProductForm document={draft} config={config} assets={assets} busy={unavailable} onChange={changeDocument} onUpload={upload} onRemoveAsset={removeAsset} onFindMaterials={findMaterials} onSaveBrand={saveBrand} />{matches && <section aria-label="匹配的产品素材" className="mt-5 border-t border-[#e2e8f0] pt-4"><div className="mb-3 flex items-center justify-between"><h2 className="text-sm font-semibold">匹配素材</h2><button type="button" onClick={() => setMatches(null)} className="min-h-9 rounded-md px-2 text-sm text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">收起</button></div>{!matches.length && <p className="text-sm text-[#66758b]">没有找到当前可用的素材,可直接上传产品图。</p>}{matches.map((match) => <div key={`${match.materialId}:${match.skuCode}`} className="mb-4"><p className="mb-2 text-sm font-medium">{match.variant || match.skuCode || match.productModel}</p><div className="flex flex-wrap gap-2">{match.assets.map((asset) => <button type="button" key={`${asset.source}:${asset.id}`} disabled={unavailable} onClick={() => importMaterial(match, asset)} className="w-28 rounded-lg border border-[#e2e8f0] p-2 text-left hover:border-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50"><img src={asset.previewUrl} alt={asset.name} className="h-20 w-full object-contain" /><span className="mt-1 block truncate text-xs text-[#4f6077]">{asset.name}</span><span className="mt-1 block text-xs font-medium text-[#176fc1]">使用图片</span></button>)}</div></div>)}</section>}<div className="mt-6 flex justify-end border-t border-[#e2e8f0] pt-4"><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={analyze}>{busy ? <Loader2 className="h-4 w-4 motion-safe:animate-spin" aria-hidden="true" /> : <WandSparkles className="h-4 w-4" aria-hidden="true" />}分析卖点与大纲</button></div></> : !draft.approvedAt && draft.analysis ? <><DetailOutlineEditor analysis={draft.analysis} busy={unavailable} onChange={(analysis) => changeDocument({ ...draft, analysis })} /><div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-[#e2e8f0] pt-4"><button type="button" className={detailButtonClass} disabled={unavailable} onClick={() => changeDocument({ ...draft, analysis: null })}><ArrowLeft className="h-4 w-4" aria-hidden="true" />修改资料</button><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={approveAndGenerate}><Check className="h-4 w-4" aria-hidden="true" />确认大纲并生成</button></div></> : <><DetailScreenEditor screens={draft.screens} configured={config?.configured === true} complete={draft.screens.length === draft.analysis?.screens.length && draft.screens.every((screen) => !!screen.image)} busy={unavailable || !!unknownJob} dirty={dirty} onChange={changeScreen} onUnsavedEdit={() => setDirty(true)} onPendingTextChange={recordPendingText} onSave={saveScreen} onReplaceImage={replaceImage} onRegenerate={regenerate} onExport={exportProject} />{draft.screens.filter((screen) => screen.image).length < (draft.analysis?.screens.length || 1) && !activeJob && <div className="mt-4 flex justify-center"><button type="button" className={detailPrimaryClass} disabled={unavailable || !config?.configured || !!unknownJob} onClick={() => void run(async () => { if (project) await queueJob(project, '/generate', 'generate') })}><WandSparkles className="h-4 w-4" aria-hidden="true" />{draft.screens.some((screen) => screen.image) ? '继续生成剩余屏' : '生成详情页'}</button></div>}</>}
{!!exportJobs.length && <div aria-label="导出文件" className="mt-5 flex flex-wrap gap-2 border-t border-[#e2e8f0] pt-4">{exportJobs.map((job) => <a key={job.id} href={assetUrl(String(job.checkpoint.exportAssetId))} download className={detailButtonClass}><Download className="h-4 w-4" aria-hidden="true" />下载{exportLabel[job.payload.format as DetailExportFormat] || '导出文件'}</a>)}</div>}
{dirty && !unavailable && <div className="mt-4 flex justify-end"><button type="button" className="min-h-9 rounded-md px-2 text-xs text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" onClick={() => { pendingCanvasRef.current.clear(); setDraft(project?.document || newDetailDocument()); setDirty(false); setError('') }}>放弃未保存修改</button></div>}
</div>
</div>
</main>
}

View File

@ -0,0 +1,19 @@
'use client'
import { Plus, Trash2 } from 'lucide-react'
import { DETAIL_MAX_SCREENS, type DetailAnalysis } from '@/lib/detail-generator/types'
import { actionKey } from './client'
import { detailButtonClass, detailFieldClass } from './DetailProductForm'
export default function DetailOutlineEditor({ analysis, busy, onChange }: { analysis: DetailAnalysis; busy: boolean; onChange: (analysis: DetailAnalysis) => void }) {
const updateScreen = (id: string, key: 'title' | 'body' | 'imagePrompt', value: string) => onChange({ ...analysis, screens: analysis.screens.map((screen) => screen.id === id ? { ...screen, [key]: value } : screen) })
return <div className="space-y-5">
<label className="block space-y-1.5 text-sm font-semibold">产品卖点<textarea disabled={busy} className={`${detailFieldClass} min-h-24 resize-y font-normal`} value={analysis.sellingPoints.join('\n')} onChange={(event) => onChange({ ...analysis, sellingPoints: event.target.value.split('\n').filter((line) => line.trim()) })} /></label>
{!!analysis.warnings.length && <div role="status" className="rounded-lg bg-[#fffaeb] p-3 text-sm text-[#854a0e]"><p className="font-semibold">需要确认</p><ul className="mt-1 list-inside list-disc">{analysis.warnings.map((warning, index) => <li key={index}>{warning}</li>)}</ul></div>}
<div className="flex items-center justify-between gap-3"><h2 className="text-base font-semibold">页面大纲 <span className="ml-1 text-sm font-normal text-[#66758b]">{analysis.screens.length} 屏</span></h2><button type="button" disabled={busy || analysis.screens.length >= DETAIL_MAX_SCREENS} className={detailButtonClass} onClick={() => onChange({ ...analysis, screens: [...analysis.screens, { id: actionKey(), title: '', body: '', imagePrompt: '' }] })}><Plus className="h-4 w-4" aria-hidden="true" />添加一屏</button></div>
<div className="divide-y divide-[#e2e8f0]">{analysis.screens.map((screen, index) => <fieldset key={screen.id} disabled={busy} className="py-4 first:pt-0">
<legend className="sr-only">第 {index + 1} 屏</legend>
<div className="flex items-start gap-3"><span className="flex h-11 w-8 shrink-0 items-center justify-center text-sm font-semibold tabular-nums text-[#66758b]">{index + 1}</span><div className="min-w-0 flex-1 space-y-3"><label className="block space-y-1.5 text-sm font-medium">标题<input aria-label={`第 ${index + 1} 屏标题`} className={detailFieldClass} value={screen.title} maxLength={120} onChange={(event) => updateScreen(screen.id, 'title', event.target.value)} /></label><label className="block space-y-1.5 text-sm font-medium">正文<textarea aria-label={`第 ${index + 1} 屏正文`} className={`${detailFieldClass} min-h-20 resize-y`} value={screen.body} maxLength={1800} onChange={(event) => updateScreen(screen.id, 'body', event.target.value)} /></label><details><summary className="cursor-pointer text-sm text-[#4f6077]">画面要求</summary><textarea aria-label={`第 ${index + 1} 屏画面要求`} className={`${detailFieldClass} mt-2 min-h-20 resize-y`} value={screen.imagePrompt} maxLength={4000} onChange={(event) => updateScreen(screen.id, 'imagePrompt', event.target.value)} /></details></div><button type="button" className={`${detailButtonClass} shrink-0 px-2`} aria-label={`移除第 ${index + 1} 屏`} disabled={busy || analysis.screens.length <= 1} onClick={() => onChange({ ...analysis, screens: analysis.screens.filter((item) => item.id !== screen.id) })}><Trash2 className="h-4 w-4" aria-hidden="true" /></button></div>
</fieldset>)}</div>
</div>
}

View File

@ -0,0 +1,88 @@
'use client'
import { Upload, X } from 'lucide-react'
import type { ChangeEvent } from 'react'
import type { DetailAsset, DetailAssetKind, DetailConfig, DetailDocument } from '@/lib/detail-generator/types'
import { assetUrl } from './client'
export const detailFieldClass = 'min-h-11 w-full rounded-lg border border-[#cfd8e3] bg-white px-3 py-2 text-sm text-[#101828] placeholder:text-[#66758b] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:bg-[#f8fafc] disabled:text-[#66758b]'
export const detailButtonClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-[#cfd8e3] bg-white px-3 text-sm font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
export const detailPrimaryClass = 'inline-flex min-h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#1677ff] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#0964de] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
export default function DetailProductForm({ document, config, assets, busy, onChange, onUpload, onRemoveAsset, onFindMaterials, onSaveBrand }: {
document: DetailDocument
config: DetailConfig | null
assets: DetailAsset[]
busy: boolean
onChange: (document: DetailDocument) => void
onUpload: (files: File[], kind: DetailAssetKind) => void
onRemoveAsset: (id: string) => void
onFindMaterials: () => void
onSaveBrand: () => void
}) {
const productChange = (key: keyof DetailDocument['product'], value: unknown) => onChange({ ...document, product: { ...document.product, [key]: value } })
const brandChange = (key: keyof DetailDocument['brand'], value: string) => onChange({ ...document, brand: { ...document.brand, [key]: value } })
const fileChange = (event: ChangeEvent<HTMLInputElement>, kind: DetailAssetKind) => {
const files = Array.from(event.target.files || [])
event.target.value = ''
if (files.length) onUpload(files, kind)
}
const collections: { kind: DetailAssetKind; label: string; ids: string[]; accept: string }[] = [
{ kind: 'product', label: '产品图', ids: document.product.assetIds, accept: 'image/png,image/jpeg,image/webp' },
{ kind: 'reference', label: '参考图', ids: document.product.referenceAssetIds, accept: 'image/png,image/jpeg,image/webp' },
{ kind: 'document', label: '产品资料', ids: document.product.documentAssetIds, accept: '.txt,.md,.pdf,.docx' },
{ kind: 'logo', label: '品牌标志', ids: document.brand.logoAssetId ? [document.brand.logoAssetId] : [], accept: 'image/png,image/jpeg,image/webp' },
]
return (
<div className="space-y-6">
<fieldset disabled={busy} className="space-y-4">
<legend className="mb-3 text-base font-semibold">产品资料</legend>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-1.5 text-sm font-medium">项目名称<input className={detailFieldClass} value={document.name} maxLength={100} onChange={(event) => onChange({ ...document, name: event.target.value })} placeholder="例如:橡木座椅详情页" /></label>
<label className="space-y-1.5 text-sm font-medium">产品名称<input required className={detailFieldClass} value={document.product.name} maxLength={120} onChange={(event) => productChange('name', event.target.value)} /></label>
</div>
<div className="flex flex-wrap items-end gap-2">
<label className="w-28 space-y-1.5 text-sm font-medium">标识类型<select className={detailFieldClass} value={document.product.identifierType} onChange={(event) => productChange('identifierType', event.target.value)}><option value="sku">SKU</option><option value="spu">SPU</option><option value="barcode">条码</option></select></label>
<label className="min-w-0 flex-1 space-y-1.5 text-sm font-medium">产品标识<input className={detailFieldClass} value={document.product.identifier} maxLength={120} onChange={(event) => productChange('identifier', event.target.value)} /></label>
<button type="button" className={detailButtonClass} disabled={!document.product.identifier.trim()} onClick={onFindMaterials}>查找素材</button>
</div>
<label className="block space-y-1.5 text-sm font-medium">具体款式/规格<input className={detailFieldClass} value={document.product.variant} maxLength={200} onChange={(event) => productChange('variant', event.target.value)} placeholder="颜色、尺寸或具体 SKU" /></label>
<label className="block space-y-1.5 text-sm font-medium">已确认的产品信息<textarea required className={`${detailFieldClass} min-h-32 resize-y`} value={document.product.facts} maxLength={30000} onChange={(event) => productChange('facts', event.target.value)} placeholder="材质、尺寸、功能等真实信息;也可上传资料" /></label>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2">
{collections.map((collection) => <section key={collection.kind} aria-label={collection.label}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{collection.label}</h3>
<label className={`${detailButtonClass} cursor-pointer focus-within:ring-2 focus-within:ring-[#1677ff] ${busy ? 'pointer-events-none opacity-50' : ''}`}><Upload className="h-4 w-4" aria-hidden="true" /><span>上传</span><input type="file" aria-label={`上传${collection.label}`} className="sr-only" disabled={busy} accept={collection.accept} multiple={collection.kind !== 'logo'} onChange={(event) => fileChange(event, collection.kind)} /></label>
</div>
<div className="flex min-h-20 flex-wrap gap-2 rounded-lg bg-[#f8fafc] p-2">
{!collection.ids.length && <p className="self-center px-2 text-sm text-[#66758b]">尚未添加</p>}
{collection.ids.map((id) => {
const asset = assets.find((item) => item.id === id)
return <div key={id} className="relative flex w-24 flex-col gap-1 rounded-md bg-white p-1.5">
{asset?.mimeType.startsWith('image/') ? <img src={assetUrl(id)} alt={asset.name} className="h-16 w-full rounded object-contain" /> : <span className="flex h-16 items-center justify-center break-all text-xs text-[#4f6077]">{asset?.name || '产品素材'}</span>}
<span className="truncate text-xs text-[#4f6077]" title={asset?.name}>{asset?.name || '素材'}</span>
<button type="button" aria-label={`移除${asset?.name || '素材'}`} disabled={busy} onClick={() => onRemoveAsset(id)} className="absolute right-0 top-0 flex h-7 w-7 items-center justify-center rounded bg-white text-[#344054] hover:bg-[#fff0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-3.5 w-3.5" aria-hidden="true" /></button>
</div>
})}
</div>
</section>)}
</div>
<fieldset disabled={busy} className="space-y-4 border-t border-[#e2e8f0] pt-5">
<legend className="mb-3 text-base font-semibold">品牌风格 <span className="ml-2 text-xs font-normal text-[#66758b]">草案</span></legend>
<label className="block space-y-1.5 text-sm font-medium">品牌<input className={detailFieldClass} list="detail-generator-brands" value={document.brand.name} maxLength={100} onChange={(event) => {
const saved = config?.brandProfiles?.find((profile) => profile.profile.name === event.target.value)
if (saved) onChange({ ...document, brand: { ...saved.profile, logoAssetId: document.brand.logoAssetId } })
else brandChange('name', event.target.value)
}} /><datalist id="detail-generator-brands">{Array.from(new Set([...(config?.brands || []), ...(config?.brandProfiles?.map((profile) => profile.profile.name) || [])])).map((brand) => <option key={brand} value={brand} />)}</datalist></label>
<label className="block space-y-1.5 text-sm font-medium">调性<textarea className={`${detailFieldClass} min-h-24 resize-y`} value={document.brand.tone} maxLength={2000} onChange={(event) => brandChange('tone', event.target.value)} placeholder="例如:自然、克制,浅色背景与温暖光线" /></label>
<div className="flex flex-wrap gap-x-6 gap-y-3">{([{ key: 'primaryColor', label: '品牌色' }, { key: 'backgroundColor', label: '背景色' }, { key: 'textColor', label: '文字色' }] as const).map((color) => <label key={color.key} className="inline-flex min-h-11 items-center gap-2 text-sm"><input type="color" aria-label={color.label} value={document.brand[color.key]} onChange={(event) => brandChange(color.key, event.target.value)} className="h-8 w-8 cursor-pointer rounded border border-[#cfd8e3] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" />{color.label}</label>)}</div>
<button type="button" className={detailButtonClass} disabled={!document.brand.name.trim()} onClick={onSaveBrand}>保存品牌草案</button>
</fieldset>
<details className="border-t border-[#e2e8f0] pt-4">
<summary className="cursor-pointer text-sm font-medium text-[#4f6077] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">高级选项</summary>
<div className="mt-4 grid gap-4 sm:grid-cols-3">{([{ key: 'analysis', label: '资料分析' }, { key: 'copy', label: '卖点文案' }, { key: 'image', label: '图片生成' }] as const).map((stage) => <label key={stage.key} className="space-y-1.5 text-sm font-medium">{stage.label}<select className={detailFieldClass} disabled={busy} value={document.models[stage.key]} onChange={(event) => onChange({ ...document, models: { ...document.models, [stage.key]: event.target.value } })}>{(config?.models[stage.key] || [document.models[stage.key]]).map((model) => <option key={model} value={model}>{model}</option>)}</select></label>)}</div>
</details>
</div>
)
}

View File

@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import { Download, RotateCcw, Save, Upload } from 'lucide-react'
import type { DetailExportFormat, DetailScreen } from '@/lib/detail-generator/types'
import DetailCanvas from './DetailCanvas'
import { assetUrl } from './client'
import { detailButtonClass, detailFieldClass, detailPrimaryClass } from './DetailProductForm'
export default function DetailScreenEditor({ screens, busy, dirty, complete, configured, onChange, onUnsavedEdit, onPendingTextChange, onSave, onReplaceImage, onRegenerate, onExport }: {
screens: DetailScreen[]
busy: boolean
dirty: boolean
complete: boolean
configured: boolean
onChange: (screen: DetailScreen) => void
onUnsavedEdit: () => void
onPendingTextChange: (screenId: string, layerId: string, text: string) => void
onSave: (screen: DetailScreen) => void
onReplaceImage: (screen: DetailScreen, file: File) => void
onRegenerate: (screenId: string) => void
onExport: (format: DetailExportFormat) => void
}) {
const [selectedId, setSelectedId] = useState('')
const [format, setFormat] = useState<DetailExportFormat>('png')
const screen = screens.find((item) => item.id === selectedId) || screens[0]
if (!screen) return <p role="status" className="py-10 text-center text-sm text-[#66758b]">等待生成图片…</p>
const changeText = (id: string, value: string) => {
const index = screen.texts.findIndex((layer) => layer.id === id)
if (index < 0 || screen.texts[index].text === value) return
onChange({ ...screen, ...(index === 0 ? { title: value } : index === 1 ? { body: value } : {}), texts: screen.texts.map((layer) => layer.id === id ? { ...layer, text: value } : layer) })
}
return <div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"><h2 className="text-base font-semibold">详情页</h2><span className="text-sm tabular-nums text-[#66758b]">{screens.length} 屏</span></div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none"><select aria-label="导出格式" className={`${detailFieldClass} !w-auto min-w-0 max-w-[148px] flex-1`} disabled={busy} value={format} onChange={(event) => setFormat(event.target.value as DetailExportFormat)}><option value="png">分屏 PNG</option><option value="jpg">分屏 JPG</option><option value="long-png">完整长图</option><option value="psd">分层 PSD</option></select><button type="button" className={detailPrimaryClass} disabled={busy || dirty || !complete} onClick={() => onExport(format)}><Download className="h-4 w-4" aria-hidden="true" />导出</button></div>
</div>
<div className="flex flex-col gap-4 xl:flex-row">
<div aria-label="页面列表" className="flex gap-2 overflow-x-auto pb-2 xl:w-24 xl:shrink-0 xl:flex-col xl:overflow-y-auto xl:pb-0">
{screens.map((item, index) => <button type="button" key={item.id} aria-current={screen.id === item.id ? 'true' : undefined} aria-label={`编辑第 ${index + 1} 屏:${item.title}`} disabled={busy || (dirty && item.id !== screen.id)} onClick={() => setSelectedId(item.id)} className={`flex min-h-11 w-20 shrink-0 flex-col gap-1 rounded-lg border p-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:opacity-50 ${screen.id === item.id ? 'border-[#1677ff] bg-[#edf5ff]' : 'border-[#e2e8f0] bg-white hover:bg-[#f8fafc]'}`}>
{item.image ? <img src={assetUrl(item.image.assetId)} alt="" className="h-16 w-full rounded object-cover" /> : <span className="flex h-16 items-center justify-center text-xs text-[#66758b]">暂无图片</span>}<span className="w-full truncate px-1 text-xs text-[#344054]">{index + 1}. {item.title}</span>
</button>)}
</div>
<div className="min-w-0 flex-1 bg-[#eef2f6] p-3 sm:p-5"><DetailCanvas screen={screen} disabled={busy} onTextChange={changeText} onUnsavedEdit={onUnsavedEdit} onPendingTextChange={(layerId, text) => onPendingTextChange(screen.id, layerId, text)} /></div>
<div className="w-full space-y-4 xl:w-64 xl:shrink-0">
<div className="flex flex-wrap items-center gap-2"><button type="button" className={`${detailButtonClass} flex-1`} disabled={busy || !dirty} onClick={() => onSave(screen)}><Save className="h-4 w-4" aria-hidden="true" />保存修改</button><span role="status" className="shrink-0 whitespace-nowrap text-xs text-[#66758b]">{dirty ? '有未保存修改' : '已保存'}</span></div>
{screen.texts.map((layer, index) => <label key={layer.id} className="block space-y-1.5 text-sm font-medium">{index === 0 ? '标题' : index === 1 ? '正文' : `文字 ${index + 1}`}<textarea className={`${detailFieldClass} ${index === 0 ? 'min-h-20' : 'min-h-28'} resize-y font-normal`} disabled={busy} maxLength={index === 0 ? 120 : 1800} value={layer.text} onChange={(event) => changeText(layer.id, event.target.value)} /></label>)}
<details><summary className="cursor-pointer text-sm text-[#4f6077]">画面要求</summary><textarea aria-label="当前屏画面要求" className={`${detailFieldClass} mt-2 min-h-24 resize-y`} disabled={busy} value={screen.imagePrompt} maxLength={4000} onChange={(event) => onChange({ ...screen, imagePrompt: event.target.value })} /></details>
<div className="flex flex-wrap gap-2 border-t border-[#e2e8f0] pt-4"><label className={`${detailButtonClass} cursor-pointer focus-within:ring-2 focus-within:ring-[#1677ff] ${busy ? 'pointer-events-none opacity-50' : ''}`}><Upload className="h-4 w-4" aria-hidden="true" />替换图片<input type="file" className="sr-only" aria-label="替换当前屏图片" accept="image/png,image/jpeg,image/webp" disabled={busy} onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ''; if (file) onReplaceImage(screen, file) }} /></label><button type="button" className={detailButtonClass} disabled={busy || dirty || !configured} onClick={() => onRegenerate(screen.id)}><RotateCcw className="h-4 w-4" aria-hidden="true" />重新生成此屏</button></div>
</div>
</div>
</div>
}

View File

@ -0,0 +1,35 @@
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '@/lib/detail-generator/types'
export class DetailClientError extends Error {
constructor(message: string, readonly status: number) { super(message) }
}
export async function detailRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`/api/detail-generator${path}`, {
...init,
cache: 'no-store',
headers: init.body instanceof FormData ? init.headers : { 'Content-Type': 'application/json', ...init.headers },
})
const data = await response.json().catch(() => ({}))
if (!response.ok) throw new DetailClientError(data.error || '操作未完成,请稍后重试', response.status)
return data as T
}
export function newDetailDocument(): DetailDocument {
return {
version: 1,
name: '',
product: { identifierType: 'sku', identifier: '', variant: '', name: '', facts: '', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '', tone: '', primaryColor: '#1677ff', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' },
models: { ...DETAIL_MODEL_DEFAULTS },
analysis: null,
approvedAt: null,
screens: [],
}
}
export function assetUrl(id: string) { return `/api/detail-generator/assets/${encodeURIComponent(id)}` }
export function actionKey(): string {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
}

View File

@ -0,0 +1,128 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useUnsavedDetailGuard } from './useUnsavedDetailGuard'
const mockPush = jest.fn()
const mockRouter = { push: mockPush }
jest.mock('next/navigation', () => ({ useRouter: () => mockRouter }))
function Fixture({ dirty, onNavigation = jest.fn(), href = '/workspaces/dashboard/core-metrics', download = false }: {
dirty: boolean; onNavigation?: () => void; href?: string; download?: boolean
}) {
const dialog = useUnsavedDetailGuard(dirty)
return <><input aria-label="未保存的文案" defaultValue="保留我的修改" /><a href={href} download={download || undefined} onClick={(event) => { event.preventDefault(); onNavigation() }}>侧栏入口</a>{dialog}</>
}
beforeEach(() => {
window.history.replaceState(null, '', '/assets/material-library')
window.history.pushState(null, '', '/assets/detail-generator?project=current')
jest.clearAllMocks()
jest.spyOn(window.history, 'back').mockImplementation(() => undefined)
jest.spyOn(window.history, 'forward').mockImplementation(() => undefined)
jest.spyOn(window.history, 'go').mockImplementation(() => undefined)
})
afterEach(() => jest.restoreAllMocks())
test('新标签没有上一页时不人为创建可返回的同页历史', () => {
jest.spyOn(window.history, 'length', 'get').mockReturnValue(1)
const push = jest.spyOn(window.history, 'pushState')
render(<Fixture dirty />)
expect(push).not.toHaveBeenCalled()
})
test('有未保存修改时刷新事件被保护,保存后正常放行', () => {
const { rerender } = render(<Fixture dirty />)
const unsavedRefresh = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unsavedRefresh)
expect(unsavedRefresh.defaultPrevented).toBe(true)
rerender(<Fixture dirty={false} />)
const savedRefresh = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(savedRefresh)
expect(savedRefresh.defaultPrevented).toBe(false)
})
test('取消内部跳转保留草稿与当前位置', async () => {
const onNavigation = jest.fn()
render(<Fixture dirty onNavigation={onNavigation} />)
fireEvent.click(screen.getByRole('link', { name: '侧栏入口' }))
expect(await screen.findByRole('alertdialog', { name: '离开此页面?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByLabelText('未保存的文案')).toHaveValue('保留我的修改')
expect(window.location.pathname).toBe('/assets/detail-generator')
expect(onNavigation).not.toHaveBeenCalled()
expect(mockPush).not.toHaveBeenCalled()
})
test('确认放弃修改后允许内部跳转,不再重复触发刷新提示', async () => {
render(<Fixture dirty />)
fireEvent.click(screen.getByRole('link', { name: '侧栏入口' }))
fireEvent.click(await screen.findByRole('button', { name: '放弃修改并离开' }))
await waitFor(() => expect(window.history.back).toHaveBeenCalled())
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/workspaces/dashboard/core-metrics'))
const refresh = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(refresh)
expect(refresh.defaultPrevented).toBe(false)
})
test('未保存时 Back 恢复守卫项,取消保留草稿且不重复添加历史', async () => {
const push = jest.spyOn(window.history, 'pushState')
render(<Fixture dirty />)
const sentinel = window.history.state
expect(push).toHaveBeenCalledTimes(1)
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
expect(window.history.forward).toHaveBeenCalledTimes(1)
act(() => { window.dispatchEvent(new PopStateEvent('popstate', { state: sentinel })) })
fireEvent.click(await screen.findByRole('button', { name: '继续编辑' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByLabelText('未保存的文案')).toHaveValue('保留我的修改')
expect(push).toHaveBeenCalledTimes(1)
expect(window.history.go).not.toHaveBeenCalled()
})
test('Back 确认后跳过唯一守卫项,真实离开事件正常传给路由', async () => {
render(<Fixture dirty />)
const sentinel = window.history.state
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
act(() => { window.dispatchEvent(new PopStateEvent('popstate', { state: sentinel })) })
fireEvent.click(await screen.findByRole('button', { name: '放弃修改并离开' }))
await waitFor(() => expect(window.history.go).toHaveBeenCalledWith(-2))
const routed = jest.fn()
window.addEventListener('popstate', routed)
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
expect(routed).toHaveBeenCalledTimes(1)
window.removeEventListener('popstate', routed)
})
test('已保存的 Back 不恢复、不弹窗、不吞路由事件', () => {
render(<Fixture dirty={false} />)
const routed = jest.fn()
window.addEventListener('popstate', routed)
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }))
expect(routed).toHaveBeenCalledTimes(1)
expect(window.history.forward).not.toHaveBeenCalled()
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
window.removeEventListener('popstate', routed)
})
test('已保存状态通过原链接正常导航', () => {
const onNavigation = jest.fn()
render(<Fixture dirty={false} onNavigation={onNavigation} />)
fireEvent.click(screen.getByRole('link', { name: '侧栏入口' }))
expect(onNavigation).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
test.each([
{ href: '#screen-a', download: false, ctrlKey: false },
{ href: '/api/detail-generator/assets/export-file', download: false, ctrlKey: false },
{ href: '/assets/detail-generator?project=current', download: false, ctrlKey: false },
{ href: '/download-output', download: true, ctrlKey: false },
{ href: '/workspaces/dashboard/core-metrics', download: false, ctrlKey: true },
])('非离开操作不弹确认:$href', ({ href, download, ctrlKey }) => {
const onNavigation = jest.fn()
render(<Fixture dirty href={href} download={download} onNavigation={onNavigation} />)
fireEvent.click(screen.getByRole('link'), { ctrlKey })
expect(onNavigation).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})

View File

@ -0,0 +1,133 @@
'use client'
import { useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
const GUARD_KEY = '__detailUnsavedGuard'
/** One reusable history sentinel supports browsers without the Navigation API. */
export function useUnsavedDetailGuard(dirty: boolean) {
const router = useRouter()
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const dirtyRef = useRef(dirty)
const pendingRef = useRef(false)
const allowedRef = useRef(false)
const armRef = useRef<() => void>(() => undefined)
const cleanRef = useRef<(after?: () => void) => void>(() => undefined)
const stateRef = useRef({ token: '', armed: false, url: '', phase: '' as '' | 'restore' | 'clean' | 'replay', steps: 0, targetUrl: '', afterClean: undefined as (() => void) | undefined })
dirtyRef.current = dirty
useEffect(() => {
let disposed = false
let resetTimer: ReturnType<typeof setTimeout> | undefined
const state = stateRef.current
const confirmLeave = () => confirmAction({ title: '离开此页面?', description: '当前修改尚未保存,离开后将丢失。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
const allowDeparture = () => {
allowedRef.current = true
clearTimeout(resetTimer)
resetTimer = setTimeout(() => { allowedRef.current = false }, 2000)
}
const arm = () => {
if (state.armed || state.phase || !dirtyRef.current || window.history.length < 2) return
state.token = `detail-${Date.now()}-${Math.random().toString(36).slice(2)}`
state.url = window.location.href
state.armed = true
window.history.pushState({ ...window.history.state, [GUARD_KEY]: state.token }, '', state.url)
}
const clean = (after?: () => void) => {
if (!state.armed) { after?.(); return }
state.afterClean = after
state.phase = 'clean'
window.history.back()
}
armRef.current = arm
cleanRef.current = clean
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirtyRef.current || allowedRef.current) return
event.preventDefault()
event.returnValue = ''
}
const pop = (event: PopStateEvent) => {
if (state.phase === 'replay') {
state.phase = ''; state.armed = false
return
}
if (state.phase === 'clean') {
event.stopImmediatePropagation()
state.phase = ''; state.armed = false
const after = state.afterClean
state.afterClean = undefined
if (after) after()
else arm()
return
}
if (!state.armed) return
if (event.state?.[GUARD_KEY] === state.token && state.phase !== 'restore') return
event.stopImmediatePropagation()
if (state.phase !== 'restore') {
state.phase = 'restore'; state.steps = 0; state.targetUrl = window.location.href
pendingRef.current = true
// The guard was appended last; restoring toward it is always forward.
window.history.forward()
return
}
state.steps++
if (event.state?.[GUARD_KEY] !== state.token) { window.history.forward(); return }
state.phase = ''
void confirmLeave().then((result) => {
pendingRef.current = false
if (disposed) return
if (!result.confirmed) { if (!dirtyRef.current) clean(); return }
const target = new URL(state.targetUrl)
const source = new URL(state.url)
const samePage = target.pathname === source.pathname && target.search === source.search
allowDeparture()
state.phase = 'replay'
window.history.go(-(state.steps + (samePage ? 1 : 0)))
})
}
const click = (event: MouseEvent) => {
if ((!dirtyRef.current && state.phase !== 'clean') || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null
if (!anchor || anchor.hasAttribute('download') || (anchor.target && anchor.target !== '_self')) return
const destination = new URL(anchor.href, window.location.href)
const current = new URL(window.location.href)
if (destination.origin !== current.origin || destination.pathname.startsWith('/api/')) return
if (destination.pathname === current.pathname && destination.search === current.search) return
event.preventDefault()
event.stopImmediatePropagation()
const leave = () => { allowDeparture(); router.push(`${destination.pathname}${destination.search}${destination.hash}`) }
if (!dirtyRef.current) { state.afterClean = leave; return }
if (pendingRef.current) return
pendingRef.current = true
allowedRef.current = false
void confirmLeave().then((result) => {
pendingRef.current = false
if (!disposed && result.confirmed) clean(leave)
})
}
window.addEventListener('beforeunload', beforeUnload)
window.addEventListener('popstate', pop, true)
document.addEventListener('click', click, true)
return () => {
disposed = true
pendingRef.current = false
clearTimeout(resetTimer)
window.removeEventListener('beforeunload', beforeUnload)
window.removeEventListener('popstate', pop, true)
document.removeEventListener('click', click, true)
}
}, [router, confirmAction])
useEffect(() => {
if (dirty) armRef.current()
else {
allowedRef.current = false
if (stateRef.current.armed && !stateRef.current.phase && !pendingRef.current) cleanRef.current()
}
}, [dirty])
return confirmActionDialog
}

68
docs/detail-generator.md Normal file
View File

@ -0,0 +1,68 @@
# 详情页生成首版
入口:`/assets/detail-generator`。功能尚未正式验收,启用后仍只允许数据库中当前有效的超级管理员使用。页面、API、私有文件及后台模型调用共用实时门禁;一般管理员角色、旧登录状态和权限组均不能绕过。
## 使用流程
1. 新建个人项目,输入 SKU/SPU/条码、具体款式、产品名称及真实资料。可以上传产品图、参考图、品牌标志和 PDF/DOCX/TXT/Markdown 文档,也可关联当前可见的素材库图片。
2. 选择或保存品牌风格草案。品牌配置来自数据库;项目保存独立快照,后续修改草案不改变旧项目。
3. 分析资料,检查卖点、缺失信息和分屏大纲,确认后生成。每屏宽 750px,最多 12 屏。
4. 修改中文文字、替换图片或单屏重新生成,再保存和导出。改字不调用生图模型。较长文案自动重排;超出每屏 2400px 时须精简后确认。
5. 导出分屏 PNG/JPG(多屏 ZIP)、完整 PNG 长图、分层 PSD。PSD 每屏独立分组,文字保持原生可编辑文字层;AI 场景仍是一张整体图片,标志另设图层。
项目及文件按创建者隔离,不自动发布到素材库。产品资料、品牌或模型变更会使原大纲失效,清除旧生成屏,要求重新确认。
## 启用配置
默认关闭。发布前先完成项目标准备份和受控迁移,应用 `20261012_detail_generator`;禁止对生产使用 `prisma db push`。
```dotenv
DETAIL_GENERATOR_ENABLED=true
DETAIL_GENERATOR_BASE_URL=https://api.vip98k.com/v1
DETAIL_GENERATOR_API_KEY=由部署环境安全注入
DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private
```
私有目录必须独立于 `uploads` 和 `public`,持久挂载并授予运行账号写入权限;不得通过 Nginx 静态映射或 S3 公共域名暴露。蓝绿应用实例共用该私有卷和数据库。Docker 镜像包含独立 Node 22/Fabric 7.4.0/Canvas 3 渲染运行时,现有平台 Canvas 2 保持独立。本地另执行 `npm ci --prefix workers/detail-renderer`;导出调用该独立 CLI,本地 Node 需至少 22。
可选 `DETAIL_RENDERER_NODE`、`DETAIL_RENDERER_ENTRY`、`DETAIL_FONT_DIR` 分别指定独立 Node、CLI 和字体目录。完整 Noto Sans SC 字体及 OFL 许可位于 `public/detail-generator/fonts`;浏览器、渲染和 PSD 使用同一字体文件与真实 PostScript 名称。使用 Photoshop 的设备也需要安装这套字体。
默认分析/文案模型为 `gemini-2.5-flash`,生图为 `nano-banana-2`。需要额外模型时,先完成对应输入、输出及错误状态验证,再通过 `DETAIL_GENERATOR_VALIDATED_MODELS_JSON` 配置三个阶段的允许列表:
```json
{"analysis":["gemini-2.5-flash"],"copy":["gemini-2.5-flash"],"image":["nano-banana-2"]}
```
仅在列表中出现不代表兼容;不能直接把网关 `/models` 结果全部开放给用户。密钥只存在于服务端,不写入 Git、项目文档、浏览器或任务记录。
## 任务与故障处理
PostgreSQL 保存任务、幂等键、租约和检查点;全局最多同时 2 个任务,每人最多 1 个。启动后每 2 秒检查队列,执行期间续租。各屏图片与项目版本原子提交,恢复时跳过已保存屏。
模型请求发出后遇到超时、传输中断或结果未知错误,任务进入 `unknown`,保留可用的服务商请求编号及内部幂等键。不会自动换模型或用新请求编号重试;该项目暂停后续模型调用。管理员须在服务商核对调用结果及计费,保留证据后另行恢复,首版没有未经核查的“强制重试”按钮。已完成项目仍可导出。
权限撤销、账号停用或归档会阻止下一次模型调用。一个已经发送到服务商的请求无法由本平台撤回;收到的结果仍处于私有项目边界。
## 本地验证与正式验收
```powershell
npm run typecheck
npm test -- --runInBand --testPathPatterns=detail-generator
npm test --prefix workers/detail-renderer
node --test scripts/safe-production-migrate.test.mjs scripts/detail-generator-migration.test.mjs
```
`scripts/verify-detail-generator.ts` 只允许连接 `127.0.0.1:15437/detail_test` 隔离数据库。它使用合成账户、产品资料和模拟网关验证完整流程,生成实际图片与 PSD 文件,不调用真实模型;输出位于 Git 忽略的 `output/detail-generator`。真实迁移 SQL 也已在独立 schema 中执行验证。
## 2026-10-12 验证记录
- 77 项相关 Jest、17 个隔离 PostgreSQL 流程、16 项迁移检查、6 项独立原生渲染检查通过。完整生产构建通过;独立 Node 22 渲染器也在平台 Node 18 Alpine 运行环境中完成检查。
- 桌面与移动端已实际检查。Canvas 中文直接修改、保存、刷新恢复,侧栏及浏览器返回的未保存确认均已验证;普通管理员入口隐藏,页面、API、私有文件及旧会话撤权检查通过。Impeccable 独立复核给出本地内测 `ship`。
- 生产构建发现的切屏崩溃已修复:React 管理稳定画布容器,Fabric 独占其子节点;回归测试模拟真实 DOM 包装。修复后实际浏览器完成第 1→2→3→1 屏切换、原生 Canvas 中文编辑后直接保存与刷新恢复,没有新增控制台错误。证据见 `.impeccable/review/detail-generator-real-preview.jpg`。
- 按用户授权,从素材库只读选取朴美 `GBPM-AD0358` 同款两张 1500px 展示图片。品牌依据同型号素材的数据库店铺字段,未推测材质、尺寸、承重或认证;未写入生产数据库或更改下载统计。
- 实际网关调用完成 2 次 Gemini 分析/文案、3 次 Nano Banana 双参考图生成、1 次单屏重生成;没有自动重试。三屏采用同一字体与配色,目视检查了主要产品结构、木纹外观和整体调性;单屏重生成后其余两屏的图片标识保持不变。
- 实际导出分屏 PNG/JPG、750×3600 长图和 PSD。PSD 结构读回包含 3 个屏幕分组、6 个可编辑中文文字层。真实样例与执行记录位于本机 Git 忽略的 `output/detail-generator/real-result`,私有模型配置不进入 Git、Docker 上下文或浏览器脚本。
这一轮只覆盖一个产品、一个品牌风格草案及两张同款参考图。AI 对细节、构图和道具的还原仍须人工确认,不能把该样例视为所有品牌、全部屏数或全部参考图组合的质量承诺。
正式开放前仍需要 Photoshop 打开 PSD、修改文字、移动图层、换图、保存重开的实际验收,以及正式品牌规范确认。当前 PSD 写入读回、中文字体、图层与合成像素验证不能替代 Photoshop 实际编辑验收。生产提交/部署及正式扩大开放范围另按明确指令执行。

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证二十八份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
当前应用发布工具验证三十三份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
目标 migration:
@ -34,6 +34,15 @@
- `20260918_add_performance_conversion_sync`
- `20260923_add_certificate_report_metadata`
- `20260924_add_certificate_tested_on`
- `20261008_permission_group_department_identity`
- `20261008_team_function_configs`
- `20261009_material_purge_outbox`
- `20261009_material_purge_reference_indexes`
- `20261012_detail_generator`
`20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。
已有标准 drain 能力和稳定代理的生产实例,继续使用当前 `direct` 发布策略。维护、备份和迁移期间同时持有发布锁与代理 reconcile 锁,验证三个入口 503、作业及连接排空、旧实例停止后再取备份。完成迁移审计才交接给同 SHA 的标准发布 payload;交接前释放锁,避免 payload 重取时自锁。恢复旧实例时按其实际 IP 生成恢复代理配置,保留原配置并验证三个入口的旧 SHA。
`20260923_add_certificate_report_metadata` 仅为 `materials` 增加五个可空字段:报告类别、证书到期日、证书编号、证书使用说明和检测报告使用规则说明。不回填或修改已有素材行;旧报告需要编辑时再补录类别和证书信息。
@ -64,7 +73,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、二十八条目标 migration 和迁移后审计;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十三条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@ -388,7 +397,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对二十八条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十三条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;最后原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@ -404,7 +413,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
- 二十八条 target migration 均 `completed=true`,source/database checksum 一致;
- 三十三条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
- 再次运行 `--audit` 仍通过;

View File

@ -11,6 +11,9 @@ export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
console.log('[Instrumentation] Next.js 服务端启动...')
const { ensureDetailGeneratorWorkerStarted } = await import('./lib/detail-generator/runtime')
ensureDetailGeneratorWorkerStarted()
const { ensureMaterialPurgeWorkerStarted } = await import('./lib/material-purge')
ensureMaterialPurgeWorkerStarted()

View File

@ -0,0 +1,171 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import { assertDetailGeneratorUser, requireDetailGeneratorUser } from '@/lib/detail-generator/access'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, getDetailModels, readDetailResponseBytes } from '@/lib/detail-generator/provider'
import { parseDetailDocument, parseDetailScreen, parseDetailJson } from '@/lib/detail-generator/validation'
import type { DetailDocument } from '@/lib/detail-generator/types'
import { estimateDetailTextHeight, reflowDetailScreen } from '@/lib/detail-generator/layout'
import { prisma } from '@/lib/prisma'
import { detailPublicJob, type StoredDetailJob } from '@/lib/detail-generator/repository'
import { GET as getCapabilities } from '@/app/api/detail-generator/capabilities/route'
import sharp from 'sharp'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
const originalFetch = global.fetch
const originalEnv = { ...process.env }
const fixture = (): DetailDocument => ({
version: 1, name: '橡木座椅', product: { identifierType: 'sku', identifier: 'SKU-001', variant: '原木色', name: '座椅', facts: '尺寸 500×500×800mm', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '测试品牌', tone: '简洁', primaryColor: '#2055AA', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' },
models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [],
})
beforeEach(() => {
jest.clearAllMocks()
process.env.DETAIL_GENERATOR_ENABLED = 'true'
process.env.DETAIL_GENERATOR_API_KEY = 'unit-test-key'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://model.example.test/v1'
delete process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
global.fetch = jest.fn()
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: 'active', isActive: true, archivedAt: null })
})
afterAll(() => { global.fetch = originalFetch; process.env = originalEnv })
describe('detail generator access', () => {
test('revoked database flag rejects a stale super-admin session before any provider request', async () => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner', isSuperAdmin: true, role: 'ADMIN' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
await expect(requireDetailGeneratorUser()).rejects.toMatchObject({ status: 403, code: 'forbidden' })
expect(isSuperAdmin).toHaveBeenCalledWith('owner')
expect(global.fetch).not.toHaveBeenCalled()
})
test('disabled beta denies even super-admins and unauthenticated requests remain 401', async () => {
process.env.DETAIL_GENERATOR_ENABLED = 'false'
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ status: 403, code: 'feature_disabled' })
await expect(assertDetailGeneratorUser(undefined)).rejects.toMatchObject({ status: 401 })
expect(isSuperAdmin).not.toHaveBeenCalled()
})
test.each([
{ status: 'suspended', isActive: true, archivedAt: null },
{ status: 'archived', isActive: true, archivedAt: null },
{ status: 'active', isActive: true, archivedAt: new Date('2026-10-12') },
{ status: 'pending', isActive: true, archivedAt: null },
])('fresh account state rejects stale active super-admin sessions: %o', async row => {
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner', isSuperAdmin: true, accountStatus: 'active', role: 'ADMIN' } })
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(row)
await expect(requireDetailGeneratorUser()).rejects.toMatchObject({ status: 403, code: 'account_inactive' })
expect(await (await getCapabilities()).json()).toEqual({ canUse: false })
expect(global.fetch).not.toHaveBeenCalled()
})
test('legacy activation flag applies only when account status is missing', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: null, isActive: true, archivedAt: null })
await expect(assertDetailGeneratorUser('owner')).resolves.toBe('owner')
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: null, isActive: false, archivedAt: null })
await expect(assertDetailGeneratorUser('owner')).rejects.toMatchObject({ code: 'account_inactive' })
})
})
test('public export jobs disclose only a valid format and never the frozen project or prompts', () => {
const base: StoredDetailJob = { id: 'job', projectId: 'project', ownerId: 'owner', kind: 'export', status: 'succeeded', payload: { format: 'psd', document: fixture(), prompt: 'private-prompt' }, checkpoint: {}, error: null, requestId: null, progress: 100, createdAt: '', updatedAt: '', fingerprint: 'test', projectRevision: 1, idempotencyKey: 'test-key', leaseToken: null, leaseExpiresAt: null }
expect(detailPublicJob(base).payload).toEqual({ format: 'psd' })
expect(detailPublicJob({ ...base, kind: 'generate' }).payload).toEqual({})
expect(detailPublicJob({ ...base, payload: { format: '../private' } }).payload).toEqual({})
expect(JSON.stringify(detailPublicJob(base))).not.toContain('private-prompt')
expect(JSON.stringify(detailPublicJob(base))).not.toContain('assetIds')
})
test('oversized chunked JSON cancels immediately without a full-body allocation', async () => {
const cancelled = jest.fn()
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new Uint8Array(128 * 1024)); controller.enqueue(new Uint8Array(128 * 1024)); controller.enqueue(new Uint8Array(1)) }, cancel: cancelled })
const request = new Request('https://platform.test/api/detail-generator', { method: 'POST', body: stream, duplex: 'half' } as RequestInit)
const fullRead = jest.spyOn(request, 'text')
await expect(parseDetailJson(request)).rejects.toMatchObject({ status: 413 })
expect(cancelled).toHaveBeenCalledTimes(1)
expect(fullRead).not.toHaveBeenCalled()
})
describe('provider outcomes and verified model selection', () => {
test('transparent product references become white-background analysis JPEGs while preserving product color', async () => {
const subject = await sharp({ create: { width: 64, height: 64, channels: 4, background: { r: 120, g: 70, b: 40, alpha: 1 } } }).png().toBuffer()
const image = await sharp({ create: { width: 128, height: 128, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).composite([{ input: subject, left: 32, top: 32 }]).png().toBuffer()
;(global.fetch as jest.Mock).mockResolvedValue(Response.json({ choices: [{ message: { content: '{"facts":[],"warnings":[]}' } }] }))
await analyzeDetailFacts(fixture(), [{ buffer: image, mimeType: 'image/png' }], '', 'white-reference-request')
const request = (global.fetch as jest.Mock).mock.calls[0][1]
const body = JSON.parse(request.body)
const url = body.messages[0].content.find((part: any) => part.type === 'image_url').image_url.url
expect(url).toMatch(/^data:image\/jpeg;base64,/)
const encoded = Buffer.from(url.split(',')[1], 'base64')
const { data, info } = await sharp(encoded).removeAlpha().raw().toBuffer({ resolveWithObject: true })
const pixel = (x: number, y: number) => Array.from(data.subarray((y * info.width + x) * info.channels, (y * info.width + x) * info.channels + 3))
expect(pixel(1, 1).every(channel => channel >= 250)).toBe(true)
pixel(64, 64).forEach((channel, index) => expect(Math.abs(channel - [120, 70, 40][index])).toBeLessThanOrEqual(8))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
test('503 unknown is recorded once with safe request ID and never retried', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ error: { code: 'request_outcome_unknown', message: 'upstream-secret' } }), { status: 503, headers: { 'x-request-id': 'provider-request-1' } }))
await expect(analyzeDetailFacts(fixture(), [], '真实产品资料', 'same-request-12345')).rejects.toMatchObject({ outcomeUnknown: true, requestId: 'provider-request-1' })
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as jest.Mock).mock.calls[0]
expect(request.headers['Idempotency-Key']).toBe('same-request-12345')
expect(request.body).toContain('真实产品资料')
})
test('transport loss is unknown and 400 is a known failure without exposing upstream text', async () => {
;(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('unit-test-key leaked in transport'))
await expect(analyzeDetailFacts(fixture(), [], '', 'transport-request-1')).rejects.toMatchObject({ outcomeUnknown: true })
;(global.fetch as jest.Mock).mockResolvedValueOnce(new Response(JSON.stringify({ error: { message: 'unit-test-key' } }), { status: 400 }))
try { await analyzeDetailFacts(fixture(), [], '', 'rejected-request-1'); throw new Error('expected error') } catch (error) {
expect(error).toBeInstanceOf(DetailProviderError)
expect(error).toMatchObject({ outcomeUnknown: false })
expect(String(error)).not.toContain('unit-test-key')
}
expect(global.fetch).toHaveBeenCalledTimes(2)
})
test('unverified models cannot dispatch and explicit validation list stays stage-specific', async () => {
const document = fixture(); document.models.image = 'unvalidated-image'
await expect(analyzeDetailFacts(document, [], '', 'request-unverified')).rejects.toThrow('已验证')
expect(global.fetch).not.toHaveBeenCalled()
process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON = JSON.stringify({ analysis: ['verified-analyzer'], image: ['verified-imager'] })
expect(getDetailModels()).toEqual({ analysis: ['gemini-2.5-flash', 'verified-analyzer'], copy: ['gemini-2.5-flash'], image: ['nano-banana-2', 'verified-imager'] })
})
test('outline validates schema and assigns independent screen identities', async () => {
;(global.fetch as jest.Mock).mockResolvedValue(new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ sellingPoints: ['尺寸已提供'], warnings: ['材质待确认'], screens: [{ title: '产品展示', body: '500×500×800mm', imagePrompt: '准确展示产品' }, { title: '使用场景', body: '', imagePrompt: '同一产品居家场景' }] }) } }] })))
const result = await composeDetailOutline(fixture(), '原始事实', 'outline-request-1')
expect(result.screens).toHaveLength(2)
expect(result.screens[0].id).not.toBe(result.screens[1].id)
expect(result.warnings).toContain('材质待确认')
})
test('chunked provider responses obey actual byte limit', async () => {
const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new Uint8Array(16)); controller.enqueue(new Uint8Array(16)); controller.close() } })
await expect(readDetailResponseBytes(new Response(stream), 20)).rejects.toThrow('响应过大')
})
})
describe('shared project document validation', () => {
test('supports empty drafts but rejects invisible fonts and screen bounds', () => {
expect(parseDetailDocument(fixture()).screens).toEqual([])
const badFont = fixture(); (badFont.brand as any).fontFamily = 'Missing Brand Font'
expect(() => parseDetailDocument(badFont)).toThrow()
expect(() => parseDetailScreen({ id: '11111111-1111-4111-8111-111111111111', title: '标题', body: '', imagePrompt: '产品', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [{ id: 'copy', text: '文字', x: 700, y: 50, width: 100, fontSize: 24, lineHeight: 1.5, color: '#111111', fontFamily: 'Noto Sans SC', align: 'left' }] })).toThrow('图层超出画布')
})
test('long Chinese copy reflows before the image and excessive copy fails before generation', () => {
const id = '11111111-1111-4111-8111-111111111111'
const screen = { id, title: '真实产品标题'.repeat(12), body: '这段产品说明用于核对中文换行与画面位置。'.repeat(10), imagePrompt: '产品', width: 750, height: 1200, backgroundColor: '#FFFFFF',
image: { assetId: '22222222-2222-4222-8222-222222222222', x: 0, y: 380, width: 750, height: 750 },
texts: [{ id: `${id}-title`, text: '', x: 48, y: 58, width: 654, fontSize: 48, lineHeight: 1.25, color: '#111111', fontFamily: 'Noto Sans SC' as const, align: 'left' as const }, { id: `${id}-body`, text: '', x: 48, y: 182, width: 654, fontSize: 26, lineHeight: 1.5, color: '#111111', fontFamily: 'Noto Sans SC' as const, align: 'left' as const }] }
const arranged = reflowDetailScreen(screen)
const titleBottom = arranged.texts[0].y + estimateDetailTextHeight(screen.title, 654, 48, 1.25)
const bodyBottom = arranged.texts[1].y + estimateDetailTextHeight(screen.body, 654, 26, 1.5)
expect(arranged.texts[1].y).toBeGreaterThan(titleBottom)
expect(arranged.image!.y).toBeGreaterThan(bodyBottom)
expect(arranged.height).toBeLessThanOrEqual(2400)
expect(arranged.image!.assetId).toBe(screen.image.assetId)
expect(() => reflowDetailScreen({ ...screen, body: '超长说明'.repeat(400) })).toThrow('文案过长')
expect(estimateDetailTextHeight('第一行\n第二行', 654, 26, 1.5)).toBeGreaterThan(estimateDetailTextHeight('第一行', 654, 26, 1.5))
})
})

View File

@ -0,0 +1,88 @@
/** @jest-environment node */
import { isSuperAdmin } from '@/lib/department-permission'
import { processDetailJob } from '@/lib/detail-generator/jobs'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage } from '@/lib/detail-generator/provider'
import { checkpointDetailJob, findDetailAsset, findDetailProject, finishDetailJob, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from '@/lib/detail-generator/repository'
import type { DetailDocument, DetailScreen } from '@/lib/detail-generator/types'
import { prisma } from '@/lib/prisma'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() } } }))
jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), checkpointDetailJob: jest.fn(), claimDetailJob: jest.fn(), findDetailAsset: jest.fn(), findDetailProject: jest.fn(), finishDetailJob: jest.fn(), renewDetailJobLease: jest.fn(), saveDetailJobAsset: jest.fn(), saveDetailJobDocument: jest.fn() }))
jest.mock('@/lib/detail-generator/storage', () => ({ deleteDetailFile: jest.fn(), readDetailFile: jest.fn().mockResolvedValue(Buffer.from('image')), saveDetailFile: jest.fn().mockResolvedValue('private/image.png') }))
jest.mock('@/lib/detail-generator/render', () => ({ renderDetailExport: jest.fn() }))
jest.mock('@/lib/detail-generator/provider', () => ({ ...jest.requireActual('@/lib/detail-generator/provider'), analyzeDetailFacts: jest.fn(), composeDetailOutline: jest.fn(), generateDetailImage: jest.fn() }))
const makeDocument = (): DetailDocument => ({ version: 1, name: '产品', product: { identifierType: 'sku', identifier: 'SKU1', variant: '白色', name: '产品', facts: '真实尺寸', assetIds: ['asset-1'], referenceAssetIds: [], documentAssetIds: ['doc-1'] }, brand: { name: '品牌', tone: '真实', primaryColor: '#111111', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' }, models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [] })
const makeJob = (kind: StoredDetailJob['kind'] = 'analyze'): StoredDetailJob => ({ id: 'job-1', projectId: 'project-1', ownerId: 'owner-1', kind, status: 'running', payload: {}, checkpoint: {}, fingerprint: 'fingerprint', idempotencyKey: 'request-id', projectRevision: 1, leaseToken: 'lease-1', leaseExpiresAt: new Date(Date.now() + 10000), progress: 0, error: null, requestId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() })
let document: DetailDocument
const originalEnabled = process.env.DETAIL_GENERATOR_ENABLED
afterAll(() => { if (originalEnabled === undefined) delete process.env.DETAIL_GENERATOR_ENABLED; else process.env.DETAIL_GENERATOR_ENABLED = originalEnabled })
beforeEach(() => {
jest.clearAllMocks(); process.env.DETAIL_GENERATOR_ENABLED = 'true'
document = makeDocument()
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ status: 'active', isActive: true, archivedAt: null })
;(findDetailProject as jest.Mock).mockImplementation(async () => ({ id: 'project-1', ownerId: 'owner-1', document, revision: 1 }))
;(findDetailAsset as jest.Mock).mockImplementation(async id => ({ id, ownerId: 'owner-1', projectId: 'project-1', kind: id === 'doc-1' ? 'document' : 'product', mimeType: 'image/png', storageKey: 'private/source.png', extractedText: '文档中的原始产品事实' }))
})
test('revocation immediately before dispatch clears marker and makes zero paid calls', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValueOnce(true).mockResolvedValueOnce(true).mockResolvedValueOnce(false)
await processDetailJob(makeJob())
expect(analyzeDetailFacts).not.toHaveBeenCalled()
expect(composeDetailOutline).not.toHaveBeenCalled()
expect(checkpointDetailJob).toHaveBeenLastCalledWith('job-1', 'lease-1', expect.objectContaining({ dispatchPending: false }), 10)
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'failed', expect.objectContaining({ dispatchPending: false }), expect.stringContaining('超级管理员'), null)
})
test('suspension immediately before model dispatch stops a queued super-admin job with zero paid calls', async () => {
;(prisma.user.findUnique as jest.Mock).mockResolvedValueOnce({ status: 'active', isActive: true, archivedAt: null }).mockResolvedValueOnce({ status: 'active', isActive: true, archivedAt: null }).mockResolvedValueOnce({ status: 'suspended', isActive: true, archivedAt: null })
await processDetailJob(makeJob())
expect(analyzeDetailFacts).not.toHaveBeenCalled()
expect(composeDetailOutline).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'failed', expect.objectContaining({ dispatchPending: false }), expect.stringContaining('账号当前不可用'), null)
})
test('uploaded product text reaches analysis and copy; saved facts checkpoint resumes without repeated analysis', async () => {
;(analyzeDetailFacts as jest.Mock).mockResolvedValue('已核对事实')
;(composeDetailOutline as jest.Mock).mockResolvedValue({ sellingPoints: ['真实卖点'], warnings: [], screens: [] })
await processDetailJob(makeJob())
expect(analyzeDetailFacts).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-product-analysis')
expect(composeDetailOutline).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('文档中的原始产品事实'), 'job-1-outline')
expect(saveDetailJobDocument).toHaveBeenCalledWith('job-1', 'lease-1', expect.objectContaining({ approvedAt: null }), expect.objectContaining({ dispatchPending: false }), 100)
jest.clearAllMocks()
const resumed = makeJob(); resumed.checkpoint = { facts: '已核对事实', dispatchPending: false }
await processDetailJob(resumed)
expect(analyzeDetailFacts).not.toHaveBeenCalled()
expect(composeDetailOutline).toHaveBeenCalledTimes(1)
})
test('unknown provider outcome is terminal without retry or advancing to copy', async () => {
;(analyzeDetailFacts as jest.Mock).mockRejectedValue(new DetailProviderError('请求结果未知', true, 'safe-request-id'))
await processDetailJob(makeJob())
expect(analyzeDetailFacts).toHaveBeenCalledTimes(1)
expect(composeDetailOutline).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ dispatchPending: true }), '请求结果未知', 'safe-request-id')
})
test('crash after durable completion finalizes without a second model or export call', async () => {
const resumed = makeJob(); resumed.checkpoint = { stage: 'complete', facts: '已保存事实', dispatchPending: false }
await processDetailJob(resumed)
expect(analyzeDetailFacts).not.toHaveBeenCalled()
expect(composeDetailOutline).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'succeeded', expect.objectContaining({ stage: 'complete' }))
})
test('remaining-screen generation skips completed imagery and stops immediately on unknown outcome', async () => {
const screen: DetailScreen = { id: 'first', title: '一', body: '', imagePrompt: '场景', width: 750, height: 1200, backgroundColor: '#FFFFFF', texts: [], image: { assetId: 'existing', x: 0, y: 380, width: 750, height: 750 } }
document.approvedAt = new Date().toISOString(); document.analysis = { sellingPoints: [], warnings: [], screens: [] }; document.screens = [screen, { ...screen, id: 'second', image: undefined }, { ...screen, id: 'third', image: undefined }]
;(generateDetailImage as jest.Mock).mockRejectedValue(new DetailProviderError('生成结果未知', true))
await processDetailJob(makeJob('generate'))
expect(generateDetailImage).toHaveBeenCalledTimes(1)
expect(generateDetailImage).toHaveBeenCalledWith(expect.anything(), '场景', expect.anything(), 'job-1-screen-second')
expect(saveDetailJobAsset).not.toHaveBeenCalled()
expect(finishDetailJob).toHaveBeenCalledWith('job-1', 'lease-1', 'unknown', expect.objectContaining({ stage: 'screen-second' }), '生成结果未知', null)
})

View File

@ -0,0 +1,46 @@
/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { canUserViewMaterial } from '@/lib/department-permission'
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
import { findDetailMaterialMatches, readDetailMaterialImage } from '@/lib/detail-generator/materials'
import { getS3ObjectStream } from '@/lib/s3'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({ prisma: { material: { findMany: jest.fn(), findUnique: jest.fn() } } }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn(), canUserViewMaterial: jest.fn() }))
jest.mock('@/lib/task-detail-privacy', () => ({ canAccessMaterialPrivacy: jest.fn() }))
jest.mock('@/lib/s3', () => ({ getS3ObjectStream: jest.fn(), isS3Url: jest.fn(), getTrustedS3MutationKey: jest.fn() }))
const material = () => ({ id: 'material-1', productModel: 'SPU-1', materialType: '标准模型', deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false,
skus: [{ id: 'sku-1', skuName: 'SKU-1', filePath: 'uploads/sku.zip', previewUrl: null, deletedAt: null, barcodes: [{ barcode: 'BAR-1' }] }],
assets: [{ id: 'asset-1', materialId: 'material-1', assetType: 'standard-model-white-bg-view:sku-1:front', fileUrl: 'uploads/white/front.png', fileName: '正面.png', skuCode: null, barcodes: [] }],
})
beforeEach(() => {
jest.clearAllMocks()
;(prisma.material.findMany as jest.Mock).mockResolvedValue([material()])
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(material())
;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
;(canAccessMaterialPrivacy as jest.Mock).mockResolvedValue(true)
})
test('SPU and barcode matches preserve concrete white-background SKU identity', async () => {
const bySpu = await findDetailMaterialMatches('owner-1', 'spu', 'spu-1')
expect(bySpu).toEqual([expect.objectContaining({ productModel: 'SPU-1', variant: 'SKU-1', skuCode: 'SKU-1', assets: [expect.objectContaining({ id: 'asset-1', source: 'asset' })] })])
const byBarcode = await findDetailMaterialMatches('owner-1', 'barcode', 'BAR-1')
expect(byBarcode[0].variant).toBe('SKU-1')
expect(canUserViewMaterial).toHaveBeenCalledWith(undefined, '', undefined, '标准模型', 'owner-1')
})
test('private-material gate runs before type permissions and no private metadata escapes', async () => {
;(canAccessMaterialPrivacy as jest.Mock).mockResolvedValue(false)
expect(await findDetailMaterialMatches('owner-1', 'spu', 'SPU-1')).toEqual([])
await expect(readDetailMaterialImage('owner-1', 'material-1', 'asset', 'asset-1')).rejects.toMatchObject({ status: 403 })
expect(canUserViewMaterial).not.toHaveBeenCalled()
expect(getS3ObjectStream).not.toHaveBeenCalled()
})
test('cross-material source IDs are rejected before reading file bytes', async () => {
await expect(readDetailMaterialImage('owner-1', 'material-1', 'asset', 'foreign-asset')).rejects.toMatchObject({ status: 404 })
expect(getS3ObjectStream).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,156 @@
/** @jest-environment node */
import { importDetailMaterial, editDetailProject, newDetailProject, getDetailProject, createDetailAction, createDetailScreen, __detailUploadTesting } from '@/lib/detail-generator/service'
import { readDetailMaterialImage } from '@/lib/detail-generator/materials'
import { attachDetailAsset, createDetailProject, findDetailProject, updateDetailProject, enqueueDetailJob, listDetailAssets, listDetailJobs } from '@/lib/detail-generator/repository'
import type { DetailDocument } from '@/lib/detail-generator/types'
import { deflateRawSync } from 'zlib'
import AdmZip from 'adm-zip'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/department-permission', () => ({ isSuperAdmin: jest.fn() }))
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/lib/detail-generator/brand-profiles', () => ({ listDetailBrandProfiles: jest.fn() }))
jest.mock('@/lib/detail-generator/materials', () => ({ readDetailMaterialImage: jest.fn() }))
jest.mock('@/lib/detail-generator/repository', () => ({ assertDetailDocumentAssets: jest.fn(), attachDetailAsset: jest.fn(), createDetailProject: jest.fn(), detailPublicAsset: jest.fn(), detailPublicJob: jest.fn(), enqueueDetailJob: jest.fn(), findDetailProject: jest.fn(), listDetailAssets: jest.fn(), listDetailJobs: jest.fn(), listDetailProjects: jest.fn(), updateDetailProject: jest.fn() }))
jest.mock('@/lib/detail-generator/storage', () => ({ saveDetailFile: jest.fn(), deleteDetailFile: jest.fn() }))
const fixture = (): DetailDocument => ({ version: 1, name: '真实产品', product: { identifierType: 'sku', identifier: 'SKU-001', variant: '', name: '产品', facts: '用户提供事实', assetIds: [], referenceAssetIds: [], documentAssetIds: [] }, brand: { name: '品牌', tone: '简洁', primaryColor: '#111111', backgroundColor: '#FFFFFF', textColor: '#111111', fontFamily: 'Noto Sans SC', status: 'draft' }, models: { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' }, analysis: null, approvedAt: null, screens: [] })
let document: DetailDocument
beforeEach(() => {
jest.clearAllMocks(); document = fixture(); delete process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
;(findDetailProject as jest.Mock).mockImplementation(async () => ({ id: 'project-1', ownerId: 'owner-1', revision: 3, document }))
;(readDetailMaterialImage as jest.Mock).mockResolvedValue({ buffer: Buffer.from('image'), name: '来源图', productModel: 'SPU-002', variant: 'SKU-002', barcodes: ['BAR-002'] })
})
test.each([['sku', 'SKU-001'], ['spu', 'SPU-001'], ['barcode', 'BAR-001']] as const)('rejects a %s source mismatch before writing a project asset', async (identifierType, identifier) => {
document.product.identifierType = identifierType; document.product.identifier = identifier
await expect(importDetailMaterial('owner-1', 'project-1', { materialId: 'material-2', source: 'asset', sourceId: 'asset-2', revision: 3 })).rejects.toMatchObject({ status: 409, code: 'product_identity_conflict' })
expect(attachDetailAsset).not.toHaveBeenCalled()
})
test('client-provided approval cannot make a draft generate-ready', async () => {
const edited = fixture(); edited.approvedAt = '2026-10-12T00:00:00.000Z'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ approvedAt: null }))
await expect(newDetailProject('owner-1', edited)).rejects.toThrow('空白草案')
expect(createDetailProject).not.toHaveBeenCalled()
})
test('changing product facts invalidates the old product imagery and approval', async () => {
const edited = fixture(); edited.product.facts = '已修正产品事实'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ approvedAt: null, analysis: null, screens: [] }))
})
function initialAnalysis() {
return { sellingPoints: ['用户给出的真实卖点'], warnings: ['认证未知'], screens: [{ id: '11111111-1111-4111-8111-111111111111', title: '原始标题', body: '原始正文', imagePrompt: '真实产品展示' }] }
}
function databaseKeyOrder(value: unknown): unknown {
if (Array.isArray(value)) return value.map(databaseKeyOrder)
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).reverse().map(([key, entry]) => [key, databaseKeyOrder(entry)]))
return value
}
test('JSONB source key reordering does not erase an edited unapproved outline', async () => {
const stored = fixture(); stored.analysis = initialAnalysis()
document = databaseKeyOrder(stored) as DetailDocument
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis!.screens[0].title = '数据库重排键后的人工标题'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: edited.analysis, approvedAt: null }))
})
test('JSONB analysis key reordering preserves an unchanged approved outline and its screens', async () => {
const stored = fixture(); stored.analysis = initialAnalysis(); stored.approvedAt = '2026-10-12T00:00:00.000Z'
stored.screens = [createDetailScreen(stored.analysis.screens[0], stored)]
document = databaseKeyOrder(stored) as DetailDocument
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.name = '仅修改项目名称'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: stored.analysis, approvedAt: stored.approvedAt, screens: stored.screens }))
})
test('human-edited unapproved outline can be saved and restored without approving it', async () => {
document.analysis = initialAnalysis()
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis!.screens[0].title = '人工修订标题'
edited.analysis!.sellingPoints[0] = '人工核对后的真实卖点'
;(updateDetailProject as jest.Mock).mockImplementation(async (id, ownerId, revision, saved) => { document = saved; return { id, ownerId, revision: revision + 1, document } })
;(listDetailAssets as jest.Mock).mockResolvedValue([]); (listDetailJobs as jest.Mock).mockResolvedValue([])
const saved = await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
const restored = await getDetailProject('owner-1', 'project-1')
expect(saved.document.analysis!.screens[0].title).toBe('人工修订标题')
expect(restored.project.document.analysis).toEqual(edited.analysis)
expect(restored.project.document.approvedAt).toBeNull()
})
test('returning to product information resets approval and allows saving facts before re-analysis', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'
document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const assetId = '22222222-2222-4222-8222-222222222222'
document.product.assetIds = [assetId]
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.analysis = null; edited.product.facts = '修正后的真实产品参数'
;(updateDetailProject as jest.Mock).mockImplementation(async (id, ownerId, revision, saved) => { document = saved; return { id, ownerId, revision: revision + 1, document } })
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(document.analysis).toBeNull(); expect(document.approvedAt).toBeNull(); expect(document.screens).toEqual([])
const previousKey = process.env.DETAIL_GENERATOR_API_KEY
process.env.DETAIL_GENERATOR_API_KEY = 'unit-test-only-key'
try { await createDetailAction('owner-1', 'project-1', 'analyze', { idempotencyKey: 'reanalyze-request-12345' }) } finally { if (previousKey === undefined) delete process.env.DETAIL_GENERATOR_API_KEY; else process.env.DETAIL_GENERATOR_API_KEY = previousKey }
expect(enqueueDetailJob).toHaveBeenCalledWith(expect.objectContaining({ kind: 'analyze', ownerId: 'owner-1' }))
})
test('resetting analysis without source changes clears old approved screens', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'; document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const edited = { ...document, analysis: null }
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: null, approvedAt: null, screens: [] }))
})
test('blank projects cannot forge analysis and approved outlines cannot be silently changed', async () => {
const forged = fixture(); forged.analysis = initialAnalysis()
await expect(editDetailProject('owner-1', 'project-1', { revision: 3, document: forged })).rejects.toThrow('先分析产品资料')
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument; edited.analysis!.screens[0].title = '不同标题'; edited.approvedAt = null
await expect(editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })).rejects.toThrow('已确认的大纲')
})
test('changing approved source data invalidates analysis before checking client outline differences', async () => {
document.analysis = initialAnalysis(); document.approvedAt = '2026-10-12T00:00:00.000Z'; document.screens = [createDetailScreen(document.analysis.screens[0], document)]
const edited = JSON.parse(JSON.stringify(document)) as DetailDocument
edited.product.facts = '新的产品资料'; edited.analysis!.screens[0].title = '客户端旧稿的修改'
await editDetailProject('owner-1', 'project-1', { revision: 3, document: edited })
expect(updateDetailProject).toHaveBeenCalledWith('project-1', 'owner-1', 3, expect.objectContaining({ analysis: null, approvedAt: null, screens: [] }))
})
test('invalid binary upload and non-UTF8 product documents fail before storage', async () => {
await expect(__detailUploadTesting.normalizeUpload(Buffer.from('<svg onload="alert(1)"></svg>'), 'fake.png', 'product')).rejects.toThrow('有效的 JPG')
await expect(__detailUploadTesting.normalizeUpload(Buffer.from([0xff, 0xfe, 0x00]), 'binary.txt', 'document')).rejects.toThrow('UTF-8')
expect(() => __detailUploadTesting.xmlText('<!DOCTYPE x [<!ENTITY secret SYSTEM "file:///etc/passwd">]><w:t>&secret;</w:t>')).toThrow('外部实体')
})
test('forged DOCX uncompressed size cannot inflate an eight-megabyte zip bomb', () => {
const compressed = deflateRawSync(Buffer.alloc(8 * 1024 * 1024, 65))
const getData = jest.fn(() => { throw new Error('Unbounded decompression must never run') })
const getCompressedData = jest.fn(() => compressed)
const entry = { isDirectory: false, header: { size: 1, compressedSize: compressed.length, method: 8, flags: 0, crc: 0 }, getData, getCompressedData }
try {
__detailUploadTesting.boundedDocxEntry(entry as any)
throw new Error('Expected bounded decompression failure')
} catch (error) { expect(error).toMatchObject({ code: 'ERR_BUFFER_TOO_LARGE' }) }
expect(getCompressedData).toHaveBeenCalledTimes(1)
expect(getData).not.toHaveBeenCalled()
getCompressedData.mockClear()
expect(() => __detailUploadTesting.boundedDocxEntry({ ...entry, header: { ...entry.header, size: 0 } } as any)).toThrow()
expect(getCompressedData).not.toHaveBeenCalled()
expect(getData).not.toHaveBeenCalled()
})
test('bounded DOCX extraction accepts a valid document with verified byte count and CRC', async () => {
const archive = new AdmZip()
archive.addFile('word/document.xml', Buffer.from('<w:document><w:p><w:r><w:t>产品真实尺寸:500mm</w:t></w:r></w:p></w:document>'))
const result = await __detailUploadTesting.normalizeUpload(archive.toBuffer(), 'product.docx', 'document')
expect(result.extractedText).toBe('产品真实尺寸:500mm')
})

View File

@ -0,0 +1,40 @@
/** @jest-environment node */
import { mkdtemp, rm } from 'fs/promises'
import os from 'os'
import path from 'path'
import { deleteDetailFile, getDetailStorageRoot, readDetailFile, saveDetailFile } from '@/lib/detail-generator/storage'
describe('detail generator private storage', () => {
let root: string
const oldStorage = process.env.DETAIL_GENERATOR_STORAGE_DIR
const oldUploads = process.env.UPLOAD_DIR
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'detail-storage-test-'))
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.join(root, 'private')
process.env.UPLOAD_DIR = path.join(root, 'uploads')
})
afterEach(async () => {
// Recursive removal is restricted to this test's freshly created temporary root.
if (path.dirname(root) !== path.resolve(os.tmpdir()) || !path.basename(root).startsWith('detail-storage-test-')) throw new Error('Unsafe test cleanup')
await rm(root, { recursive: true, force: true })
if (oldStorage === undefined) delete process.env.DETAIL_GENERATOR_STORAGE_DIR; else process.env.DETAIL_GENERATOR_STORAGE_DIR = oldStorage
if (oldUploads === undefined) delete process.env.UPLOAD_DIR; else process.env.UPLOAD_DIR = oldUploads
})
it('stores files outside generic uploads and reads the exact bytes', async () => {
const key = await saveDetailFile('project-1', Buffer.from('private bytes'), 'png')
expect(key).toMatch(/^project-1\/[a-f0-9-]{36}\.png$/)
expect((await readDetailFile(key)).toString()).toBe('private bytes')
await deleteDetailFile(key)
await expect(readDetailFile(key)).rejects.toThrow()
})
it('rejects encoded, absolute and parent traversal keys', async () => {
for (const key of ['../private.png', '/etc/passwd', 'p/%2e%2e/secret.png', 'p\\secret.png', 'http://host/image.png']) await expect(readDetailFile(key)).rejects.toThrow('无效')
await expect(saveDetailFile('../project', Buffer.from('x'), 'png')).rejects.toThrow('无效')
})
it('rejects private roots inside either public serving directory', () => {
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.join(root, 'uploads', 'secret')
expect(getDetailStorageRoot).toThrow('之外')
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.join(process.cwd(), 'public', 'secret')
expect(getDetailStorageRoot).toThrow('之外')
})
})

View File

@ -0,0 +1,51 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { isSuperAdmin } from '@/lib/department-permission'
import { prisma } from '@/lib/prisma'
export class DetailGeneratorError extends Error {
constructor(message: string, readonly status = 400, readonly code = 'invalid_request') {
super(message)
this.name = 'DetailGeneratorError'
}
}
/** Beta access is deliberately independent of ordinary administrator roles. */
export async function assertDetailGeneratorUser(userId: string | null | undefined): Promise<string> {
if (!userId) throw new DetailGeneratorError('请先登录', 401, 'unauthenticated')
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true') throw new DetailGeneratorError('详情页生成功能尚未启用', 403, 'feature_disabled')
if (!(await isSuperAdmin(userId))) {
throw new DetailGeneratorError('此功能暂仅向超级管理员开放', 403, 'forbidden')
}
const user = await prisma.user.findUnique({ where: { id: userId }, select: { status: true, isActive: true, archivedAt: true } })
const status = user?.status || (user?.isActive ? 'active' : 'pending')
if (!user || status !== 'active' || user.archivedAt) throw new DetailGeneratorError('账号当前不可用,无法使用详情页生成', 403, 'account_inactive')
return userId
}
export async function requireDetailGeneratorUser(): Promise<string> {
const session = await getServerSession(authOptions)
return assertDetailGeneratorUser((session?.user as { id?: string } | undefined)?.id)
}
export async function assertDetailGeneratorAccess(session: { user?: { id?: string } } | null): Promise<string> {
return assertDetailGeneratorUser(session?.user?.id)
}
export function detailGeneratorErrorResponse(error: unknown): NextResponse {
const known = error instanceof DetailGeneratorError
return NextResponse.json({
error: known ? error.message : '详情页操作暂未完成,请稍后重试',
code: known ? error.code : 'internal_error',
}, { status: known ? error.status : 500, headers: { 'Cache-Control': 'private, no-store' } })
}
export async function detailGeneratorRoute<T>(action: (userId: string) => Promise<T>): Promise<NextResponse> {
try {
const userId = await requireDetailGeneratorUser()
return NextResponse.json(await action(userId), { headers: { 'Cache-Control': 'private, no-store' } })
} catch (error) {
return detailGeneratorErrorResponse(error)
}
}

View File

@ -0,0 +1,31 @@
import { randomUUID } from 'crypto'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from './access'
import { DETAIL_FONT_FAMILY, type DetailBrandProfileRecord } from './types'
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/)
const schema = z.object({
name: z.string().trim().min(1).max(100), tone: z.string().max(2000),
primaryColor: color, backgroundColor: color, textColor: color,
fontFamily: z.literal(DETAIL_FONT_FAMILY), status: z.literal('draft'),
}).strict()
export async function listDetailBrandProfiles(): Promise<DetailBrandProfileRecord[]> {
return prisma.$queryRaw<DetailBrandProfileRecord[]>`SELECT id, revision, profile FROM detail_generator_brand_profiles ORDER BY name`
}
export async function saveDetailBrandProfile(value: unknown, revision: unknown): Promise<DetailBrandProfileRecord> {
const parsed = schema.safeParse(value)
if (!parsed.success) throw new DetailGeneratorError('品牌草案格式无效;品牌标志请在各项目中上传')
if (revision != null && (!Number.isSafeInteger(revision) || Number(revision) < 1)) throw new DetailGeneratorError('品牌版本无效')
const profile = parsed.data
const rows = await prisma.$queryRaw<DetailBrandProfileRecord[]>`
INSERT INTO detail_generator_brand_profiles (id, name, profile, revision)
VALUES (${randomUUID()}, ${profile.name}, ${JSON.stringify(profile)}::jsonb, 1)
ON CONFLICT (name) DO UPDATE SET profile=EXCLUDED.profile, revision=detail_generator_brand_profiles.revision+1, updated_at=NOW()
WHERE detail_generator_brand_profiles.revision=${revision == null ? -1 : Number(revision)}
RETURNING id, revision, profile`
if (!rows[0]) throw new DetailGeneratorError('品牌草案已更新,请重新载入后保存', 409, 'revision_conflict')
return rows[0]
}

View File

@ -0,0 +1,35 @@
import { DETAIL_FONT_FAMILY } from './types'
/** The same unmodified font files are loaded by Fabric in the browser and Node worker. */
export const DETAIL_FONT_REGISTRY = [
{ family: DETAIL_FONT_FAMILY, weight: 400, postScriptName: 'NotoSansSC-Regular', filename: 'NotoSansSC-Regular.otf', url: '/detail-generator/fonts/NotoSansSC-Regular.otf' },
{ family: DETAIL_FONT_FAMILY, weight: 700, postScriptName: 'NotoSansSC-Bold', filename: 'NotoSansSC-Bold.otf', url: '/detail-generator/fonts/NotoSansSC-Bold.otf' },
] as const
let fontLoading: Promise<void> | null = null
export function ensureDetailFontsLoaded(): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
if (!fontLoading) {
fontLoading = Promise.all(DETAIL_FONT_REGISTRY.map(async font => {
const face = new FontFace(font.family, `url("${font.url}")`, { weight: String(font.weight), style: 'normal' })
document.fonts.add(await face.load())
})).then(() => undefined).catch(error => {
fontLoading = null
throw error
})
}
return fontLoading
}
/** Center-crop geometry shared with the exporter's FabricImage placement. */
export function getDetailImageCoverGeometry(sourceWidth: number, sourceHeight: number, width: number, height: number) {
const scale = Math.max(width / sourceWidth, height / sourceHeight)
return { cropX: (sourceWidth - width / scale) / 2, cropY: (sourceHeight - height / scale) / 2, width: width / scale, height: height / scale, scaleX: scale, scaleY: scale }
}
/** Brand marks fit completely inside the frame instead of losing letters to cropping. */
export function getDetailImageContainGeometry(sourceWidth: number, sourceHeight: number, width: number, height: number) {
const scale = Math.min(width / sourceWidth, height / sourceHeight)
return { cropX: 0, cropY: 0, width: sourceWidth, height: sourceHeight, scaleX: scale, scaleY: scale, offsetX: (width - sourceWidth * scale) / 2, offsetY: (height - sourceHeight * scale) / 2 }
}

View File

@ -0,0 +1,156 @@
import { randomUUID } from 'crypto'
import sharp from 'sharp'
import { assertDetailGeneratorUser, DetailGeneratorError } from './access'
import { analyzeDetailFacts, composeDetailOutline, DetailProviderError, generateDetailImage, type DetailProviderImage } from './provider'
import { assertDetailDocumentAssets, checkpointDetailJob, claimDetailJob, findDetailAsset, findDetailProject, finishDetailJob, renewDetailJobLease, saveDetailJobAsset, saveDetailJobDocument, type StoredDetailJob } from './repository'
import { deleteDetailFile, readDetailFile, saveDetailFile } from './storage'
import { renderDetailExport } from './render'
import { type DetailDocument, type DetailExportFormat } from './types'
async function inputImages(document: DetailDocument, ownerId: string, projectId: string): Promise<DetailProviderImage[]> {
const ids = [...document.product.assetIds, ...document.product.referenceAssetIds].slice(0, 8)
const result: DetailProviderImage[] = []
for (const id of ids) {
const asset = await findDetailAsset(id, ownerId)
if (asset.projectId !== projectId || !['product', 'reference'].includes(asset.kind)) throw new DetailGeneratorError('模型输入文件不可访问', 403)
result.push({ buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType })
}
return result
}
async function productText(document: DetailDocument, ownerId: string, projectId: string): Promise<string> {
const text = [document.product.facts]
for (const id of document.product.documentAssetIds) {
const asset = await findDetailAsset(id, ownerId)
if (asset.projectId !== projectId || asset.kind !== 'document') throw new DetailGeneratorError('产品资料不可访问', 403)
text.push(asset.extractedText || '')
}
const supplied = text.join('\n')
if (supplied.length > 60000) throw new DetailGeneratorError('产品资料合计超过 6 万字,请精简后再分析')
return supplied
}
export async function processDetailJob(current: StoredDetailJob): Promise<void> {
const token = current.leaseToken
if (!token) throw new Error('Job lease missing')
let checkpoint: Record<string, unknown> = { ...current.checkpoint }
let leaseLost = false
let heartbeatBusy = false
const heartbeat = setInterval(async () => {
if (heartbeatBusy) return
heartbeatBusy = true
try { if (!(await renewDetailJobLease(current.id, token))) leaseLost = true } catch { leaseLost = true } finally { heartbeatBusy = false }
}, 20000)
heartbeat.unref?.()
const persistCheckpoint = async (progress: number) => {
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
await checkpointDetailJob(current.id, token, checkpoint, progress)
}
const dispatch = async <T>(stage: string, progress: number, call: (key: string) => Promise<T>): Promise<T> => {
if (leaseLost) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
await assertDetailGeneratorUser(current.ownerId)
const key = `${current.id}-${stage}`
checkpoint = { ...checkpoint, stage, dispatchPending: true, dispatchKey: key }
await persistCheckpoint(progress)
try { await assertDetailGeneratorUser(current.ownerId) } catch (error) {
checkpoint = { ...checkpoint, dispatchPending: false }
await persistCheckpoint(progress)
throw error
}
try { return await call(key) } catch (error) {
if (error instanceof DetailProviderError && !error.outcomeUnknown || error instanceof DetailGeneratorError) {
checkpoint = { ...checkpoint, dispatchPending: false }
await persistCheckpoint(progress)
}
throw error
}
}
try {
await assertDetailGeneratorUser(current.ownerId)
const project = await findDetailProject(current.projectId, current.ownerId)
if (project.revision !== current.projectRevision) throw new DetailGeneratorError('项目版本发生变化,任务已停止', 409)
let document: DetailDocument = JSON.parse(JSON.stringify(project.document))
await assertDetailDocumentAssets(document, current.projectId, current.ownerId)
if (checkpoint.dispatchPending === true) throw new DetailProviderError('模型请求结果未知,任务已暂停;请核查服务商记录', true)
if (checkpoint.stage === 'complete') {
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
return
}
if (current.kind === 'analyze') {
const images = await inputImages(document, current.ownerId, current.projectId)
const suppliedText = await productText(document, current.ownerId, current.projectId)
let facts = typeof checkpoint.facts === 'string' ? checkpoint.facts : null
if (!facts) {
facts = await dispatch('product-analysis', 10, key => analyzeDetailFacts(document, images, suppliedText, key))
checkpoint = { ...checkpoint, facts, stage: 'selling-points', dispatchPending: false }
await persistCheckpoint(40)
}
// Document text is included in the copy step as well as the multimodal analysis.
const analysis = await dispatch('outline', 45, key => composeDetailOutline(document, `${facts}\n用户原始资料:${suppliedText}`, key))
document = { ...document, analysis, approvedAt: null }
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
await saveDetailJobDocument(current.id, token, document, checkpoint, 100)
} else if (current.kind === 'generate' || current.kind === 'regenerate') {
if (!document.approvedAt || !document.analysis) throw new DetailGeneratorError('卖点和大纲尚未确认')
const images = await inputImages(document, current.ownerId, current.projectId)
const target = current.kind === 'regenerate' ? document.screens.filter(screen => screen.id === current.payload.screenId) : document.screens
if (!target.length) throw new DetailGeneratorError('没有待生成屏幕')
const completed = new Set(Array.isArray(checkpoint.completedScreenIds) ? checkpoint.completedScreenIds as string[] : [])
for (let index = 0; index < target.length; index++) {
const screen = target[index]
if (completed.has(screen.id) || (current.kind === 'generate' && screen.image)) continue
const progress = Math.round(index / target.length * 90)
const buffer = await dispatch(`screen-${screen.id}`, progress, key => generateDetailImage(document, screen.imagePrompt, images, key))
const metadata = await sharp(buffer).metadata()
const assetId = randomUUID()
const storageKey = await saveDetailFile(current.projectId, buffer, 'png')
const area = screen.imageArea || { x: 0, y: 380, width: 750, height: Math.min(750, screen.height - 380) }
const replaced = { ...screen, image: { assetId, ...area } }
document = { ...document, screens: document.screens.map(value => value.id === screen.id ? replaced : value) }
completed.add(screen.id)
const receivedCheckpoint = { ...checkpoint, completedScreenIds: Array.from(completed), stage: 'rendering', dispatchPending: false }
try {
await saveDetailJobAsset(current.id, token, { id: assetId, projectId: current.projectId, ownerId: current.ownerId, kind: 'generated', name: `${index + 1}-${screen.title}.png`, mimeType: 'image/png', storageKey, byteSize: buffer.length, width: metadata.width, height: metadata.height }, document, receivedCheckpoint, Math.round((index + 1) / target.length * 95))
checkpoint = receivedCheckpoint
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
} else if (current.kind === 'export') {
const assets: Record<string, DetailProviderImage> = {}
const required = new Set(document.screens.flatMap(screen => [screen.image?.assetId, screen.logo?.assetId].filter((id): id is string => Boolean(id))))
for (const id of Array.from(required)) {
const asset = await findDetailAsset(id, current.ownerId)
if (asset.projectId !== current.projectId) throw new DetailGeneratorError('导出图片不可访问', 403)
assets[id] = { buffer: await readDetailFile(asset.storageKey), mimeType: asset.mimeType }
}
checkpoint = { ...checkpoint, stage: 'export' }; await persistCheckpoint(10)
await assertDetailGeneratorUser(current.ownerId)
const output = await renderDetailExport(document, assets, current.payload.format as DetailExportFormat)
const extension = output.filename.split('.').pop() || 'bin'
const storageKey = await saveDetailFile(current.projectId, output.buffer, extension)
const id = randomUUID()
const received = { ...checkpoint, stage: 'complete', exportAssetId: id, dispatchPending: false }
try {
await saveDetailJobAsset(current.id, token, { id, projectId: current.projectId, ownerId: current.ownerId, kind: 'export', name: output.filename, mimeType: output.mimeType, storageKey, byteSize: output.buffer.length }, null, received, 100)
checkpoint = received
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
checkpoint = { ...checkpoint, stage: 'complete', dispatchPending: false }
await finishDetailJob(current.id, token, 'succeeded', checkpoint)
} catch (error) {
const unknown = error instanceof DetailProviderError ? error.outcomeUnknown : checkpoint.dispatchPending === true
const safeMessage = error instanceof DetailProviderError || error instanceof DetailGeneratorError ? error.message : unknown ? '模型结果尚未确认,请核查请求记录,勿重复提交' : '任务执行失败,请检查输入资料或服务端配置'
const requestId = error instanceof DetailProviderError ? error.requestId : null
await finishDetailJob(current.id, token, unknown ? 'unknown' : 'failed', checkpoint, safeMessage, requestId)
} finally { clearInterval(heartbeat) }
}
export async function runDetailGeneratorWorkerOnce(): Promise<boolean> {
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true') return false
const claimed = await claimDetailJob()
if (!claimed) return false
await processDetailJob(claimed)
return true
}

View File

@ -0,0 +1,38 @@
import type { DetailScreen } from './types'
const combiningMark = new RegExp('\\p{Mark}', 'u')
/** Conservative grapheme wrapping matches the fixed font and Fabric splitByGrapheme layout. */
export function estimateDetailTextHeight(text: string, width: number, fontSize: number, lineHeight: number): number {
if (!text) return Math.ceil(fontSize * 1.13)
let lines = 0
for (const paragraph of text.split('\n')) {
let occupied = 0; let paragraphLines = 1
for (const character of Array.from(paragraph)) {
if (combiningMark.test(character)) continue
const factor = /[MW@%]/.test(character) ? 1 : /[\x20-\x7e]/.test(character) ? 0.65 : 1.05
const next = fontSize * factor
if (occupied > 0 && occupied + next > width) { paragraphLines++; occupied = 0 }
occupied += next
}
lines += paragraphLines
}
return Math.ceil(lines * fontSize * 1.13 * lineHeight)
}
/** v1 preserves the template while letting copy grow without covering the product image. */
export function reflowDetailScreen(screen: DetailScreen): DetailScreen {
const [title, body] = screen.texts
if (!title || !body || !title.id.endsWith('-title') || !body.id.endsWith('-body')) return screen
const titleY = screen.logo ? Math.max(58, screen.logo.y + screen.logo.height + 24) : 58
const titleHeight = estimateDetailTextHeight(screen.title, title.width, title.fontSize, title.lineHeight)
const bodyY = titleY + titleHeight + 24
const bodyHeight = estimateDetailTextHeight(screen.body, body.width, body.fontSize, body.lineHeight)
const imageArea = { x: 0, y: bodyY + bodyHeight + 32, width: 750, height: 750 }
const height = Math.max(1200, imageArea.y + imageArea.height + 48)
if (height > 2400) throw new Error('文案过长,请精简后确认;每屏高度最多 2400px')
return {
...screen, height, imageArea,
...(screen.image ? { image: { assetId: screen.image.assetId, ...imageArea } } : {}),
texts: [{ ...title, text: screen.title, y: titleY }, { ...body, text: screen.body, y: bodyY }, ...screen.texts.slice(2)],
}
}

View File

@ -0,0 +1,104 @@
import path from 'path'
import { prisma } from '@/lib/prisma'
import { canUserViewMaterial } from '@/lib/department-permission'
import { canAccessMaterialPrivacy } from '@/lib/task-detail-privacy'
import { getS3ObjectStream, getTrustedS3MutationKey, isS3Url } from '@/lib/s3'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { readBoundedRegularFile } from '@/lib/bounded-file-reader'
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
import { parseStandardModelWhiteBgAssetType } from '@/lib/standard-model-white-background'
import { DetailGeneratorError } from './access'
const MAX_IMAGE_BYTES = 16 * 1024 * 1024
const imagePath = (value: string) => /\.(png|jpe?g|webp)(?:\?|$)/i.test(value)
async function assertVisible(material: any, ownerId: string) {
if (material.deletedAt || material.isIncompleteShell || !(await canAccessMaterialPrivacy(material, ownerId)) || !(await canUserViewMaterial(undefined, '', undefined, material.materialType, ownerId))) {
throw new DetailGeneratorError('素材不可访问', 403, 'material_forbidden')
}
}
export async function findDetailMaterialMatches(ownerId: string, identifierType: unknown, identifier: unknown) {
if (!['sku', 'spu', 'barcode'].includes(String(identifierType)) || typeof identifier !== 'string' || !identifier.trim() || identifier.length > 200) throw new DetailGeneratorError('请提供有效的产品编号')
const value = identifier.trim().toUpperCase()
const match = identifierType === 'spu' ? { productModel: { equals: value, mode: 'insensitive' as const } }
: identifierType === 'sku' ? { OR: [{ skus: { some: { deletedAt: null, skuName: { equals: value, mode: 'insensitive' as const } } } }, { assets: { some: { skuCode: { equals: value, mode: 'insensitive' as const } } } }] }
: { OR: [{ skus: { some: { deletedAt: null, barcodes: { some: { barcode: value, deletedAt: null } } } } }, { assets: { some: { barcodes: { some: { barcode: value } } } } }] }
const rows = await prisma.material.findMany({
where: { deletedAt: null, isIncompleteShell: false, ...match }, take: 30,
include: { assets: { include: { barcodes: true }, orderBy: { sortOrder: 'asc' } }, skus: { where: { deletedAt: null }, include: { barcodes: { where: { deletedAt: null } } }, orderBy: { sortOrder: 'asc' } } },
})
const matches: any[] = []
for (const material of rows) {
try { await assertVisible(material, ownerId) } catch (error) { if (error instanceof DetailGeneratorError && error.status === 403) continue; throw error }
const candidates = new Map<string, { materialId: string; productModel: string; skuCode: string; variant: string; assets: any[] }>()
const append = (variant: string, item: any) => {
const found = candidates.get(variant) || { materialId: material.id, productModel: material.productModel, skuCode: variant, variant, assets: [] }
found.assets.push(item); candidates.set(variant, found)
}
for (const sku of material.skus) {
if (identifierType === 'sku' && sku.skuName.toUpperCase() !== value) continue
if (identifierType === 'barcode' && !sku.barcodes.some(code => code.barcode === value)) continue
const source = sku.previewUrl && imagePath(sku.previewUrl) ? sku.previewUrl : imagePath(sku.filePath) ? sku.filePath : null
if (source) append(sku.skuName, { id: sku.id, source: 'sku', name: sku.skuName, previewUrl: `/api/detail-generator/materials/${material.id}/images/sku/${sku.id}` })
}
for (const asset of material.assets) {
if (!imagePath(asset.fileUrl)) continue
const whiteBackground = parseStandardModelWhiteBgAssetType(asset.assetType)
const linkedSku = whiteBackground ? material.skus.find(sku => sku.id === whiteBackground.skuId) : undefined
const variant = asset.skuCode || linkedSku?.skuName || ''
if (identifierType === 'sku' && variant.toUpperCase() !== value) continue
if (identifierType === 'barcode' && !asset.barcodes.some(code => code.barcode === value) && !linkedSku?.barcodes.some(code => code.barcode === value)) continue
append(variant, { id: asset.id, source: 'asset', name: asset.fileName || asset.assetType, previewUrl: `/api/detail-generator/materials/${material.id}/images/asset/${asset.id}` })
}
matches.push(...Array.from(candidates.values()))
}
return matches.slice(0, 40)
}
export async function readDetailMaterialImage(ownerId: string, materialId: string, source: string, sourceId: string): Promise<{ buffer: Buffer; name: string; variant: string; productModel: string; barcodes: string[] }> {
const material = await prisma.material.findUnique({ where: { id: materialId }, include: { assets: { include: { barcodes: true } }, skus: { where: { deletedAt: null }, include: { barcodes: { where: { deletedAt: null } } } } } })
if (!material) throw new DetailGeneratorError('素材不存在', 404)
await assertVisible(material, ownerId)
let storedPath: string
let name: string
let variant: string
let barcodes: string[]
if (source === 'asset') {
const asset = material.assets.find(item => item.id === sourceId)
if (!asset || !imagePath(asset.fileUrl)) throw new DetailGeneratorError('图片不存在', 404)
const whiteBackground = parseStandardModelWhiteBgAssetType(asset.assetType)
const linkedSku = whiteBackground ? material.skus.find(sku => sku.id === whiteBackground.skuId) : undefined
storedPath = asset.fileUrl; name = asset.fileName || asset.assetType; variant = asset.skuCode || linkedSku?.skuName || ''
barcodes = Array.from(new Set([...asset.barcodes.map(value => value.barcode), ...(linkedSku?.barcodes.map(value => value.barcode) || [])]))
} else if (source === 'sku') {
const sku = material.skus.find(item => item.id === sourceId)
if (!sku) throw new DetailGeneratorError('具体产品款式不存在', 404)
storedPath = sku.previewUrl && imagePath(sku.previewUrl) ? sku.previewUrl : sku.filePath
if (!imagePath(storedPath)) throw new DetailGeneratorError('款式没有可用产品图片', 400)
name = sku.skuName; variant = sku.skuName
barcodes = sku.barcodes.map(value => value.barcode)
} else throw new DetailGeneratorError('图片来源无效')
let buffer: Buffer
if (isS3Url(storedPath)) {
const { body, contentLength } = await getS3ObjectStream(getTrustedS3MutationKey(storedPath))
if (contentLength && contentLength > MAX_IMAGE_BYTES) { body.destroy(); throw new DetailGeneratorError('产品图片超过 16MB 限制', 413) }
const chunks: Buffer[] = []; let bytes = 0
try {
for await (const chunk of body) {
bytes += chunk.length
if (bytes > MAX_IMAGE_BYTES) throw new DetailGeneratorError('产品图片超过 16MB 限制', 413)
chunks.push(Buffer.from(chunk))
}
buffer = Buffer.concat(chunks, bytes)
} finally { body.destroy() }
} else {
if (/^[a-z]+:/i.test(storedPath)) throw new DetailGeneratorError('该图片不在当前受信任存储中')
const file = resolveStoredUploadPath(storedPath.replace(/^\/uploads\//i, 'uploads/'))
const root = path.resolve(getUploadRootDir())
const relative = path.relative(root, path.resolve(file))
if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || !(await isPathFreeOfSymbolicLinks(root, file))) throw new DetailGeneratorError('图片存储路径不可访问', 403)
buffer = await readBoundedRegularFile(file, { maxBytes: MAX_IMAGE_BYTES, label: '产品图片' })
}
return { buffer, name, variant, productModel: material.productModel, barcodes }
}

View File

@ -0,0 +1,147 @@
import { randomUUID } from 'crypto'
import sharp from 'sharp'
import { DetailGeneratorError } from './access'
import { DETAIL_MAX_SCREENS, DETAIL_MODEL_DEFAULTS, type DetailAnalysis, type DetailDocument } from './types'
import { detailAnalysisSchema } from './validation'
export interface DetailProviderImage { buffer: Buffer; mimeType: string }
export class DetailProviderError extends Error {
constructor(message: string, readonly outcomeUnknown: boolean, readonly requestId: string | null = null) { super(message); this.name = 'DetailProviderError' }
}
export function getDetailModels(): { analysis: string[]; copy: string[]; image: string[] } {
const defaults: { analysis: string[]; copy: string[]; image: string[] } = { analysis: [DETAIL_MODEL_DEFAULTS.analysis], copy: [DETAIL_MODEL_DEFAULTS.copy], image: [DETAIL_MODEL_DEFAULTS.image] }
const setting = process.env.DETAIL_GENERATOR_VALIDATED_MODELS_JSON
if (!setting) return defaults
let parsed: unknown
try { parsed = JSON.parse(setting) } catch { throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid') }
if (!parsed || typeof parsed !== 'object') throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid')
for (const stage of ['analysis', 'copy', 'image'] as const) {
const values = (parsed as Record<string, unknown>)[stage]
if (values !== undefined && (!Array.isArray(values) || values.some(value => typeof value !== 'string' || !/^[\w.:-]{1,120}$/.test(value)))) {
throw new DetailGeneratorError('已验证模型配置格式无效', 503, 'configuration_invalid')
}
if (Array.isArray(values)) defaults[stage] = Array.from(new Set([...defaults[stage], ...values]))
}
return defaults
}
export function assertDetailModels(models: DetailDocument['models']): void {
const allowed = getDetailModels()
for (const stage of ['analysis', 'copy', 'image'] as const) {
if (!allowed[stage].includes(models[stage])) throw new DetailGeneratorError('只能选择已验证兼容的模型')
}
}
export function isDetailProviderConfigured(): boolean { return Boolean(process.env.DETAIL_GENERATOR_API_KEY?.trim()) }
function providerConfig() {
const apiKey = process.env.DETAIL_GENERATOR_API_KEY?.trim()
if (!apiKey) throw new DetailGeneratorError('请先在服务端配置详情页模型密钥', 503, 'provider_not_configured')
const configured = process.env.DETAIL_GENERATOR_BASE_URL?.trim() || 'https://api.vip98k.com/v1'
let base: URL
try { base = new URL(configured) } catch { throw new DetailGeneratorError('模型服务地址配置无效', 503, 'configuration_invalid') }
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) throw new DetailGeneratorError('模型服务须使用 HTTPS 地址', 503, 'configuration_invalid')
const baseUrl = base.href.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1'
return { apiKey, baseUrl }
}
function requestIdFrom(response: Response, data: any): string | null {
const value = response.headers.get('x-request-id') || data?.request_id || data?.error?.request_id
return typeof value === 'string' && /^[\w-]{1,120}$/.test(value) ? value : null
}
async function callProvider(endpoint: string, body: string | FormData, idempotencyKey: string): Promise<any> {
const { apiKey, baseUrl } = providerConfig()
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 180_000)
try {
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST', signal: controller.signal,
headers: { Authorization: `Bearer ${apiKey}`, 'Idempotency-Key': idempotencyKey, ...(typeof body === 'string' ? { 'Content-Type': 'application/json' } : {}) }, body,
})
const bytes = await readDetailResponseBytes(response, 32 * 1024 * 1024)
let data: any
try { data = JSON.parse(bytes.toString('utf8')) } catch { throw new DetailProviderError('模型响应格式无法确认,请核查请求结果', true, requestIdFrom(response, null)) }
const requestId = requestIdFrom(response, data)
if (!response.ok) {
const unknown = response.status >= 500 || data?.error?.code === 'request_outcome_unknown' || data?.code === 'request_outcome_unknown'
throw new DetailProviderError(unknown ? '模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交' : `模型服务拒绝了请求(${response.status})`, unknown, requestId)
}
return data
} catch (error) {
if (error instanceof DetailProviderError || error instanceof DetailGeneratorError) throw error
throw new DetailProviderError('模型请求结果未知,已暂停;请核查服务商请求记录,勿重复提交', true)
} finally { clearTimeout(timeout) }
}
export async function readDetailResponseBytes(response: Response, maxBytes: number): Promise<Buffer> {
if (Number(response.headers.get('content-length') || 0) > maxBytes) throw new Error('响应过大')
if (!response.body) throw new Error('响应为空')
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) throw new Error('响应过大')
chunks.push(value)
}
return Buffer.concat(chunks, total)
} catch (error) { await reader.cancel().catch(() => undefined); throw error } finally { reader.releaseLock() }
}
async function imageContent(images: DetailProviderImage[]) {
return Promise.all(images.slice(0, 8).map(async (image) => ({
type: 'image_url', image_url: { url: `data:image/jpeg;base64,${(await sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1024, 1024, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()).toString('base64')}` },
})))
}
export async function analyzeDetailFacts(document: DetailDocument, images: DetailProviderImage[], extractedText: string, idempotencyKey: string): Promise<string> {
assertDetailModels(document.models)
const prompt = `你是产品资料分析员。只提取用户明确提供的产品事实,图片只能判断可见外观,不得推测材质、尺寸、认证、价格或性能。识别冲突和缺失信息,不要编造。返回 JSON,字段 facts(事实字符串数组)、warnings(待确认字符串数组)。\n产品:${JSON.stringify(document.product)}\n用户资料:${extractedText.slice(0, 60000)}`
const data = await callProvider('/chat/completions', JSON.stringify({ model: document.models.analysis, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }, ...await imageContent(images)] }], temperature: 0.2, max_tokens: 3000 }), idempotencyKey)
const content = data?.choices?.[0]?.message?.content
if (typeof content !== 'string' || !content.trim()) throw new DetailProviderError('模型未返回可用的产品分析', false)
return content.slice(0, 30000)
}
export async function composeDetailOutline(document: DetailDocument, facts: string, idempotencyKey: string): Promise<DetailAnalysis> {
assertDetailModels(document.models)
const prompt = `根据给出的真实产品资料提炼卖点和电商详情页大纲,不添加未经提供的参数、认证、性能或促销承诺。缺少的信息写入 warnings,不用于卖点。每屏标题和正文使用中文;imagePrompt 描述不含文字、标志的场景,保持产品结构和外观。品牌是草案,不能声称已获品牌认可。返回严格 JSON:{"sellingPoints":["..."],"warnings":["..."],"screens":[{"title":"...","body":"...","imagePrompt":"..."}]},最多${DETAIL_MAX_SCREENS}屏,建议4至6屏。\n产品资料:${JSON.stringify(document.product)}\n分析:${facts}\n品牌风格草案:${JSON.stringify(document.brand)}`
const data = await callProvider('/chat/completions', JSON.stringify({ model: document.models.copy, messages: [{ role: 'user', content: prompt }], temperature: 0.3, max_tokens: 8000 }), idempotencyKey)
const content = data?.choices?.[0]?.message?.content
let value: any
try {
if (typeof content !== 'string') throw new Error('empty')
value = JSON.parse(content.replace(/^\s*```(?:json)?\s*/i, '').replace(/\s*```\s*$/, ''))
if (Array.isArray(value?.screens)) value.screens = value.screens.map((screen: any) => ({ id: randomUUID(), title: screen.title, body: screen.body, imagePrompt: screen.imagePrompt }))
} catch { throw new DetailProviderError('模型未返回可用的大纲,请检查产品资料', false) }
const parsed = detailAnalysisSchema.safeParse(value)
if (!parsed.success) throw new DetailProviderError('模型返回的大纲格式不符合要求', false)
return parsed.data
}
export async function generateDetailImage(document: DetailDocument, prompt: string, images: DetailProviderImage[], idempotencyKey: string): Promise<Buffer> {
assertDetailModels(document.models)
if (!images.length) throw new DetailGeneratorError('请先上传或关联产品图片')
const body = new FormData()
body.set('model', document.models.image)
body.set('prompt', `生成用于同一产品详情页的一张高质量场景图片。严格保持产品参考中的具体款式、颜色、结构和比例;不得替换为相似产品。全套品牌调性一致:${document.brand.tone || '简洁、真实、清晰的产品摄影'};配色:${document.brand.primaryColor}、${document.brand.backgroundColor}。图片中不要生成文字、价格、标志或水印。第一张是实际产品;其余为该产品或风格参考,不得将参考中的其他产品混入。\n${prompt}`)
body.set('n', '1'); body.set('size', '1024x1024'); body.set('response_format', 'b64_json')
for (let index = 0; index < Math.min(images.length, 8); index++) {
const image = await sharp(images[index].buffer, { limitInputPixels: 40_000_000 }).rotate().resize(1536, 1536, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
body.append(images.length === 1 ? 'image' : 'image[]', new Blob([new Uint8Array(image)], { type: 'image/png' }), `reference-${index + 1}.png`)
}
const data = await callProvider('/images/edits', body, idempotencyKey)
const encoded = data?.data?.[0]?.b64_json
if (typeof encoded !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length > 24 * 1024 * 1024) throw new DetailProviderError('模型未返回可保存的图片', false)
const buffer = Buffer.from(encoded, 'base64')
try {
const output = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
if (output.length > 20 * 1024 * 1024) throw new Error('size')
return output
} catch { throw new DetailProviderError('模型返回的图片无法读取或超出导出限制', false) }
}

View File

@ -0,0 +1,54 @@
import { spawn } from 'child_process'
import path from 'path'
import AdmZip from 'adm-zip'
import type { DetailDocument, DetailExportFormat } from './types'
type RenderAsset = { buffer: Buffer; mimeType: string }
type RenderFile = { filename: string; mimeType: string; base64: string }
const RENDER_TIMEOUT_MS = 180_000
const MAX_IO_BYTES = 192 * 1024 * 1024
/** Assets are already read through the project/owner checked private storage boundary. */
export async function renderDetailExport(document: DetailDocument, assets: Record<string, RenderAsset>, format: DetailExportFormat): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
const input = JSON.stringify({ document, format, assets: Object.fromEntries(Object.entries(assets).map(([id, asset]) => [id, { mimeType: asset.mimeType, base64: asset.buffer.toString('base64') }])) })
if (Buffer.byteLength(input) > MAX_IO_BYTES) throw new Error('导出图片总量超过上限')
const executable = process.env.DETAIL_RENDERER_NODE || process.execPath
const entry = process.env.DETAIL_RENDERER_ENTRY || path.resolve(process.cwd(), 'workers/detail-renderer/cli.mjs')
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(executable, ['--max-old-space-size=768', entry], { cwd: process.cwd(), windowsHide: true, shell: false, env: { ...process.env, DETAIL_FONT_DIR: process.env.DETAIL_FONT_DIR || path.resolve(process.cwd(), 'public/detail-generator/fonts') }, stdio: ['pipe', 'pipe', 'pipe'] })
const chunks: Buffer[] = []
let bytes = 0
let stderr = ''
let settled = false
const finish = (error?: Error, result?: string) => {
if (settled) return
settled = true
clearTimeout(timeout)
if (error) reject(error)
else resolve(result || '')
}
const timeout = setTimeout(() => { child.kill('SIGKILL'); finish(new Error('详情页导出超时,请减少屏数或素材尺寸')) }, RENDER_TIMEOUT_MS)
child.on('error', error => finish(error))
child.stdin.on('error', () => undefined) // spawn/exit handling below reports the actual failure.
child.stdout.on('data', (chunk: Buffer) => {
bytes += chunk.length
if (bytes > MAX_IO_BYTES) { child.kill('SIGKILL'); finish(new Error('导出结果超过上限')); return }
chunks.push(chunk)
})
child.stderr.on('data', (chunk: Buffer) => { if (stderr.length < 2048) stderr += chunk.toString('utf8') })
child.on('close', code => {
if (code !== 0) { finish(new Error(stderr.trim() || '详情页渲染进程失败,请检查独立渲染依赖')); return }
finish(undefined, Buffer.concat(chunks).toString('utf8'))
})
child.stdin.end(input)
})
const parsed = JSON.parse(output) as { files: RenderFile[] }
if (!Array.isArray(parsed.files) || !parsed.files.length) throw new Error('导出未产生文件')
if (parsed.files.length === 1) {
const file = parsed.files[0]
return { buffer: Buffer.from(file.base64, 'base64'), mimeType: file.mimeType, filename: file.filename }
}
const zip = new AdmZip()
for (const file of parsed.files) zip.addFile(file.filename, Buffer.from(file.base64, 'base64'))
return { buffer: zip.toBuffer(), mimeType: 'application/zip', filename: `detail-screens-${format}.zip` }
}

View File

@ -0,0 +1,215 @@
import { createHash, randomUUID } from 'crypto'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from './access'
import type { DetailAsset, DetailAssetKind, DetailDocument, DetailJob, DetailJobKind, DetailJobStatus, DetailProject } from './types'
type Database = Prisma.TransactionClient | typeof prisma
type Row = Record<string, any>
export interface StoredDetailAsset extends DetailAsset { byteSize: number }
export interface StoredDetailJob extends DetailJob {
fingerprint: string; projectRevision: number; idempotencyKey: string; leaseToken: string | null; leaseExpiresAt: Date | null; progress: number
}
const projectColumns = `id, owner_id AS "ownerId", document, revision, created_at AS "createdAt", updated_at AS "updatedAt"`
const assetColumns = `a.id, a.project_id AS "projectId", a.owner_id AS "ownerId", a.kind, a.name, a.mime_type AS "mimeType", a.storage_key AS "storageKey", a.byte_size AS "byteSize", a.width, a.height, a.extracted_text AS "extractedText", a.created_at AS "createdAt"`
const jobColumns = `id, project_id AS "projectId", owner_id AS "ownerId", kind, status, payload, checkpoint, fingerprint, project_revision AS "projectRevision", idempotency_key AS "idempotencyKey", lease_token AS "leaseToken", lease_expires_at AS "leaseExpiresAt", progress, error, request_id AS "requestId", created_at AS "createdAt", updated_at AS "updatedAt"`
function iso(value: Date | string): string { return value instanceof Date ? value.toISOString() : value }
function project(row: Row): DetailProject { return { ...row, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) } as DetailProject }
function asset(row: Row): StoredDetailAsset { return { ...row, createdAt: iso(row.createdAt) } as StoredDetailAsset }
function job(row: Row): StoredDetailJob { return { ...row, createdAt: iso(row.createdAt), updatedAt: iso(row.updatedAt) } as StoredDetailJob }
export async function detailTransactionLock(tx: Database, key: string): Promise<void> {
await tx.$queryRawUnsafe('SELECT 1 AS locked FROM (SELECT pg_advisory_xact_lock(hashtext($1))) AS lock_result', key)
}
export async function listDetailProjects(ownerId: string): Promise<DetailProject[]> {
const rows = await prisma.$queryRawUnsafe<Row[]>(`SELECT ${projectColumns} FROM detail_generator_projects WHERE owner_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 100`, ownerId)
return rows.map(project)
}
export async function findDetailProject(id: string, ownerId: string, db: Database = prisma): Promise<DetailProject> {
const rows = await db.$queryRawUnsafe<Row[]>(`SELECT ${projectColumns} FROM detail_generator_projects WHERE id = $1 AND owner_id = $2 AND deleted_at IS NULL`, id, ownerId)
if (!rows[0]) throw new DetailGeneratorError('项目不存在', 404, 'not_found')
return project(rows[0])
}
export async function createDetailProject(ownerId: string, document: DetailDocument): Promise<DetailProject> {
const rows = await prisma.$queryRawUnsafe<Row[]>(`INSERT INTO detail_generator_projects (id, owner_id, document) VALUES ($1, $2, $3::jsonb) RETURNING ${projectColumns}`, randomUUID(), ownerId, JSON.stringify(document))
return project(rows[0])
}
export async function assertDetailProjectIdle(id: string, ownerId: string, db: Database = prisma): Promise<void> {
const rows = await db.$queryRawUnsafe<Row[]>('SELECT id FROM detail_generator_jobs WHERE project_id = $1 AND owner_id = $2 AND status IN (\'queued\', \'running\') LIMIT 1', id, ownerId)
if (rows.length) throw new DetailGeneratorError('项目任务正在执行,请完成后再编辑', 409, 'project_busy')
}
export async function updateDetailProject(id: string, ownerId: string, revision: number, document: DetailDocument): Promise<DetailProject> {
return prisma.$transaction(async tx => {
await detailTransactionLock(tx, `detail-project:${id}`)
await findDetailProject(id, ownerId, tx)
await assertDetailProjectIdle(id, ownerId, tx)
return updateDetailProjectRevision(tx, id, ownerId, revision, document)
})
}
async function updateDetailProjectRevision(db: Database, id: string, ownerId: string, revision: number, document: DetailDocument): Promise<DetailProject> {
const rows = await db.$queryRawUnsafe<Row[]>(`UPDATE detail_generator_projects SET document = $4::jsonb, revision = revision + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 AND revision = $3 AND deleted_at IS NULL RETURNING ${projectColumns}`, id, ownerId, revision, JSON.stringify(document))
if (!rows[0]) throw new DetailGeneratorError('项目已更新,请刷新后重试', 409, 'revision_conflict')
return project(rows[0])
}
export async function deleteDetailProject(id: string, ownerId: string): Promise<void> {
await prisma.$transaction(async tx => {
await detailTransactionLock(tx, `detail-project:${id}`)
await findDetailProject(id, ownerId, tx)
await assertDetailProjectIdle(id, ownerId, tx)
await tx.$executeRawUnsafe('UPDATE detail_generator_projects SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND owner_id = $2', id, ownerId)
})
}
export async function listDetailAssets(projectId: string, ownerId: string): Promise<StoredDetailAsset[]> {
await findDetailProject(projectId, ownerId)
const rows = await prisma.$queryRawUnsafe<Row[]>(`SELECT ${assetColumns} FROM detail_generator_assets a WHERE a.project_id = $1 AND a.owner_id = $2 ORDER BY a.created_at`, projectId, ownerId)
return rows.map(asset)
}
export async function findDetailAsset(id: string, ownerId: string): Promise<StoredDetailAsset> {
const rows = await prisma.$queryRawUnsafe<Row[]>(`SELECT ${assetColumns} FROM detail_generator_assets a JOIN detail_generator_projects p ON p.id = a.project_id AND p.owner_id = a.owner_id WHERE a.id = $1 AND a.owner_id = $2 AND p.deleted_at IS NULL`, id, ownerId)
if (!rows[0]) throw new DetailGeneratorError('文件不存在', 404, 'not_found')
return asset(rows[0])
}
export async function insertDetailAsset(input: { id?: string; projectId: string; ownerId: string; kind: DetailAssetKind; name: string; mimeType: string; storageKey: string; byteSize: number; width?: number | null; height?: number | null; extractedText?: string | null }, db: Database = prisma): Promise<StoredDetailAsset> {
await findDetailProject(input.projectId, input.ownerId, db)
const rows = await db.$queryRawUnsafe<Row[]>(`INSERT INTO detail_generator_assets AS a (id, project_id, owner_id, kind, name, mime_type, storage_key, byte_size, width, height, extracted_text) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING ${assetColumns}`, input.id || randomUUID(), input.projectId, input.ownerId, input.kind, input.name, input.mimeType, input.storageKey, input.byteSize, input.width ?? null, input.height ?? null, input.extractedText ?? null)
return asset(rows[0])
}
export async function assertDetailDocumentAssets(document: DetailDocument, projectId: string, ownerId: string): Promise<void> {
const required = [
...document.product.assetIds.map(id => ({ id, kinds: ['product'] })),
...document.product.referenceAssetIds.map(id => ({ id, kinds: ['reference'] })),
...document.product.documentAssetIds.map(id => ({ id, kinds: ['document'] })),
...(document.brand.logoAssetId ? [{ id: document.brand.logoAssetId, kinds: ['logo'] }] : []),
...document.screens.flatMap(screen => [...(screen.image ? [{ id: screen.image.assetId, kinds: ['generated', 'product', 'reference'] }] : []), ...(screen.logo ? [{ id: screen.logo.assetId, kinds: ['logo'] }] : [])]),
]
if (!required.length) return
const assets = await listDetailAssets(projectId, ownerId)
const map = new Map(assets.map(value => [value.id, value]))
for (const entry of required) {
const found = map.get(entry.id)
if (!found || found.projectId !== projectId || !entry.kinds.includes(found.kind)) throw new DetailGeneratorError('项目引用了不可访问或类型不匹配的文件', 403, 'invalid_asset')
}
}
export async function attachDetailAsset(input: Parameters<typeof insertDetailAsset>[0], revision: number, document: DetailDocument): Promise<{ asset: StoredDetailAsset; project: DetailProject }> {
return prisma.$transaction(async tx => {
await detailTransactionLock(tx, `detail-project:${input.projectId}`)
await findDetailProject(input.projectId, input.ownerId, tx)
await assertDetailProjectIdle(input.projectId, input.ownerId, tx)
const stored = await insertDetailAsset(input, tx)
const updated = await updateDetailProjectRevision(tx, input.projectId, input.ownerId, revision, document)
return { asset: stored, project: updated }
})
}
export async function listDetailJobs(projectId: string, ownerId: string): Promise<StoredDetailJob[]> {
await findDetailProject(projectId, ownerId)
const rows = await prisma.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE project_id = $1 AND owner_id = $2 ORDER BY created_at DESC LIMIT 50`, projectId, ownerId)
return rows.map(job)
}
export async function findDetailJob(id: string, ownerId: string): Promise<StoredDetailJob> {
const rows = await prisma.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE id = $1 AND owner_id = $2 AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = project_id AND p.owner_id = $2 AND p.deleted_at IS NULL)`, id, ownerId)
if (!rows[0]) throw new DetailGeneratorError('任务不存在', 404, 'not_found')
return job(rows[0])
}
export async function enqueueDetailJob(input: { projectId: string; ownerId: string; kind: DetailJobKind; idempotencyKey: string; payload?: Record<string, unknown>; revision: number }): Promise<StoredDetailJob> {
const payload = input.payload || {}
const fingerprint = createHash('sha256').update(JSON.stringify([input.projectId, input.kind, payload])).digest('hex')
return prisma.$transaction(async tx => {
await detailTransactionLock(tx, 'detail-generator-admission')
await detailTransactionLock(tx, `detail-project:${input.projectId}`)
const previous = await tx.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE owner_id = $1 AND idempotency_key = $2`, input.ownerId, input.idempotencyKey)
if (previous[0]) {
if (previous[0].fingerprint !== fingerprint) throw new DetailGeneratorError('同一请求编号不能用于不同操作', 409, 'idempotency_conflict')
await findDetailProject(input.projectId, input.ownerId, tx)
return job(previous[0])
}
const current = await findDetailProject(input.projectId, input.ownerId, tx)
if (current.revision !== input.revision) throw new DetailGeneratorError('项目已更新,请刷新后重试', 409, 'revision_conflict')
const busy = await tx.$queryRawUnsafe<Row[]>('SELECT id FROM detail_generator_jobs WHERE owner_id = $1 AND status IN (\'queued\', \'running\') LIMIT 1', input.ownerId)
if (busy.length) throw new DetailGeneratorError('您已有任务正在执行,请完成后再提交', 429, 'user_capacity')
if (input.kind !== 'export') {
const unresolved = await tx.$queryRawUnsafe<Row[]>('SELECT id FROM detail_generator_jobs WHERE project_id = $1 AND owner_id = $2 AND status = \'unknown\' LIMIT 1', input.projectId, input.ownerId)
if (unresolved.length) throw new DetailGeneratorError('该项目存在结果未知的模型请求,请先核查服务商记录', 409, 'outcome_unknown')
}
const rows = await tx.$queryRawUnsafe<Row[]>(`INSERT INTO detail_generator_jobs (id, project_id, owner_id, kind, idempotency_key, fingerprint, project_revision, payload) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb) RETURNING ${jobColumns}`, randomUUID(), input.projectId, input.ownerId, input.kind, input.idempotencyKey, fingerprint, current.revision, JSON.stringify({ ...payload, document: current.document }))
return job(rows[0])
})
}
/** Execution admission is serialized across worker processes using the database clock. */
export async function claimDetailJob(): Promise<StoredDetailJob | null> {
return prisma.$transaction(async tx => {
await detailTransactionLock(tx, 'detail-generator-execution')
await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN 'unknown' ELSE 'queued' END, error = CASE WHEN checkpoint->>'dispatchPending' = 'true' THEN '执行中断,模型请求结果未知;请核查服务商记录,勿重复提交' ELSE NULL END, lease_token = NULL, lease_expires_at = NULL, updated_at = NOW() WHERE status = 'running' AND lease_expires_at < NOW()`)
const count = await tx.$queryRawUnsafe<Row[]>('SELECT COUNT(*)::int AS count FROM detail_generator_jobs WHERE status = \'running\' AND lease_expires_at >= NOW()')
if (Number(count[0]?.count) >= 2) return null
const candidates = await tx.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs j WHERE status = 'queued' AND EXISTS (SELECT 1 FROM detail_generator_projects p WHERE p.id = j.project_id AND p.deleted_at IS NULL) AND NOT EXISTS (SELECT 1 FROM detail_generator_jobs other WHERE other.owner_id = j.owner_id AND other.status = 'running' AND other.lease_expires_at >= NOW()) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`)
if (!candidates[0]) return null
const rows = await tx.$queryRawUnsafe<Row[]>(`UPDATE detail_generator_jobs SET status = 'running', lease_token = $2, lease_expires_at = NOW() + INTERVAL '10 minutes', started_at = COALESCE(started_at, NOW()), updated_at = NOW() WHERE id = $1 RETURNING ${jobColumns}`, candidates[0].id, randomUUID())
return job(rows[0])
})
}
export async function renewDetailJobLease(id: string, token: string): Promise<boolean> {
const count = await prisma.$executeRawUnsafe(`UPDATE detail_generator_jobs SET lease_expires_at = NOW() + INTERVAL '10 minutes', updated_at = NOW() WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW()`, id, token)
return count === 1
}
export async function checkpointDetailJob(id: string, token: string, checkpoint: Record<string, unknown>, progress: number): Promise<void> {
const count = await prisma.$executeRawUnsafe(`UPDATE detail_generator_jobs SET checkpoint = $3::jsonb, progress = $4, updated_at = NOW() WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW()`, id, token, JSON.stringify(checkpoint), progress)
if (count !== 1) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
}
export async function saveDetailJobDocument(jobId: string, token: string, document: DetailDocument, checkpoint: Record<string, unknown>, progress: number): Promise<DetailProject> {
return prisma.$transaction(async tx => {
const rows = await tx.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW() FOR UPDATE`, jobId, token)
if (!rows[0]) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
const current = job(rows[0])
const updated = await updateDetailProjectRevision(tx, current.projectId, current.ownerId, current.projectRevision, document)
await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET project_revision = $3, checkpoint = $4::jsonb, progress = $5, updated_at = NOW() WHERE id = $1 AND lease_token = $2`, jobId, token, updated.revision, JSON.stringify(checkpoint), progress)
return updated
})
}
export async function saveDetailJobAsset(jobId: string, token: string, input: Parameters<typeof insertDetailAsset>[0], document: DetailDocument | null, checkpoint: Record<string, unknown>, progress: number): Promise<StoredDetailAsset> {
return prisma.$transaction(async tx => {
const rows = await tx.$queryRawUnsafe<Row[]>(`SELECT ${jobColumns} FROM detail_generator_jobs WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW() FOR UPDATE`, jobId, token)
if (!rows[0]) throw new DetailGeneratorError('任务执行租约已失效', 409, 'lease_lost')
const current = job(rows[0])
if (current.projectId !== input.projectId || current.ownerId !== input.ownerId) throw new DetailGeneratorError('任务文件归属无效', 403)
const stored = await insertDetailAsset(input, tx)
const revision = document ? (await updateDetailProjectRevision(tx, current.projectId, current.ownerId, current.projectRevision, document)).revision : current.projectRevision
await tx.$executeRawUnsafe(`UPDATE detail_generator_jobs SET project_revision = $3, checkpoint = $4::jsonb, progress = $5, updated_at = NOW() WHERE id = $1 AND lease_token = $2`, jobId, token, revision, JSON.stringify(checkpoint), progress)
return stored
})
}
export async function finishDetailJob(id: string, token: string, status: DetailJobStatus, checkpoint: Record<string, unknown>, error: string | null = null, requestId: string | null = null): Promise<void> {
await prisma.$executeRawUnsafe(`UPDATE detail_generator_jobs SET status = $3, checkpoint = $4::jsonb, error = $5, request_id = $6, progress = CASE WHEN $3 = 'succeeded' THEN 100 ELSE progress END, lease_token = NULL, lease_expires_at = NULL, completed_at = NOW(), updated_at = NOW() WHERE id = $1 AND lease_token = $2 AND status = 'running' AND lease_expires_at >= NOW()`, id, token, status, JSON.stringify(checkpoint), error, requestId)
}
export function detailPublicAsset(value: StoredDetailAsset) {
const { storageKey: _storageKey, extractedText: _extractedText, ...safe } = value
return { ...safe, url: `/api/detail-generator/assets/${value.id}` }
}
export function detailPublicJob(value: StoredDetailJob) {
const format = value.payload.format
const payload = value.kind === 'export' && typeof format === 'string' && ['png', 'jpg', 'long-png', 'psd'].includes(format) ? { format } : {}
return { id: value.id, projectId: value.projectId, ownerId: value.ownerId, kind: value.kind, status: value.status, progress: value.progress,
checkpoint: { stage: value.checkpoint.stage, exportAssetId: value.checkpoint.exportAssetId, completedScreenIds: value.checkpoint.completedScreenIds },
payload, error: value.error, requestId: value.requestId, createdAt: value.createdAt, updatedAt: value.updatedAt }
}

View File

@ -0,0 +1,20 @@
type WorkerState = { timer: ReturnType<typeof setInterval>; active: number; stopped: boolean }
const globalWorker = globalThis as typeof globalThis & { __detailGeneratorWorker?: WorkerState }
export function ensureDetailGeneratorWorkerStarted(): void {
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true' || globalWorker.__detailGeneratorWorker) return
const tick = () => {
const state = globalWorker.__detailGeneratorWorker
if (!state || state.stopped) return
while (state.active < 2) {
state.active += 1
void import('./jobs').then(({ runDetailGeneratorWorkerOnce }) => runDetailGeneratorWorkerOnce())
.catch(() => { console.error('[detail-generator] 队列检查失败,请检查数据库迁移与连接') })
.finally(() => { state.active -= 1 })
}
}
const timer = setInterval(tick, 2_000)
timer.unref?.()
globalWorker.__detailGeneratorWorker = { timer, active: 0, stopped: false }
tick()
}

View File

@ -0,0 +1,260 @@
import { randomUUID } from 'crypto'
import { spawn } from 'child_process'
import path from 'path'
import AdmZip from 'adm-zip'
import { inflateRawSync } from 'zlib'
import { isDeepStrictEqual } from 'util'
import sharp from 'sharp'
import { prisma } from '@/lib/prisma'
import { DetailGeneratorError } from './access'
import { listDetailBrandProfiles } from './brand-profiles'
import { assertDetailModels, getDetailModels, isDetailProviderConfigured, readDetailResponseBytes } from './provider'
import {
assertDetailDocumentAssets, attachDetailAsset, createDetailProject, detailPublicAsset, detailPublicJob,
enqueueDetailJob, findDetailProject, listDetailAssets, listDetailJobs, listDetailProjects, updateDetailProject,
} from './repository'
import { saveDetailFile, deleteDetailFile } from './storage'
import { detailAnalysisSchema, parseDetailDocument, parseDetailScreen, parseIdempotencyKey, parseRevision } from './validation'
import { readDetailMaterialImage } from './materials'
import { reflowDetailScreen } from './layout'
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, DETAIL_WIDTH, type DetailAssetKind, type DetailConfig, type DetailDocument, type DetailScreen } from './types'
export async function getDetailConfig(): Promise<DetailConfig> {
const [knowledge, brandProfiles] = await Promise.all([
prisma.brandKnowledge.findMany({ distinct: ['brand'], select: { brand: true }, where: { brand: { not: '' } }, take: 100 }),
listDetailBrandProfiles(),
])
return { access: true, configured: isDetailProviderConfigured(), models: getDetailModels(), defaults: DETAIL_MODEL_DEFAULTS,
brands: Array.from(new Set([...knowledge.map(value => value.brand), ...brandProfiles.map(value => value.profile.name)])).sort(), brandProfiles }
}
export async function getDetailHome(ownerId: string) { return { config: await getDetailConfig(), projects: await listDetailProjects(ownerId) } }
export async function getDetailProject(ownerId: string, id: string) {
const project = await findDetailProject(id, ownerId)
const [assets, jobs] = await Promise.all([listDetailAssets(id, ownerId), listDetailJobs(id, ownerId)])
return { project, assets: assets.map(detailPublicAsset), jobs: jobs.map(detailPublicJob) }
}
export async function newDetailProject(ownerId: string, input: unknown) {
const document = parseDetailDocument(input)
assertDetailModels(document.models)
if (document.product.assetIds.length || document.product.referenceAssetIds.length || document.product.documentAssetIds.length || document.brand.logoAssetId || document.screens.length || document.analysis || document.approvedAt) throw new DetailGeneratorError('新项目须从空白草案创建')
return createDetailProject(ownerId, document)
}
export async function editDetailProject(ownerId: string, id: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
const document = parseDetailDocument(body.document)
assertDetailModels(document.models)
document.approvedAt = current.document.approvedAt
const sourceChanged = !isDeepStrictEqual([document.product, document.brand, document.models], [current.document.product, current.document.brand, current.document.models])
if (sourceChanged || document.analysis === null) {
document.analysis = null; document.approvedAt = null; document.screens = []
} else if (!isDeepStrictEqual(document.analysis, current.document.analysis)) {
if (!current.document.analysis) throw new DetailGeneratorError('请先分析产品资料后再保存大纲')
if (current.document.approvedAt) throw new DetailGeneratorError('已确认的大纲须先返回资料步骤,再重新分析确认')
document.approvedAt = null; document.screens = []
}
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
export function createDetailScreen(outline: { id: string; title: string; body: string; imagePrompt: string }, document: DetailDocument): DetailScreen {
return reflowDetailScreen({
...outline, width: DETAIL_WIDTH, height: 1200, backgroundColor: document.brand.backgroundColor,
...(document.brand.logoAssetId ? { logo: { assetId: document.brand.logoAssetId, x: 600, y: 32, width: 100, height: 56 } } : {}),
texts: [
{ id: `${outline.id}-title`, text: outline.title, x: 48, y: 58, width: 654, fontSize: 48, lineHeight: 1.25, color: document.brand.textColor, fontFamily: DETAIL_FONT_FAMILY, align: 'left' },
{ id: `${outline.id}-body`, text: outline.body, x: 48, y: 182, width: 654, fontSize: 26, lineHeight: 1.5, color: document.brand.textColor, fontFamily: DETAIL_FONT_FAMILY, align: 'left' },
],
})
}
export async function approveDetailAnalysis(ownerId: string, id: string, body: Record<string, unknown>) {
const project = await findDetailProject(id, ownerId)
const parsed = detailAnalysisSchema.safeParse(body.analysis)
if (!project.document.analysis || !parsed.success) throw new DetailGeneratorError('请先分析产品资料并提供有效大纲')
const document = { ...project.document, analysis: parsed.data, approvedAt: new Date().toISOString() }
const existing = new Map(document.screens.map(screen => [screen.id, screen]))
try {
document.screens = parsed.data.screens.map(outline => {
const current = existing.get(outline.id)
if (!current) return createDetailScreen(outline, document)
return reflowDetailScreen({ ...current, ...outline })
})
} catch { throw new DetailGeneratorError('文案过长,请精简后确认;每屏高度最多 2400px') }
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
export async function editDetailScreen(ownerId: string, id: string, screenId: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
let screen = parseDetailScreen(body.screen)
if (screen.id !== screenId || !current.document.screens.some(value => value.id === screenId)) throw new DetailGeneratorError('屏幕不存在', 404)
try { screen = reflowDetailScreen(screen) } catch { throw new DetailGeneratorError('文案过长,请精简后保存;每屏高度最多 2400px') }
const document = { ...current.document, screens: current.document.screens.map(value => value.id === screenId ? screen : value) }
await assertDetailDocumentAssets(document, id, ownerId)
return updateDetailProject(id, ownerId, parseRevision(body.revision), document)
}
export async function createDetailAction(ownerId: string, id: string, kind: 'analyze' | 'generate' | 'regenerate' | 'export', body: Record<string, unknown>, screenId?: string) {
const project = await findDetailProject(id, ownerId)
const document = project.document
const payload: Record<string, unknown> = {}
await assertDetailDocumentAssets(document, id, ownerId)
if (kind !== 'export') {
assertDetailModels(document.models)
if (!isDetailProviderConfigured()) throw new DetailGeneratorError('请先在服务端配置模型密钥', 503, 'provider_not_configured')
if (!document.product.assetIds.length) throw new DetailGeneratorError('请先上传或关联实际产品图片')
if (document.product.assetIds.length + document.product.referenceAssetIds.length > 8) throw new DetailGeneratorError('产品图与参考图合计最多 8 张,请移除多余图片')
if (!document.product.name.trim() || (!document.product.facts.trim() && !document.product.documentAssetIds.length)) throw new DetailGeneratorError('请补充产品名称和真实产品资料')
if (document.product.identifierType === 'spu' && !document.product.variant.trim()) throw new DetailGeneratorError('SPU 下有不同款式时,请填写或选择本次生成的具体款式')
}
if (kind === 'generate' || kind === 'regenerate') {
if (!document.approvedAt || !document.analysis || !document.screens.length) throw new DetailGeneratorError('请先确认卖点和大纲')
if (kind === 'regenerate') {
if (!screenId || !document.screens.some(screen => screen.id === screenId)) throw new DetailGeneratorError('屏幕不存在', 404)
payload.screenId = screenId
}
}
if (kind === 'export') {
if (!['png', 'jpg', 'long-png', 'psd'].includes(String(body.format))) throw new DetailGeneratorError('导出格式无效')
if (!document.screens.length) throw new DetailGeneratorError('请先生成详情页')
if (document.screens.some(screen => !screen.image)) throw new DetailGeneratorError('请先完成所有屏幕的图片生成')
payload.format = body.format
}
const result = await enqueueDetailJob({ projectId: id, ownerId, kind, idempotencyKey: parseIdempotencyKey(body.idempotencyKey), revision: project.revision, payload })
return detailPublicJob(result)
}
function xmlText(value: string): string {
if (/<!DOCTYPE|<!ENTITY/i.test(value)) throw new DetailGeneratorError('不支持含外部实体的文档')
return value.replace(/<\/w:p>/g, '\n').replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number(number))).trim()
}
const DOCX_XML_MAX_BYTES = 512 * 1024
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1
return value >>> 0
})
function boundedDocxEntry(entry: AdmZip.IZipEntry): Buffer {
const { size, compressedSize, method, flags, crc } = entry.header
if (entry.isDirectory || !Number.isSafeInteger(size) || size <= 0 || size > DOCX_XML_MAX_BYTES || !Number.isSafeInteger(compressedSize) || compressedSize <= 0 || compressedSize > 8 * 1024 * 1024 || (flags & 0x41) !== 0 || ![0, 8].includes(method)) throw new Error('Invalid DOCX entry')
const compressed = entry.getCompressedData()
if (compressed.length !== compressedSize) throw new Error('Truncated DOCX entry')
const output = method === 0 ? compressed : inflateRawSync(compressed, { maxOutputLength: DOCX_XML_MAX_BYTES })
if (output.length !== size || output.length > DOCX_XML_MAX_BYTES) throw new Error('DOCX size mismatch')
let checksum = 0xffffffff
for (let index = 0; index < output.length; index++) checksum = crcTable[(checksum ^ output[index]) & 0xff] ^ (checksum >>> 8)
if (((checksum ^ 0xffffffff) >>> 0) !== (crc >>> 0)) throw new Error('DOCX CRC mismatch')
return output
}
async function extractPdf(buffer: Buffer): Promise<string> {
if (buffer.subarray(0, 5).toString() !== '%PDF-') throw new DetailGeneratorError('PDF 文件格式无效')
return new Promise((resolve, reject) => {
const child = spawn('pdftotext', ['-enc', 'UTF-8', '-layout', '-', '-'], { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
const chunks: Buffer[] = []; let size = 0; let settled = false
const finish = (error?: Error, value?: string) => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(value || '') }
const timer = setTimeout(() => { child.kill(); finish(new DetailGeneratorError('PDF 解析超时,请精简产品资料')) }, 20000)
child.stdout.on('data', (chunk: Buffer) => { size += chunk.length; if (size > 256 * 1024) { child.kill(); finish(new DetailGeneratorError('PDF 文字过多,请精简产品资料')) } else chunks.push(chunk) })
child.stderr.on('data', () => undefined)
child.on('error', () => finish(new DetailGeneratorError('服务端尚未安装 PDF 解析工具 pdftotext', 503)))
child.on('close', code => code === 0 ? finish(undefined, Buffer.concat(chunks).toString('utf8')) : finish(new DetailGeneratorError('PDF 无法读取,请检查文件')))
child.stdin.on('error', () => undefined); child.stdin.end(buffer)
})
}
async function normalizeUpload(buffer: Buffer, name: string, kind: DetailAssetKind) {
if (!buffer.length) throw new DetailGeneratorError('文件为空')
if (kind !== 'document') {
if (buffer.length > 16 * 1024 * 1024) throw new DetailGeneratorError('图片超过 16MB 限制', 413)
let normalized: Buffer
try {
const metadata = await sharp(buffer, { limitInputPixels: 40_000_000 }).metadata()
if (!['jpeg', 'png', 'webp'].includes(metadata.format || '') || (metadata.pages || 1) > 1) throw new Error('format')
normalized = await sharp(buffer, { limitInputPixels: 40_000_000 }).rotate().resize(4096, 4096, { fit: 'inside', withoutEnlargement: true }).png().toBuffer()
} catch { throw new DetailGeneratorError('仅支持有效的 JPG、PNG 或 WebP 单帧图片') }
if (normalized.length > 20 * 1024 * 1024) throw new DetailGeneratorError('图片过大,请先缩小尺寸', 413)
const metadata = await sharp(normalized).metadata()
return { buffer: normalized, extension: 'png', mimeType: 'image/png', width: metadata.width!, height: metadata.height!, extractedText: null }
}
if (buffer.length > 8 * 1024 * 1024) throw new DetailGeneratorError('产品文档超过 8MB 限制', 413)
const extension = path.extname(name).toLowerCase().slice(1)
let extractedText: string; let mimeType: string
if (extension === 'pdf') { extractedText = await extractPdf(buffer); mimeType = 'application/pdf' }
else if (extension === 'docx') {
try {
const zip = new AdmZip(buffer)
const entry = zip.getEntry('word/document.xml')
if (!entry) throw new Error('missing')
extractedText = xmlText(boundedDocxEntry(entry).toString('utf8'))
mimeType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
} catch (error) { if (error instanceof DetailGeneratorError) throw error; throw new DetailGeneratorError('DOCX 文档无法读取或文字过多') }
} else if (extension === 'txt' || extension === 'md') {
try { extractedText = new TextDecoder('utf-8', { fatal: true }).decode(buffer); if (extractedText.includes('\u0000')) throw new Error('binary') } catch { throw new DetailGeneratorError('文字资料须为 UTF-8 文本') }
mimeType = 'text/plain; charset=utf-8'
} else throw new DetailGeneratorError('产品文档支持 PDF、DOCX、TXT 或 Markdown')
extractedText = extractedText.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '').trim()
if (!extractedText) throw new DetailGeneratorError('文档没有可识别文字,请补充文字资料;扫描件请作为参考图片上传')
if (extractedText.length > 60000) throw new DetailGeneratorError('文档文字过多,请精简至 6 万字以内')
return { buffer, extension, mimeType, width: null, height: null, extractedText }
}
async function attachUpload(ownerId: string, id: string, revision: number, name: string, kind: DetailAssetKind, raw: Buffer, variant?: { name: string; productModel: string }) {
const current = await findDetailProject(id, ownerId)
if (current.revision !== revision) throw new DetailGeneratorError('项目已更新,请刷新后重试', 409, 'revision_conflict')
const existing = await listDetailAssets(id, ownerId)
if (existing.filter(value => value.kind === kind).length >= (kind === 'logo' ? 5 : 30)) throw new DetailGeneratorError('项目文件数量已达到上限', 413)
const prepared = await normalizeUpload(raw, name, kind)
const assetId = randomUUID()
const document: DetailDocument = JSON.parse(JSON.stringify(current.document))
if (kind === 'logo') document.brand.logoAssetId = assetId
else if (kind !== 'generated') {
const field = kind === 'product' ? 'assetIds' : kind === 'reference' ? 'referenceAssetIds' : 'documentAssetIds'
if (document.product[field].length >= 8) throw new DetailGeneratorError('同类输入文件最多 8 个')
document.product[field].push(assetId)
}
if (variant) {
if (document.product.variant && variant.name && document.product.variant !== variant.name && current.document.product.assetIds.length) throw new DetailGeneratorError('同一项目不能混用不同产品款式')
document.product.variant = variant.name || document.product.variant
}
if (kind !== 'generated') { document.analysis = null; document.approvedAt = null; document.screens = [] }
const storageKey = await saveDetailFile(id, prepared.buffer, prepared.extension)
try {
const result = await attachDetailAsset({ id: assetId, projectId: id, ownerId, kind, name: path.basename(name).slice(0, 180), mimeType: prepared.mimeType, storageKey, byteSize: prepared.buffer.length,
width: prepared.width, height: prepared.height, extractedText: prepared.extractedText }, revision, document)
return { asset: detailPublicAsset(result.asset), project: result.project }
} catch (error) { await deleteDetailFile(storageKey).catch(() => undefined); throw error }
}
export async function uploadDetailAsset(ownerId: string, id: string, request: Request) {
await findDetailProject(id, ownerId)
const maxBytes = 17 * 1024 * 1024
const bytes = await readDetailResponseBytes(new Response(request.body, { headers: { 'content-length': request.headers.get('content-length') || '' } }), maxBytes).catch(() => { throw new DetailGeneratorError('上传文件过大或请求不完整', 413) })
let form: FormData
try { form = await new Response(new Uint8Array(bytes), { headers: { 'content-type': request.headers.get('content-type') || '' } }).formData() } catch { throw new DetailGeneratorError('上传格式无效') }
const file = form.get('file')
const kind = String(form.get('kind') || '')
if (!file || typeof file === 'string' || !['product', 'reference', 'document', 'logo'].includes(kind)) throw new DetailGeneratorError('请选择文件和用途')
const revision = parseRevision(Number(form.get('revision')))
const purpose = form.get('purpose')
if (purpose && purpose !== 'screen-replacement') throw new DetailGeneratorError('文件用途无效')
if (purpose === 'screen-replacement' && kind !== 'product') throw new DetailGeneratorError('替换图片须选择图片用途')
return attachUpload(ownerId, id, revision, file.name, purpose === 'screen-replacement' ? 'generated' : kind as DetailAssetKind, Buffer.from(await file.arrayBuffer()))
}
export async function importDetailMaterial(ownerId: string, id: string, body: Record<string, unknown>) {
const current = await findDetailProject(id, ownerId)
const source = await readDetailMaterialImage(ownerId, String(body.materialId || ''), String(body.source || ''), String(body.sourceId || ''))
const product = current.document.product
const identifier = product.identifier.trim().toUpperCase()
if (identifier && (product.identifierType === 'spu' && source.productModel.toUpperCase() !== identifier || product.identifierType === 'sku' && source.variant.toUpperCase() !== identifier || product.identifierType === 'barcode' && !source.barcodes.includes(identifier))) {
throw new DetailGeneratorError('选中图片与项目的产品编号不一致,请重新匹配', 409, 'product_identity_conflict')
}
return attachUpload(ownerId, id, parseRevision(body.revision), source.name, 'product', source.buffer, { name: source.variant, productModel: source.productModel })
}
export const __detailUploadTesting = { normalizeUpload, xmlText, boundedDocxEntry }

View File

@ -0,0 +1,57 @@
import { randomUUID } from 'crypto'
import { lstat, mkdir, readFile, unlink, writeFile } from 'fs/promises'
import path from 'path'
import { getUploadRootDir } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks } from '@/lib/path-security'
const ID = /^[a-zA-Z0-9_-]{1,80}$/
const KEY = /^[a-zA-Z0-9_-]{1,80}\/[a-f0-9-]{36}\.[a-z0-9]{1,8}$/
// Keep files outside the generic /uploads serving roots, including on local dev.
export function getDetailStorageRoot(): string {
const root = path.resolve(process.env.DETAIL_GENERATOR_STORAGE_DIR || path.join(getUploadRootDir(), '..', 'detail-generator-private'))
const uploadRoot = path.resolve(getUploadRootDir())
const publicRoot = path.resolve(process.cwd(), 'public')
for (const unsafe of [uploadRoot, publicRoot]) {
const relative = path.relative(unsafe, root)
if (!relative || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) {
throw new Error('详情页私有目录必须位于 uploads 和 public 之外')
}
}
return root
}
async function resolvePrivateFile(key: string, create = false): Promise<string> {
if (!KEY.test(key)) throw new Error('无效的详情页文件标识')
const root = getDetailStorageRoot()
const file = path.join(root, ...key.split('/'))
if (create) {
if (!(await isPathFreeOfSymbolicLinks(path.parse(root).root, path.dirname(file), { allowMissingDescendants: true }))) {
throw new Error('详情页文件路径不可用')
}
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
}
if (!(await isPathFreeOfSymbolicLinks(path.parse(root).root, create ? path.dirname(file) : file))) {
throw new Error('详情页文件路径不可用')
}
return file
}
export async function saveDetailFile(projectId: string, buffer: Buffer, extension: string): Promise<string> {
if (!ID.test(projectId) || !/^[a-z0-9]{1,8}$/.test(extension)) throw new Error('无效的详情页文件标识')
const key = `${projectId}/${randomUUID()}.${extension}`
await writeFile(await resolvePrivateFile(key, true), buffer, { flag: 'wx', mode: 0o600 })
return key
}
export async function readDetailFile(key: string): Promise<Buffer> {
const file = await resolvePrivateFile(key)
if (!(await lstat(file)).isFile()) throw new Error('详情页文件不可用')
return readFile(file)
}
export async function deleteDetailFile(key: string): Promise<void> {
const file = await resolvePrivateFile(key)
if (!(await lstat(file)).isFile()) throw new Error('详情页文件不可用')
await unlink(file)
}

View File

@ -0,0 +1,113 @@
export const DETAIL_WIDTH = 750
export const DETAIL_MAX_SCREENS = 12
export const DETAIL_FONT_FAMILY = 'Noto Sans SC'
export const DETAIL_FONT_POSTSCRIPT = 'NotoSansSC-Regular'
export const DETAIL_MODEL_DEFAULTS = { analysis: 'gemini-2.5-flash', copy: 'gemini-2.5-flash', image: 'nano-banana-2' } as const
export type DetailAssetKind = 'product' | 'reference' | 'document' | 'generated' | 'logo' | 'export'
export type DetailExportFormat = 'png' | 'jpg' | 'long-png' | 'psd'
export type DetailJobKind = 'analyze' | 'generate' | 'regenerate' | 'export'
export type DetailJobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'unknown' | 'cancelled'
export interface DetailProduct {
identifierType: 'sku' | 'spu' | 'barcode'
identifier: string
variant: string
name: string
facts: string
assetIds: string[]
referenceAssetIds: string[]
documentAssetIds: string[]
}
export interface DetailBrandProfile {
name: string
tone: string
primaryColor: string
backgroundColor: string
textColor: string
fontFamily: typeof DETAIL_FONT_FAMILY
logoAssetId?: string
status: 'draft'
}
export interface DetailBrandProfileRecord { id: string; revision: number; profile: DetailBrandProfile }
export interface DetailOutlineScreen { id: string; title: string; body: string; imagePrompt: string }
export interface DetailAnalysis { sellingPoints: string[]; warnings: string[]; screens: DetailOutlineScreen[] }
export interface DetailTextLayer {
id: string
text: string
x: number
y: number
width: number
fontSize: number
lineHeight: number
color: string
fontFamily: typeof DETAIL_FONT_FAMILY
align: 'left' | 'center' | 'right'
}
export interface DetailImageLayer { assetId: string; x: number; y: number; width: number; height: number }
export interface DetailScreen {
id: string
title: string
body: string
imagePrompt: string
width: number
height: number
backgroundColor: string
imageArea?: { x: number; y: number; width: number; height: number }
image?: DetailImageLayer
logo?: DetailImageLayer
texts: DetailTextLayer[]
}
export interface DetailDocument {
version: 1
name: string
product: DetailProduct
brand: DetailBrandProfile
models: { analysis: string; copy: string; image: string }
analysis: DetailAnalysis | null
approvedAt: string | null
screens: DetailScreen[]
}
export interface DetailProject {
id: string
ownerId: string
document: DetailDocument
revision: number
createdAt: string
updatedAt: string
}
export interface DetailAsset {
id: string
projectId: string
ownerId: string
kind: DetailAssetKind
name: string
mimeType: string
storageKey: string
width: number | null
height: number | null
extractedText: string | null
createdAt: string
}
export interface DetailJob {
id: string
projectId: string
ownerId: string
kind: DetailJobKind
status: DetailJobStatus
payload: Record<string, unknown>
checkpoint: Record<string, unknown>
error: string | null
requestId: string | null
progress: number
createdAt: string
updatedAt: string
}
export interface DetailConfig {
access: true
configured: boolean
models: { analysis: string[]; copy: string[]; image: string[] }
brands: string[]
brandProfiles: DetailBrandProfileRecord[]
defaults: typeof DETAIL_MODEL_DEFAULTS
}

View File

@ -0,0 +1,93 @@
import { z } from 'zod'
import { DetailGeneratorError } from './access'
import { DETAIL_FONT_FAMILY, DETAIL_MAX_SCREENS, DETAIL_WIDTH, type DetailDocument, type DetailScreen } from './types'
const id = z.string().uuid()
const shortText = z.string().trim().max(200)
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, '颜色须为六位十六进制')
const imageLayer = z.object({ assetId: id, x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
const imageArea = z.object({ x: z.number().min(0).max(750), y: z.number().min(0).max(2400), width: z.number().min(1).max(750), height: z.number().min(1).max(2400) }).strict()
const textLayer = z.object({
id: z.string().min(1).max(100), text: z.string().max(3000), x: z.number().min(0).max(750), y: z.number().min(0).max(2400),
width: z.number().min(1).max(750), fontSize: z.number().min(12).max(120), lineHeight: z.number().min(1).max(2.5),
color, fontFamily: z.literal(DETAIL_FONT_FAMILY), align: z.enum(['left', 'center', 'right']),
}).strict()
export const detailOutlineSchema = z.object({ id: id, title: z.string().trim().min(1).max(120), body: z.string().trim().max(1800), imagePrompt: z.string().trim().min(1).max(4000) }).strict()
export const detailAnalysisSchema = z.object({ sellingPoints: z.array(z.string().max(500)).max(20), warnings: z.array(z.string().max(500)).max(20), screens: z.array(detailOutlineSchema).min(1).max(DETAIL_MAX_SCREENS) }).strict().superRefine((value, ctx) => {
if (new Set(value.screens.map(screen => screen.id)).size !== value.screens.length) ctx.addIssue({ code: 'custom', message: '大纲编号重复' })
})
export const detailScreenSchema = z.object({
id, title: z.string().max(120), body: z.string().max(1800), imagePrompt: z.string().max(4000), width: z.literal(DETAIL_WIDTH),
height: z.number().int().min(400).max(2400), backgroundColor: color, imageArea: imageArea.optional(), image: imageLayer.optional(), logo: imageLayer.optional(), texts: z.array(textLayer).max(20),
}).strict().superRefine((screen, ctx) => {
if (new Set(screen.texts.map(layer => layer.id)).size !== screen.texts.length) ctx.addIssue({ code: 'custom', message: '文字图层编号重复' })
for (const layer of [...screen.texts, ...(screen.imageArea ? [screen.imageArea] : []), ...(screen.image ? [screen.image] : []), ...(screen.logo ? [screen.logo] : [])]) {
if (layer.x + layer.width > screen.width || layer.y >= screen.height) ctx.addIssue({ code: 'custom', message: '图层超出画布' })
if ('height' in layer && layer.y + layer.height > screen.height) ctx.addIssue({ code: 'custom', message: '图片超出画布' })
}
})
export const detailDocumentSchema = z.object({
version: z.literal(1), name: z.string().trim().min(1).max(100),
product: z.object({ identifierType: z.enum(['sku', 'spu', 'barcode']), identifier: shortText, variant: shortText, name: shortText, facts: z.string().max(30000),
assetIds: z.array(id).max(8), referenceAssetIds: z.array(id).max(8), documentAssetIds: z.array(id).max(8), }).strict(),
brand: z.object({ name: shortText, tone: z.string().max(2000), primaryColor: color, backgroundColor: color, textColor: color, fontFamily: z.literal(DETAIL_FONT_FAMILY), logoAssetId: id.optional(), status: z.literal('draft') }).strict(),
models: z.object({ analysis: z.string().min(1).max(120), copy: z.string().min(1).max(120), image: z.string().min(1).max(120) }).strict(),
analysis: detailAnalysisSchema.nullable(), approvedAt: z.string().datetime().nullable(), screens: z.array(detailScreenSchema).max(DETAIL_MAX_SCREENS),
}).strict().superRefine((doc, ctx) => {
if (new Set(doc.screens.map(screen => screen.id)).size !== doc.screens.length) ctx.addIssue({ code: 'custom', message: '屏幕编号重复' })
if (doc.analysis && new Set(doc.analysis.screens.map(screen => screen.id)).size !== doc.analysis.screens.length) ctx.addIssue({ code: 'custom', message: '大纲编号重复' })
})
export function parseDetailDocument(value: unknown): DetailDocument {
const result = detailDocumentSchema.safeParse(value)
if (!result.success) throw new DetailGeneratorError(result.error.issues[0]?.message || '项目格式无效')
return result.data
}
export function parseDetailScreen(value: unknown): DetailScreen {
const result = detailScreenSchema.safeParse(value)
if (!result.success) throw new DetailGeneratorError(result.error.issues[0]?.message || '画布格式无效')
return result.data
}
export function parseRevision(value: unknown): number {
if (!Number.isSafeInteger(value) || Number(value) < 1) throw new DetailGeneratorError('请提供当前项目版本', 400, 'revision_required')
return Number(value)
}
export function parseIdempotencyKey(value: unknown): string {
if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{16,100}$/.test(value)) throw new DetailGeneratorError('请提供有效的幂等请求编号')
return value
}
export async function parseDetailJson(request: Request): Promise<Record<string, unknown>> {
const maxBytes = 256 * 1024
const length = Number(request.headers.get('content-length') || 0)
if (length > maxBytes) throw new DetailGeneratorError('请求内容过大', 413)
if (!request.body) throw new DetailGeneratorError('请求内容格式无效')
const reader = request.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) throw new DetailGeneratorError('请求内容过大', 413)
chunks.push(value)
}
} catch (error) { await reader.cancel().catch(() => undefined); throw error } finally { reader.releaseLock() }
const raw = Buffer.concat(chunks, total).toString('utf8')
try {
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid')
return value as Record<string, unknown>
} catch { throw new DetailGeneratorError('请求内容格式无效') }
}
export function assertDetailId(value: string): string {
if (!id.safeParse(value).success) throw new DetailGeneratorError('编号无效', 404, 'not_found')
return value
}

View File

@ -38,6 +38,10 @@ const nextConfig = {
'*': [
// 排除本地上传文件
'uploads/**',
'detail-generator-private/**',
'.private/**',
// The isolated renderer is copied explicitly by Docker with its own native dependencies.
'workers/detail-renderer/**',
// 排除构建缓存
'.build-cache/**',
'.docker-cache/**',

341
package-lock.json generated
View File

@ -25,6 +25,7 @@
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"exceljs": "^4.4.0",
"fabric": "7.4.0",
"file-saver": "^2.0.5",
"formidable": "^3.5.1",
"gsap": "^3.15.0",
@ -35,7 +36,7 @@
"mime-types": "^2.1.35",
"mssql": "10.0.4",
"music-metadata": "^11.10.3",
"next": "^14.2.0",
"next": "14.2.33",
"next-auth": "^4.24.0",
"pinyin-pro": "^3.28.0",
"react": "^18.3.0",
@ -99,7 +100,7 @@
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.3",
@ -113,7 +114,7 @@
"version": "10.4.3",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/@aws-crypto/crc32": {
@ -2736,7 +2737,7 @@
"version": "5.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -2756,7 +2757,7 @@
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz",
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -2780,7 +2781,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -2808,7 +2809,7 @@
"version": "3.0.5",
"resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -2831,7 +2832,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -8233,7 +8234,7 @@
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
@ -8261,7 +8262,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
@ -8275,7 +8276,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@ -8288,7 +8289,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@ -8298,7 +8299,7 @@
"version": "14.2.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
@ -8396,7 +8397,7 @@
"version": "10.6.0",
"resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/decompress-response": {
@ -8427,6 +8428,16 @@
}
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -8689,7 +8700,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@ -9712,6 +9723,16 @@
"node": ">= 0.8.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/expect": {
"version": "30.2.0",
"resolved": "https://registry.npmmirror.com/expect/-/expect-30.2.0.tgz",
@ -9730,6 +9751,34 @@
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/fabric": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/fabric/-/fabric-7.4.0.tgz",
"integrity": "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
},
"optionalDependencies": {
"canvas": "^3.2.0",
"jsdom": "^26.1.0"
}
},
"node_modules/fabric/node_modules/canvas": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.3.tgz",
"integrity": "sha512-PzE5nJZPz72YUAfo8oTp0u3fqqY7IzlTubneAihqDYAUcBk7ryeCmBbdJBEdaH0bptSOe2VT2Zwcb3UaFyaSWw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.0.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": "^18.12.0 || >= 20.9.0"
}
},
"node_modules/fast-check": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.5.0.tgz",
@ -10335,6 +10384,13 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT",
"optional": true
},
"node_modules/glob": {
"version": "10.3.10",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
@ -10556,7 +10612,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"whatwg-encoding": "^3.1.1"
@ -10740,6 +10796,13 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC",
"optional": true
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@ -11091,7 +11154,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/is-regex": {
@ -12386,7 +12449,7 @@
"version": "26.1.0",
"resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"cssstyle": "^4.2.1",
@ -12426,7 +12489,7 @@
"version": "7.1.4",
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 14"
@ -12436,7 +12499,7 @@
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
@ -12450,7 +12513,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@ -12463,7 +12526,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"devOptional": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@ -12473,7 +12536,7 @@
"version": "14.2.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
@ -13183,6 +13246,13 @@
"node": ">=10"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT",
"optional": true
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -13285,6 +13355,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT",
"optional": true
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@ -13424,12 +13501,32 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.98.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.98.0.tgz",
"integrity": "sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@ -13531,7 +13628,7 @@
"version": "2.2.23",
"resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz",
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/oauth": {
@ -13854,7 +13951,7 @@
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@ -14228,6 +14325,89 @@
"preact": ">=10"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install/node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prebuild-install/node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -14341,11 +14521,22 @@
"react-is": "^16.13.1"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@ -14389,6 +14580,32 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -14699,7 +14916,7 @@
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/run-parallel": {
@ -14808,7 +15025,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
@ -15575,7 +15792,7 @@
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/synckit": {
@ -15660,6 +15877,43 @@
"node": ">=10"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"optional": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC",
"optional": true
},
"node_modules/tar-fs/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tar-stream": {
"version": "3.1.7",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.1.7.tgz",
@ -15902,7 +16156,7 @@
"version": "6.1.86",
"resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^6.1.86"
@ -15915,7 +16169,7 @@
"version": "6.1.86",
"resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/tmp": {
@ -15979,7 +16233,7 @@
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^6.1.32"
@ -16063,6 +16317,19 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -16387,7 +16654,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
@ -16417,7 +16684,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
@ -16430,7 +16697,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18"
@ -16707,7 +16974,7 @@
"version": "8.18.3",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@ -16729,7 +16996,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"

View File

@ -45,6 +45,7 @@
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"exceljs": "^4.4.0",
"fabric": "7.4.0",
"file-saver": "^2.0.5",
"formidable": "^3.5.1",
"gsap": "^3.15.0",
@ -93,5 +94,8 @@
},
"optionalDependencies": {
"canvas": "^2.11.2"
},
"overrides": {
"fabric": { "canvas": "2.11.2" }
}
}

View File

@ -0,0 +1,62 @@
-- Private beta data is isolated from the production materials tables.
CREATE TABLE "detail_generator_projects" (
"id" TEXT PRIMARY KEY,
"owner_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"document" JSONB NOT NULL,
"revision" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"deleted_at" TIMESTAMPTZ,
CONSTRAINT "detail_generator_project_document_object" CHECK (jsonb_typeof("document") = 'object')
);
CREATE INDEX "detail_generator_projects_owner_updated_idx" ON "detail_generator_projects"("owner_id", "updated_at" DESC) WHERE "deleted_at" IS NULL;
CREATE TABLE "detail_generator_assets" (
"id" TEXT PRIMARY KEY,
"project_id" TEXT NOT NULL REFERENCES "detail_generator_projects"("id") ON DELETE CASCADE,
"owner_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"kind" TEXT NOT NULL CHECK ("kind" IN ('product', 'reference', 'document', 'logo', 'generated', 'export')),
"name" TEXT NOT NULL,
"mime_type" TEXT NOT NULL,
"storage_key" TEXT NOT NULL UNIQUE,
"byte_size" INTEGER NOT NULL CHECK ("byte_size" > 0),
"width" INTEGER,
"height" INTEGER,
"extracted_text" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX "detail_generator_assets_project_idx" ON "detail_generator_assets"("project_id", "owner_id");
CREATE TABLE "detail_generator_jobs" (
"id" TEXT PRIMARY KEY,
"project_id" TEXT NOT NULL REFERENCES "detail_generator_projects"("id") ON DELETE CASCADE,
"owner_id" TEXT NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
"kind" TEXT NOT NULL CHECK ("kind" IN ('analyze', 'generate', 'regenerate', 'export')),
"status" TEXT NOT NULL DEFAULT 'queued' CHECK ("status" IN ('queued', 'running', 'succeeded', 'failed', 'unknown', 'cancelled')),
"idempotency_key" TEXT NOT NULL,
"fingerprint" TEXT NOT NULL,
"project_revision" INTEGER NOT NULL,
"payload" JSONB NOT NULL DEFAULT '{}',
"checkpoint" JSONB NOT NULL DEFAULT '{}',
"progress" INTEGER NOT NULL DEFAULT 0 CHECK ("progress" BETWEEN 0 AND 100),
"error" TEXT,
"request_id" TEXT,
"lease_token" TEXT,
"lease_expires_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"started_at" TIMESTAMPTZ,
"completed_at" TIMESTAMPTZ,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE("owner_id", "idempotency_key")
);
CREATE INDEX "detail_generator_jobs_claim_idx" ON "detail_generator_jobs"("status", "created_at");
CREATE INDEX "detail_generator_jobs_owner_status_idx" ON "detail_generator_jobs"("owner_id", "status");
CREATE INDEX "detail_generator_jobs_lease_idx" ON "detail_generator_jobs"("lease_expires_at") WHERE "status" = 'running';
CREATE TABLE "detail_generator_brand_profiles" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL UNIQUE,
"profile" JSONB NOT NULL,
"revision" INTEGER NOT NULL DEFAULT 1,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@ -61,6 +61,9 @@ model User {
brandKnowledgeUploads BrandKnowledge[] @relation("BrandKnowledgeUploader")
brandKnowledgeLikes BrandKnowledgeLike[]
brandKnowledgeFavorites BrandKnowledgeFavorite[]
detailGeneratorProjects DetailGeneratorProject[]
detailGeneratorAssets DetailGeneratorAsset[]
detailGeneratorJobs DetailGeneratorJob[]
@@index([status])
@@index([archivedAt])
@ -70,6 +73,76 @@ model User {
@@map("users")
}
model DetailGeneratorProject {
id String @id
ownerId String @map("owner_id")
document Json
revision Int @default(1)
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
deletedAt DateTime? @db.Timestamptz(6) @map("deleted_at")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
assets DetailGeneratorAsset[]
jobs DetailGeneratorJob[]
@@map("detail_generator_projects")
}
model DetailGeneratorBrandProfile {
id String @id
name String @unique
profile Json
revision Int @default(1)
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
@@map("detail_generator_brand_profiles")
}
model DetailGeneratorAsset {
id String @id
projectId String @map("project_id")
ownerId String @map("owner_id")
kind String
name String
mimeType String @map("mime_type")
storageKey String @unique @map("storage_key")
byteSize Int @map("byte_size")
width Int?
height Int?
extractedText String? @map("extracted_text")
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
project DetailGeneratorProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@index([projectId, ownerId], map: "detail_generator_assets_project_idx")
@@map("detail_generator_assets")
}
model DetailGeneratorJob {
id String @id
projectId String @map("project_id")
ownerId String @map("owner_id")
kind String
status String @default("queued")
idempotencyKey String @map("idempotency_key")
fingerprint String
projectRevision Int @map("project_revision")
payload Json @default("{}")
checkpoint Json @default("{}")
progress Int @default(0)
error String?
requestId String? @map("request_id")
leaseToken String? @map("lease_token")
leaseExpiresAt DateTime? @db.Timestamptz(6) @map("lease_expires_at")
createdAt DateTime @default(now()) @db.Timestamptz(6) @map("created_at")
startedAt DateTime? @db.Timestamptz(6) @map("started_at")
completedAt DateTime? @db.Timestamptz(6) @map("completed_at")
updatedAt DateTime @default(now()) @db.Timestamptz(6) @map("updated_at")
project DetailGeneratorProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@unique([ownerId, idempotencyKey])
@@index([status, createdAt], map: "detail_generator_jobs_claim_idx")
@@index([ownerId, status], map: "detail_generator_jobs_owner_status_idx")
@@map("detail_generator_jobs")
}
model Material {
id String @id @default(uuid())
previewUrl String

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,92 @@
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,15 @@
# Detail generator fonts
These unmodified OpenType fonts are distributed under the SIL Open Font License 1.1 in `OFL.txt`.
Source: https://github.com/notofonts/noto-cjk/tree/f8d157532fbfaeda587e826d4cd5b21a49186f7c/Sans/SubsetOTF/SC
- `NotoSansSC-Regular.otf`: PostScript name `NotoSansSC-Regular`, weight 400.
- `NotoSansSC-Bold.otf`: PostScript name `NotoSansSC-Bold`, weight 700.
SHA-256:
- Regular: `faa6c9df652116dde789d351359f3d7e5d2285a2b2a1f04a2d7244df706d5ea9`
- Bold: `c6cb5a93abaa9edc8ee7463b7ebb7f42d618d40e6ed2f7a5371c97b0b64767c0`
The web canvas and isolated export renderer load these same complete Simplified Chinese fonts. The PSD text layers use their actual PostScript names. Install the corresponding font on the computer running Photoshop before editing text; fonts are not embedded in PSD.

View File

@ -0,0 +1,19 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { assertDetailGeneratorEvidence } from './safe-production-migrate.mjs'
test('release refuses a missing generator schema instead of enabling a broken feature', () => {
assert.throws(() => assertDetailGeneratorEvidence({ columns: [] }), /detail_generator_projects.id/)
})
test('release refuses incorrectly typed private state/lease fields', () => {
const columns = []
for (const [table, names] of Object.entries({
detail_generator_projects: ['id', 'owner_id', 'document', 'revision'],
detail_generator_assets: ['id', 'project_id', 'owner_id', 'storage_key', 'byte_size', 'mime_type'],
detail_generator_jobs: ['id', 'project_id', 'owner_id', 'status', 'idempotency_key', 'checkpoint', 'project_revision', 'lease_token', 'lease_expires_at'],
detail_generator_brand_profiles: ['id', 'name', 'profile', 'revision'],
})) for (const name of names) columns.push({ table_name: table, column_name: name, data_type: ['document','checkpoint','profile'].includes(name) ? 'jsonb' : ['revision','project_revision','byte_size'].includes(name) ? 'integer' : name === 'lease_expires_at' ? 'timestamp with time zone' : 'text' })
assert.doesNotThrow(() => assertDetailGeneratorEvidence({ columns }))
columns.find(row => row.column_name === 'lease_expires_at').data_type = 'text'
assert.throws(() => assertDetailGeneratorEvidence({ columns }), /lease_expires_at/)
})

View File

@ -1079,6 +1079,8 @@ MIGRATION_AUDIT_SHA256="$1"
MIGRATION_AUDIT_SOURCE_COMMIT="$2"
REQUIRE_MIGRATION_AUDIT="$3"
DEPLOYMENT_STRATEGY="${4:-direct}"
DETAIL_PRIVATE_MOUNT="/mnt/PoolOne/apps/youyidian-3d/detail-generator-private"
DETAIL_FEATURE_ENABLED=false
log() { printf '[INFO] %s\n' "$*"; }
ok() { printf '[OK] %s\n' "$*"; }
@ -1097,12 +1099,12 @@ create_container() {
transcode_instance_id="${name}-${COMMIT_SHA}-${deploy_version}-$$"
sanitized_env_file="$(mktemp "/run/${CONTAINER_NAME}.env.XXXXXX")" || return 1
chmod 600 "$sanitized_env_file"
if ! awk '!/^UPLOAD_DIR=/' "$ENV_FILE_PATH" >"$sanitized_env_file"; then
if ! awk '!/^UPLOAD_DIR=/ && !/^DETAIL_GENERATOR_STORAGE_DIR=/ && !/^DETAIL_GENERATOR_ENABLED=/' "$ENV_FILE_PATH" >"$sanitized_env_file"; then
rm -f "$sanitized_env_file"
return 1
fi
run_args=(
local run_args=(
docker create
--name "$name"
--network "$NETWORK_NAME"
@ -1110,6 +1112,8 @@ create_container() {
-v "/mnt/PoolOne/apps/youyidian-3d/public-traffic-logs:/var/log/nginx:ro"
--env-file "$sanitized_env_file"
-e "UPLOAD_DIR=/app/uploads"
-e "DETAIL_GENERATOR_ENABLED=$DETAIL_FEATURE_ENABLED"
-e "DETAIL_GENERATOR_STORAGE_DIR=/app/detail-generator-private"
-e "USE_REDIS=true"
-e "REDIS_URL=redis://gongxiangpan-redis:6379"
-e "NEXTAUTH_URL=$PUBLIC_APP_URL"
@ -1131,6 +1135,11 @@ create_container() {
--restart=unless-stopped
)
if [ "$DETAIL_FEATURE_ENABLED" = "true" ]; then
if ! detail_private_mount_is_safe; then rm -f "$sanitized_env_file"; return 1; fi
run_args+=(--mount "type=bind,source=${DETAIL_PRIVATE_MOUNT},target=/app/detail-generator-private")
fi
if [ "$publish_host_port" = "1" ]; then
run_args+=(-p "${PORT}:3000")
fi
@ -1192,6 +1201,100 @@ command -v timeout >/dev/null 2>&1 || fail "Missing timeout on production server
require_file "$ENV_FILE_PATH"
detail_private_mount_is_safe() {
[ "$DETAIL_PRIVATE_MOUNT" = "/mnt/PoolOne/apps/youyidian-3d/detail-generator-private" ] || return 1
case "${DETAIL_PRIVATE_MOUNT}/" in "${UPLOADS_MOUNT}/"*) return 1 ;; esac
case "${UPLOADS_MOUNT}/" in "${DETAIL_PRIVATE_MOUNT}/"*) return 1 ;; esac
local ancestor="$DETAIL_PRIVATE_MOUNT"
while [ "$ancestor" != "/" ]; do
[ ! -L "$ancestor" ] || return 1
if [ -e "$ancestor" ] && [ ! -d "$ancestor" ]; then return 1; fi
ancestor="${ancestor%/*}"
[ -n "$ancestor" ] || ancestor="/"
done
}
prepare_detail_generator_release() {
# Validate actual Docker env-file semantics; never put the provider key in argv or logs.
local feature_state=""
if ! feature_state="$(docker run --rm -i --network none --env-file "$ENV_FILE_PATH" --entrypoint node "$REGISTRY_COMMIT" <<'NODE'
try {
if (process.env.DETAIL_GENERATOR_ENABLED !== 'true') { process.stdout.write('false'); process.exit(0); }
if (!String(process.env.DETAIL_GENERATOR_API_KEY || '').trim() || !String(process.env.DETAIL_GENERATOR_BASE_URL || '').trim()) process.exit(1);
const base = new URL(process.env.DETAIL_GENERATOR_BASE_URL);
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) process.exit(1);
if (process.env.DETAIL_GENERATOR_STORAGE_DIR && process.env.DETAIL_GENERATOR_STORAGE_DIR !== '/app/detail-generator-private') process.exit(1);
process.stdout.write('true');
} catch { process.exit(1); }
NODE
)"; then
warn "Detail generator configuration is incomplete or invalid; no secret values were printed"
return 1
fi
case "$feature_state" in true|false) DETAIL_FEATURE_ENABLED="$feature_state" ;; *) return 1 ;; esac
[ "$DETAIL_FEATURE_ENABLED" = "true" ] || return 0
detail_private_mount_is_safe || return 1
[ "$(docker run --rm --network none --entrypoint id "$REGISTRY_COMMIT" -u nextjs)" = "1001" ] || return 1
[ "$(docker run --rm --network none --entrypoint id "$REGISTRY_COMMIT" -g nextjs)" = "1001" ] || return 1
install -d -m 0700 -o 1001 -g 1001 "$DETAIL_PRIVATE_MOUNT" || return 1
detail_private_mount_is_safe || return 1
[ "$(stat -c '%u:%g:%a' "$DETAIL_PRIVATE_MOUNT")" = "1001:1001:700" ]
}
verify_detail_generator_mount() {
local name="$1"
[ "$DETAIL_FEATURE_ENABLED" = "true" ] || return 0
detail_private_mount_is_safe || return 1
local metadata=""
metadata="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/app/detail-generator-private"}}{{.Source}}|{{.Destination}}|{{.Type}}|{{.RW}}{{println}}{{end}}{{end}}' "$name" 2>/dev/null | tr -d '\r')" || return 1
[ "$metadata" = "${DETAIL_PRIVATE_MOUNT}|/app/detail-generator-private|bind|true" ]
}
verify_detail_generator_schema() {
local name="$1"
docker exec -i "$name" node <<'NODE'
const fs = require('fs'), crypto = require('crypto'), { PrismaClient } = require('@prisma/client');
const migration = '20261012_detail_generator';
const checksum = crypto.createHash('sha256').update(fs.readFileSync(`/app/prisma/migrations/${migration}/migration.sql`, 'utf8').replace(/\r\n/g, '\n')).digest('hex');
const required = { detail_generator_projects: ['id','owner_id','document','revision','deleted_at'], detail_generator_assets: ['id','project_id','owner_id','storage_key','byte_size'], detail_generator_jobs: ['id','project_id','owner_id','status','idempotency_key','fingerprint','project_revision','payload','checkpoint','lease_token','lease_expires_at'], detail_generator_brand_profiles: ['id','name','profile','revision'] };
const p = new PrismaClient({ log: [] });
(async () => {
const completed = await p.$queryRawUnsafe('SELECT checksum FROM "_prisma_migrations" WHERE migration_name=$1 AND finished_at IS NOT NULL AND rolled_back_at IS NULL', migration);
if (completed.length !== 1 || completed[0].checksum !== checksum) throw new Error('migration');
const columns = await p.$queryRawUnsafe("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public' AND table_name IN ('detail_generator_projects','detail_generator_assets','detail_generator_jobs','detail_generator_brand_profiles')");
for (const [table, names] of Object.entries(required)) for (const column of names) if (!columns.some(row => row.table_name === table && row.column_name === column)) throw new Error('schema');
})().catch(() => { process.exitCode = 1; }).finally(() => p.$disconnect());
NODE
}
verify_detail_generator_dependencies() {
local name="$1"
verify_detail_generator_mount "$name" || return 1
docker exec -i "$name" node <<'NODE'
const fs = require('fs'), path = require('path'), crypto = require('crypto'), { execFileSync } = require('child_process');
(async () => {
const node = process.env.DETAIL_RENDERER_NODE;
if (node !== '/opt/detail-renderer/bin/node' || process.env.DETAIL_RENDERER_ENTRY !== '/opt/detail-renderer/cli.mjs') throw new Error('renderer');
if (Number(execFileSync(node, ['-p', 'process.versions.node.split(".")[0]'], { encoding: 'utf8' }).trim()) !== 22) throw new Error('node');
const fonts = '/app/public/detail-generator/fonts';
if (process.env.DETAIL_FONT_DIR && path.resolve(process.env.DETAIL_FONT_DIR) !== fonts) throw new Error('font configuration');
for (const name of ['NotoSansSC-Regular.otf','NotoSansSC-Bold.otf','OFL.txt']) if (!fs.statSync(path.join(fonts, name)).isFile()) throw new Error('font');
if (process.env.DETAIL_GENERATOR_ENABLED === 'true') {
if (!String(process.env.DETAIL_GENERATOR_API_KEY || '').trim() || !String(process.env.DETAIL_GENERATOR_BASE_URL || '').trim()) throw new Error('provider configuration');
const base = new URL(process.env.DETAIL_GENERATOR_BASE_URL);
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) throw new Error('provider configuration');
if (process.env.DETAIL_GENERATOR_STORAGE_DIR !== '/app/detail-generator-private') throw new Error('storage');
const root = '/app/detail-generator-private', stat = fs.lstatSync(root);
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== 1001 || stat.gid !== 1001 || (stat.mode & 0o777) !== 0o700 || fs.realpathSync(root) !== root) throw new Error('storage');
const probe = path.join(root, `.deploy-probe-${crypto.randomUUID()}`);
try { fs.writeFileSync(probe, 'private-storage-probe', { flag: 'wx', mode: 0o600 }); if (fs.readFileSync(probe, 'utf8') !== 'private-storage-probe') throw new Error('storage'); } finally { if (fs.existsSync(probe)) fs.unlinkSync(probe); }
}
const smoke = `import { renderRequest } from '/opt/detail-renderer/render.mjs'; import { createRequire } from 'node:module'; const require=createRequire('/opt/detail-renderer/package.json'); const sharp=require('sharp'); const {readPsd}=require('ag-psd'); const request={document:{screens:[{id:'deploy',title:'中文发布验证',width:750,height:400,backgroundColor:'#ffffff',texts:[{id:'copy',text:'中文发布验证',x:24,y:24,width:702,fontSize:28,lineHeight:1.25,color:'#111111',fontFamily:'Noto Sans SC',align:'left'}]}]},assets:{},format:'psd'}; const [psdFile]=await renderRequest(request,{fontDirectory:'/app/public/detail-generator/fonts'}); const bytes=Buffer.from(psdFile.base64,'base64'); if(bytes.subarray(0,4).toString()!=='8BPS')throw Error('PSD'); const psd=readPsd(bytes,{skipLayerImageData:true,skipCompositeImageData:true,skipThumbnail:true}); if(!psd.children?.[0]?.children?.some(layer=>layer.text?.text?.includes('中文发布验证')))throw Error('text'); const [png]=await renderRequest({...request,format:'png'},{fontDirectory:'/app/public/detail-generator/fonts'}); const pixels=await sharp(Buffer.from(png.base64,'base64')).removeAlpha().raw().toBuffer(); if(!pixels.some(value=>value<200))throw Error('glyph');`;
execFileSync(node, ['--input-type=module', '--eval', smoke], { timeout: 60000, maxBuffer: 4 * 1024 * 1024, stdio: ['ignore','pipe','pipe'] });
})().catch(() => { process.stderr.write('Detail generator private storage, fonts, or native rendering verification failed\n'); process.exitCode = 1; });
NODE
}
uploads_findmnt_metadata_is_trusted() {
local mount_path="$1"
local mount_line="$2"
@ -1525,6 +1628,7 @@ const expectedMigrationNames = new Set([
'20261008_team_function_configs',
'20261009_material_purge_outbox',
'20261009_material_purge_reference_indexes',
'20261012_detail_generator',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
@ -2102,6 +2206,23 @@ http {
proxy_buffering off;
}
location ~ "^/api/detail-generator/projects/[0-9a-fA-F-]{36}/assets\$" {
client_max_body_size 17m;
proxy_request_buffering off;
proxy_pass http://app_backend;
proxy_hide_header X-Material-Proxy-Target;
add_header X-Material-Proxy-Target ${target_container} always;
proxy_http_version 1.1;
proxy_set_header Host \$http_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 \$forwarded_proto;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
proxy_buffering off;
}
location ~ "^/api/materials/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\$" {
client_max_body_size 11m;
proxy_request_buffering off;
@ -2548,6 +2669,7 @@ direct_replace_active_container() {
verify_container_uploads_storage "$name" || fail "Active container uploads storage could not be verified before direct replacement"
verify_image_revision_label "$REGISTRY_COMMIT" "$COMMIT_SHA" || fail "Direct replacement image label does not match the exact Git commit"
prepare_worker_runtime_directories || fail "Worker runtime directories could not be prepared"
prepare_detail_generator_release || fail "Detail generator configuration or private persistent storage is invalid"
log "Replacing active container directly: $name"
if ! docker stop --time 10 "$name" >/dev/null; then
@ -2578,6 +2700,9 @@ direct_replace_active_container() {
if ! verify_task_detail_upload_lease_schema "$name"; then
fail_direct_replace_with_restore "New direct container cannot verify task-detail upload job lease schema" "$name" "$previous_image"
fi
if ! verify_detail_generator_schema "$name" || ! verify_detail_generator_dependencies "$name"; then
fail_direct_replace_with_restore "New direct container cannot verify detail generator migration, private storage, fonts, or native renderer" "$name" "$previous_image"
fi
if ! verify_brand_logo_defaults "$name"; then
fail_direct_replace_with_restore "New direct container cannot verify safe brand logo database defaults" "$name" "$previous_image"
fi

View File

@ -1,8 +1,10 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import test from 'node:test'
const bash = process.platform === 'win32' && existsSync('C:/Program Files/Git/bin/bash.exe') ? 'C:/Program Files/Git/bin/bash.exe' : 'bash'
import {
extractRemoteTemplate,
parseArguments,
@ -12,7 +14,7 @@ import {
test('generated remote deployment payload is valid bash', () => {
const source = readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8')
const payload = extractRemoteTemplate(source).replace(/__[A-Z0-9_]+__/g, "''")
const result = spawnSync('bash', ['-n'], {
const result = spawnSync(bash, ['-n'], {
input: payload,
encoding: 'utf8',
})
@ -30,7 +32,7 @@ test('Mac entry renders every canonical remote deployment token', () => {
const payload = renderRemoteTemplate(template, values)
assert.doesNotMatch(payload, /__[A-Z0-9_]+__/)
const result = spawnSync('bash', ['-n'], { input: payload, encoding: 'utf8' })
const result = spawnSync(bash, ['-n'], { input: payload, encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr || result.stdout)
})
@ -74,6 +76,7 @@ test('migration deployment gate recognizes the latest approved schema migration'
assert.match(gate, /20260914_expand_brand_knowledge_preview_slices/)
assert.match(gate, /20260915_add_brand_knowledge_soft_delete/)
assert.match(gate, /20260918_add_performance_conversion_sync/)
assert.match(gate, /20261012_detail_generator/)
})
test('direct replacement runs database and dependency gates before switching traffic', () => {
@ -95,12 +98,84 @@ test('direct replacement runs database and dependency gates before switching tra
'verify_candidate_brand_and_storage_dependencies "$name" "$replacement_ip"',
'verify_video_transcode_capability "$name"',
'verify_task_detail_upload_capability "$name"',
'verify_detail_generator_schema "$name"',
'verify_detail_generator_dependencies "$name"',
]) {
const position = direct.indexOf(gate)
assert.ok(position > 0 && position < switchTraffic, `${gate} must run before the direct traffic switch`)
}
assert.match(direct, /fail_direct_replace_with_restore "New direct container database identity differs/)
assert.match(direct, /fail_direct_replace_with_restore "New direct container cannot authenticate to S3/)
assert.ok(direct.indexOf('prepare_detail_generator_release') < direct.indexOf('docker stop --time 10'), 'configuration and private volume must be validated before stopping the old slot')
})
function deploySource() { return readFileSync(new URL('./git-incremental-deploy.ps1', import.meta.url), 'utf8').replace(/\r\n/g, '\n') }
function shellFunction(name) {
const source = deploySource()
const start = source.indexOf(`${name}() {`)
const end = source.indexOf('\n}\n\n', start)
assert.ok(start >= 0 && end > start, `missing ${name}`)
return source.slice(start, end + 3)
}
test('detail beta configuration validates enabled key/base without exposing provider secrets', () => {
const source = shellFunction('prepare_detail_generator_release')
const code = source.match(/<<'NODE'\n([\s\S]*?)\nNODE/)[1]
const env = { ...process.env, DETAIL_GENERATOR_ENABLED: 'true', DETAIL_GENERATOR_API_KEY: 'synthetic-private-key', DETAIL_GENERATOR_BASE_URL: 'https://model.example.invalid/v1', DETAIL_GENERATOR_STORAGE_DIR: '/app/detail-generator-private' }
for (const input of [
{ env, status: 0, output: 'true' },
{ env: { ...env, DETAIL_GENERATOR_API_KEY: '' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_BASE_URL: '' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_BASE_URL: 'https://user:synthetic-private-key@model.example.invalid' }, status: 1, output: '' },
{ env: { ...env, DETAIL_GENERATOR_ENABLED: '', DETAIL_GENERATOR_API_KEY: '' }, status: 0, output: 'false' },
{ env: { ...env, DETAIL_GENERATOR_ENABLED: 'false', DETAIL_GENERATOR_API_KEY: '' }, status: 0, output: 'false' },
]) {
const result = spawnSync(process.execPath, ['-e', code], { env: input.env, encoding: 'utf8' })
assert.equal(result.status, input.status)
assert.equal(result.stdout, input.output)
assert.doesNotMatch(result.stdout + result.stderr, /synthetic-private-key/)
}
assert.match(source, /--env-file "\$ENV_FILE_PATH"/)
assert.doesNotMatch(source, /-e [^\n]*DETAIL_GENERATOR_API_KEY/)
})
test('fixed private storage rejects overlapping uploads before creating directories', () => {
const check = shellFunction('detail_private_mount_is_safe')
const privateRoot = '/mnt/PoolOne/apps/youyidian-3d/detail-generator-private'
for (const upload of [privateRoot, '/mnt/PoolOne/apps/youyidian-3d', `${privateRoot}/uploads`]) {
const result = spawnSync(bash, ['-s'], { input: `DETAIL_PRIVATE_MOUNT='${privateRoot}'\nUPLOADS_MOUNT='${upload}'\n${check}\ndetail_private_mount_is_safe\n`, encoding: 'utf8' })
assert.equal(result.status, 1, `overlap accepted: ${upload}`)
}
assert.match(check, /while \[ "\$ancestor" != "\/" \]/)
assert.match(check, /\[ ! -L "\$ancestor" \]/)
const prepare = shellFunction('prepare_detail_generator_release')
assert.match(prepare, /install -d -m 0700 -o 1001 -g 1001/)
assert.match(prepare, /1001:1001:700/)
})
test('enabled containers bind private persistence and verify actual Node22 PSD/PNG without model calls', () => {
const create = shellFunction('create_container')
assert.match(create, /if \[ "\$DETAIL_FEATURE_ENABLED" = "true" \]/)
assert.match(create, /--mount "type=bind,source=\$\{DETAIL_PRIVATE_MOUNT\},target=\/app\/detail-generator-private"/)
assert.match(create, /--env-file "\$sanitized_env_file"/)
assert.doesNotMatch(create, /DETAIL_GENERATOR_API_KEY/)
const schema = shellFunction('verify_detail_generator_schema')
assert.match(schema, /20261012_detail_generator/)
assert.match(schema, /completed\[0\]\.checksum !== checksum/)
for (const table of ['projects', 'assets', 'jobs', 'brand_profiles']) assert.match(schema, new RegExp(`detail_generator_${table}`))
const runtime = shellFunction('verify_detail_generator_dependencies')
assert.match(runtime, /\/opt\/detail-renderer\/bin\/node/)
assert.match(runtime, /!== 22/)
assert.match(runtime, /renderRequest/)
assert.match(runtime, /format:'psd'/)
assert.match(runtime, /format:'png'/)
assert.match(runtime, /8BPS/)
assert.match(runtime, /\.deploy-probe-/)
assert.doesNotMatch(runtime, /fetch\(|generateDetailImage|runDetailGeneratorWorker/)
})
test('detail uploads keep multipart headroom without expanding all proxy routes', () => {
assert.match(deploySource(), /location ~ "\^\/api\/detail-generator\/projects\/\[0-9a-fA-F-\]\{36\}\/assets\\\$" \{\n\s+client_max_body_size 17m;\n\s+proxy_request_buffering off;/)
})
test('brand knowledge upload stays bounded in the route and stable proxy', () => {

View File

@ -41,6 +41,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20261008_team_function_configs',
'20261009_material_purge_outbox',
'20261009_material_purge_reference_indexes',
'20261012_detail_generator',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -354,6 +355,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'material_permission_pack_items' AND column_name IN ('can_access_private'))
OR table_name IN ('material_permission_pack_departments', 'team_function_configs', 'team_function_config_audits', 'material_purge_jobs', 'material_purge_audits')
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
@ -867,6 +869,7 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
}
}
assertOrganizationConfigurationEvidence(evidence)
assertDetailGeneratorEvidence(evidence)
assertMaterialPurgeEvidence(evidence)
assertMaterialPurgeReferenceEvidence(evidence)
}
@ -888,6 +891,21 @@ export function materialPurgeReferenceExpectations() {
return { referenceFunctions: functions, indexes }
}
export function assertDetailGeneratorEvidence(evidence) {
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
const required = {
detail_generator_projects: { id: 'text', owner_id: 'text', document: 'jsonb', revision: 'integer' },
detail_generator_assets: { id: 'text', project_id: 'text', owner_id: 'text', storage_key: 'text', byte_size: 'integer', mime_type: 'text' },
detail_generator_jobs: { id: 'text', project_id: 'text', owner_id: 'text', status: 'text', idempotency_key: 'text', checkpoint: 'jsonb', project_revision: 'integer', lease_token: 'text', lease_expires_at: 'timestamp with time zone' },
detail_generator_brand_profiles: { id: 'text', name: 'text', profile: 'jsonb', revision: 'integer' },
}
for (const [table, fields] of Object.entries(required)) {
for (const [column, type] of Object.entries(fields)) {
if (columns.get(`${table}.${column}`)?.data_type !== type) fail(`Database column mismatch: ${table}.${column}`)
}
}
}
export function assertMaterialPurgeReferenceEvidence(evidence) {
const expected = materialPurgeReferenceExpectations()
for (const item of expected.indexes) {

View File

@ -423,6 +423,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20261008_team_function_configs', { checksum: 'ad' }],
['20261009_material_purge_outbox', { checksum: 'ae' }],
['20261009_material_purge_reference_indexes', { checksum: 'af' }],
['20261012_detail_generator', { checksum: 'ag' }],
])
const evidence = {
migrationRows: [
@ -695,6 +696,13 @@ test('target evidence rejects nullable logo columns and structurally wrong index
}
evidence.migrationRows.push({ migration_name: '20261009_material_purge_outbox', checksum: 'ae', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261009_material_purge_reference_indexes', checksum: 'af', finished_at: 'now', rolled_back_at: null })
evidence.migrationRows.push({ migration_name: '20261012_detail_generator', checksum: 'ag', finished_at: 'now', rolled_back_at: null })
for (const [table, fields] of Object.entries({
detail_generator_projects: { id: 'text', owner_id: 'text', document: 'jsonb', revision: 'integer' },
detail_generator_assets: { id: 'text', project_id: 'text', owner_id: 'text', storage_key: 'text', byte_size: 'integer', mime_type: 'text' },
detail_generator_jobs: { id: 'text', project_id: 'text', owner_id: 'text', status: 'text', idempotency_key: 'text', checkpoint: 'jsonb', project_revision: 'integer', lease_token: 'text', lease_expires_at: 'timestamp with time zone' },
detail_generator_brand_profiles: { id: 'text', name: 'text', profile: 'jsonb', revision: 'integer' },
})) for (const [name, type] of Object.entries(fields)) evidence.columns.push({ table_name: table, column_name: name, data_type: type, is_nullable: ['lease_token', 'lease_expires_at'].includes(name) ? 'YES' : 'NO' })
const references = materialPurgeReferenceExpectations()
evidence.indexes.push(...references.indexes)
evidence.referenceFunctions = references.referenceFunctions
@ -1056,8 +1064,13 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /二十八份目标 migration/)
assert.match(sop, /二十八条目标 migration/)
assert.match(sop, /三十三份目标 migration/)
assert.match(sop, /三十三条目标 migration/)
const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1]
assert.ok(targetBlock, 'Migrator target list must be inspectable')
const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1])
assert.equal(targets.length, 33)
for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)

View File

@ -0,0 +1,228 @@
/** Disposable local PostgreSQL acceptance. Never connects to the production database. */
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import bcrypt from 'bcryptjs'
import sharp from 'sharp'
import { readPsd } from 'ag-psd'
import { prisma } from '../lib/prisma'
import { assertDetailGeneratorUser } from '../lib/detail-generator/access'
import { approveDetailAnalysis, createDetailAction, editDetailProject, editDetailScreen, getDetailProject, newDetailProject, uploadDetailAsset } from '../lib/detail-generator/service'
import { claimDetailJob, enqueueDetailJob, findDetailAsset, findDetailJob, findDetailProject, finishDetailJob } from '../lib/detail-generator/repository'
import { runDetailGeneratorWorkerOnce } from '../lib/detail-generator/jobs'
import { readDetailFile } from '../lib/detail-generator/storage'
import { saveDetailBrandProfile } from '../lib/detail-generator/brand-profiles'
import { DETAIL_FONT_FAMILY, DETAIL_MODEL_DEFAULTS, type DetailDocument } from '../lib/detail-generator/types'
async function main() {
const db = new URL(process.env.DATABASE_URL || '')
assert.equal(db.hostname, '127.0.0.1', 'Only disposable local PostgreSQL is permitted')
assert.equal(db.port, '15437')
assert.equal(db.pathname, '/detail_test')
process.env.DETAIL_GENERATOR_ENABLED = 'true'
process.env.DETAIL_GENERATOR_API_KEY = 'synthetic-test-key-no-provider-spend'
process.env.DETAIL_GENERATOR_BASE_URL = 'https://detail-provider.invalid/v1'
process.env.DETAIL_GENERATOR_STORAGE_DIR = path.resolve('output/detail-generator/private')
const password = 'LocalDetailPreviewOnly-20261012'
const hashed = await bcrypt.hash(password, 4)
const owners = await Promise.all([0, 1, 2, 3].map(async index => prisma.user.upsert({
where: { email: `detail-test-${index}@example.invalid` },
update: { isSuperAdmin: index !== 3, status: 'active', isActive: true },
create: { email: `detail-test-${index}@example.invalid`, username: `详情页本地验收${index}`, password: hashed,
isSuperAdmin: index !== 3, role: 'ADMIN', status: 'active', isActive: true },
})))
for (const owner of owners) await prisma.$executeRaw`DELETE FROM detail_generator_projects WHERE owner_id=${owner.id}`
await prisma.$executeRaw`DELETE FROM detail_generator_brand_profiles WHERE name='本地验收品牌'`
await assertDetailGeneratorUser(owners[0].id)
await assert.rejects(assertDetailGeneratorUser(owners[3].id), /超级管理员/)
const document = (): DetailDocument => ({
version: 1, name: '本地验收 · 产品详情页',
product: { identifierType: 'sku', identifier: 'TEST-SKU-001', variant: '验收款式', name: '本地验收产品',
facts: '仅用于测试:木色外观、圆角设计。没有提供尺寸、材质、承重和认证。', assetIds: [], referenceAssetIds: [], documentAssetIds: [] },
brand: { name: '本地验收品牌', tone: '自然、简洁,真实产品摄影', primaryColor: '#415947', backgroundColor: '#ffffff', textColor: '#101828', fontFamily: DETAIL_FONT_FAMILY, status: 'draft' },
models: { ...DETAIL_MODEL_DEFAULTS }, analysis: null, approvedAt: null, screens: [],
})
const image = await sharp(Buffer.from('<svg width="750" height="750" xmlns="http://www.w3.org/2000/svg"><rect width="750" height="750" fill="#f4f0e8"/><rect x="210" y="190" width="330" height="330" rx="32" fill="#aa855a"/><path d="M240 520v100M510 520v100" stroke="#785a3b" stroke-width="30"/></svg>')).png().toBuffer()
async function inputProject(ownerId: string) {
let project = await newDetailProject(ownerId, document())
for (const kind of ['product', 'reference', 'document'] as const) {
const form = new FormData()
form.set('kind', kind); form.set('revision', String(project.revision))
const bytes = kind === 'document' ? Buffer.from('测试产品资料:木色外观、圆角设计。禁止编造尺寸、认证或材质。') : image
form.set('file', new Blob([new Uint8Array(bytes)], { type: kind === 'document' ? 'text/plain' : 'image/png' }), kind === 'document' ? '产品资料.txt' : `${kind}.png`)
const response = await uploadDetailAsset(ownerId, project.id, new Request('http://localhost/upload', { method: 'POST', body: form }))
project = response.project
}
return project
}
const project = await inputProject(owners[0].id)
await assert.rejects(findDetailProject(project.id, owners[1].id), /项目不存在/)
let providerCalls = 0
let imageCalls = 0
let returnUnknown = false
let revokeDuringAnalysis: string | null = null
const realFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
assert.ok(String(input).startsWith('https://detail-provider.invalid/'), 'No live provider calls')
assert.ok(new Headers(init?.headers).get('Idempotency-Key'))
providerCalls++
if (returnUnknown) return Response.json({ error: { code: 'request_outcome_unknown' } }, { status: 503, headers: { 'x-request-id': 'test-unknown-request' } })
if (String(input).endsWith('/images/edits')) {
imageCalls++
assert.ok(init?.body instanceof FormData)
return Response.json({ data: [{ b64_json: image.toString('base64') }] })
}
const body = JSON.parse(String(init?.body))
const isAnalysis = Array.isArray(body.messages[0].content)
if (isAnalysis) {
assert.ok(JSON.stringify(body).includes('测试产品资料'), 'Documents included in analysis')
if (revokeDuringAnalysis) await prisma.user.update({ where: { id: revokeDuringAnalysis }, data: { isSuperAdmin: false } })
}
return Response.json({ choices: [{ message: { content: JSON.stringify(isAnalysis ? { facts: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'] } : {
sellingPoints: ['木色外观', '圆角设计'], warnings: ['未提供材质和尺寸'], screens: [
{ title: '木色,自然融入空间', body: '本地验收示例。展示真实外观与圆角细节。', imagePrompt: '简洁自然背景,保持产品款式' },
{ title: '圆角细节', body: '本地验收示例。呈现可见细节。', imagePrompt: '近距离展示实际圆角细节' },
],
}) } }] })
}
try {
const key = randomUUID()
const job = await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })
assert.equal((await createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: key })).id, job.id)
await assert.rejects(createDetailAction(owners[0].id, project.id, 'analyze', { idempotencyKey: randomUUID() }), /已有任务/)
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(job.id, owners[0].id)).status, 'succeeded')
let current = (await getDetailProject(owners[0].id, project.id)).project
const humanDraft = structuredClone(current.document)
assert.ok(humanDraft.analysis)
humanDraft.analysis.screens[0].title = '人工核对:木色自然融入空间'
humanDraft.analysis.sellingPoints[0] = '人工核对后的木色外观'
const beforeDraftSave = providerCalls
current = await editDetailProject(owners[0].id, current.id, { document: humanDraft, revision: current.revision })
current = (await getDetailProject(owners[0].id, current.id)).project
assert.deepEqual(current.document.analysis, humanDraft.analysis)
assert.equal(current.document.approvedAt, null)
assert.equal(providerCalls, beforeDraftSave, 'Saving an unapproved human draft must not call a model')
current = await approveDetailAnalysis(owners[0].id, project.id, { analysis: current.document.analysis, revision: current.revision })
const generation = await createDetailAction(owners[0].id, project.id, 'generate', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(generation.id, owners[0].id)).status, 'succeeded')
assert.equal(imageCalls, 2)
current = (await getDetailProject(owners[0].id, project.id)).project
const previousCalls = providerCalls
const screen = current.document.screens[0]
const edited = { ...screen, body: '修改后的中文文案\n本地验收,不调用生图。', texts: screen.texts.map(layer => layer.id.endsWith('-body') ? { ...layer, text: '修改后的中文文案\n本地验收,不调用生图。' } : layer) }
current = await editDetailScreen(owners[0].id, current.id, edited.id, { screen: edited, revision: current.revision })
assert.equal(providerCalls, previousCalls)
const style = await saveDetailBrandProfile(current.document.brand, undefined)
await saveDetailBrandProfile({ ...style.profile, tone: '修改后的品牌草案' }, style.revision)
await assert.rejects(saveDetailBrandProfile(style.profile, style.revision), /已更新/)
assert.equal(current.document.brand.tone, '自然、简洁,真实产品摄影')
await mkdir('output/detail-generator', { recursive: true })
for (const format of ['png', 'jpg', 'long-png', 'psd'] as const) {
const exportJob = await createDetailAction(owners[0].id, current.id, 'export', { format, idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
const result = await findDetailJob(exportJob.id, owners[0].id)
assert.equal(result.status, 'succeeded', result.error || undefined)
const asset = await findDetailAsset(String(result.checkpoint.exportAssetId), owners[0].id)
await assert.rejects(findDetailAsset(asset.id, owners[1].id), /文件不存在/)
const bytes = await readDetailFile(asset.storageKey)
await writeFile(path.join('output/detail-generator', asset.name), bytes)
if (format === 'psd') {
const psd = readPsd(bytes, { skipLayerImageData: true, skipCompositeImageData: true, skipThumbnail: true })
assert.equal(psd.children?.length, 2)
assert.ok(JSON.stringify(psd).includes('修改后的中文文案'))
}
}
const untouchedImage = current.document.screens[1].image?.assetId
const regenerate = await createDetailAction(owners[0].id, current.id, 'regenerate', { idempotencyKey: randomUUID() }, screen.id)
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(regenerate.id, owners[0].id)).status, 'succeeded')
current = (await getDetailProject(owners[0].id, current.id)).project
assert.equal(current.document.screens[1].image?.assetId, untouchedImage)
assert.equal(imageCalls, 3)
// Separate projects exercise draft reset without altering the generated browser preview.
const blankProject = await newDetailProject(owners[2].id, document())
const forged = { ...blankProject.document, analysis: structuredClone(current.document.analysis), approvedAt: new Date().toISOString() }
const beforeForged = providerCalls
await assert.rejects(editDetailProject(owners[2].id, blankProject.id, { document: forged, revision: blankProject.revision }), /请先分析产品资料/)
assert.equal(providerCalls, beforeForged)
assert.equal((await getDetailProject(owners[2].id, blankProject.id)).project.document.analysis, null)
let workflow = await inputProject(owners[2].id)
const workflowAnalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(workflowAnalysis.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
assert.ok(workflow.document.approvedAt)
assert.ok(workflow.document.screens.length)
const resetDraft = structuredClone(workflow.document)
resetDraft.analysis = null
resetDraft.product.facts = '人工修正后的测试产品资料:木色外观、圆角设计;其余参数未提供。'
workflow = await editDetailProject(owners[2].id, workflow.id, { document: resetDraft, revision: workflow.revision })
assert.equal(workflow.document.analysis, null)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
const reanalysis = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(reanalysis.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
assert.ok(workflow.document.analysis)
assert.equal(workflow.document.approvedAt, null)
workflow = await approveDetailAnalysis(owners[2].id, workflow.id, { analysis: workflow.document.analysis, revision: workflow.revision })
const changedSource = structuredClone(workflow.document)
changedSource.brand.tone = '修改后的测试品牌风格草案'
const beforeSourceChange = providerCalls
workflow = await editDetailProject(owners[2].id, workflow.id, { document: changedSource, revision: workflow.revision })
assert.equal(workflow.document.analysis, null)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
assert.equal(providerCalls, beforeSourceChange)
const outlinePreviewJob = await createDetailAction(owners[2].id, workflow.id, 'analyze', { idempotencyKey: randomUUID() })
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(outlinePreviewJob.id, owners[2].id)).status, 'succeeded')
workflow = (await getDetailProject(owners[2].id, workflow.id)).project
assert.ok(workflow.document.analysis)
assert.equal(workflow.document.approvedAt, null)
assert.deepEqual(workflow.document.screens, [])
const unknownProject = await inputProject(owners[1].id)
returnUnknown = true
const unknownJob = await createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() })
const beforeUnknown = providerCalls
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(unknownJob.id, owners[1].id)).status, 'unknown')
await runDetailGeneratorWorkerOnce()
assert.equal(providerCalls, beforeUnknown + 1)
await assert.rejects(createDetailAction(owners[1].id, unknownProject.id, 'analyze', { idempotencyKey: randomUUID() }), /结果未知/)
returnUnknown = false
const revokedProject = await inputProject(owners[2].id)
revokeDuringAnalysis = owners[2].id
const revokedJob = await createDetailAction(owners[2].id, revokedProject.id, 'analyze', { idempotencyKey: randomUUID() })
const beforeRevoked = providerCalls
await runDetailGeneratorWorkerOnce()
assert.equal((await findDetailJob(revokedJob.id, owners[2].id)).status, 'failed')
assert.equal(providerCalls, beforeRevoked + 1, 'No copy/image call after revocation')
await assert.rejects(assertDetailGeneratorUser(owners[2].id), /超级管理员/)
await prisma.user.update({ where: { id: owners[2].id }, data: { isSuperAdmin: true } })
revokeDuringAnalysis = null
// Concurrent claims prove the limit under real PostgreSQL locks.
const queueProjects = await Promise.all(owners.slice(0, 3).map(owner => newDetailProject(owner.id, document())))
const queue = await Promise.all(queueProjects.map((item, i) => enqueueDetailJob({ projectId: item.id, ownerId: owners[i].id, kind: 'export', revision: item.revision, idempotencyKey: randomUUID(), payload: { format: 'png' } })))
const claimed = (await Promise.all([claimDetailJob(), claimDetailJob(), claimDetailJob()])).filter(item => item !== null)
assert.equal(claimed.length, 2)
assert.equal(new Set(claimed.map(item => item.ownerId)).size, 2)
for (const item of claimed) await finishDetailJob(item.id, item.leaseToken!, 'failed', {}, 'Synthetic capacity test')
const last = await claimDetailJob()
assert.ok(last)
await prisma.$executeRaw`UPDATE detail_generator_jobs SET checkpoint='{"dispatchPending":true}'::jsonb,lease_expires_at=NOW()-INTERVAL '1 second' WHERE id=${last.id}`
assert.equal(await claimDetailJob(), null)
assert.equal((await findDetailJob(last.id, last.ownerId)).status, 'unknown')
assert.equal(queue.length, 3)
console.log(JSON.stringify({ passed: ['DB super-admin gate', 'creator isolation', 'uploads/documents', 'analyze+approve+generate', 'human outline draft save+restore', 'blank analysis forgery denied', 'reset facts+reanalyze', 'approved source invalidation', 'idempotency', 'text edit without image call', 'brand draft snapshot+conflict', '4 export formats', 'single-screen regeneration', 'unknown no replay', 'revocation before next call', 'concurrent max2', 'expired dispatch no replay'], providerCalls, realProviderCalls: 0, previewEmail: owners[0].email, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
await writeFile('output/detail-generator/preview.json', JSON.stringify({ email: owners[0].email, password, projectId: current.id, outlineProjectId: workflow.id, outlineEmail: owners[2].email }))
} finally { globalThis.fetch = realFetch; await prisma.$disconnect() }
}
main().catch(error => { console.error(error); process.exitCode = 1 })

1
workers/detail-renderer/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,13 @@
# Isolated detail-page renderer
This package pins Fabric.js `7.4.0`, Canvas `3.2.0`, Sharp `0.34.4`, and ag-psd `28.4.1`. It deliberately has its own dependency tree, because the existing application optionally uses Canvas 2.x for older media workflows.
Run `npm ci` in this directory with Node.js 22 or newer. Run `npm test` for native image/PSD tests. Set `DETAIL_RENDER_TEST_ARTIFACTS=1` to retain the synthetic PNG, long image, and PSD under the ignored `output/detail-generator-render` directory for visual inspection.
The application invokes `cli.mjs` as a child process. Production must set `DETAIL_RENDERER_NODE` to the isolated Node.js binary and `DETAIL_RENDERER_ENTRY` to the copied CLI entry. `DETAIL_FONT_DIR` points to the unmodified files from `public/detail-generator/fonts`. The Docker stages install native Canvas dependencies separately and retain the app's existing Node.js/Canvas versions.
The input is a bounded JSON document and a map of previously authorized image bytes encoded as base64. The renderer never reads URLs from the document and does not fetch assets. Rendering validates dimensions, colors, text layout, source MIME types, and missing assets before export. The parent process imposes an input/output cap, timeout, and separate 768 MiB JS heap.
Each screen uses an explicit left/top Fabric origin, centered proportion-preserving image crops, and the same complete Chinese font as the browser. PSD contains one group per screen, a background layer, raster scene/logo layers, and editable horizontal text with actual PostScript font names and explicit initial line breaks. Native text line spacing uses Fabric's measured line height. The saved composite and text raster layers preserve the initial pixels.
Automated tests verify exported PNG/JPEG pixels, stitched-image parity, font internal names, PSD composite parity, native text and group metadata, CLI transport, and explicit failure on overflowing copy or unavailable images. Photoshop opening, editing, and save/reopen acceptance requires the actual Photoshop application and is a separate manual gate; ag-psd can request a text-layer redraw on open. Install the matching font files before editing PSD text.

View File

@ -0,0 +1,17 @@
import { renderRequest } from './render.mjs'
const INPUT_LIMIT = 192 * 1024 * 1024
let length = 0
const chunks = []
try {
for await (const chunk of process.stdin) {
length += chunk.length
if (length > INPUT_LIMIT) throw new Error('导出输入超过上限')
chunks.push(chunk)
}
const files = await renderRequest(JSON.parse(Buffer.concat(chunks).toString('utf8')))
process.stdout.write(JSON.stringify({ files }))
} catch (error) {
process.stderr.write(error instanceof Error ? error.message : '详情页导出失败')
process.exitCode = 1
}

1551
workers/detail-renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
{
"name": "gongxiangp2-detail-renderer",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=22" },
"scripts": { "test": "node --test render.test.mjs" },
"dependencies": {
"fabric": "7.4.0",
"canvas": "3.2.0",
"sharp": "0.34.4",
"ag-psd": "28.4.1"
}
}

View File

@ -0,0 +1,173 @@
import path from 'node:path'
import fs from 'node:fs'
import { StaticCanvas, FabricImage, Textbox } from 'fabric/node'
import { createCanvas, createImageData, registerFont } from 'canvas'
import sharp from 'sharp'
import { initializeCanvas, writePsdBuffer } from 'ag-psd'
const FONT_FAMILY = 'Noto Sans SC'
const MAX_SCREENS = 12
const MAX_HEIGHT = 30000 // PSD v1 dimensions, and a bounded raster allocation.
const MAX_ASSET_BYTES = 20 * 1024 * 1024
let fontsDirectory = null
function initializeFonts(directory) {
if (fontsDirectory === directory) return
for (const [weight, filename] of [['400', 'NotoSansSC-Regular.otf'], ['700', 'NotoSansSC-Bold.otf']]) {
const filenamePath = path.join(directory, filename)
if (!fs.existsSync(filenamePath)) throw new Error('详情页字体未安装:请提供完整 Noto Sans SC 字体文件')
registerFont(filenamePath, { family: FONT_FAMILY, weight, style: 'normal' })
}
initializeCanvas(createCanvas, createImageData)
fontsDirectory = directory
}
function finite(value, min, max, name) {
if (!Number.isFinite(value) || value < min || value > max) throw new Error(`无效的渲染参数:${name}`)
}
export function validateRequest(request) {
if (!request || !['png', 'jpg', 'long-png', 'psd'].includes(request.format)) throw new Error('不支持的导出格式')
const screens = request.document?.screens
if (!Array.isArray(screens) || screens.length < 1 || screens.length > MAX_SCREENS) throw new Error('导出需要 1 至 12 屏')
let totalHeight = 0
for (const screen of screens) {
if (screen.width !== 750 || !Number.isInteger(screen.height)) throw new Error('详情页宽度必须为 750px')
finite(screen.height, 400, 2400, 'screen.height')
totalHeight += screen.height
if (!/^#[0-9a-f]{6}$/i.test(screen.backgroundColor)) throw new Error('无效的背景颜色')
if (!Array.isArray(screen.texts) || screen.texts.length > 20) throw new Error('每屏最多 20 个文字图层')
for (const text of screen.texts) {
if (typeof text.text !== 'string' || text.text.length > 3000 || text.fontFamily !== FONT_FAMILY) throw new Error('无效的文字图层')
finite(text.x, 0, 750, 'text.x'); finite(text.y, 0, screen.height, 'text.y')
finite(text.width, 1, 750 - text.x, 'text.width'); finite(text.fontSize, 12, 120, 'text.fontSize')
finite(text.lineHeight, 1, 2.5, 'text.lineHeight')
if (!['left', 'center', 'right'].includes(text.align) || !/^#[0-9a-f]{6}$/i.test(text.color)) throw new Error('无效的文字样式')
}
for (const layer of [screen.image, screen.logo].filter(Boolean)) {
finite(layer.x, 0, 749, 'image.x'); finite(layer.y, 0, screen.height - 1, 'image.y')
finite(layer.width, 1, 750 - layer.x, 'image.width'); finite(layer.height, 1, screen.height - layer.y, 'image.height')
const asset = request.assets?.[layer.assetId]
if (!asset || !['image/png', 'image/jpeg', 'image/webp'].includes(asset.mimeType) || typeof asset.base64 !== 'string') throw new Error('缺少可用的图片素材')
if (asset.base64.length > Math.ceil(MAX_ASSET_BYTES * 4 / 3) + 4) throw new Error('导出图片素材过大')
}
}
if (totalHeight > MAX_HEIGHT) throw new Error('详情页总高度超过导出上限')
}
function textBox(layer, x = layer.x, y = layer.y) {
return new Textbox(layer.text, {
left: x, top: y, originX: 'left', originY: 'top', width: layer.width, fontSize: layer.fontSize,
fontFamily: FONT_FAMILY, fontWeight: '400', lineHeight: layer.lineHeight,
fill: layer.color, textAlign: layer.align, splitByGrapheme: true,
padding: 0, strokeWidth: 0, objectCaching: false,
})
}
export function coverGeometry(sourceWidth, sourceHeight, width, height) {
const scale = Math.max(width / sourceWidth, height / sourceHeight)
return { cropX: (sourceWidth - width / scale) / 2, cropY: (sourceHeight - height / scale) / 2, width: width / scale, height: height / scale, scaleX: scale, scaleY: scale }
}
export function containGeometry(sourceWidth, sourceHeight, width, height) {
const scale = Math.min(width / sourceWidth, height / sourceHeight)
return { cropX: 0, cropY: 0, width: sourceWidth, height: sourceHeight, scaleX: scale, scaleY: scale, offsetX: (width - sourceWidth * scale) / 2, offsetY: (height - sourceHeight * scale) / 2 }
}
async function imageObject(layer, asset, contain = false) {
const image = await sharp(Buffer.from(asset.base64, 'base64'), { failOn: 'error', limitInputPixels: 25000000, animated: false }).rotate().png().toBuffer()
const object = await FabricImage.fromURL(`data:image/png;base64,${image.toString('base64')}`)
const geometry = contain ? containGeometry(object.width, object.height, layer.width, layer.height) : coverGeometry(object.width, object.height, layer.width, layer.height)
object.set({ left: layer.x + (geometry.offsetX || 0), top: layer.y + (geometry.offsetY || 0), originX: 'left', originY: 'top', ...geometry, strokeWidth: 0, objectCaching: false })
return object
}
function pngFromCanvas(canvas) {
canvas.renderAll()
return Buffer.from(canvas.toDataURL({ format: 'png', multiplier: 1 }).split(',')[1], 'base64')
}
async function rawPixels(png) {
const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
return { data: new Uint8ClampedArray(data), width: info.width, height: info.height }
}
async function layerBitmap(object, width, height, origin = { x: object.left, y: object.top }) {
const canvas = new StaticCanvas(null, { width: Math.ceil(width), height: Math.ceil(height), renderOnAddRemove: false, enableRetinaScaling: false })
try {
const clone = await object.clone()
clone.set({ left: object.left - origin.x, top: object.top - origin.y })
canvas.add(clone)
return await rawPixels(pngFromCanvas(canvas))
} finally { await canvas.dispose() }
}
async function renderScreen(screen, assets, offset, includeLayers) {
const canvas = new StaticCanvas(null, { width: screen.width, height: screen.height, backgroundColor: screen.backgroundColor, renderOnAddRemove: false, enableRetinaScaling: false })
const children = []
try {
if (includeLayers) {
const background = await sharp({ create: { width: screen.width, height: screen.height, channels: 4, background: screen.backgroundColor } }).png().toBuffer()
children.push({ name: '背景', left: 0, top: offset, imageData: await rawPixels(background) })
}
if (screen.image) {
const object = await imageObject(screen.image, assets[screen.image.assetId])
canvas.add(object)
if (includeLayers) children.push({ name: '产品场景', left: Math.round(screen.image.x), top: Math.round(offset + screen.image.y), imageData: await layerBitmap(object, screen.image.width, screen.image.height) })
}
for (let index = 0; index < screen.texts.length; index++) {
const layer = screen.texts[index]
if (!layer.text) continue
const object = textBox(layer)
if (layer.y + object.height > screen.height + 1) throw new Error(`「${screen.title}」文字超出画布,请缩短文案或调整排版`)
canvas.add(object)
if (includeLayers) {
const color = layer.color.slice(1)
// Explicit initial line breaks retain Fabric's CJK wrapping when Photoshop opens the file.
const wrapped = object._textLines.map(line => line.join('')).join('\n')
children.push({
name: index === 0 ? '标题' : `正文 ${index}`, left: Math.round(layer.x), top: Math.round(offset + layer.y),
imageData: await layerBitmap(object, layer.width, object.height),
text: {
text: wrapped, transform: [1, 0, 0, 1, layer.x, offset + layer.y], orientation: 'horizontal', antiAlias: 'smooth',
shapeType: 'box', boxBounds: [0, 0, layer.width, Math.ceil(object.height + layer.fontSize)],
style: { font: { name: 'NotoSansSC-Regular' }, fontSize: layer.fontSize, autoLeading: false, leading: object.getHeightOfLine(0),
fillColor: { r: parseInt(color.slice(0, 2), 16), g: parseInt(color.slice(2, 4), 16), b: parseInt(color.slice(4, 6), 16) } },
paragraphStyle: { justification: layer.align, autoHyphenate: false },
},
})
}
}
if (screen.logo) {
const object = await imageObject(screen.logo, assets[screen.logo.assetId], true)
canvas.add(object)
if (includeLayers) children.push({ name: '品牌标志', left: Math.round(screen.logo.x), top: Math.round(offset + screen.logo.y), imageData: await layerBitmap(object, screen.logo.width, screen.logo.height, screen.logo) })
}
return { png: pngFromCanvas(canvas), group: { name: screen.title || `第 ${offset} 屏`, opened: true, children } }
} finally { await canvas.dispose() }
}
export async function renderRequest(request, options = {}) {
validateRequest(request)
initializeFonts(options.fontDirectory || process.env.DETAIL_FONT_DIR || path.resolve(process.cwd(), 'public/detail-generator/fonts'))
const rendered = []
let offset = 0
for (const screen of request.document.screens) {
rendered.push(await renderScreen(screen, request.assets, offset, request.format === 'psd'))
offset += screen.height
}
if (request.format === 'png' || request.format === 'jpg') {
return Promise.all(rendered.map(async ({ png }, index) => ({ filename: `screen-${String(index + 1).padStart(2, '0')}.${request.format}`, mimeType: request.format === 'png' ? 'image/png' : 'image/jpeg',
base64: (request.format === 'png' ? png : await sharp(png).flatten({ background: '#ffffff' }).jpeg({ quality: 95, chromaSubsampling: '4:4:4' }).toBuffer()).toString('base64') })))
}
let top = 0
const composites = rendered.map((screen, index) => {
const item = { input: screen.png, left: 0, top }
top += request.document.screens[index].height
return item
})
const composite = await sharp({ create: { width: 750, height: offset, channels: 4, background: '#ffffff' } }).composite(composites).png().toBuffer()
if (request.format === 'long-png') return [{ filename: 'detail-long.png', mimeType: 'image/png', base64: composite.toString('base64') }]
const psd = writePsdBuffer({ width: 750, height: offset, bitsPerChannel: 8, imageData: await rawPixels(composite), children: rendered.map(screen => screen.group) }, { generateThumbnail: false, invalidateTextLayers: true })
return [{ filename: 'detail-layered.psd', mimeType: 'image/vnd.adobe.photoshop', base64: psd.toString('base64') }]
}

View File

@ -0,0 +1,154 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import sharp from 'sharp'
import { readPsd } from 'ag-psd'
import { renderRequest, coverGeometry, containGeometry } from './render.mjs'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const options = { fontDirectory: path.join(root, 'public/detail-generator/fonts') }
const text = '真实橡木座椅\n清晰的产品卖点与中文换行'
function document(screens = 1) {
return { version: 1, name: '中文详情页验收', screens: Array.from({ length: screens }, (_, index) => ({
id: `screen-${index}`, title: `第 ${index + 1} 屏`, width: 750, height: 900, backgroundColor: '#f6f5f2',
image: { assetId: 'product', x: 50, y: 240, width: 650, height: 600 },
logo: { assetId: 'logo', x: 640, y: 30, width: 60, height: 60 },
texts: [{ id: 'title', text, x: 50, y: 80, width: 630, fontSize: 40, lineHeight: 1.4, color: '#182a36', fontFamily: 'Noto Sans SC', align: 'left' }],
})) }
}
async function assets() {
const product = await sharp({ create: { width: 800, height: 500, channels: 4, background: '#d48756' } }).composite([{ input: { text: { text: '<span foreground="white">产品图片</span>', width: 300, height: 80, font: 'sans 48', rgba: true } }, left: 250, top: 210 }]).png().toBuffer()
const logo = await sharp({ create: { width: 60, height: 60, channels: 4, background: '#2488ac' } }).png().toBuffer()
return { product: { mimeType: 'image/png', base64: product.toString('base64') }, logo: { mimeType: 'image/png', base64: logo.toString('base64') } }
}
test('center cover cropping preserves proportions', () => {
const geometry = coverGeometry(800, 500, 650, 600)
assert.equal(geometry.scaleX, geometry.scaleY)
assert.equal(geometry.height, 500)
assert.equal(geometry.cropY, 0)
assert.ok(geometry.cropX > 0)
const logo = containGeometry(300, 60, 60, 60)
assert.equal(logo.scaleX, 0.2)
assert.equal(logo.offsetY, 24)
assert.equal(logo.cropX, 0)
})
test('font files contain the exact PostScript names used by native PSD text layers', async () => {
for (const name of ['NotoSansSC-Regular', 'NotoSansSC-Bold']) {
const font = await fs.readFile(path.join(options.fontDirectory, `${name}.otf`))
let nameTable = 0
for (let i = 0; i < font.readUInt16BE(4); i++) {
const record = 12 + 16 * i
if (font.subarray(record, record + 4).toString('ascii') === 'name') nameTable = font.readUInt32BE(record + 8)
}
assert.ok(nameTable)
const names = []
const strings = nameTable + font.readUInt16BE(nameTable + 4)
for (let i = 0; i < font.readUInt16BE(nameTable + 2); i++) {
const record = nameTable + 6 + 12 * i
if (font.readUInt16BE(record + 6) !== 6) continue
const start = strings + font.readUInt16BE(record + 10)
const bytes = font.subarray(start, start + font.readUInt16BE(record + 8))
names.push(font.readUInt16BE(record) === 3 ? Buffer.from(bytes).swap16().toString('utf16le') : bytes.toString('ascii'))
}
assert.ok(names.includes(name), `Bundled font PostScript name must be ${name}`)
}
})
test('Chinese text, logo, screen images, long image, and editable PSD agree', async () => {
const request = { document: document(2), assets: await assets(), format: 'png' }
const files = await renderRequest(request, options)
assert.equal(files.length, 2)
const png = Buffer.from(files[0].base64, 'base64')
const metadata = await sharp(png).metadata()
assert.equal(metadata.width, 750); assert.equal(metadata.height, 900)
const raw = await sharp(png).ensureAlpha().raw().toBuffer()
const pixel = (x, y) => Array.from(raw.subarray((y * 750 + x) * 4, (y * 750 + x) * 4 + 3))
assert.deepEqual(pixel(670, 60), [36, 136, 172]) // logo remains distinct from the scene.
assert.deepEqual(pixel(100, 350), [212, 135, 86])
let textPixels = 0
for (let y = 80; y < 210; y++) for (let x = 50; x < 680; x++) {
const [r, g, b] = pixel(x, y)
if (r < 100 && g < 100 && b < 100) textPixels++
}
assert.ok(textPixels > 1000, 'Chinese text must be visibly rendered with the bundled font')
const [jpg] = await renderRequest({ ...request, document: document(), format: 'jpg' }, options)
assert.equal((await sharp(Buffer.from(jpg.base64, 'base64')).metadata()).format, 'jpeg')
const [long] = await renderRequest({ ...request, format: 'long-png' }, options)
const longBytes = Buffer.from(long.base64, 'base64')
assert.equal((await sharp(longBytes).metadata()).height, 1800)
const firstScreen = await sharp(longBytes).extract({ left: 0, top: 0, width: 750, height: 900 }).png().toBuffer()
assert.deepEqual(await sharp(firstScreen).raw().toBuffer(), await sharp(png).raw().toBuffer())
const [psdFile] = await renderRequest({ ...request, format: 'psd' }, options)
const psdBytes = Buffer.from(psdFile.base64, 'base64')
assert.equal(psdBytes.subarray(0, 4).toString('ascii'), '8BPS')
const psd = readPsd(psdBytes, { useImageData: true, skipThumbnail: true })
assert.equal(psd.width, 750); assert.equal(psd.height, 1800)
assert.equal(psd.children.length, 2)
const title = psd.children[0].children.find(layer => layer.name === '标题')
assert.equal(title.text.text, text)
assert.equal(title.text.style.font.name, 'NotoSansSC-Regular')
assert.equal(title.text.orientation, 'horizontal')
assert.ok(Math.abs(title.text.style.leading - 40 * 1.13 * 1.4) < 0.001)
assert.equal(psd.children[1].children.find(layer => layer.name === '标题').text.transform[5], 980)
assert.ok(psd.children[0].children.some(layer => layer.name === '产品场景'))
assert.ok(psd.children[0].children.some(layer => layer.name === '品牌标志'))
assert.deepEqual(Buffer.from(psd.imageData.data), await sharp(longBytes).ensureAlpha().raw().toBuffer())
if (process.env.DETAIL_RENDER_TEST_ARTIFACTS === '1') {
const output = path.join(root, 'output/detail-generator-render')
await fs.mkdir(output, { recursive: true })
await Promise.all([fs.writeFile(path.join(output, 'screen-01.png'), png), fs.writeFile(path.join(output, 'detail-long.png'), longBytes), fs.writeFile(path.join(output, 'detail-layered.psd'), psdBytes)])
}
})
test('isolated CLI accepts bytes, emits a valid PNG, and never needs a URL fetch', async () => {
const input = JSON.stringify({ document: document(), assets: await assets(), format: 'png' })
const output = await new Promise((resolve, reject) => {
const child = spawn(process.execPath, [path.join(root, 'workers/detail-renderer/cli.mjs')], { cwd: root, env: { ...process.env, DETAIL_FONT_DIR: options.fontDirectory }, stdio: ['pipe', 'pipe', 'pipe'] })
const stdout = []; let stderr = ''
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => { stderr += chunk.toString() })
child.on('error', reject)
child.on('close', code => code === 0 ? resolve(Buffer.concat(stdout).toString()) : reject(new Error(stderr)))
child.stdin.end(input)
})
const result = JSON.parse(output)
assert.equal(result.files[0].filename, 'screen-01.png')
const metadata = await sharp(Buffer.from(result.files[0].base64, 'base64')).metadata()
assert.equal(metadata.width, 750)
assert.equal(metadata.height, 900)
})
test('wide brand logos retain their full aspect ratio in PNG and the PSD layer', async () => {
const inputAssets = await assets()
const wideLogo = await sharp({ create: { width: 300, height: 60, channels: 4, background: '#2488ac' } }).png().toBuffer()
inputAssets.logo.base64 = wideLogo.toString('base64')
const request = { document: document(), assets: inputAssets, format: 'png' }
const [pngFile] = await renderRequest(request, options)
const pixels = await sharp(Buffer.from(pngFile.base64, 'base64')).ensureAlpha().raw().toBuffer()
const pixel = (x, y) => Array.from(pixels.subarray((y * 750 + x) * 4, (y * 750 + x) * 4 + 3))
assert.deepEqual(pixel(670, 35), [246, 245, 242])
assert.deepEqual(pixel(670, 60), [36, 136, 172])
const [psdFile] = await renderRequest({ ...request, format: 'psd' }, options)
const psd = readPsd(Buffer.from(psdFile.base64, 'base64'), { useImageData: true, skipThumbnail: true })
const logo = psd.children[0].children.find(layer => layer.name === '品牌标志')
assert.equal(logo.top, 30)
assert.equal(logo.bottom - logo.top, 60)
assert.equal(logo.right - logo.left, 60)
assert.equal(logo.imageData.data[(5 * 60 + 30) * 4 + 3], 0)
assert.equal(logo.imageData.data[(30 * 60 + 30) * 4 + 3], 255)
assert.deepEqual(Buffer.from(psd.imageData.data), pixels)
})
test('missing images, arbitrary URL sources, and overflowing text fail explicitly', async () => {
const request = { document: document(), assets: {}, format: 'png' }
await assert.rejects(renderRequest(request, options), /缺少可用的图片素材/)
await assert.rejects(renderRequest({ ...request, assets: { product: { mimeType: 'image/svg+xml', base64: '<svg></svg>' } } }, options), /缺少可用的图片素材/)
request.assets = await assets()
request.document.screens[0].texts[0].text = '中文'.repeat(500)
await assert.rejects(renderRequest(request, options), /文字超出画布/)
})