97 lines
6.5 KiB
TypeScript
97 lines
6.5 KiB
TypeScript
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 modelBinding = z.object({ providerId: id, modelKey: id, modelId: z.string().min(1).max(200), providerName: z.string().min(1).max(100), baseUrl: z.string().max(500), revision: z.number().int().min(1) }).strict()
|
|
export const detailModelBindingsSchema = z.object({ analysis: modelBinding, copy: modelBinding, image: modelBinding }).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().max(120), copy: z.string().max(120), image: z.string().max(120) }).strict(),
|
|
modelBindings: detailModelBindingsSchema.optional(),
|
|
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
|
|
}
|