diff --git a/furniture-3d-system/src/app/api/share/[token]/route.ts b/furniture-3d-system/src/app/api/share/[token]/route.ts
index 99988a97..52d2a264 100644
--- a/furniture-3d-system/src/app/api/share/[token]/route.ts
+++ b/furniture-3d-system/src/app/api/share/[token]/route.ts
@@ -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) => {
diff --git a/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx b/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx
index 84063e60..0b0fd5c8 100644
--- a/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx
+++ b/furniture-3d-system/src/app/share/[token]/SharePageClient.tsx
@@ -11,7 +11,6 @@ import gsap from 'gsap'
// 产品公开信息(不包含敏感数据)
interface ShareProduct {
- id: string
name: string
modelNumber: string
description?: string | null
diff --git a/furniture-3d-system/src/app/share/[token]/page.tsx b/furniture-3d-system/src/app/share/[token]/page.tsx
index 38560a02..3b1b5681 100644
--- a/furniture-3d-system/src/app/share/[token]/page.tsx
+++ b/furniture-3d-system/src/app/share/[token]/page.tsx
@@ -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 (
-
-
-
-
链接已过期
-
此分享链接已过期,请联系分享者获取新链接
-
-
- )
-}
-
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>
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 (
| 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}
/>
diff --git a/furniture-3d-system/src/server/repositories/share.repository.ts b/furniture-3d-system/src/server/repositories/share.repository.ts
index 10bbd04c..83bc2d1e 100644
--- a/furniture-3d-system/src/server/repositories/share.repository.ts
+++ b/furniture-3d-system/src/server/repositories/share.repository.ts
@@ -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,
},
},
},
diff --git a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts
index e9c74738..58440b37 100644
--- a/furniture-3d-system/src/server/services/__tests__/share.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/share.service.test.ts
@@ -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)
+
+ jest.useRealTimers()
+ })
})
diff --git a/furniture-3d-system/src/server/services/share-rate-limit.service.ts b/furniture-3d-system/src/server/services/share-rate-limit.service.ts
new file mode 100644
index 00000000..0f848fbc
--- /dev/null
+++ b/furniture-3d-system/src/server/services/share-rate-limit.service.ts
@@ -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()
+
+export interface ShareAccessRateLimitInput {
+ token: string
+ forwardedFor?: string | null
+ realIp?: string | null
+ userAgent?: string | null
+}
+
+export interface ShareAccessRateLimitResult {
+ limited: boolean
+}
+
+function resolveClientIp(input: Pick): 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 }
+}
diff --git a/furniture-3d-system/src/server/services/share.service.ts b/furniture-3d-system/src/server/services/share.service.ts
index 4399c075..dca5e441 100644
--- a/furniture-3d-system/src/server/services/share.service.ts
+++ b/furniture-3d-system/src/server/services/share.service.ts
@@ -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 | 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 {
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 | 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
+ 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)