feat: add super-admin product detail generator
This commit is contained in:
parent
5b07f5ca54
commit
c53fa0e292
@ -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
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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": {
|
||||
|
||||
31
DESIGN.md
31
DESIGN.md
@ -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:
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@ -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 && \
|
||||
|
||||
18
app/api/detail-generator/assets/[assetId]/route.ts
Normal file
18
app/api/detail-generator/assets/[assetId]/route.ts
Normal 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) }
|
||||
}
|
||||
12
app/api/detail-generator/brand-profiles/route.ts
Normal file
12
app/api/detail-generator/brand-profiles/route.ts
Normal 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) }
|
||||
})
|
||||
}
|
||||
9
app/api/detail-generator/capabilities/route.ts
Normal file
9
app/api/detail-generator/capabilities/route.ts
Normal 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' } })
|
||||
}
|
||||
6
app/api/detail-generator/jobs/[id]/route.ts
Normal file
6
app/api/detail-generator/jobs/[id]/route.ts
Normal 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)) })) }
|
||||
@ -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) }
|
||||
}
|
||||
5
app/api/detail-generator/materials/route.ts
Normal file
5
app/api/detail-generator/materials/route.ts
Normal 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')) } }) }
|
||||
5
app/api/detail-generator/projects/[id]/analyze/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/analyze/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/approve/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/approve/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/assets/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/assets/route.ts
Normal 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)) }
|
||||
5
app/api/detail-generator/projects/[id]/exports/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/exports/route.ts
Normal 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)) })) }
|
||||
5
app/api/detail-generator/projects/[id]/generate/route.ts
Normal file
5
app/api/detail-generator/projects/[id]/generate/route.ts
Normal 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)) })) }
|
||||
@ -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))) }
|
||||
10
app/api/detail-generator/projects/[id]/route.ts
Normal file
10
app/api/detail-generator/projects/[id]/route.ts
Normal 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 } }) }
|
||||
@ -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)) })) }
|
||||
7
app/api/detail-generator/route.ts
Normal file
7
app/api/detail-generator/route.ts
Normal 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) })) }
|
||||
23
app/assets/detail-generator/page.tsx
Normal file
23
app/assets/detail-generator/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
@ -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}
|
||||
|
||||
150
components/detail-generator/DetailCanvas.test.tsx
Normal file
150
components/detail-generator/DetailCanvas.test.tsx
Normal 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()
|
||||
})
|
||||
173
components/detail-generator/DetailCanvas.tsx
Normal file
173
components/detail-generator/DetailCanvas.tsx
Normal 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>
|
||||
}
|
||||
185
components/detail-generator/DetailGeneratorWorkspace.test.tsx
Normal file
185
components/detail-generator/DetailGeneratorWorkspace.test.tsx
Normal 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保存后保持换行。'))
|
||||
})
|
||||
309
components/detail-generator/DetailGeneratorWorkspace.tsx
Normal file
309
components/detail-generator/DetailGeneratorWorkspace.tsx
Normal 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>
|
||||
}
|
||||
19
components/detail-generator/DetailOutlineEditor.tsx
Normal file
19
components/detail-generator/DetailOutlineEditor.tsx
Normal 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>
|
||||
}
|
||||
88
components/detail-generator/DetailProductForm.tsx
Normal file
88
components/detail-generator/DetailProductForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
components/detail-generator/DetailScreenEditor.tsx
Normal file
53
components/detail-generator/DetailScreenEditor.tsx
Normal 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>
|
||||
}
|
||||
35
components/detail-generator/client.ts
Normal file
35
components/detail-generator/client.ts
Normal 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)}`
|
||||
}
|
||||
128
components/detail-generator/useUnsavedDetailGuard.test.tsx
Normal file
128
components/detail-generator/useUnsavedDetailGuard.test.tsx
Normal 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()
|
||||
})
|
||||
133
components/detail-generator/useUnsavedDetailGuard.tsx
Normal file
133
components/detail-generator/useUnsavedDetailGuard.tsx
Normal 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
68
docs/detail-generator.md
Normal 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 实际编辑验收。生产提交/部署及正式扩大开放范围另按明确指令执行。
|
||||
@ -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` 仍通过;
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
171
lib/__tests__/detail-generator-backend.test.ts
Normal file
171
lib/__tests__/detail-generator-backend.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
88
lib/__tests__/detail-generator-jobs.test.ts
Normal file
88
lib/__tests__/detail-generator-jobs.test.ts
Normal 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)
|
||||
})
|
||||
46
lib/__tests__/detail-generator-materials.test.ts
Normal file
46
lib/__tests__/detail-generator-materials.test.ts
Normal 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()
|
||||
})
|
||||
156
lib/__tests__/detail-generator-service.test.ts
Normal file
156
lib/__tests__/detail-generator-service.test.ts
Normal 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')
|
||||
})
|
||||
40
lib/__tests__/detail-generator-storage.test.ts
Normal file
40
lib/__tests__/detail-generator-storage.test.ts
Normal 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('之外')
|
||||
})
|
||||
})
|
||||
51
lib/detail-generator/access.ts
Normal file
51
lib/detail-generator/access.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
31
lib/detail-generator/brand-profiles.ts
Normal file
31
lib/detail-generator/brand-profiles.ts
Normal 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]
|
||||
}
|
||||
35
lib/detail-generator/fonts.ts
Normal file
35
lib/detail-generator/fonts.ts
Normal 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 }
|
||||
}
|
||||
156
lib/detail-generator/jobs.ts
Normal file
156
lib/detail-generator/jobs.ts
Normal 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
|
||||
}
|
||||
38
lib/detail-generator/layout.ts
Normal file
38
lib/detail-generator/layout.ts
Normal 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)],
|
||||
}
|
||||
}
|
||||
104
lib/detail-generator/materials.ts
Normal file
104
lib/detail-generator/materials.ts
Normal 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 }
|
||||
}
|
||||
147
lib/detail-generator/provider.ts
Normal file
147
lib/detail-generator/provider.ts
Normal 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) }
|
||||
}
|
||||
54
lib/detail-generator/render.ts
Normal file
54
lib/detail-generator/render.ts
Normal 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` }
|
||||
}
|
||||
215
lib/detail-generator/repository.ts
Normal file
215
lib/detail-generator/repository.ts
Normal 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 }
|
||||
}
|
||||
20
lib/detail-generator/runtime.ts
Normal file
20
lib/detail-generator/runtime.ts
Normal 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()
|
||||
}
|
||||
260
lib/detail-generator/service.ts
Normal file
260
lib/detail-generator/service.ts
Normal 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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 }
|
||||
57
lib/detail-generator/storage.ts
Normal file
57
lib/detail-generator/storage.ts
Normal 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)
|
||||
}
|
||||
113
lib/detail-generator/types.ts
Normal file
113
lib/detail-generator/types.ts
Normal 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
|
||||
}
|
||||
93
lib/detail-generator/validation.ts
Normal file
93
lib/detail-generator/validation.ts
Normal 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
|
||||
}
|
||||
@ -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
341
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
62
prisma/migrations/20261012_detail_generator/migration.sql
Normal file
62
prisma/migrations/20261012_detail_generator/migration.sql
Normal 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()
|
||||
);
|
||||
@ -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
|
||||
|
||||
BIN
public/detail-generator/fonts/NotoSansSC-Bold.otf
Normal file
BIN
public/detail-generator/fonts/NotoSansSC-Bold.otf
Normal file
Binary file not shown.
BIN
public/detail-generator/fonts/NotoSansSC-Regular.otf
Normal file
BIN
public/detail-generator/fonts/NotoSansSC-Regular.otf
Normal file
Binary file not shown.
92
public/detail-generator/fonts/OFL.txt
Normal file
92
public/detail-generator/fonts/OFL.txt
Normal 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.
|
||||
15
public/detail-generator/fonts/README.md
Normal file
15
public/detail-generator/fonts/README.md
Normal 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.
|
||||
19
scripts/detail-generator-migration.test.mjs
Normal file
19
scripts/detail-generator-migration.test.mjs
Normal 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/)
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
228
scripts/verify-detail-generator.ts
Normal file
228
scripts/verify-detail-generator.ts
Normal 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
1
workers/detail-renderer/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
||||
13
workers/detail-renderer/README.md
Normal file
13
workers/detail-renderer/README.md
Normal 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.
|
||||
17
workers/detail-renderer/cli.mjs
Normal file
17
workers/detail-renderer/cli.mjs
Normal 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
1551
workers/detail-renderer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
workers/detail-renderer/package.json
Normal file
14
workers/detail-renderer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
173
workers/detail-renderer/render.mjs
Normal file
173
workers/detail-renderer/render.mjs
Normal 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') }]
|
||||
}
|
||||
154
workers/detail-renderer/render.test.mjs
Normal file
154
workers/detail-renderer/render.test.mjs
Normal 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), /文字超出画布/)
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user