加固产品分享链接公开访问
This commit is contained in:
parent
ca81d44421
commit
491c2a24ac
@ -8,6 +8,7 @@ import { NextRequest } from 'next/server'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { shareService } from '@/server/services'
|
||||
import { checkShareAccessRateLimit } from '@/server/services/share-rate-limit.service'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@ -23,6 +24,17 @@ export async function GET(
|
||||
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'Token 不能为空', 400, ctx.requestId)
|
||||
}
|
||||
|
||||
const rateLimit = checkShareAccessRateLimit({
|
||||
token,
|
||||
forwardedFor: request.headers.get('x-forwarded-for'),
|
||||
realIp: request.headers.get('x-real-ip'),
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
})
|
||||
|
||||
if (rateLimit.limited) {
|
||||
return jsonError(ErrorCode.SYSTEM_RATE_LIMIT, '访问过于频繁,请稍后重试', 429, ctx.requestId)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await shareService.getShareDetail(token, skuId)
|
||||
shareService.incrementViewCount(token, true).catch((error) => {
|
||||
|
||||
@ -11,7 +11,6 @@ import gsap from 'gsap'
|
||||
|
||||
// 产品公开信息(不包含敏感数据)
|
||||
interface ShareProduct {
|
||||
id: string
|
||||
name: string
|
||||
modelNumber: string
|
||||
description?: string | null
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { SharePageClient } from './SharePageClient'
|
||||
import { headers } from 'next/headers'
|
||||
import type { Transform } from '@/types/product'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { shareService } from '@/server/services'
|
||||
import { checkShareAccessRateLimit } from '@/server/services/share-rate-limit.service'
|
||||
|
||||
interface SharePageParams {
|
||||
token: string
|
||||
@ -13,43 +15,32 @@ interface SharePageProps {
|
||||
searchParams: Promise<{ sku?: string }>
|
||||
}
|
||||
|
||||
function renderExpiredState() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-apple-bg-secondary">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="w-16 h-16 text-apple-label-tertiary mx-auto mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="text-2xl font-semibold text-apple-label-primary mb-2">链接已过期</h1>
|
||||
<p className="text-apple-label-secondary">此分享链接已过期,请联系分享者获取新链接</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function SharePage({ params, searchParams }: SharePageProps) {
|
||||
const { token } = await params
|
||||
const { sku: skuId } = await searchParams
|
||||
const headerStore = await headers()
|
||||
const rateLimit = checkShareAccessRateLimit({
|
||||
token,
|
||||
forwardedFor: headerStore.get('x-forwarded-for'),
|
||||
realIp: headerStore.get('x-real-ip'),
|
||||
userAgent: headerStore.get('user-agent'),
|
||||
})
|
||||
|
||||
if (rateLimit.limited) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
let detail: Awaited<ReturnType<typeof shareService.getShareDetail>>
|
||||
try {
|
||||
detail = await shareService.getShareDetail(token, skuId)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
if (error.code === ErrorCode.AUTH_TOKEN_EXPIRED) {
|
||||
return renderExpiredState()
|
||||
}
|
||||
if (error.code === ErrorCode.FILE_NOT_FOUND || error.code === ErrorCode.PRODUCT_NOT_FOUND) {
|
||||
if (
|
||||
error.code === ErrorCode.SHARE_LINK_NOT_FOUND ||
|
||||
error.code === ErrorCode.SHARE_LINK_EXPIRED ||
|
||||
error.code === ErrorCode.FILE_NOT_FOUND ||
|
||||
error.code === ErrorCode.PRODUCT_NOT_FOUND
|
||||
) {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
@ -68,7 +59,6 @@ export default async function SharePage({ params, searchParams }: SharePageProps
|
||||
return (
|
||||
<SharePageClient
|
||||
product={{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
modelNumber: product.modelNumber,
|
||||
description: product.description,
|
||||
@ -92,8 +82,8 @@ export default async function SharePage({ params, searchParams }: SharePageProps
|
||||
attributes: sku.attributes as Record<string, string> | null,
|
||||
}))}
|
||||
steps={product.assemblySteps.map((step) => ({
|
||||
id: step.id,
|
||||
productId: step.productId,
|
||||
id: `share-step-${step.order}`,
|
||||
productId: 'shared-product',
|
||||
order: step.order,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
@ -101,20 +91,20 @@ export default async function SharePage({ params, searchParams }: SharePageProps
|
||||
transform: (step.transform as unknown as Transform) || {},
|
||||
duration: step.duration,
|
||||
easing: step.easing,
|
||||
createdAt: step.createdAt,
|
||||
updatedAt: step.updatedAt,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
}))}
|
||||
panels={product.panels.map((panel) => ({
|
||||
id: panel.id,
|
||||
productId: panel.productId,
|
||||
productId: 'shared-product',
|
||||
code: panel.code,
|
||||
name: panel.name,
|
||||
gltfNodeName: panel.gltfNodeName,
|
||||
gltfNodeName: panel.id,
|
||||
length: panel.length,
|
||||
width: panel.width,
|
||||
thickness: panel.thickness,
|
||||
createdAt: panel.createdAt,
|
||||
updatedAt: panel.updatedAt,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
}))}
|
||||
shareToken={token}
|
||||
/>
|
||||
|
||||
@ -39,10 +39,7 @@ export interface ShareLinkPermissionInfo {
|
||||
|
||||
export type PublicShareDetail = Prisma.ShareLinkGetPayload<{
|
||||
select: {
|
||||
id: true
|
||||
token: true
|
||||
viewCount: true
|
||||
createdAt: true
|
||||
expiresAt: true
|
||||
product: {
|
||||
select: {
|
||||
@ -67,9 +64,6 @@ export type PublicShareDetail = Prisma.ShareLinkGetPayload<{
|
||||
attributes: true
|
||||
isActive: true
|
||||
modelUrl: true
|
||||
images: true
|
||||
createdAt: true
|
||||
updatedAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -161,10 +155,7 @@ class ShareRepositoryClass extends BaseRepository<
|
||||
return this.prisma.shareLink.findUnique({
|
||||
where: { token },
|
||||
select: {
|
||||
id: true,
|
||||
token: true,
|
||||
viewCount: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
product: {
|
||||
select: {
|
||||
@ -185,9 +176,6 @@ class ShareRepositoryClass extends BaseRepository<
|
||||
attributes: true,
|
||||
isActive: true,
|
||||
modelUrl: true,
|
||||
images: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -3,6 +3,7 @@ import { ShareService } from '../share.service'
|
||||
import { shareRepository } from '../../repositories/share.repository'
|
||||
import { assemblyStepRepository } from '../../repositories/assembly.repository'
|
||||
import { panelRepository } from '../../repositories/panel.repository'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
jest.mock('../../repositories/share.repository', () => ({
|
||||
shareRepository: {
|
||||
@ -48,7 +49,7 @@ describe('ShareService.getShareDetail', () => {
|
||||
id: 'sku-1',
|
||||
skuCode: 'SKU-001',
|
||||
price: 99,
|
||||
attributes: { color: 'oak' },
|
||||
attributes: { color: 'oak', _skuCadData: { nodes: ['internal-node'] }, internalCount: 2 },
|
||||
isActive: true,
|
||||
modelUrl: 'https://example.com/sku-1.glb',
|
||||
images: [],
|
||||
@ -136,8 +137,27 @@ describe('ShareService.getShareDetail', () => {
|
||||
})
|
||||
expect(result.currentSkuId).toBe('sku-1')
|
||||
expect(result.product.modelUrl).toBe('https://example.com/sku-1.glb')
|
||||
expect(result.product.assemblySteps).toEqual([skuLevelStep])
|
||||
expect(result.product.panels).toEqual([skuLevelPanel])
|
||||
expect(result.product.assemblySteps).toEqual([
|
||||
{
|
||||
order: skuLevelStep.order,
|
||||
title: skuLevelStep.title,
|
||||
description: skuLevelStep.description,
|
||||
partIds: skuLevelStep.partIds,
|
||||
transform: {},
|
||||
duration: skuLevelStep.duration,
|
||||
easing: skuLevelStep.easing,
|
||||
},
|
||||
])
|
||||
expect(result.product.panels).toEqual([
|
||||
{
|
||||
id: skuLevelPanel.id,
|
||||
code: skuLevelPanel.code,
|
||||
name: skuLevelPanel.name,
|
||||
length: skuLevelPanel.length,
|
||||
width: skuLevelPanel.width,
|
||||
thickness: skuLevelPanel.thickness,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('带 sku 且该 SKU 无步骤/板件时,不再回退到产品级数据', async () => {
|
||||
@ -173,8 +193,8 @@ describe('ShareService.getShareDetail', () => {
|
||||
})
|
||||
expect(result.currentSkuId).toBeNull()
|
||||
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
|
||||
expect(result.product.assemblySteps).toEqual([productLevelStep])
|
||||
expect(result.product.panels).toEqual([productLevelPanel])
|
||||
expect(result.product.assemblySteps).toHaveLength(1)
|
||||
expect(result.product.panels).toHaveLength(1)
|
||||
expect(result.product).not.toHaveProperty('creator')
|
||||
})
|
||||
|
||||
@ -192,8 +212,8 @@ describe('ShareService.getShareDetail', () => {
|
||||
})
|
||||
expect(result.currentSkuId).toBeNull()
|
||||
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
|
||||
expect(result.product.assemblySteps).toEqual([productLevelStep])
|
||||
expect(result.product.panels).toEqual([productLevelPanel])
|
||||
expect(result.product.assemblySteps).toHaveLength(1)
|
||||
expect(result.product.panels).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('指定 sku 且该 sku 没有模型时,只回退到产品级模型,不回退到其他 sku 的模型', async () => {
|
||||
@ -225,4 +245,94 @@ describe('ShareService.getShareDetail', () => {
|
||||
expect(result.currentSkuId).toBe('sku-1')
|
||||
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
|
||||
})
|
||||
|
||||
it('公开详情只返回展示必要字段,不泄露内部 SKU、分享统计和结构原始数据', async () => {
|
||||
mockedShareRepository.findPublicDetailByToken.mockResolvedValue({
|
||||
...baseLink,
|
||||
product: {
|
||||
...baseLink.product,
|
||||
skus: [
|
||||
{
|
||||
...baseLink.product.skus[0],
|
||||
attributes: {
|
||||
color: 'oak',
|
||||
empty: '',
|
||||
numberValue: 1,
|
||||
_skuCadData: { secret: true },
|
||||
_cadExcludedHoles: 'hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
...baseLink.product.skus[0],
|
||||
id: 'sku-inactive',
|
||||
skuCode: 'SKU-INACTIVE',
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never)
|
||||
mockedAssemblyStepRepository.findByProductId.mockResolvedValue([
|
||||
{
|
||||
...productLevelStep,
|
||||
partIds: ['panel-a', '', ' '.repeat(2), 'x'.repeat(300)],
|
||||
transform: {
|
||||
position: [1, 2, 3],
|
||||
rotation: ['4', 5, 6],
|
||||
internalNotes: 'hidden',
|
||||
},
|
||||
cameraPosition: { position: [9, 9, 9] },
|
||||
infoCardData: { secret: true },
|
||||
},
|
||||
])
|
||||
mockedPanelRepository.findByProductId.mockResolvedValue([
|
||||
{
|
||||
...productLevelPanel,
|
||||
assemblySemantic: { secret: true },
|
||||
},
|
||||
])
|
||||
|
||||
const result = await service.getShareDetail('token-1')
|
||||
|
||||
expect(result.shareLink).toEqual({ token: 'token-1' })
|
||||
expect(result.product).not.toHaveProperty('id')
|
||||
expect(result.product.skus).toEqual([
|
||||
{
|
||||
id: 'sku-1',
|
||||
skuCode: 'SKU-001',
|
||||
price: 99,
|
||||
attributes: { color: 'oak' },
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(result)).not.toContain('_skuCadData')
|
||||
expect(JSON.stringify(result)).not.toContain('SKU-INACTIVE')
|
||||
expect(JSON.stringify(result)).not.toContain('cameraPosition')
|
||||
expect(JSON.stringify(result)).not.toContain('assemblySemantic')
|
||||
expect(result.product.assemblySteps[0]).toEqual({
|
||||
order: 1,
|
||||
title: '产品级步骤',
|
||||
description: null,
|
||||
partIds: ['panel-a'],
|
||||
transform: {
|
||||
position: [1, 2, 3],
|
||||
rotation: [4, 5, 6],
|
||||
},
|
||||
duration: 1500,
|
||||
easing: 'power2.inOut',
|
||||
})
|
||||
})
|
||||
|
||||
it('过期分享统一按不可用处理,避免泄露 token 状态', async () => {
|
||||
mockedShareRepository.findPublicDetailByToken.mockResolvedValue({
|
||||
...baseLink,
|
||||
expiresAt: new Date('2026-03-12T00:00:00.000Z'),
|
||||
} as never)
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-03-13T00:00:00.000Z'))
|
||||
|
||||
await expect(service.getShareDetail('token-1')).rejects.toMatchObject({
|
||||
code: ErrorCode.SHARE_LINK_NOT_FOUND,
|
||||
message: '分享链接不可用',
|
||||
} satisfies Partial<AppError>)
|
||||
|
||||
jest.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
const SHARE_ACCESS_WINDOW_MS = 60 * 1000
|
||||
const SHARE_ACCESS_MAX_PER_WINDOW = 60
|
||||
const SHARE_ACCESS_MAX_BUCKETS = 10_000
|
||||
const SHARE_ACCESS_BUCKETS = new Map<string, { count: number; windowStart: number }>()
|
||||
|
||||
export interface ShareAccessRateLimitInput {
|
||||
token: string
|
||||
forwardedFor?: string | null
|
||||
realIp?: string | null
|
||||
userAgent?: string | null
|
||||
}
|
||||
|
||||
export interface ShareAccessRateLimitResult {
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
function resolveClientIp(input: Pick<ShareAccessRateLimitInput, 'forwardedFor' | 'realIp'>): string {
|
||||
return input.forwardedFor?.split(',')[0]?.trim() || input.realIp?.trim() || 'unknown'
|
||||
}
|
||||
|
||||
function resolveUserAgent(userAgent?: string | null): string {
|
||||
return userAgent?.slice(0, 80) || 'unknown'
|
||||
}
|
||||
|
||||
function pruneExpired(now: number): void {
|
||||
for (const [key, value] of SHARE_ACCESS_BUCKETS.entries()) {
|
||||
if (now - value.windowStart >= SHARE_ACCESS_WINDOW_MS) {
|
||||
SHARE_ACCESS_BUCKETS.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function checkShareAccessRateLimit(input: ShareAccessRateLimitInput): ShareAccessRateLimitResult {
|
||||
const now = Date.now()
|
||||
pruneExpired(now)
|
||||
|
||||
const key = `${input.token}:${resolveClientIp(input)}:${resolveUserAgent(input.userAgent)}`
|
||||
const current = SHARE_ACCESS_BUCKETS.get(key)
|
||||
|
||||
if (!current || now - current.windowStart >= SHARE_ACCESS_WINDOW_MS) {
|
||||
if (SHARE_ACCESS_BUCKETS.size >= SHARE_ACCESS_MAX_BUCKETS) {
|
||||
const oldestKey = SHARE_ACCESS_BUCKETS.keys().next().value
|
||||
if (oldestKey) {
|
||||
SHARE_ACCESS_BUCKETS.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
SHARE_ACCESS_BUCKETS.set(key, { count: 1, windowStart: now })
|
||||
return { limited: false }
|
||||
}
|
||||
|
||||
if (current.count >= SHARE_ACCESS_MAX_PER_WINDOW) {
|
||||
return { limited: true }
|
||||
}
|
||||
|
||||
current.count += 1
|
||||
SHARE_ACCESS_BUCKETS.set(key, current)
|
||||
return { limited: false }
|
||||
}
|
||||
@ -25,12 +25,57 @@ import type { Prisma } from '@prisma/client'
|
||||
// ============ 常量配置 ============
|
||||
|
||||
const SHARE_CONFIG = {
|
||||
TOKEN_LENGTH: 16,
|
||||
TOKEN_LENGTH: 24,
|
||||
MIN_EXPIRES_DAYS: 1,
|
||||
MAX_EXPIRES_DAYS: 365,
|
||||
DEFAULT_EXPIRES_DAYS: 30,
|
||||
}
|
||||
|
||||
type PublicVector3 = [number, number, number]
|
||||
|
||||
interface PublicTransform {
|
||||
position?: PublicVector3
|
||||
rotation?: PublicVector3
|
||||
scale?: PublicVector3
|
||||
}
|
||||
|
||||
export interface PublicShareDetailDTO {
|
||||
shareLink: {
|
||||
token: string
|
||||
}
|
||||
currentSkuId: string | null
|
||||
product: {
|
||||
name: string
|
||||
modelNumber: string
|
||||
description: string | null
|
||||
modelUrl: string | null
|
||||
thumbnailUrl: string | null
|
||||
skus: Array<{
|
||||
id: string
|
||||
skuCode: string
|
||||
price: number
|
||||
attributes: Record<string, string> | null
|
||||
}>
|
||||
assemblySteps: Array<{
|
||||
order: number
|
||||
title: string
|
||||
description: string | null
|
||||
partIds: string[]
|
||||
transform: PublicTransform
|
||||
duration: number
|
||||
easing: string
|
||||
}>
|
||||
panels: Array<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
length: number
|
||||
width: number
|
||||
thickness: number
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
export interface CreateShareLinkInput {
|
||||
@ -128,25 +173,31 @@ class ShareService {
|
||||
*
|
||||
* @security 2026-02-14 - 使用字段白名单,不暴露敏感数据(RISK-008)
|
||||
*/
|
||||
async getShareDetail(token: string, requestedSkuId?: string | null) {
|
||||
async getShareDetail(token: string, requestedSkuId?: string | null): Promise<PublicShareDetailDTO> {
|
||||
const link = await shareRepository.findPublicDetailByToken(token)
|
||||
|
||||
if (!link) {
|
||||
throw new AppError(ErrorCode.FILE_NOT_FOUND, '分享链接不存在')
|
||||
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
|
||||
throw new AppError(ErrorCode.AUTH_TOKEN_EXPIRED, '分享链接已过期')
|
||||
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
|
||||
}
|
||||
|
||||
// 检查产品是否已删除
|
||||
if (link.product.deletedAt) {
|
||||
throw new AppError(ErrorCode.PRODUCT_NOT_FOUND, '产品已被删除')
|
||||
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
|
||||
}
|
||||
|
||||
const normalizedRequestedSkuId = this.normalizeSkuId(requestedSkuId)
|
||||
const currentSku = this.resolveCurrentSku(link.product.skus, normalizedRequestedSkuId)
|
||||
const publicSkus = link.product.skus
|
||||
.filter((sku) => sku.isActive)
|
||||
.map((sku) => this.toPublicSku(sku))
|
||||
const currentSku = this.resolveCurrentSku(
|
||||
link.product.skus.filter((sku) => sku.isActive),
|
||||
normalizedRequestedSkuId
|
||||
)
|
||||
const scopedSkuId = currentSku?.id ?? null
|
||||
|
||||
const [assemblySteps, panels] = await Promise.all([
|
||||
@ -161,22 +212,33 @@ class ShareService {
|
||||
// 返回白名单字段(公开分享数据)
|
||||
return {
|
||||
shareLink: {
|
||||
id: link.id,
|
||||
token: link.token,
|
||||
viewCount: link.viewCount,
|
||||
createdAt: link.createdAt,
|
||||
},
|
||||
currentSkuId: scopedSkuId,
|
||||
product: {
|
||||
id: link.product.id,
|
||||
name: link.product.name,
|
||||
modelNumber: link.product.modelNumber,
|
||||
description: link.product.description,
|
||||
modelUrl: resolvedModelUrl,
|
||||
thumbnailUrl: link.product.thumbnailUrl,
|
||||
skus: link.product.skus,
|
||||
assemblySteps,
|
||||
panels,
|
||||
skus: publicSkus,
|
||||
assemblySteps: assemblySteps.map((step) => ({
|
||||
order: step.order,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
partIds: this.toPublicPartIds(step.partIds),
|
||||
transform: this.toPublicTransform(step.transform),
|
||||
duration: Number.isFinite(step.duration) ? step.duration : 1500,
|
||||
easing: this.toPublicEasing(step.easing),
|
||||
})),
|
||||
panels: panels.map((panel) => ({
|
||||
id: panel.id,
|
||||
code: panel.code,
|
||||
name: panel.name,
|
||||
length: panel.length,
|
||||
width: panel.width,
|
||||
thickness: panel.thickness,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -390,6 +452,83 @@ class ShareService {
|
||||
return skus.find((sku) => sku.id === normalizedSkuId) ?? null
|
||||
}
|
||||
|
||||
private toPublicSku(sku: {
|
||||
id: string
|
||||
skuCode: string
|
||||
price: unknown
|
||||
attributes: Prisma.JsonValue
|
||||
}) {
|
||||
return {
|
||||
id: sku.id,
|
||||
skuCode: sku.skuCode,
|
||||
price: Number(sku.price),
|
||||
attributes: this.toPublicAttributes(sku.attributes),
|
||||
}
|
||||
}
|
||||
|
||||
private toPublicAttributes(attributes: Prisma.JsonValue): Record<string, string> | null {
|
||||
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const publicEntries = Object.entries(attributes)
|
||||
.filter(([key, value]) => !key.startsWith('_') && typeof value === 'string')
|
||||
.map(([key, value]) => [key, (value as string).trim()] as const)
|
||||
.filter(([, value]) => value.length > 0)
|
||||
|
||||
if (publicEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Object.fromEntries(publicEntries)
|
||||
}
|
||||
|
||||
private toPublicPartIds(partIds: string[]): string[] {
|
||||
return partIds
|
||||
.filter((partId) => typeof partId === 'string')
|
||||
.map((partId) => partId.trim())
|
||||
.filter((partId) => partId.length > 0 && partId.length <= 256)
|
||||
}
|
||||
|
||||
private toPublicTransform(transform: Prisma.JsonValue): PublicTransform {
|
||||
if (!transform || typeof transform !== 'object' || Array.isArray(transform)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const record = transform as Record<string, unknown>
|
||||
const position = this.toPublicVector3(record.position)
|
||||
const rotation = this.toPublicVector3(record.rotation)
|
||||
const scale = this.toPublicVector3(record.scale)
|
||||
|
||||
return {
|
||||
...(position ? { position } : {}),
|
||||
...(rotation ? { rotation } : {}),
|
||||
...(scale ? { scale } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
private toPublicVector3(value: unknown): PublicVector3 | null {
|
||||
if (!Array.isArray(value) || value.length !== 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
const vector = value.map((item) => Number(item))
|
||||
if (!vector.every(Number.isFinite)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [vector[0], vector[1], vector[2]]
|
||||
}
|
||||
|
||||
private toPublicEasing(easing: string): string {
|
||||
const normalized = easing.trim()
|
||||
if (normalized.length === 0 || normalized.length > 64 || !/^[a-zA-Z0-9.()_-]+$/.test(normalized)) {
|
||||
return 'power2.inOut'
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private async loadScopedAssemblySteps(productId: string, skuId: string | null) {
|
||||
if (skuId) {
|
||||
return assemblyStepRepository.findByProductId(productId, skuId)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user