GRAPHQL_REVIEW_S3_UPLOAD.md 14 KB

GraphQL Product Review 图片上传到 S3

本文档说明如何通过 GraphQL mutation 创建产品评价并上传图片到 AWS S3。

📋 目录

GraphQL Mutation 定义

CreateProductReview Mutation

mutation CreateProductReview($input: CreateProductReviewInput!) {
  createProductReview(input: $input) {
    id
    productId
    title
    comment
    rating
    name
    status
    createdAt
    attachments
  }
}

Input 类型字段

input CreateProductReviewInput {
  productId: Int!          # 产品ID(必填)
  title: String!           # 评价标题(必填)
  comment: String!         # 评价内容(必填)
  rating: Int!             # 评分 1-5(必填)
  name: String!            # 评价者姓名(必填)
  email: String            # 邮箱(可选)
  status: Int              # 状态:0=pending, 1=approved(可选,默认pending)
  
  # 旧方式:Base64 JSON 字符串(向后兼容)
  attachments: String      
  
  # 新方式:图片数组(推荐,支持S3上传)⭐
  images: [ImageInput]     
}

input ImageInput {
  data: String!            # Base64编码的图片数据
  name: String             # 文件名(可选)
  type: String             # MIME类型(可选)
}

使用方法

方法一:使用 images 数组(推荐)⭐

这是新的推荐方式,会自动使用 ImageUploadService 上传到 S3。

简单 Base64 数组

mutation {
  createProductReview(input: {
    productId: 1
    title: "Great Product!"
    comment: "I love this product. Highly recommended!"
    rating: 5
    name: "John Doe"
    images: [
      "data:image/jpeg;base64,/9j/4AAQSkZJRg...",
      "data:image/png;base64,iVBORw0KGgo..."
    ]
  }) {
    id
    title
    attachments
  }
}

结构化对象数组(更灵活)

mutation {
  createProductReview(input: {
    productId: 1
    title: "Amazing Quality"
    comment: "The quality exceeded my expectations."
    rating: 5
    name: "Jane Smith"
    images: [
      {
        data: "/9j/4AAQSkZJRg..."  # 不带 data URI 前缀的纯 Base64
        name: "product_front.jpg"
        type: "image/jpeg"
      },
      {
        data: "iVBORw0KGgo..."
        name: "product_side.png"
        type: "image/png"
      }
    ]
  }) {
    id
    title
    attachments
  }
}

方法二:使用 attachments JSON 字符串(向后兼容)

旧的方式仍然支持,用于向后兼容。

mutation {
  createProductReview(input: {
    productId: 1
    title: "Good Product"
    comment: "Works as expected."
    rating: 4
    name: "Bob Wilson"
    attachments: "[\"data:image/jpeg;base64,/9j/4AAQ...\"]"
  }) {
    id
    title
    attachments
  }
}

图片格式说明

支持的图片格式

  • ✅ JPEG/JPG
  • ✅ PNG
  • ✅ GIF
  • ✅ WebP
  • ✅ SVG

文件大小限制

  • 单张图片最大:5 MB
  • 建议数量:最多 10 张图片

Base64 格式

带 Data URI 前缀(标准格式)

data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA...

纯 Base64(不带前缀)

/9j/4AAQSkZJRgABAQEAYABgAAD...

使用纯 Base64 时,需要在对象中指定 type 字段:

{
  "data": "/9j/4AAQSkZJRg...",
  "name": "photo.jpg",
  "type": "image/jpeg"
}

完整示例

JavaScript/Fetch 示例

// 将文件转换为 Base64
function fileToBase64(file) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => resolve(reader.result);
    reader.onerror = error => reject(error);
  });
}

// 创建评价并上传图片
async function createReviewWithImages(productId, reviewData, files) {
  // 转换所有文件为 Base64
  const images = await Promise.all(
    files.map(async (file) => {
      const base64 = await fileToBase64(file);
      return {
        data: base64,
        name: file.name,
        type: file.type
      };
    })
  );

  // GraphQL mutation
  const mutation = `
    mutation CreateProductReview($input: CreateProductReviewInput!) {
      createProductReview(input: $input) {
        id
        productId
        title
        comment
        rating
        name
        status
        attachments
        createdAt
      }
    }
  `;

  const variables = {
    input: {
      productId: productId,
      title: reviewData.title,
      comment: reviewData.comment,
      rating: reviewData.rating,
      name: reviewData.name,
      email: reviewData.email,
      images: images  // 使用新的 images 字段
    }
  };

  try {
    const response = await fetch('/graphql', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer YOUR_TOKEN'  // 如果需要认证
      },
      body: JSON.stringify({
        query: mutation,
        variables: variables
      })
    });

    const result = await response.json();
    
    if (result.errors) {
      throw new Error(result.errors[0].message);
    }

    console.log('评价创建成功:', result.data.createProductReview);
    return result.data.createProductReview;
    
  } catch (error) {
    console.error('创建评价失败:', error);
    throw error;
  }
}

// 使用示例
document.getElementById('reviewForm').addEventListener('submit', async (e) => {
  e.preventDefault();
  
  const files = Array.from(document.getElementById('images').files);
  
  const reviewData = {
    title: document.getElementById('title').value,
    comment: document.getElementById('comment').value,
    rating: parseInt(document.getElementById('rating').value),
    name: document.getElementById('name').value,
    email: document.getElementById('email').value
  };
  
  try {
    const result = await createReviewWithImages(1, reviewData, files);
    alert('评价提交成功!');
    console.log('附件信息:', result.attachments);
  } catch (error) {
    alert('提交失败: ' + error.message);
  }
});

Vue.js 示例

<template>
  <form @submit.prevent="submitReview">
    <div>
      <label>标题</label>
      <input v-model="form.title" required />
    </div>
    
    <div>
      <label>评价内容</label>
      <textarea v-model="form.comment" required></textarea>
    </div>
    
    <div>
      <label>评分</label>
      <select v-model.number="form.rating">
        <option v-for="n in 5" :key="n" :value="n">{{ n }} 星</option>
      </select>
    </div>
    
    <div>
      <label>上传图片</label>
      <input 
        type="file" 
        ref="fileInput"
        multiple 
        accept="image/*"
        @change="handleFileSelect"
      />
    </div>
    
    <!-- 图片预览 -->
    <div v-if="previews.length > 0" class="preview-grid">
      <div v-for="(preview, index) in previews" :key="index">
        <img :src="preview" style="max-width: 100px;" />
      </div>
    </div>
    
    <button type="submit" :disabled="uploading">
      {{ uploading ? '上传中...' : '提交评价' }}
    </button>
  </form>
</template>

<script>
export default {
  props: {
    productId: {
      type: Number,
      required: true
    }
  },
  
  data() {
    return {
      form: {
        title: '',
        comment: '',
        rating: 5,
        name: '',
        email: ''
      },
      selectedFiles: [],
      previews: [],
      uploading: false
    };
  },
  
  methods: {
    handleFileSelect(event) {
      const files = Array.from(event.target.files);
      this.selectedFiles = files;
      
      // 生成预览
      this.previews = files.map(file => URL.createObjectURL(file));
    },
    
    async fileToBase64(file) {
      return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.readAsDataURL(file);
        reader.onload = () => resolve(reader.result);
        reader.onerror = reject;
      });
    },
    
    async submitReview() {
      this.uploading = true;
      
      try {
        // 转换文件为 Base64
        const images = await Promise.all(
          this.selectedFiles.map(async (file) => ({
            data: await this.fileToBase64(file),
            name: file.name,
            type: file.type
          }))
        );
        
        // 调用 GraphQL
        const response = await this.$apollo.mutate({
          mutation: gql`
            mutation CreateProductReview($input: CreateProductReviewInput!) {
              createProductReview(input: $input) {
                id
                title
                attachments
              }
            }
          `,
          variables: {
            input: {
              productId: this.productId,
              ...this.form,
              images: images
            }
          }
        });
        
        this.$toast.success('评价提交成功!');
        this.resetForm();
        
      } catch (error) {
        this.$toast.error('提交失败: ' + error.message);
      } finally {
        this.uploading = false;
      }
    },
    
    resetForm() {
      this.form = {
        title: '',
        comment: '',
        rating: 5,
        name: '',
        email: ''
      };
      this.selectedFiles = [];
      this.previews = [];
      this.$refs.fileInput.value = '';
    }
  }
};
</script>

React 示例

import React, { useState } from 'react';
import { useMutation, gql } from '@apollo/client';

const CREATE_REVIEW_MUTATION = gql`
  mutation CreateProductReview($input: CreateProductReviewInput!) {
    createProductReview(input: $input) {
      id
      title
      comment
      rating
      attachments
    }
  }
`;

function ReviewForm({ productId }) {
  const [createReview] = useMutation(CREATE_REVIEW_MUTATION);
  const [form, setForm] = useState({
    title: '',
    comment: '',
    rating: 5,
    name: '',
    email: ''
  });
  const [files, setFiles] = useState([]);
  const [uploading, setUploading] = useState(false);

  const fileToBase64 = (file) => {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onload = () => resolve(reader.result);
      reader.onerror = reject;
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setUploading(true);

    try {
      // 转换文件
      const images = await Promise.all(
        files.map(async (file) => ({
          data: await fileToBase64(file),
          name: file.name,
          type: file.type
        }))
      );

      // 提交 mutation
      const { data } = await createReview({
        variables: {
          input: {
            productId,
            ...form,
            images
          }
        }
      });

      alert('评价提交成功!');
      console.log('附件:', data.createProductReview.attachments);
      
      // 重置表单
      setForm({ title: '', comment: '', rating: 5, name: '', email: '' });
      setFiles([]);
      
    } catch (error) {
      alert('提交失败: ' + error.message);
    } finally {
      setUploading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="标题"
        value={form.title}
        onChange={(e) => setForm({...form, title: e.target.value})}
        required
      />
      
      <textarea
        placeholder="评价内容"
        value={form.comment}
        onChange={(e) => setForm({...form, comment: e.target.value})}
        required
      />
      
      <select
        value={form.rating}
        onChange={(e) => setForm({...form, rating: parseInt(e.target.value)})}
      >
        {[1, 2, 3, 4, 5].map(n => (
          <option key={n} value={n}>{n} 星</option>
        ))}
      </select>
      
      <input
        type="file"
        multiple
        accept="image/*"
        onChange={(e) => setFiles(Array.from(e.target.files))}
      />
      
      <button type="submit" disabled={uploading}>
        {uploading ? '上传中...' : '提交评价'}
      </button>
    </form>
  );
}

export default ReviewForm;

响应格式

成功的响应会包含上传后的图片信息:

{
  "data": {
    "createProductReview": {
      "id": 123,
      "productId": 1,
      "title": "Great Product!",
      "comment": "I love this product!",
      "rating": 5,
      "name": "John Doe",
      "status": 0,
      "attachments": "[{\"type\":\"image\",\"url\":\"https://bucket.s3.region.amazonaws.com/review/123/abc123.webp\",\"path\":\"review/123/abc123.webp\"}]",
      "createdAt": "2026-05-27 10:30:00"
    }
  }
}

解析 attachments 字段:

const attachments = JSON.parse(response.attachments);
// [
//   {
//     "type": "image",
//     "url": "https://bucket.s3.region.amazonaws.com/review/123/abc123.webp",
//     "path": "review/123/abc123.webp"
//   }
// ]

错误处理

常见错误

  1. 图片大小超限

    {
     "errors": [{
       "message": "File size exceeds the 5MB limit"
     }]
    }
    
  2. 无效的图片格式

    {
     "errors": [{
       "message": "Invalid file format"
     }]
    }
    
  3. Base64 解码失败

    {
     "errors": [{
       "message": "Invalid base64 encoding"
     }]
    }
    

配置说明

确保 .env 文件中正确配置了 S3:

IMAGE_UPLOAD_DISK=s3

AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
AWS_URL=https://your-bucket.s3.us-east-1.amazonaws.com

性能优化建议

  1. 前端压缩图片:在上传前压缩图片,减少传输时间
  2. 限制图片数量:建议最多上传 5-10 张
  3. 显示上传进度:对于大文件,显示进度条
  4. 异步上传:先提交评价文本,再异步上传图片

安全注意事项

  1. ✅ 服务端验证文件类型和大小
  2. ✅ 使用 S3 预签名 URL(可选增强)
  3. ✅ 限制每个评价的图片数量
  4. ✅ 记录上传日志用于审计

需要帮助? 查看 ImageUpload 模块文档