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)