feat(storage): 支持七牛历史素材迁移

This commit is contained in:
WuYongJie 2026-06-03 21:40:13 +08:00
parent 2ca459e027
commit 79106b69da
11 changed files with 1503 additions and 97 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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