feat: 切换对象读取到 CDN 直连签名跳转
This commit is contained in:
parent
7a3fd5c42f
commit
768b988843
@ -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` 就当回滚完成。
|
||||
- 如果后续要回退,优先按“运行代码版本 + 共享环境变量”成组回退,避免出现“旧代码配新域名”或“新代码配旧口径”的半回滚状态。
|
||||
@ -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 资源加载可能失败。
|
||||
|
||||
## 后续迁移规则
|
||||
|
||||
@ -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. 与备份/运维的衔接
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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>)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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: '/**',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user