fix(app-assets): restore atomic batch upload for nested folder targets

The previous nested folder upload flow bypassed the backend batch-upload
contract when parentId was set. Instead of creating the whole metadata
tree in one backend operation, the frontend recursively called
createFolder/getFileUploadUrl for each node.

That introduced two regressions for uploads into subfolders:

- consistency regression: mid-sequence failures could leave partially
  created folder trees under the destination folder
- performance regression: metadata creation degraded from a single
  batch request to O(files + folders) round-trips before file bytes
  were uploaded

This change moves nested uploads back to the original batch semantics:

- add optional parent_id support to app asset batch-upload payload
- create the whole nested tree under the target parent in
  AppAssetService.batch_create_from_tree
- pass parentId through useBatchUpload instead of using per-node
  createFolder/getFileUploadUrl calls
- remove the now-unnecessary useBatchUploadOperation wrapper
- add a backend unit test covering batch tree creation under an
  existing parent folder

After this change, both root uploads and subfolder uploads use the same
single-request metadata creation path, preserving atomic tree creation
semantics and avoiding avoidable metadata round-trips.
This commit is contained in:
yyh
2026-03-26 15:16:55 +08:00
parent 4d95cee44e
commit 12ca422c8a
7 changed files with 123 additions and 158 deletions

View File

@ -1,147 +0,0 @@
'use client'
import type { BatchUploadNodeInput, BatchUploadNodeOutput } from '@/types/app-asset'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { consoleClient, consoleQuery } from '@/service/client'
import { uploadToPresignedUrl } from '@/service/upload-to-presigned-url'
import { useBatchUpload } from '@/service/use-app-asset'
type BatchUploadOperationVariables = {
appId: string
tree: BatchUploadNodeInput[]
files: Map<string, File>
parentId?: string | null
onProgress?: (uploaded: number, total: number) => void
}
type BatchUploadTask = {
file: File
url: string
}
const uploadBatchUploadTasks = async ({
tasks,
onProgress,
}: {
tasks: BatchUploadTask[]
onProgress?: (uploaded: number, total: number) => void
}) => {
let completed = 0
const total = tasks.length
await Promise.all(
tasks.map(async (task) => {
await uploadToPresignedUrl({
file: task.file,
uploadUrl: task.url,
})
completed++
onProgress?.(completed, total)
}),
)
}
const createBatchUploadTreeInParent = async ({
appId,
tree,
files,
parentId,
pathPrefix = '',
}: {
appId: string
tree: BatchUploadNodeInput[]
files: Map<string, File>
parentId: string
pathPrefix?: string
}): Promise<{ nodes: BatchUploadNodeOutput[], tasks: BatchUploadTask[] }> => {
const nodes: BatchUploadNodeOutput[] = []
const tasks: BatchUploadTask[] = []
for (const inputNode of tree) {
const sourcePath = pathPrefix ? `${pathPrefix}/${inputNode.name}` : inputNode.name
if (inputNode.node_type === 'folder') {
const folder = await consoleClient.appAsset.createFolder({
params: { appId },
body: { name: inputNode.name, parent_id: parentId },
})
const childrenResult = await createBatchUploadTreeInParent({
appId,
tree: inputNode.children ?? [],
files,
parentId: folder.id,
pathPrefix: sourcePath,
})
nodes.push({
id: folder.id,
name: folder.name,
node_type: folder.node_type,
size: folder.size,
children: childrenResult.nodes,
})
tasks.push(...childrenResult.tasks)
continue
}
const file = files.get(sourcePath)
if (!file)
throw new Error(`Missing file for batch upload path: ${sourcePath}`)
const { node, upload_url } = await consoleClient.appAsset.getFileUploadUrl({
params: { appId },
body: {
name: inputNode.name,
size: inputNode.size ?? file.size,
parent_id: parentId,
},
})
nodes.push({
id: node.id,
name: node.name,
node_type: node.node_type,
size: node.size,
children: [],
upload_url,
})
tasks.push({ file, url: upload_url })
}
return { nodes, tasks }
}
export function useBatchUploadOperation() {
const queryClient = useQueryClient()
const batchUpload = useBatchUpload()
return useMutation({
mutationKey: consoleQuery.appAsset.batchUpload.mutationKey(),
mutationFn: async (variables: BatchUploadOperationVariables): Promise<BatchUploadNodeOutput[]> => {
if (!variables.parentId)
return batchUpload.mutateAsync(variables)
try {
const result = await createBatchUploadTreeInParent({
appId: variables.appId,
tree: variables.tree,
files: variables.files,
parentId: variables.parentId,
})
await uploadBatchUploadTasks({
tasks: result.tasks,
onProgress: variables.onProgress,
})
return result.nodes
}
finally {
await queryClient.invalidateQueries({
queryKey: consoleQuery.appAsset.tree.key({ type: 'query', input: { params: { appId: variables.appId } } }),
})
}
},
})
}

View File

@ -5,12 +5,12 @@ import type { SkillEditorSliceShape } from '@/app/components/workflow/store/work
import type { BatchUploadNodeInput } from '@/types/app-asset'
import { useCallback, useRef } from 'react'
import {
useBatchUpload,
useCreateAppAssetFolder,
useUploadFileWithPresignedUrl,
} from '@/service/use-app-asset'
import { prepareSkillUploadFile } from '../../../utils/skill-upload-utils'
import { useSkillTreeUpdateEmitter } from '../data/use-skill-tree-collaboration'
import { useBatchUploadOperation } from './use-batch-upload-operation'
type UseCreateOperationsOptions = {
parentId: string | null
@ -34,7 +34,7 @@ export function useCreateOperations({
const { isPending: isCreateFolderPending } = useCreateAppAssetFolder()
const { mutateAsync: uploadFileAsync, isPending: isUploadFilePending } = useUploadFileWithPresignedUrl()
const { mutateAsync: batchUploadAsync, isPending: isBatchUploadPending } = useBatchUploadOperation()
const { mutateAsync: batchUploadAsync, isPending: isBatchUploadPending } = useBatchUpload()
const emitTreeUpdate = useSkillTreeUpdateEmitter()
const handleNewFile = useCallback(() => {