加固产品分享链接公开访问

This commit is contained in:
WuYongJie 2026-05-24 21:21:16 +08:00
parent ca81d44421
commit 491c2a24ac
7 changed files with 366 additions and 70 deletions

View File

@ -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) => {

View File

@ -11,7 +11,6 @@ import gsap from 'gsap'
// 产品公开信息(不包含敏感数据)
interface ShareProduct {
id: string
name: string
modelNumber: string
description?: string | null

View File

@ -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}
/>

View File

@ -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,
},
},
},

View File

@ -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()
})
})

View File

@ -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 }
}

View File

@ -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)