feat(storage): 支持七牛历史素材迁移
This commit is contained in:
parent
2ca459e027
commit
79106b69da
1
furniture-3d-system/.gitignore
vendored
1
furniture-3d-system/.gitignore
vendored
@ -44,6 +44,7 @@ next-env.d.ts
|
||||
/src/generated/prisma
|
||||
.database-backups/
|
||||
.master-data-audits/
|
||||
.storage-migration-audits/
|
||||
.master-data-cleanup/
|
||||
|
||||
# local workspace artifacts
|
||||
|
||||
@ -26,6 +26,8 @@
|
||||
"open-platform:seed": "tsx scripts/seed-open-platform-demo.ts",
|
||||
"type-check": "node scripts/run-type-check.js",
|
||||
"storage:verify-config": "tsx scripts/verify-object-storage-config.ts",
|
||||
"storage:migrate-historical-assets": "tsx scripts/migrate-historical-assets-to-qiniu.ts",
|
||||
"storage:migrate-historical-assets:apply": "tsx scripts/migrate-historical-assets-to-qiniu.ts --apply",
|
||||
"demo:create": "node scripts/create-demo-data.js",
|
||||
"demo:setup": "npm run prisma:push && npm run demo:create",
|
||||
"demo:update": "node scripts/update-demo-models.js",
|
||||
|
||||
1115
furniture-3d-system/scripts/migrate-historical-assets-to-qiniu.ts
Normal file
1115
furniture-3d-system/scripts/migrate-historical-assets-to-qiniu.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@ import { NextRequest } from "next/server";
|
||||
import {
|
||||
buildNonIntegratedDocumentPreviewUrl,
|
||||
getNonIntegratedAssetDirectory,
|
||||
resolveNonIntegratedAssetFilePath,
|
||||
materializeNonIntegratedAssetFile,
|
||||
} from "@/app/intake/non-integrated/nonIntegratedAssetServer";
|
||||
import { NON_INTEGRATED_DOCUMENT_PREVIEW_DIRECTORY } from "@/app/intake/non-integrated/nonIntegratedAssetUrl";
|
||||
import { AppError, ErrorCode } from "@/server/core/errors";
|
||||
@ -286,37 +286,47 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const sourceFilePath = resolveNonIntegratedAssetFilePath(selectedFileUrl);
|
||||
|
||||
if (!sourceFilePath || !existsSync(sourceFilePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, "The document file to preview was not found.", 404, requestId);
|
||||
}
|
||||
|
||||
const extension = path.extname(sourceFilePath).toLowerCase();
|
||||
if (!DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) {
|
||||
return jsonError(
|
||||
ErrorCode.FILE_TYPE_NOT_ALLOWED,
|
||||
"Only PDF, PPT, or PPTX files can be converted to image previews.",
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath);
|
||||
const cacheKey = createHash("sha1")
|
||||
.update(`${sourceFilePath}:${sourceStat.size}:${sourceStat.mtimeMs}:jpg-v4`)
|
||||
.digest("hex");
|
||||
const cacheDirectory = path.join(DOCUMENT_PREVIEW_DIR, cacheKey);
|
||||
const cachedUrls = await listCachedPreviewUrls(cacheKey, cacheDirectory);
|
||||
|
||||
if (cachedUrls.length > 0) {
|
||||
return jsonSuccess({ imageUrls: cachedUrls }, 200, requestId);
|
||||
}
|
||||
|
||||
let tempDirectory = "";
|
||||
let cacheDirectory = "";
|
||||
|
||||
try {
|
||||
tempDirectory = await mkdtemp(path.join(tmpdir(), "non-integrated-document-preview-"));
|
||||
const materializedFile = await materializeNonIntegratedAssetFile(
|
||||
selectedFileUrl,
|
||||
tempDirectory,
|
||||
);
|
||||
|
||||
if (!materializedFile || !existsSync(materializedFile.filePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, "The document file to preview was not found.", 404, requestId);
|
||||
}
|
||||
|
||||
const sourceFilePath = materializedFile.filePath;
|
||||
const extension = path.extname(sourceFilePath).toLowerCase();
|
||||
if (!DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) {
|
||||
return jsonError(
|
||||
ErrorCode.FILE_TYPE_NOT_ALLOWED,
|
||||
"Only PDF, PPT, or PPTX files can be converted to image previews.",
|
||||
400,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath);
|
||||
const cacheTimestamp = materializedFile.isTemporary
|
||||
? "object-storage"
|
||||
: String(sourceStat.mtimeMs);
|
||||
const cacheKey = createHash("sha1")
|
||||
.update(
|
||||
`${materializedFile.cacheIdentity}:${sourceStat.size}:${cacheTimestamp}:jpg-v5`,
|
||||
)
|
||||
.digest("hex");
|
||||
cacheDirectory = path.join(DOCUMENT_PREVIEW_DIR, cacheKey);
|
||||
const cachedUrls = await listCachedPreviewUrls(cacheKey, cacheDirectory);
|
||||
|
||||
if (cachedUrls.length > 0) {
|
||||
return jsonSuccess({ imageUrls: cachedUrls }, 200, requestId);
|
||||
}
|
||||
|
||||
const pdfFilePath = PRESENTATION_EXTENSIONS.has(extension)
|
||||
? await convertPresentationToPdf({
|
||||
sourceFilePath,
|
||||
@ -335,7 +345,9 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
|
||||
return jsonSuccess({ imageUrls }, 200, requestId);
|
||||
} catch (error) {
|
||||
await rm(cacheDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (cacheDirectory) {
|
||||
await rm(cacheDirectory, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
return jsonError(
|
||||
ErrorCode.FILE_UPLOAD_FAILED,
|
||||
|
||||
@ -9,7 +9,7 @@ import { NextRequest } from 'next/server'
|
||||
import {
|
||||
buildNonIntegratedPresentationPreviewUrl,
|
||||
getNonIntegratedAssetDirectory,
|
||||
resolveNonIntegratedAssetFilePath,
|
||||
materializeNonIntegratedAssetFile,
|
||||
} from '@/app/intake/non-integrated/nonIntegratedAssetServer'
|
||||
import { NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
@ -71,42 +71,51 @@ export async function POST(request: NextRequest, context: RouteContext) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const sourceFilePath = resolveNonIntegratedAssetFilePath(selectedFileUrl)
|
||||
|
||||
if (!sourceFilePath || !existsSync(sourceFilePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, 'The PPT file to preview was not found.', 404, requestId)
|
||||
}
|
||||
|
||||
const extension = path.extname(sourceFilePath).toLowerCase()
|
||||
if (!PRESENTATION_EXTENSIONS.has(extension)) {
|
||||
return jsonError(
|
||||
ErrorCode.FILE_TYPE_NOT_ALLOWED,
|
||||
'Only PPT or PPTX files can be previewed here.',
|
||||
400,
|
||||
requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath)
|
||||
const cacheKey = createHash('sha1')
|
||||
.update(`${sourceFilePath}:${sourceStat.size}:${sourceStat.mtimeMs}`)
|
||||
.digest('hex')
|
||||
const cachedFileName = `${cacheKey}.pdf`
|
||||
const cachedFilePath = path.join(PRESENTATION_PREVIEW_DIR, cachedFileName)
|
||||
const pdfUrl = buildNonIntegratedPresentationPreviewUrl(cachedFileName)
|
||||
|
||||
if (existsSync(cachedFilePath)) {
|
||||
return jsonSuccess({ pdfUrl }, 200, requestId)
|
||||
}
|
||||
|
||||
if (!existsSync(PRESENTATION_PREVIEW_DIR)) {
|
||||
await mkdir(PRESENTATION_PREVIEW_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
let tempDirectory = ''
|
||||
|
||||
try {
|
||||
tempDirectory = await mkdtemp(path.join(tmpdir(), 'non-integrated-presentation-preview-'))
|
||||
const materializedFile = await materializeNonIntegratedAssetFile(
|
||||
selectedFileUrl,
|
||||
tempDirectory,
|
||||
)
|
||||
|
||||
if (!materializedFile || !existsSync(materializedFile.filePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, 'The PPT file to preview was not found.', 404, requestId)
|
||||
}
|
||||
|
||||
const sourceFilePath = materializedFile.filePath
|
||||
const extension = path.extname(sourceFilePath).toLowerCase()
|
||||
if (!PRESENTATION_EXTENSIONS.has(extension)) {
|
||||
return jsonError(
|
||||
ErrorCode.FILE_TYPE_NOT_ALLOWED,
|
||||
'Only PPT or PPTX files can be previewed here.',
|
||||
400,
|
||||
requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath)
|
||||
const cacheTimestamp = materializedFile.isTemporary
|
||||
? 'object-storage'
|
||||
: String(sourceStat.mtimeMs)
|
||||
const cacheKey = createHash('sha1')
|
||||
.update(
|
||||
`${materializedFile.cacheIdentity}:${sourceStat.size}:${cacheTimestamp}:pdf-v2`,
|
||||
)
|
||||
.digest('hex')
|
||||
const cachedFileName = `${cacheKey}.pdf`
|
||||
const cachedFilePath = path.join(PRESENTATION_PREVIEW_DIR, cachedFileName)
|
||||
const pdfUrl = buildNonIntegratedPresentationPreviewUrl(cachedFileName)
|
||||
|
||||
if (existsSync(cachedFilePath)) {
|
||||
return jsonSuccess({ pdfUrl }, 200, requestId)
|
||||
}
|
||||
|
||||
if (!existsSync(PRESENTATION_PREVIEW_DIR)) {
|
||||
await mkdir(PRESENTATION_PREVIEW_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const tempSourcePath = path.join(tempDirectory, `source${extension}`)
|
||||
const tempOutputPath = path.join(tempDirectory, 'source.pdf')
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ const mockMkdir = jest.fn()
|
||||
const mockMkdtemp = jest.fn()
|
||||
const mockRm = jest.fn()
|
||||
const mockStat = jest.fn()
|
||||
const mockResolveNonIntegratedAssetFilePath = jest.fn()
|
||||
const mockMaterializeNonIntegratedAssetFile = jest.fn()
|
||||
const mockBuildNonIntegratedPresentationPreviewUrl = jest.fn(
|
||||
() => '/non-integrated/presentation-previews/cache.pdf',
|
||||
)
|
||||
@ -42,8 +42,8 @@ jest.mock('@/app/intake/non-integrated/nonIntegratedAssetServer', () => ({
|
||||
buildNonIntegratedPresentationPreviewUrl: (...args: unknown[]) =>
|
||||
mockBuildNonIntegratedPresentationPreviewUrl(...args),
|
||||
getNonIntegratedAssetDirectory: (...args: unknown[]) => mockGetNonIntegratedAssetDirectory(...args),
|
||||
resolveNonIntegratedAssetFilePath: (...args: unknown[]) =>
|
||||
mockResolveNonIntegratedAssetFilePath(...args),
|
||||
materializeNonIntegratedAssetFile: (...args: unknown[]) =>
|
||||
mockMaterializeNonIntegratedAssetFile(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/app/intake/non-integrated/nonIntegratedAssetUrl', () => ({
|
||||
@ -96,7 +96,11 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
|
||||
}))
|
||||
|
||||
mockGetNonIntegratedAssetDirectory.mockReturnValue('D:/mock/non-integrated')
|
||||
mockResolveNonIntegratedAssetFilePath.mockReturnValue('D:/mock/non-integrated/uploads/slides.pptx')
|
||||
mockMaterializeNonIntegratedAssetFile.mockResolvedValue({
|
||||
filePath: 'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
cacheIdentity: 'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
isTemporary: false,
|
||||
})
|
||||
mockBuildNonIntegratedPresentationPreviewUrl.mockReturnValue('/non-integrated/presentation-previews/cache.pdf')
|
||||
mockExistsSync.mockImplementation((filePath: string) =>
|
||||
filePath === 'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
@ -120,8 +124,9 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mockResolveNonIntegratedAssetFilePath).toHaveBeenCalledWith(
|
||||
expect(mockMaterializeNonIntegratedAssetFile).toHaveBeenCalledWith(
|
||||
'/intake/non-integrated/uploads/slides.pptx',
|
||||
'D:/temp/non-integrated-presentation-preview-1',
|
||||
)
|
||||
expect(mockCopyFile).toHaveBeenCalledWith(
|
||||
'D:/mock/non-integrated/uploads/slides.pptx',
|
||||
|
||||
@ -9,7 +9,7 @@ import { NextRequest } from 'next/server'
|
||||
import {
|
||||
buildNonIntegratedPresentationPreviewUrl,
|
||||
getNonIntegratedAssetDirectory,
|
||||
resolveNonIntegratedAssetFilePath,
|
||||
materializeNonIntegratedAssetFile,
|
||||
} from '@/app/intake/non-integrated/nonIntegratedAssetServer'
|
||||
import { NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY } from '@/app/intake/non-integrated/nonIntegratedAssetUrl'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
@ -31,41 +31,51 @@ export async function POST(request: NextRequest) {
|
||||
async ({ requestId }) => {
|
||||
const payload = (await request.json().catch(() => null)) as { fileUrl?: string } | null
|
||||
const fileUrl = typeof payload?.fileUrl === 'string' ? payload.fileUrl : ''
|
||||
const sourceFilePath = resolveNonIntegratedAssetFilePath(fileUrl)
|
||||
|
||||
if (!fileUrl) {
|
||||
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请先提供需要预览的 PPT 文件。', 400, requestId)
|
||||
}
|
||||
|
||||
if (!sourceFilePath || !existsSync(sourceFilePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, '没有找到需要预览的 PPT 文件。', 404, requestId)
|
||||
}
|
||||
|
||||
const extension = path.extname(sourceFilePath).toLowerCase()
|
||||
if (!PRESENTATION_EXTENSIONS.has(extension)) {
|
||||
return jsonError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '当前文件不是 PPT 或 PPTX,无法生成原样预览。', 400, requestId)
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath)
|
||||
const cacheKey = createHash('sha1')
|
||||
.update(`${sourceFilePath}:${sourceStat.size}:${sourceStat.mtimeMs}`)
|
||||
.digest('hex')
|
||||
const cachedFileName = `${cacheKey}.pdf`
|
||||
const cachedFilePath = path.join(PRESENTATION_PREVIEW_DIR, cachedFileName)
|
||||
const pdfUrl = buildNonIntegratedPresentationPreviewUrl(cachedFileName)
|
||||
|
||||
if (existsSync(cachedFilePath)) {
|
||||
return jsonSuccess({ pdfUrl }, 200, requestId)
|
||||
}
|
||||
|
||||
if (!existsSync(PRESENTATION_PREVIEW_DIR)) {
|
||||
await mkdir(PRESENTATION_PREVIEW_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
let tempDirectory = ''
|
||||
|
||||
try {
|
||||
tempDirectory = await mkdtemp(path.join(tmpdir(), 'non-integrated-presentation-preview-'))
|
||||
const materializedFile = await materializeNonIntegratedAssetFile(
|
||||
fileUrl,
|
||||
tempDirectory,
|
||||
)
|
||||
|
||||
if (!materializedFile || !existsSync(materializedFile.filePath)) {
|
||||
return jsonError(ErrorCode.FILE_NOT_FOUND, '没有找到需要预览的 PPT 文件。', 404, requestId)
|
||||
}
|
||||
|
||||
const sourceFilePath = materializedFile.filePath
|
||||
const extension = path.extname(sourceFilePath).toLowerCase()
|
||||
if (!PRESENTATION_EXTENSIONS.has(extension)) {
|
||||
return jsonError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '当前文件不是 PPT 或 PPTX,无法生成原样预览。', 400, requestId)
|
||||
}
|
||||
|
||||
const sourceStat = await stat(sourceFilePath)
|
||||
const cacheTimestamp = materializedFile.isTemporary
|
||||
? 'object-storage'
|
||||
: String(sourceStat.mtimeMs)
|
||||
const cacheKey = createHash('sha1')
|
||||
.update(
|
||||
`${materializedFile.cacheIdentity}:${sourceStat.size}:${cacheTimestamp}:pdf-v2`,
|
||||
)
|
||||
.digest('hex')
|
||||
const cachedFileName = `${cacheKey}.pdf`
|
||||
const cachedFilePath = path.join(PRESENTATION_PREVIEW_DIR, cachedFileName)
|
||||
const pdfUrl = buildNonIntegratedPresentationPreviewUrl(cachedFileName)
|
||||
|
||||
if (existsSync(cachedFilePath)) {
|
||||
return jsonSuccess({ pdfUrl }, 200, requestId)
|
||||
}
|
||||
|
||||
if (!existsSync(PRESENTATION_PREVIEW_DIR)) {
|
||||
await mkdir(PRESENTATION_PREVIEW_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const tempSourcePath = path.join(tempDirectory, `source${extension}`)
|
||||
const tempOutputPath = path.join(tempDirectory, 'source.pdf')
|
||||
|
||||
|
||||
@ -5,7 +5,9 @@ import { NextRequest } from 'next/server'
|
||||
const mockHandleRequest: any = jest.fn()
|
||||
const mockJsonError: any = jest.fn()
|
||||
const mockCreateObjectReadUrl: any = jest.fn()
|
||||
const mockGetStorageFilePathPrefix: any = jest.fn()
|
||||
const mockFetch: any = jest.fn()
|
||||
const mockAssertCanDownload: any = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
@ -14,6 +16,13 @@ jest.mock('@/server/core/response', () => ({
|
||||
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
createObjectReadUrl: (...args: unknown[]) => mockCreateObjectReadUrl(...args),
|
||||
getStorageFilePathPrefix: (...args: unknown[]) => mockGetStorageFilePathPrefix(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/intake-non-integrated-product.service', () => ({
|
||||
intakeNonIntegratedProductService: {
|
||||
assertCanDownload: (...args: unknown[]) => mockAssertCanDownload(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { GET } = require('./route')
|
||||
@ -53,6 +62,8 @@ describe('GET /api/storage/object', () => {
|
||||
mockCreateObjectReadUrl.mockImplementation(
|
||||
(key: string) => `https://cdn.example.com/${key}?token=signed`,
|
||||
)
|
||||
mockGetStorageFilePathPrefix.mockReturnValue('')
|
||||
mockAssertCanDownload.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('proxies a signed HTTPS storage URL instead of redirecting the browser cross-origin', async () => {
|
||||
@ -158,4 +169,40 @@ 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',
|
||||
),
|
||||
headers: { get: jest.fn(() => null) },
|
||||
} as NextRequest
|
||||
|
||||
const response = await GET(request)
|
||||
|
||||
expect(mockAssertCanDownload).toHaveBeenCalledWith(
|
||||
'SZ0230',
|
||||
{ id: 'user-1', role: 'ADMIN' },
|
||||
'产品模型',
|
||||
)
|
||||
expect(mockCreateObjectReadUrl).toHaveBeenCalledWith(
|
||||
'prod/non-integrated-assets/SZ0230/产品模型/file.pdf',
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError } from '@/server/core/response'
|
||||
import { createObjectReadUrl } from '@/lib/storage'
|
||||
import { createObjectReadUrl, getStorageFilePathPrefix } from '@/lib/storage'
|
||||
import {
|
||||
buildObjectReadResponse,
|
||||
ObjectReadProxyError,
|
||||
} from '@/lib/object-storage-read-response'
|
||||
import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@ -18,10 +19,34 @@ function normalizeRequestKey(value: string | null): string | null {
|
||||
return key.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
function stripConfiguredStoragePrefix(key: string) {
|
||||
const prefix = getStorageFilePathPrefix()
|
||||
if (prefix && key.startsWith(prefix)) {
|
||||
return key.slice(prefix.length)
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
function parseNonIntegratedObjectScope(key: string) {
|
||||
const normalizedKey = stripConfiguredStoragePrefix(key)
|
||||
const prefix = 'non-integrated-assets/'
|
||||
if (!normalizedKey.startsWith(prefix)) return null
|
||||
|
||||
const relativePath = normalizedKey.slice(prefix.length)
|
||||
const parts = relativePath.split('/').map((part) => part.trim()).filter(Boolean)
|
||||
if (parts.length < 3) return null
|
||||
|
||||
const [productId, category] = parts
|
||||
if (!productId || !category) return null
|
||||
|
||||
return { productId, category }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(
|
||||
request,
|
||||
async ({ request, requestId }) => {
|
||||
async ({ request, requestId, user }) => {
|
||||
const key = normalizeRequestKey(request.nextUrl.searchParams.get('key'))
|
||||
if (!key) {
|
||||
return jsonError(
|
||||
@ -32,6 +57,22 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const nonIntegratedScope = parseNonIntegratedObjectScope(key)
|
||||
if (nonIntegratedScope) {
|
||||
try {
|
||||
await intakeNonIntegratedProductService.assertCanDownload(
|
||||
nonIntegratedScope.productId,
|
||||
user,
|
||||
nonIntegratedScope.category,
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return jsonError(error.code, error.message, error.statusCode, requestId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const readUrl = createObjectReadUrl(key)
|
||||
try {
|
||||
return (await buildObjectReadResponse(readUrl, request)) as unknown as NextResponse
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { existsSync } from "fs";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import {
|
||||
NON_INTEGRATED_ASSET_DIRECTORY_NAME,
|
||||
@ -7,8 +9,16 @@ import {
|
||||
buildNonIntegratedAssetUrl,
|
||||
extractNonIntegratedAssetRelativePath,
|
||||
} from "./nonIntegratedAssetUrl";
|
||||
import { createObjectReadUrl, normalizeObjectStorageKey } from "@/lib/storage";
|
||||
|
||||
const NON_INTEGRATED_PRIVATE_ASSET_DIRECTORY_NAME = "non-integrated-assets";
|
||||
const OBJECT_STORAGE_ROUTE_PATH = "/api/storage/object";
|
||||
|
||||
export type MaterializedNonIntegratedAssetFile = {
|
||||
filePath: string;
|
||||
cacheIdentity: string;
|
||||
isTemporary: boolean;
|
||||
};
|
||||
|
||||
function resolveInsideBase(baseDirectory: string, relativePath: string) {
|
||||
const base = path.resolve(baseDirectory);
|
||||
@ -69,6 +79,75 @@ export function resolveNonIntegratedAssetFilePath(
|
||||
return resolveNonIntegratedAssetRelativeFilePath(relativePath, cwd);
|
||||
}
|
||||
|
||||
export function extractObjectStorageKeyFromUrl(value: string) {
|
||||
const normalizedValue = value.trim();
|
||||
if (!normalizedValue) return "";
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedValue, "http://storage.local");
|
||||
if (parsed.pathname !== OBJECT_STORAGE_ROUTE_PATH) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const rawKey = parsed.searchParams.get("key")?.trim();
|
||||
if (!rawKey) return "";
|
||||
|
||||
return normalizeObjectStorageKey(rawKey);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSafeMaterializedFileName(objectKey: string) {
|
||||
const extension = path.extname(objectKey).replace(/[^a-zA-Z0-9.]/g, "");
|
||||
const baseName =
|
||||
path
|
||||
.basename(objectKey, extension)
|
||||
.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 80) || "object-storage-file";
|
||||
|
||||
return `${baseName}-${randomUUID()}${extension}`;
|
||||
}
|
||||
|
||||
export async function materializeNonIntegratedAssetFile(
|
||||
fileUrl: string,
|
||||
tempDirectory: string,
|
||||
cwd = process.cwd(),
|
||||
): Promise<MaterializedNonIntegratedAssetFile | null> {
|
||||
const localPath = resolveNonIntegratedAssetFilePath(fileUrl, cwd);
|
||||
if (localPath && existsSync(localPath)) {
|
||||
return {
|
||||
filePath: localPath,
|
||||
cacheIdentity: localPath,
|
||||
isTemporary: false,
|
||||
};
|
||||
}
|
||||
|
||||
const objectKey = extractObjectStorageKeyFromUrl(fileUrl);
|
||||
if (!objectKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(createObjectReadUrl(objectKey));
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await mkdir(tempDirectory, { recursive: true });
|
||||
const filePath = path.join(
|
||||
tempDirectory,
|
||||
buildSafeMaterializedFileName(objectKey),
|
||||
);
|
||||
await writeFile(filePath, Buffer.from(await response.arrayBuffer()));
|
||||
|
||||
return {
|
||||
filePath,
|
||||
cacheIdentity: objectKey,
|
||||
isTemporary: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNonIntegratedPresentationPreviewUrl(fileName: string) {
|
||||
return buildNonIntegratedAssetUrl(
|
||||
`${NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY}/${fileName}`,
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
import {
|
||||
classifyLegacyReference,
|
||||
replaceLegacyStringsInJson,
|
||||
} from '../../scripts/migrate-historical-assets-to-qiniu'
|
||||
|
||||
describe('migrate-historical-assets-to-qiniu helpers', () => {
|
||||
it('maps legacy S3 furniture paths into migrated object keys', () => {
|
||||
const plan = classifyLegacyReference(
|
||||
'https://s3.jiansun.vip/furniture-3d-system/asset-center/board/demo.obj',
|
||||
'D:/workspace',
|
||||
)
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'legacy-s3',
|
||||
targetKey: 'migrated/s3/asset-center/board/demo.obj',
|
||||
source: {
|
||||
type: 'http',
|
||||
},
|
||||
contentType: 'model/obj',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps non-integrated runtime assets into the private object namespace', () => {
|
||||
const plan = classifyLegacyReference(
|
||||
'/api/intake/non-integrated/assets/SZ0230/%E4%BA%A7%E5%93%81%E6%A8%A1%E5%9E%8B/file.pdf',
|
||||
'D:/workspace',
|
||||
)
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'non-integrated-asset',
|
||||
targetKey: 'non-integrated-assets/SZ0230/产品模型/file.pdf',
|
||||
source: {
|
||||
type: 'local',
|
||||
},
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps material texture URLs to the same object-storage namespace', () => {
|
||||
const plan = classifyLegacyReference(
|
||||
'/materials/textures/material-library/oak.webp',
|
||||
'D:/workspace',
|
||||
)
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
kind: 'material-texture',
|
||||
targetKey: 'materials/textures/material-library/oak.webp',
|
||||
source: {
|
||||
type: 'local',
|
||||
},
|
||||
contentType: 'image/webp',
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces matching legacy strings inside nested JSON without touching other values', () => {
|
||||
const replacements = new Map([
|
||||
['/api/intake/non-integrated/assets/SZ0230/产品模型/file.pdf', '/api/storage/object?key=prod%2Fnon-integrated-assets%2FSZ0230%2F%E4%BA%A7%E5%93%81%E6%A8%A1%E5%9E%8B%2Ffile.pdf'],
|
||||
])
|
||||
|
||||
const result = replaceLegacyStringsInJson(
|
||||
{
|
||||
uploadFiles: [
|
||||
{
|
||||
dataUrl: '/api/intake/non-integrated/assets/SZ0230/产品模型/file.pdf',
|
||||
name: 'file.pdf',
|
||||
},
|
||||
],
|
||||
untouched: '/uploads/legacy-local-file.pdf',
|
||||
},
|
||||
replacements,
|
||||
)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect(result.value).toEqual({
|
||||
uploadFiles: [
|
||||
{
|
||||
dataUrl:
|
||||
'/api/storage/object?key=prod%2Fnon-integrated-assets%2FSZ0230%2F%E4%BA%A7%E5%93%81%E6%A8%A1%E5%9E%8B%2Ffile.pdf',
|
||||
name: 'file.pdf',
|
||||
},
|
||||
],
|
||||
untouched: '/uploads/legacy-local-file.pdf',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user