返回

Strapi 文件上传与模型创建同步:完整解决方案

javascript

Strapi: 如何同时上传文件并创建新模型条目?

搞开发的时候,经常需要在 Strapi 里面一次性做两件事:上传文件,然后创建一个新的数据条目,把文件跟数据条目关联起来。Strapi 的后台管理面板能这么干,那咱们自己的应用里当然也得行。问题就出在,创建新条目之前,你是不知道它的 ID 的 (RefID),所以直接把文件和还没影儿的数据条目关联上,这事儿有点麻烦。我试了好几种办法,下面给大家捋捋,哪些能成,哪些是坑。

问题根源在哪儿?

Strapi 的数据创建和文件上传是分开处理的。 你往 /jobs 发个 POST 请求创建数据,文件上传则需要另一个接口,一般是 /upload。 直接把文件和其它数据揉到一个 FormData 里发过去,Strapi 就有点懵,不知道怎么处理了。所以经常会碰到报错,比如“table jobs has no column named fields”,或者就是干脆文件上传失败.

靠谱的解决方案

好,咱们来看看解决这个问题的几个法子。

方法一:两步走,先创建条目,再上传文件

这是最稳妥的方法, 分成两个步骤:

  1. 先创建数据条目: 只发送除文件外的其他数据到 /jobs 接口创建条目。创建成功后,你会从响应里拿到新条目的 ID。

  2. 再上传文件并关联: 利用上一步拿到的 ID,通过 /upload 接口上传文件。上传的时候,通过 refrefIdfield 这几个参数,把文件和刚创建的数据条目关联上。

代码示例:

先调整你的 createJob 服务:

// services/job.service.ts

public createJobWithoutFiles = (payload: IJob): Observable<IJob> => {
  // 注意这里只发送纯数据,不包含 files 字段
  const { files, ...jobData } = payload; // 把 files 字段排除掉
  return this.http.post<IJob>(`${this.env.backendUrl}/jobs`, jobData);
}

public uploadFilesAndLink = (jobId: string, files: File[], fieldName: string): Observable<any> => {
    const data = new FormData();
    files.forEach(file => {
        data.append('files', file);
    });
    data.append('ref', 'job'); // 你的模型名称
    data.append('refId', jobId); // 新创建的 job 的 ID
    data.append('field', fieldName); // job 模型里对应的文件字段名,比如 'files'

    return this.http.post(`${this.env.backendUrl}/upload`, data);
}

然后在你的 action 或组件里,把这两个步骤串起来:

// 在你的 effect 或者 component 里面.

this.jobService.createJobWithoutFiles(jobsForm.value)
  .pipe(
    switchMap(createdJob => {
        const jobId = createdJob.id; // 从返回的结果里拿到 ID
        const files: File[] = jobsForm.value.files; //从表单获取 File 对象
        return this.jobService.uploadFilesAndLink(jobId, files, 'files'); //假设字段名为 'files'
    })
  )
    .subscribe(
        () => {
           //两个操作都搞定了! 文件已上传,关联也没问题!
           console.log("Job created and Files Upload Success!");
         },
         err => {
            // 处理各种错误
            console.log("出错了:",err);
         }
);

安全提示:

  • 确保上传接口做了适当的权限控制,只允许授权用户上传文件。
  • 对上传的文件类型和大小进行限制,防止恶意文件上传。

方法二: 使用 Strapi 的 content-type: multipart/form-data (Strapi v4+)

从 Strapi v4 开始,可以直接使用 multipart/form-data 的请求一次性完成条目创建和文件上传,但是需要特殊的处理。

  1. 构建 FormData: 数据结构需要调整,把普通字段放到一个叫 data 的 JSON 字符串里,文件还是放在 files.<fieldName> 里.

代码示例:

// service
public createJobWithFiles = (payload: IJob): Observable<IJob> => {
    const data = new FormData();

    // 把除 files 之外的字段都放到 data 里,并转成 JSON 字符串
    const { files, ...jobData } = payload;
    data.append('data', JSON.stringify(jobData));

    // 处理文件字段, 把每个文件都加进去
    if (files) {
        for (let i = 0; i < files.length; i++) {
            data.append(`files.files`, files[i]); // 假设字段名叫 files
        }
    }

    return this.http.post<IJob>(`${this.env.backendUrl}/jobs`, data);
}

组件或 Action 里调用:

this.jobService.createJobWithFiles(this.jobsForm.value).subscribe(
    () => console.log('搞定!数据和文件一次性创建上传!'),
    (err) => console.error("哎呀,报错了",err)
);

关键点解释:

  • data 字段: 包含除了文件之外的所有数据。要把它转成 JSON 字符串。
  • files.<fieldName> fieldName 替换成你的模型里文件字段的名称 (比如 files.myFiles, files.documents)。可以一次上传多个文件到同一个字段。
  • 如果你用了更复杂的结构,例如你的files字段在一个叫做job_details的组件里, 你的files.<fieldName>会变成files.job_details.files.

安全提示:
和方法一类似,要做足文件校验和权限管理.

进阶技巧: 自定义 Controller 增强控制 (可选)

如果你需要对整个过程做更细粒度的控制, 比如更复杂的关联逻辑, 文件预处理, 或者整合第三方服务,可以自定义 Strapi 的 Controller。

  1. 创建自定义 Controller:
    src/api/job/controllers下,创建或修改文件 job.js:

     // src/api/job/controllers/job.js
     'use strict';
    
     const { createCoreController } = require('@strapi/strapi').factories;
    
     module.exports = createCoreController('api::job.job', ({ strapi }) => ({
       async createWithFiles(ctx) {
    
           try {
    
                 let { data, files } = ctx.request.body;
                 data = JSON.parse(data); //解析 'data'
    
                 // 使用 Strapi 的 entityService 来创建条目.
                 const newJob = await strapi.entityService.create('api::job.job', {
                    data,
                 });
    
                  // 如果有文件,处理文件上传.
                 if (files && Object.keys(files).length > 0) {
    
                   // 循环 files 对象, 使用 Strapi 内部的 upload service上传并关联.
                     for (const fileKey in files) {
                        await strapi.plugins.upload.services.upload.upload({
                          data: {
                              refId: newJob.id,
                              ref: 'api::job.job', //必须是api::<name>.<name>格式
                              field: fileKey.split('.').pop(),//获取字段名称
                           },
                          files: files[fileKey]
                         });
                    }
                }
    
                 return newJob;
    
           } catch(err) {
                 ctx.throw(500, err);
                 console.log(err)
           }
       }
     }));
    
  2. 配置路由:

src/api/job/routes/job.js (如果需要自定义, 创建或者修改此文件)

   // src/api/job/routes/job.js

   'use strict';

module.exports = {
  routes: [
     {
        method: 'POST',
        path: '/jobs/create-with-files',  // 你自定义的路由
        handler: 'job.createWithFiles',   // 指向你自定义的 controller 和方法
         config: {
           policies: [], // 如果需要,可以添加策略
            middlewares: []
        },
     },
  ],
};

  1. 修改前端服务以使用新路由:
    现在你的前端服务应该调用/jobs/create-with-files, 而不是原来的 /jobs

  public createJobWithFilesCustom = (payload: IJob): Observable<IJob> => {
        const data = new FormData();
        const { files, ...jobData } = payload;

        data.append('data', JSON.stringify(jobData));

          if(files) {
              for( let i = 0; i < files.length; i++ ) {
                  data.append(`files.files`, files[i]); //假设字段名叫 files
              }
          }

        return this.http.post<IJob>(`${this.env.backendUrl}/jobs/create-with-files`, data);
  }

注意

  • api::. 这种格式是 Strapi 内部使用的.
  • 上述示例中, 我们用到了strapi.plugins.upload.services.upload.upload方法, 这属于Strapi内部API,将来Strapi版本更新有变动的可能.
  • 使用自定义 Controller 之后, 相当于完全接管了处理流程, Strapi 自带的一些功能(比如数据验证), 可能需要你手动去实现。

总结一下

通常情况下,方法二 (直接用 multipart/form-data) 就够用了,简单直接。 如果你有特殊的业务逻辑需求,再考虑自定义 Controller。