feat: 切换对象读取到 CDN 直连签名跳转

This commit is contained in:
WuYongJie 2026-06-06 21:42:53 +08:00
parent 7a3fd5c42f
commit 768b988843
14 changed files with 519 additions and 79 deletions

View File

@ -0,0 +1,62 @@
# CDN 直连上线交接(2026-06-06)
适用范围:`furniture-3d-system` 正式环境对象读取链路。
## 1. 当前结论
- 七牛 CDN 直连方案已在正式环境落地。
- 业务系统继续保留系统内稳定素材入口,不把七牛域名、bucket 或签名 URL 写死到业务页面和数据库字段里。
- 正式环境的对象读取链路已经从“应用服务器代理素材响应体”切到“应用鉴权后 `307` 跳转到七牛短时签名 CDN 地址”。
## 2. 当前正式环境口径
- 正式站点:`https://cp.jiansun.vip`
- 七牛空间:`sucaiku-js`
- 正式 CDN 域名:`https://qnycdn.jiansun.vip`
- 生产环境变量口径:`QINIU_PUBLIC_DOMAIN=https://qnycdn.jiansun.vip`
- 素材真相口径:
- 数据库继续保存系统稳定地址或对象 key
- 登录态素材稳定入口:`/api/storage/object?key=...`
- 分享态素材稳定入口:`/api/share/[token]/object?key=...`
- 服务端按当前 provider 生成短时签名外链,浏览器最终直连 CDN
## 3. 这次已确认的内容
- 代码侧已具备正式直连能力:
- `src/lib/storage.ts` 已明确区分稳定客户端入口和服务端直读地址
- `src/lib/object-storage-read-response.ts` 已支持 `HTTPS -> 307`
- `src/app/api/share/[token]/object/route.ts` 已存在
- `next.config.js` 已放行正式 CDN 域名
- 生产配置已切到正式 CDN 域名:`QINIU_PUBLIC_DOMAIN=https://qnycdn.jiansun.vip`
- 生产侧配置只读校验已通过:`npm run storage:verify-config`
- 真实业务侧抽验已完成:
- 页面:`https://cp.jiansun.vip/asset-center/material-library`
- 页面内真实纹理素材使用系统稳定入口 `/api/storage/object?...`
- 对该真实业务素材地址做校验时,服务端返回 `HTTP 307`
- `Location` 最终落到 `https://qnycdn.jiansun.vip/...`
## 4. 这次没走的路
- 审批访问页不是匿名链路,页面和接口都要求登录,不能拿它当公开验收入口。
- 当时生产里没有现成可直接拿来做公开分享验收的正式素材,所以分享页没有完成页面级抽验。
- 本次“真实业务侧”验收最终选用的是已登录正式环境中的资料页,而不是临时造生产数据。
## 5. 后续建议补的检查
1. 再抽验 `产品详情 / SKU 详情 / 3D 模型页`,确认模型和贴图都走 `307 -> qnycdn.jiansun.vip`。
2. 条件允许时补一轮 `分享页` 页面级验收,确认公开链路也落到正式 CDN。
3. 观察一到两天生产监控,重点看应用服务器出口流量、页面加载耗时、七牛命中率和回源量。
4. 后续任何人如果更换 bucket、CDN 域名或对象存储 provider,只改环境变量和 `src/lib/storage.ts` 的 storage adapter,不改业务页链接逻辑。
## 6. 回归检查清单
- `GET /api/storage/object?key=...` 对浏览器可直连资源仍返回 `307`
- `Location` 仍指向正式 CDN 域名,而不是旧测试域名或应用服务器自身
- `next.config.js` 与 CSP 仍放行 `https://qnycdn.jiansun.vip`
- 数据库里没有把短时签名 URL 当长期字段保存
- 分享页和审批页没有在前端直接拼七牛域名
## 7. 回滚提醒
- 不能只改 `QINIU_PUBLIC_DOMAIN` 就当回滚完成。
- 如果后续要回退,优先按“运行代码版本 + 共享环境变量”成组回退,避免出现“旧代码配新域名”或“新代码配旧口径”的半回滚状态。

View File

@ -14,6 +14,13 @@
统一入口是 `src/lib/storage.ts`。
当前读取真相已明确拆成两层:
- 稳定客户端入口:数据库和 DTO 只保存系统稳定地址,例如登录态 `/api/storage/object?key=...`。
- 服务端直读地址:由 storage adapter 按当前 provider 生成临时可访问地址,例如签名 CDN URL。
业务页面、服务层和 DTO 只依赖“稳定入口”,不写死 bucket、CDN 域名或七牛 SDK 细节。
当前支持:
- `STORAGE_PROVIDER=s3`:MinIO / S3 兼容对象存储。
@ -60,6 +67,14 @@ HTTP 测试域名只适合临时验证。素材需要保密时,正式环境应
- 七牛 HTTP 测试域名:系统稳定读取接口不把 HTTP 签名 URL 直接暴露给浏览器,而是由后端代理读取并返回,避免 HTTPS 页面出现混合内容拦截。
- 后端代理只解决浏览器侧混合内容和签名 URL 暴露问题;HTTP 测试域名在七牛到应用服务器这一段仍不是加密传输,不能作为正式保密链路长期使用。
当前稳定读取入口分三类:
- 登录态素材:`GET /api/storage/object?key=...`,先走应用鉴权,再 `307` 到短时签名 CDN URL。
- 分享态素材:`GET /api/share/[token]/object?key=...&sku=...`,先校验分享 token 和对象范围,再 `307` 到短时签名 CDN URL。
- 审批态素材:审批 DTO 继续输出稳定系统入口,最终仍通过登录态读取链路完成鉴权和 `307` 跳转。
换 bucket、换 CDN 域名、甚至未来换 provider 时,只允许改环境变量和 `src/lib/storage.ts` 里的 storage adapter。业务页、分享页、审批页和服务层不应该重写链接拼接逻辑。
七牛侧如果保持“无自定义跨域规则”,空间默认允许跨域访问;如果后续新增自定义 CORS 规则,必须允许本系统正式域名和本地调试域名读取模型、贴图和缩略图,并暴露 `Content-Range`、`Accept-Ranges` 等范围请求相关响应头,否则浏览器中的 3D/WebGL 资源加载可能失败。
## 后续迁移规则

View File

@ -111,7 +111,7 @@ STORAGE_PROVIDER=qiniu
QINIU_ACCESS_KEY=<只放在服务器环境变量里>
QINIU_SECRET_KEY=<只放在服务器环境变量里>
QINIU_BUCKET=<七牛空间名称>
QINIU_PUBLIC_DOMAIN=https://<七牛绑定域名>
QINIU_PUBLIC_DOMAIN=https://<七牛 CDN 域名>
QINIU_FILE_PATH=prod
QINIU_USE_HTTPS=true
QINIU_PRIVATE_BUCKET=true
@ -126,6 +126,12 @@ QINIU_PRIVATE_URL_EXPIRES_SECONDS=3600
- HTTP 测试域名只适合临时验证。素材需要保密时,正式环境应使用已绑定证书的 HTTPS CDN 域名,避免浏览器混合内容拦截和传输层明文风险。
- 系统稳定读取接口会自动处理私有桶读取:HTTPS 正式域名走 307 签名跳转;HTTP 测试域名由后端代理读取,避免浏览器直接访问 HTTP 签名 URL。
- 私有空间模式下,数据库保存系统内稳定访问地址,由后端按请求生成七牛临时签名 URL,不能把短期签名 URL 当作长期资料字段保存。
- 当前稳定入口约定:
- 登录态素材走 `/api/storage/object?key=...`
- 分享态素材走 `/api/share/[token]/object?key=...`
- 审批态素材继续输出系统稳定入口,再复用登录态鉴权读取链路
- 新增写入链路默认也应回写系统稳定入口,而不是把七牛外链或短时签名 URL 直接落库。
- 后续如果更换 bucket、CDN 域名或对象存储 provider,只改环境变量和 storage adapter,不改业务页链接逻辑。
- 七牛侧如果保持“无自定义跨域规则”,空间默认允许跨域访问;如果后续新增自定义 CORS 规则,必须至少包含:
- `Allowed Origins`:正式域名 `https://cp.jiansun.vip`,本地调试 `http://localhost:3000`,必要时加生产内网调试地址。
- `Allowed Methods`:`GET`、`HEAD`、`OPTIONS`。
@ -135,6 +141,14 @@ QINIU_PRIVATE_URL_EXPIRES_SECONDS=3600
- 生产服务器填完对象存储环境变量后,先运行 `npm run storage:verify-config` 做只读配置校验。该命令不会上传文件,也不会打印 AK/SK 或签名 token。
- 原有函数名 `uploadToS3`、`uploadBufferToS3` 仍保留为兼容入口,但内部已按 `STORAGE_PROVIDER` 分发到统一对象存储。
当前正式环境口径(2026-06-06):
- 七牛空间:`sucaiku-js`
- 正式 CDN 域名:`https://qnycdn.jiansun.vip`
- 正式环境对象读取:业务页继续使用系统稳定入口,服务端鉴权后统一 `307` 跳转到七牛短时签名 CDN 地址
- 数据库存储口径不变:继续保存系统稳定地址或对象 key,不把短时签名 URL 入库
- 若后续回滚,不要只改 `QINIU_PUBLIC_DOMAIN`;应把运行代码版本和对象存储配置作为一组一起回退
### 4.3 启动基础服务(数据库 / Redis / MinIO)
在 `furniture-3d-system` 目录下执行:
@ -245,6 +259,12 @@ npm run dev
4. **系统健康页面**
- 访问 `/system-health`,检查是否能正常渲染,且不会抛出前端错误
5. **对象存储直连验收**
- 登录态页面、分享页、审批页中的图片、贴图、模型和缩略图都能正常显示
- 浏览器网络面板里,大部分最终素材请求应落到正式 CDN 域名,而不是持续由 `cp.jiansun.vip` 回流响应体
- 如正式环境已启用 `QINIU_PUBLIC_DOMAIN=https://<cdn-domain>`,应确认 `307 -> signed CDN URL` 工作正常
- 2026-06-06 已在正式环境 `https://cp.jiansun.vip/asset-center/material-library` 做过一次真实业务侧抽验:页面纹理稳定入口指向 `/api/storage/object?...`,服务端响应为 `307`,最终 `Location` 落到 `https://qnycdn.jiansun.vip/...`
---
## 7. 与备份/运维的衔接

View File

@ -118,7 +118,10 @@ function buildRemotePatternFromUrl(rawUrl) {
}
try {
const parsed = new URL(normalized)
const withProtocol = /^https?:\/\//i.test(normalized)
? normalized
: `https://${normalized}`
const parsed = new URL(withProtocol)
const pathname = parsed.pathname.replace(/\/+$/, '')
return {
protocol: parsed.protocol.replace(':', ''),
@ -156,6 +159,9 @@ for (const candidate of [
process.env.S3_PUBLIC_ENDPOINT,
process.env.S3_PUBLIC_URL,
process.env.S3_ENDPOINT,
process.env.QINIU_PUBLIC_DOMAIN,
process.env.QINIU_PUBLIC_ENDPOINT,
process.env.QINIU_PUBLIC_URL,
]) {
const pattern = buildRemotePatternFromUrl(candidate)
if (!pattern) {

View File

@ -0,0 +1,97 @@
export {}
import { NextRequest } from 'next/server'
const mockHandleRequest: any = jest.fn()
const mockJsonError: any = jest.fn()
const mockCreateObjectReadUrl: any = jest.fn()
const mockNormalizeObjectStorageKey: any = jest.fn()
const mockAssertCanAccessObjectByToken: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
jest.mock('@/lib/storage', () => ({
createObjectReadUrl: (...args: unknown[]) => mockCreateObjectReadUrl(...args),
normalizeObjectStorageKey: (...args: unknown[]) => mockNormalizeObjectStorageKey(...args),
}))
jest.mock('@/server/services', () => ({
shareService: {
assertCanAccessObjectByToken: (...args: unknown[]) => mockAssertCanAccessObjectByToken(...args),
},
}))
const { GET } = require('./route')
function getHeader(response: Response, name: string): string | null {
return response.headers.get(name) ?? response.headers.get(name.toLowerCase())
}
describe('GET /api/share/[token]/object', () => {
beforeEach(() => {
jest.clearAllMocks()
mockHandleRequest.mockImplementation(
async (request: NextRequest, handler: any) =>
handler({
request,
requestId: 'req-share-object',
user: null,
}),
)
mockJsonError.mockImplementation((code, message, status, requestId) => ({
success: false,
code,
message,
status,
requestId,
}))
mockNormalizeObjectStorageKey.mockImplementation((key: string) => key.trim())
mockCreateObjectReadUrl.mockImplementation(
(key: string) => `https://cdn.example.com/${key}?token=signed`,
)
mockAssertCanAccessObjectByToken.mockResolvedValue(undefined)
})
it('redirects a valid shared object request to signed CDN url', async () => {
const request = {
nextUrl: new URL('http://localhost/api/share/token-1/object?key=prod%2Fmodels%2Ftable.glb&sku=sku-1'),
headers: { get: jest.fn(() => null) },
} as unknown as NextRequest
const response = await GET(request, { params: Promise.resolve({ token: 'token-1' }) })
expect(mockAssertCanAccessObjectByToken).toHaveBeenCalledWith(
'token-1',
'prod/models/table.glb',
'sku-1',
)
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith('prod/models/table.glb')
expect(response.status).toBe(307)
expect(getHeader(response, 'location')).toBe(
'https://cdn.example.com/prod/models/table.glb?token=signed',
)
})
it('rejects invalid keys', async () => {
mockNormalizeObjectStorageKey.mockImplementation(() => {
throw new Error('bad key')
})
const request = {
nextUrl: new URL('http://localhost/api/share/token-1/object?key=https://evil.test/a.glb'),
headers: { get: jest.fn(() => null) },
} as unknown as NextRequest
const response = await GET(request, { params: Promise.resolve({ token: 'token-1' }) })
expect(mockCreateObjectReadUrl).not.toHaveBeenCalled()
expect(response).toEqual(
expect.objectContaining({
success: false,
status: 400,
}),
)
})
})

View File

@ -0,0 +1,70 @@
import { NextRequest } from 'next/server'
import { AppError, ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { buildObjectReadResponse, ObjectReadProxyError } from '@/lib/object-storage-read-response'
import { createObjectReadUrl, normalizeObjectStorageKey } from '@/lib/storage'
import { shareService } from '@/server/services'
function normalizeRequestKey(value: string | null): string | null {
const key = value?.trim()
if (!key) return null
if (key.includes('..') || key.includes('\\')) return null
if (/^(https?:|data:|file:|\/\/)/i.test(key)) return null
try {
return normalizeObjectStorageKey(key)
} catch {
return null
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const buildReadResponse = async (key: string, request: NextRequest) =>
(await buildObjectReadResponse(createObjectReadUrl(key), request)) as never
return handleRequest(
request,
async (ctx) => {
const { token } = await params
const key = normalizeRequestKey(request.nextUrl.searchParams.get('key'))
const skuId = request.nextUrl.searchParams.get('sku')
if (!token) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'Token 不能为空', 400, ctx.requestId)
}
if (!key) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '缺少有效的对象 key', 400, ctx.requestId)
}
try {
await shareService.assertCanAccessObjectByToken(token, key, skuId)
} catch (error) {
if (error instanceof AppError) {
return jsonError(error.code, error.message, error.statusCode, ctx.requestId)
}
throw error
}
try {
return await buildReadResponse(key, request)
} catch (error) {
if (error instanceof ObjectReadProxyError && error.statusCode === 404) {
return jsonError(ErrorCode.FILE_NOT_FOUND, '对象不存在', 404, ctx.requestId)
}
if (error instanceof ObjectReadProxyError) {
return jsonError(
ErrorCode.SYSTEM_STORAGE,
'对象存储读取失败',
502,
ctx.requestId,
)
}
throw error
}
},
{ requireAuth: false },
)
}

View File

@ -66,22 +66,7 @@ describe('GET /api/storage/object', () => {
mockAssertCanDownload.mockResolvedValue(undefined)
})
it('proxies a signed HTTPS storage URL instead of redirecting the browser cross-origin', async () => {
const upstreamBody = new Response('obj-bytes').body
mockFetch.mockResolvedValue({
ok: true,
status: 200,
body: upstreamBody,
headers: {
get: jest.fn((name: string) => {
const headers: Record<string, string> = {
'content-type': 'model/obj',
'content-length': '9',
}
return headers[name.toLowerCase()] ?? null
}),
},
})
it('redirects a signed HTTPS storage URL to the browser directly', async () => {
const request = {
nextUrl: new URL('http://localhost/api/storage/object?key=models/table.obj'),
headers: { get: jest.fn(() => null) },
@ -95,16 +80,12 @@ describe('GET /api/storage/object', () => {
{ requireAuth: true },
)
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith('models/table.obj')
expect(mockFetch).toHaveBeenCalledWith(
expect(mockFetch).not.toHaveBeenCalled()
expect(response.status).toBe(307)
expect(getResponseHeader(response, 'location')).toBe(
'https://cdn.example.com/models/table.obj?token=signed',
expect.objectContaining({
cache: 'no-store',
}),
)
expect(response.status).toBe(200)
expect(getResponseHeader(response, 'content-type')).toBe('model/obj')
expect(getResponseHeader(response, 'content-length')).toBe('9')
await expect(response.text()).resolves.toBe('obj-bytes')
expect(getResponseHeader(response, 'cache-control')).toBe('private, no-store')
})
it('proxies a signed HTTP storage URL instead of redirecting the browser to HTTP', async () => {
@ -172,20 +153,6 @@ describe('GET /api/storage/object', () => {
it('keeps scoped download permission for migrated non-integrated assets', async () => {
mockGetStorageFilePathPrefix.mockReturnValue('prod/')
const upstreamBody = new Response('pdf-bytes').body
mockFetch.mockResolvedValue({
ok: true,
status: 200,
body: upstreamBody,
headers: {
get: jest.fn((name: string) => {
const headers: Record<string, string> = {
'content-type': 'application/pdf',
}
return headers[name.toLowerCase()] ?? null
}),
},
})
const request = {
nextUrl: new URL(
'http://localhost/api/storage/object?key=prod%2Fnon-integrated-assets%2FSZ0230%2F%E4%BA%A7%E5%93%81%E6%A8%A1%E5%9E%8B%2Ffile.pdf',
@ -203,6 +170,10 @@ describe('GET /api/storage/object', () => {
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith(
'prod/non-integrated-assets/SZ0230/产品模型/file.pdf',
)
expect(response.status).toBe(200)
expect(mockFetch).not.toHaveBeenCalled()
expect(response.status).toBe(307)
expect(getResponseHeader(response, 'location')).toBe(
'https://cdn.example.com/prod/non-integrated-assets/SZ0230/产品模型/file.pdf?token=signed',
)
})
})

View File

@ -44,6 +44,21 @@ export async function buildObjectReadResponse(
})
}
try {
const parsed = new URL(readUrl)
if (parsed.protocol === 'https:') {
return new Response(null, {
status: 307,
headers: {
location: readUrl,
'cache-control': 'private, no-store',
},
})
}
} catch {
// Fall through to proxy mode for invalid URLs so caller gets existing behavior.
}
const upstreamHeaders: Record<string, string> = {}
const range = request?.headers.get('range')
if (range) {

View File

@ -108,6 +108,9 @@ describe('storage object provider', () => {
expect(storage.buildPublicObjectUrl('/models/table.glb')).toBe(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
)
expect(storage.buildStableObjectAccessUrl('/models/table.glb')).toBe(
'/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb',
)
expect(storage.buildPublicS3ObjectUrl('prod-assets/models/table.glb')).toBe(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
)
@ -123,6 +126,16 @@ describe('storage object provider', () => {
).toBe(
'https://threejs.org/examples/models/gltf/DamagedHelmet/glTF/DamagedHelmet.gltf',
)
expect(
storage.readObjectStorageKeyFromUrl(
'/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb',
),
).toBe('prod-assets/models/table.glb')
expect(
storage.readObjectStorageKeyFromUrl(
'https://cdn.example.com/furniture/prod-assets/models/table.glb',
),
).toBe('prod-assets/models/table.glb')
})
it('rejects unsafe object keys at the storage boundary', async () => {
@ -167,7 +180,7 @@ describe('storage object provider', () => {
'/models/table.glb',
'model/gltf-binary',
),
).resolves.toBe('https://cdn.example.com/prod-assets/models/table.glb')
).resolves.toBe('/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb')
expect(storage.getStorageProvider()).toBe('qiniu')
expect(storage.getStorageBucketName()).toBe('qiniu-bucket')
@ -207,7 +220,7 @@ describe('storage object provider', () => {
'model/gltf-binary',
),
).resolves.toEqual({
url: 'https://cdn.example.com/prod-assets/models/table.glb',
url: '/api/storage/object?key=prod-assets%2Fmodels%2Ftable.glb',
storageKey: 'prod-assets/models/table.glb',
})
})

View File

@ -120,6 +120,10 @@ function buildPrivateObjectAccessUrl(key: string): string {
return `${PRIVATE_OBJECT_ROUTE_PATH}?${params.toString()}`
}
export function buildStableObjectAccessUrl(key: string): string {
return buildPrivateObjectAccessUrl(key)
}
function readPrivateObjectAccessKey(value: string): string | null {
const normalized = trimToNull(value)
if (!normalized) return null
@ -206,6 +210,20 @@ function readPublicObjectAccessKey(value: string): string | null {
return null
}
export function readObjectStorageKeyFromUrl(value: string): string | null {
const objectKey =
readPrivateObjectAccessKey(value) ?? readPublicObjectAccessKey(value)
if (!objectKey) {
return null
}
try {
return normalizeObjectKey(objectKey)
} catch {
return null
}
}
function buildS3PublicBaseUrl(): string {
const configured =
trimToNull(process.env.S3_PUBLIC_DOMAIN) ||
@ -572,7 +590,9 @@ export async function uploadBufferToObjectStorage(
key: string,
contentType: string,
): Promise<string> {
return getStorageAdapter().uploadBuffer(buffer, key, contentType)
const storageKey = normalizeObjectKey(key)
await getStorageAdapter().uploadBuffer(buffer, storageKey, contentType)
return buildStableObjectAccessUrl(storageKey)
}
export async function uploadBufferToObjectStorageWithMetadata(
@ -581,7 +601,8 @@ export async function uploadBufferToObjectStorageWithMetadata(
contentType: string,
): Promise<{ url: string; storageKey: string }> {
const storageKey = normalizeObjectKey(key)
const url = await getStorageAdapter().uploadBuffer(buffer, storageKey, contentType)
await getStorageAdapter().uploadBuffer(buffer, storageKey, contentType)
const url = buildStableObjectAccessUrl(storageKey)
return { url, storageKey }
}
@ -590,23 +611,33 @@ export function buildPublicObjectUrl(key: string): string {
return getStorageAdapter().buildPublicUrl(key)
}
export function buildStableObjectAccessUrl(key: string): string {
return getStorageAdapter().buildPublicUrl(key)
}
export function createObjectReadUrl(key: string): string {
return getStorageAdapter().buildDirectReadUrl(key)
}
export function buildSharedObjectAccessUrl(params: {
token: string
key: string
skuId?: string | null
}): string {
const searchParams = new URLSearchParams({
key: normalizeObjectKey(params.key),
})
if (params.skuId?.trim()) {
searchParams.set('sku', params.skuId.trim())
}
return `/api/share/${encodeURIComponent(params.token)}/object?${searchParams.toString()}`
}
export function resolveObjectStorageUrlForServerAccess(url: string): string {
const objectKey = readPrivateObjectAccessKey(url)
return objectKey ? createObjectReadUrl(objectKey) : url
}
export function resolveObjectStorageUrlForClientAccess(url: string): string {
const objectKey =
readPrivateObjectAccessKey(url) ?? readPublicObjectAccessKey(url)
return objectKey ? buildPrivateObjectAccessUrl(objectKey) : url
const objectKey = readObjectStorageKeyFromUrl(url)
return objectKey ? buildStableObjectAccessUrl(objectKey) : url
}
export async function doesObjectExist(key: string): Promise<boolean> {

View File

@ -1,10 +1,19 @@
import type { AssemblyStep, Panel } from '@prisma/client'
import { buildStableObjectAccessUrl, normalizeObjectStorageKey } from '@/lib/storage'
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'
function buildSharedAssetUrl(token: string, key: string, skuId?: string | null) {
const params = new URLSearchParams({ key: normalizeObjectStorageKey(key) })
if (skuId) {
params.set('sku', skuId)
}
return `/api/share/${token}/object?${params.toString()}`
}
jest.mock('../../repositories/share.repository', () => ({
shareRepository: {
findPublicDetailByToken: jest.fn(),
@ -41,8 +50,8 @@ describe('ShareService.getShareDetail', () => {
name: '测试产品',
modelNumber: 'P-001',
description: '测试描述',
modelUrl: 'https://example.com/product.glb',
thumbnailUrl: 'https://example.com/thumb.png',
modelUrl: buildStableObjectAccessUrl('product.glb'),
thumbnailUrl: buildStableObjectAccessUrl('thumb.png'),
deletedAt: null,
skus: [
{
@ -53,7 +62,7 @@ describe('ShareService.getShareDetail', () => {
attributes: { color: 'oak', _skuCadData: { nodes: ['internal-node'] }, internalCount: 2 },
assetProfile: null,
isActive: true,
modelUrl: 'https://example.com/sku-1.glb',
modelUrl: buildStableObjectAccessUrl('sku-1.glb'),
images: [],
createdAt: new Date('2026-03-13T00:00:00.000Z'),
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
@ -138,7 +147,12 @@ describe('ShareService.getShareDetail', () => {
skuId: 'sku-1',
})
expect(result.currentSkuId).toBe('sku-1')
expect(result.product.modelUrl).toBe('https://example.com/sku-1.glb')
expect(result.product.modelUrl).toBe(
buildSharedAssetUrl('token-1', 'sku-1.glb', 'sku-1'),
)
expect(result.product.thumbnailUrl).toBe(
buildSharedAssetUrl('token-1', 'thumb.png', 'sku-1'),
)
expect(result.product.assemblySteps).toEqual([
{
order: skuLevelStep.order,
@ -194,7 +208,8 @@ describe('ShareService.getShareDetail', () => {
skuId: null,
})
expect(result.currentSkuId).toBeNull()
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
expect(result.product.modelUrl).toBe(buildSharedAssetUrl('token-1', 'product.glb'))
expect(result.product.thumbnailUrl).toBe(buildSharedAssetUrl('token-1', 'thumb.png'))
expect(result.product.assemblySteps).toHaveLength(1)
expect(result.product.panels).toHaveLength(1)
expect(result.product).not.toHaveProperty('creator')
@ -213,7 +228,7 @@ describe('ShareService.getShareDetail', () => {
skuId: null,
})
expect(result.currentSkuId).toBeNull()
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
expect(result.product.modelUrl).toBe(buildSharedAssetUrl('token-1', 'product.glb'))
expect(result.product.assemblySteps).toHaveLength(1)
expect(result.product.panels).toHaveLength(1)
})
@ -223,7 +238,7 @@ describe('ShareService.getShareDetail', () => {
...baseLink,
product: {
...baseLink.product,
modelUrl: 'https://example.com/product.glb',
modelUrl: buildStableObjectAccessUrl('product.glb'),
skus: [
{
...baseLink.product.skus[0],
@ -234,7 +249,7 @@ describe('ShareService.getShareDetail', () => {
...baseLink.product.skus[0],
id: 'sku-2',
skuCode: 'SKU-002',
modelUrl: 'https://example.com/sku-2.glb',
modelUrl: buildStableObjectAccessUrl('sku-2.glb'),
},
],
},
@ -245,7 +260,9 @@ describe('ShareService.getShareDetail', () => {
const result = await service.getShareDetail('token-1', 'sku-1')
expect(result.currentSkuId).toBe('sku-1')
expect(result.product.modelUrl).toBe('https://example.com/product.glb')
expect(result.product.modelUrl).toBe(
buildSharedAssetUrl('token-1', 'product.glb', 'sku-1'),
)
})
it('公开详情只返回展示必要字段,不泄露内部 SKU、分享统计和结构原始数据', async () => {
@ -398,4 +415,30 @@ describe('ShareService.getShareDetail', () => {
jest.useRealTimers()
})
it('分享态对象鉴权只放行当前分享已暴露的对象 key', async () => {
await expect(
service.assertCanAccessObjectByToken(
'token-1',
normalizeObjectStorageKey('sku-1.glb'),
'sku-1',
),
).resolves.toBeUndefined()
await expect(
service.assertCanAccessObjectByToken(
'token-1',
normalizeObjectStorageKey('thumb.png'),
'sku-1',
),
).resolves.toBeUndefined()
})
it('分享态对象鉴权拒绝未暴露的对象 key', async () => {
await expect(
service.assertCanAccessObjectByToken('token-1', 'hidden.glb', 'sku-1'),
).rejects.toMatchObject({
code: ErrorCode.AUTH_PERMISSION_DENIED,
message: '当前分享链接无权访问该对象',
} satisfies Partial<AppError>)
})
})

View File

@ -8,6 +8,7 @@ import {
normalizeNonIntegratedProductRecord,
type NonIntegratedProductRecord,
} from '@/app/intake/non-integrated/nonIntegratedStorage'
import { resolveObjectStorageUrlForClientAccess } from '@/lib/storage'
interface FieldMatrixItem {
key: string
@ -380,7 +381,9 @@ class ApprovalWorkflowService {
: openingReference.productImage.trim() ||
normalizedProduct?.previewImage?.trim() ||
null
const thumbnailUrl = product.profile?.productImageUrl?.trim() || legacyThumbnailUrl
const thumbnailUrl = resolveObjectStorageUrlForClientAccess(
product.profile?.productImageUrl?.trim() || legacyThumbnailUrl || '',
) || null
return {
id: product.id,

View File

@ -26,6 +26,11 @@ import {
toProductSkuAssetProfileDTO,
type ProductSkuAssetProfileDTO,
} from '../dto/product.dto'
import {
buildSharedObjectAccessUrl,
readObjectStorageKeyFromUrl,
resolveObjectStorageUrlForClientAccess,
} from '@/lib/storage'
// ============ 常量配置 ============
@ -181,21 +186,7 @@ class ShareService {
* @security 2026-02-14 - 使用字段白名单,不暴露敏感数据(RISK-008)
*/
async getShareDetail(token: string, requestedSkuId?: string | null): Promise<PublicShareDetailDTO> {
const link = await shareRepository.findPublicDetailByToken(token)
if (!link) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
// 检查是否过期
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
// 检查产品是否已删除
if (link.product.deletedAt) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
const link = await this.getAvailableShareLinkOrThrow(token)
const normalizedRequestedSkuId = this.normalizeSkuId(requestedSkuId)
const publicSkus = link.product.skus
@ -226,8 +217,8 @@ class ShareService {
name: link.product.name,
modelNumber: link.product.modelNumber,
description: link.product.description,
modelUrl: resolvedModelUrl,
thumbnailUrl: link.product.thumbnailUrl,
modelUrl: this.toPublicSharedAssetUrl(token, resolvedModelUrl, scopedSkuId),
thumbnailUrl: this.toPublicSharedAssetUrl(token, link.product.thumbnailUrl, scopedSkuId),
skus: publicSkus,
assemblySteps: assemblySteps.map((step) => ({
order: step.order,
@ -250,6 +241,18 @@ class ShareService {
}
}
async assertCanAccessObjectByToken(
token: string,
key: string,
requestedSkuId?: string | null,
): Promise<void> {
const link = await this.getAvailableShareLinkOrThrow(token)
const allowedKeys = this.collectAllowedObjectKeys(link, requestedSkuId)
if (!allowedKeys.has(key)) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前分享链接无权访问该对象')
}
}
/**
* 创建分享链接
*/
@ -445,6 +448,54 @@ class ShareService {
return normalized.length > 0 ? normalized : null
}
private async getAvailableShareLinkOrThrow(token: string) {
const link = await shareRepository.findPublicDetailByToken(token)
if (!link) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
if (link.product.deletedAt) {
throw new AppError(ErrorCode.SHARE_LINK_NOT_FOUND, '分享链接不可用')
}
return link
}
private collectAllowedObjectKeys(
link: NonNullable<Awaited<ReturnType<typeof shareRepository.findPublicDetailByToken>>>,
requestedSkuId?: string | null,
): Set<string> {
const normalizedRequestedSkuId = this.normalizeSkuId(requestedSkuId)
const activeSkus = link.product.skus.filter((sku) => sku.isActive)
const currentSku = this.resolveCurrentSku(activeSkus, normalizedRequestedSkuId)
const allowedKeys = new Set<string>()
const collect = (url: string | null | undefined) => {
if (typeof url !== 'string') return
const objectKey = readObjectStorageKeyFromUrl(url)
if (objectKey) {
allowedKeys.add(objectKey)
}
}
collect(link.product.thumbnailUrl)
if (currentSku) {
collect(currentSku.modelUrl)
if (!currentSku.modelUrl) {
collect(link.product.modelUrl)
}
return allowedKeys
}
collect(link.product.modelUrl)
return allowedKeys
}
private resolveCurrentSku<
TSku extends {
id: string
@ -477,6 +528,28 @@ class ShareService {
}
}
private toPublicSharedAssetUrl(
token: string,
rawUrl: string | null | undefined,
skuId: string | null,
): string | null {
if (typeof rawUrl !== 'string') return null
const normalized = rawUrl.trim()
if (!normalized) return null
const stableUrl = resolveObjectStorageUrlForClientAccess(normalized)
const objectKey = readObjectStorageKeyFromUrl(stableUrl)
if (!objectKey) {
return normalized
}
return buildSharedObjectAccessUrl({
token,
key: objectKey,
skuId,
})
}
private toPublicLegacyAttributes(attributes: Prisma.JsonValue): Record<string, string> | null {
return toLegacySkuDisplayAttributes(attributes)
}

View File

@ -21,6 +21,10 @@ describe('next.config security headers', () => {
return rootHeaders.find((header) => header.key === 'Content-Security-Policy')?.value ?? ''
}
function readRemotePatterns(config) {
return config.images?.remotePatterns ?? []
}
it('allows configured object storage origins for 3D model fetches', async () => {
const config = loadConfigWithEnv({
NODE_ENV: 'production',
@ -37,4 +41,21 @@ describe('next.config security headers', () => {
expect(csp).toContain('https://s3.jiansun.vip')
expect(csp).toContain('http://tevepd6od.hd-bkt.clouddn.com')
})
it('accepts storage image domains configured without protocol', () => {
const config = loadConfigWithEnv({
NODE_ENV: 'production',
QINIU_PUBLIC_DOMAIN: 'qnycdn.jiansun.vip',
})
expect(readRemotePatterns(config)).toEqual(
expect.arrayContaining([
expect.objectContaining({
protocol: 'https',
hostname: 'qnycdn.jiansun.vip',
pathname: '/**',
}),
]),
)
})
})