阿里云OSS从0到1实战:为宠物收养系统打造图片上传功能

项目背景 :本文以「爪印之约」宠物收养管理系统为例,手把手教你从零开始集成阿里云OSS,实现宠物图片的上传、存储与展示。项目基于 FastAPI + MySQL + 原生JS 构建,适合有 Python Web 开发经验的同学参考。

1. 前置准备:阿里云OSS注册与配置

在写代码之前,需要先在阿里云完成以下步骤:

1.1 开通OSS服务

  1. 访问 阿里云OSS控制台 并登录(需要手机号实名认证)

  2. 首次进入会提示开通 OSS 服务,点击开通即可

  3. 免费额度:每月 5GB 标准存储 + 10GB 外网流出流量,个人项目完全够用

1.2 创建Bucket

  1. 在OSS控制台点击「创建 Bucket」

  2. 填写配置:

    • Bucket 名称pet-adoption-xxx(需全局唯一,我用了 pet-adoption-zsx

    • 区域 :选择离你最近的(我选的是华东1-杭州,对应 Endpoint oss-cn-hangzhou.aliyuncs.com

    • 读写权限 :我设置的是私有,后续通过签名URL来访问图片(更安全)

![Bucket创建示意 - 名称和区域是关键配置]

1.3 获取AccessKey

  1. 鼠标悬停右上角头像 → 点击「AccessKey管理」

  2. 点击「创建AccessKey」,完成手机验证后获得:

    • AccessKey ID :形如 LTAI5txxxxxxxxxxx

    • AccessKey Secret :仅显示一次,务必保存好

⚠️ 安全提示:AccessKey Secret 只在创建时显示一次,请务必复制保存。如果泄漏,可以在控制台禁用并重新创建。

注意:到目前为止你只需要拿到ID和Secret还有一开始创建的Bucket名称即可配置OSS

1.4 配置Bucket跨域(CORS)

如果你的前端要通过浏览器直接上传到OSS(本文走的是后端代理上传,但后续可能用到),建议提前配置:

  1. Bucket 列表 → 点击你的 Bucket → 数据安全 → 跨域设置

  2. 添加规则:

    • 来源*(生产环境建议限制为具体域名)

    • 允许MethodsGET POST PUT

    • 允许Headers*


2. 项目依赖安装

在项目的 requirements.txt 中,OSS 相关依赖有两项:

复制代码
# requirements.txt(关键依赖)
oss2>=2.18.0          # 阿里云官方 Python SDK
Pillow>=10.0.0        # 图片处理(压缩、裁剪)
python-dotenv>=1.0.0  # 环境变量管理
python-multipart>=0.0.6  # FastAPI 文件上传支持
fastapi>=0.104.1
uvicorn>=0.24.0
sqlalchemy>=2.0.23
pymysql>=1.1.0

安装依赖:

bash 复制代码
pip install -r requirements.txt

3. 环境变量配置

为了安全性,OSS 的密钥不硬编码在代码中,而是通过 .env 文件管理:

bash 复制代码
# .env 文件(放在项目根目录 pet_adoption_system/ 下)

# OSS AccessKey ID(在阿里云控制台创建)
OSS_ACCESS_KEY_ID=LTAI5txxxxxxxxxxx

# OSS AccessKey Secret
OSS_ACCESS_KEY_SECRET=xxxxxxxxxxxxxxxxxxxxxx

# OSS Bucket名称(创建Bucket时填写的名称)
OSS_BUCKET_NAME=pet-adoption-zsx

# OSS Endpoint(根据Bucket区域选择)
OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com

# OSS域名(可选,绑定了CDN自定义域名时填写)
OSS_DOMAIN=

# 签名URL过期时间(秒),默认3600 = 1小时
OSS_EXPIRE_TIME=3600

Endpoint 区域对照表

区域 Endpoint
华东1-杭州 oss-cn-hangzhou.aliyuncs.com
华东2-上海 oss-cn-shanghai.aliyuncs.com
华北2-北京 oss-cn-beijing.aliyuncs.com
华南1-深圳 oss-cn-shenzhen.aliyuncs.com

4. 后端:OSS配置模块

创建 config/oss_config.py,从环境变量中读取配置:

python 复制代码
import os
from dotenv import load_dotenv

load_dotenv()


class OSSConfig:
    ACCESS_KEY_ID = os.getenv('OSS_ACCESS_KEY_ID', '')
    ACCESS_KEY_SECRET = os.getenv('OSS_ACCESS_KEY_SECRET', '')
    BUCKET_NAME = os.getenv('OSS_BUCKET_NAME', '')
    ENDPOINT = os.getenv('OSS_ENDPOINT', 'https://oss-cn-hangzhou.aliyuncs.com')
    DOMAIN = os.getenv('OSS_DOMAIN', '')
    EXPIRE_TIME = int(os.getenv('OSS_EXPIRE_TIME', '3600'))

💡 设计要点 :所有配置项都提供默认值,即使 .env 文件缺失也不会导致程序崩溃,而是优雅地提示"OSS未配置"。


5. 后端:OSS服务模块(核心)

这是整个OSS集成的核心------services/oss_service.py ,它封装了初始化、图片处理、上传、签名URL生成、删除等全套能力。

5.1 完整代码

python 复制代码
import oss2
import time
from io import BytesIO
from PIL import Image
from config.oss_config import OSSConfig
from common.exceptions import UploadError


class OSSService:
    def __init__(self):
        self.is_configured = bool(
            OSSConfig.BUCKET_NAME 
            and OSSConfig.ACCESS_KEY_ID 
            and OSSConfig.ACCESS_KEY_SECRET
        )
        if self.is_configured:
            auth = oss2.Auth(OSSConfig.ACCESS_KEY_ID, OSSConfig.ACCESS_KEY_SECRET)
            self.bucket = oss2.Bucket(auth, OSSConfig.ENDPOINT, OSSConfig.BUCKET_NAME)
        else:
            self.bucket = None

    def check_configured(self):
        if not self.is_configured:
            raise UploadError("OSS未配置,请先在.env文件中配置OSS相关参数")
        return True

    def process_image(self, file_content: bytes, size: int = 400) -> bytes:
        """压缩和裁剪图片为正方形"""
        img = Image.open(BytesIO(file_content))

        if img.mode != 'RGB':
            img = img.convert('RGB')

        # 中心裁剪为正方形
        width, height = img.size
        if width != height:
            min_side = min(width, height)
            left = (width - min_side) / 2
            top = (height - min_side) / 2
            right = (width + min_side) / 2
            bottom = (height + min_side) / 2
            img = img.crop((left, top, right, bottom))

        # 缩放到目标尺寸
        if width > size or height > size:
            img = img.resize((size, size), Image.Resampling.LANCZOS)

        # 转换为JPEG并控制质量
        output = BytesIO()
        img.save(output, format='JPEG', quality=85)
        return output.getvalue()

    def upload_image(self, file_content: bytes, pet_id: int, filename: str) -> str:
        """上传图片到OSS,返回object_key"""
        processed_content = self.process_image(file_content)
        object_key = f"pets/{pet_id}/{filename}"
        try:
            self.bucket.put_object(object_key, processed_content)
            return object_key
        except Exception as e:
            raise UploadError(f"图片上传失败: {str(e)}")

    def get_signed_url(self, object_key: str) -> str:
        """获取私有bucket的签名URL"""
        if not object_key:
            return ''
        try:
            url = self.bucket.sign_url('GET', object_key, OSSConfig.EXPIRE_TIME)
            return url
        except Exception as e:
            raise UploadError(f"获取图片链接失败: {str(e)}")

    def delete_image(self, object_key: str) -> bool:
        """删除OSS中的图片"""
        if not object_key:
            return False
        try:
            self.bucket.delete_object(object_key)
            return True
        except Exception as e:
            raise UploadError(f"删除图片失败: {str(e)}")

5.2 核心设计思路

模块 说明
__init__ 惰性初始化,判断是否有配置才创建 Bucket 客户端,未配置时不报错
process_image 使用 Pillow 做中心裁剪→缩放→JPEG压缩,统一输出 400×400 正方形
upload_image pets/{pet_id}/{timestamp}.{ext} 规则命名,调用 put_object 上传
get_signed_url 私有 Bucket 必须用签名 URL 才能访问,有效期由 EXPIRE_TIME 控制
delete_image 删除宠物时同步清理 OSS 上的图片

5.3 为什么选择签名URL而不是公共读?

  • 公共读Bucket:图片URL永久有效,但任何人都能访问(包括爬虫、未授权用户)

  • 私有Bucket + 签名URL:只有持有签名链接的人才能访问,且可以控制有效期(本项目设为1小时),更安全

每次前端请求宠物列表时,后端动态生成签名URL,过期后自动刷新。

6. 后端:上传接口实现

controllers/pet_controller.py 中添加图片上传接口:

python 复制代码
from fastapi import APIRouter, Depends, UploadFile, File
from sqlalchemy.orm import Session
from database import get_db
from services import pet_service
from services.auth_service import require_admin

router = APIRouter(prefix="/api/pets", tags=["宠物管理"])

ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp'}
MAX_FILE_SIZE = 5 * 1024 * 1024  # 5MB


@router.post("/{pet_id}/upload-image")
def upload_pet_image(
    pet_id: int,
    file: UploadFile = File(...),
    db: Session = Depends(get_db),
    admin_user = Depends(require_admin)
):
    """管理员上传宠物图片到OSS"""
    # 1. 校验文件扩展名
    ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
    if ext not in ALLOWED_EXTENSIONS:
        return {"msg": f"不支持的文件格式,仅支持: {', '.join(ALLOWED_EXTENSIONS)}"}

    # 2. 校验文件大小
    content = file.file.read()
    if len(content) > MAX_FILE_SIZE:
        return {"msg": "文件大小不能超过5MB"}

    # 3. 生成文件名并上传
    filename = f"{int(time.time())}.{ext}"
    result = pet_service.upload_pet_image(
        db, pet_id=pet_id, file_content=content, filename=filename
    )
    return {"msg": "上传成功", "image_url": result['image_url']}

接口说明

项目 内容
接口路径 POST /api/pets/{pet_id}/upload-image
权限要求 管理员登录(JWT Token)
请求格式 multipart/form-data,字段名 file
格式限制 JPG / PNG / GIF / WebP
大小限制 ≤ 5MB
处理流程 校验格式 → 校验大小 → 压缩裁剪 → 上传OSS → 更新数据库 → 返回签名URL

7. 后端:Service层集成OSS

services/pet_service.py 中,有两个关键方法:

7.1 上传并更新数据库

python 复制代码
def upload_pet_image(db: Session, pet_id: int, file_content: bytes, filename: str) -> dict:
    """上传宠物图片到OSS并更新数据库"""
    pet = pet_crud.get_pet_by_id(db, pet_id)
    if not pet:
        raise NotFoundError("宠物不存在")

    oss_service = OSSService()
    # 上传图片,返回 object_key (如 pets/5/1715432000.jpg)
    object_key = oss_service.upload_image(file_content, pet_id, filename)
    # 获取可访问的签名URL
    signed_url = oss_service.get_signed_url(object_key)

    # 数据库中存储的是 object_key,而非完整URL
    # 这样签名过期后可以重新生成,URL更灵活
    pet_crud.update_pet(db, pet_id=pet_id, update_data={"image_url": object_key})

    return {"object_key": object_key, "image_url": signed_url}

7.2 查询时动态生成签名URL

python 复制代码
def get_pet_by_id(db: Session, pet_id: int):
    pet = pet_crud.get_pet_by_id(db, pet_id=pet_id)
    # 如果 image_url 不是外部链接(HTTP开头或/static/开头),说明是OSS的object_key
    if pet and pet.image_url and \
       not pet.image_url.startswith(('http://', 'https://', '/static/')):
        oss_service = OSSService()
        if oss_service.is_configured:
            pet.image_url = oss_service.get_signed_url(pet.image_url)
    return pet

💡 设计亮点 :数据库存的是 object_key(如 pets/5/1715432000.jpg),查询时动态生成签名URL。这样做的好处是:

  • 签名URL过期后自动刷新,用户始终能看到图片

  • 切换 CDN 域名只需改配置,无需迁移数据库

  • 兼容旧数据(外部图片URL以 http://https:///static/ 开头的直接透传)

7.3 数据库模型

python 复制代码
# models/model.py
class Pet(Base):
    __tablename__ = "pets"

    id = Column(Integer, primary_key=True, index=True, autoincrement=True)
    name = Column(String(50), nullable=False)
    species = Column(String(20), nullable=False)
    breed = Column(String(50), nullable=True)
    age = Column(Float, nullable=True)
    gender = Column(String(10), nullable=True)
    # ... 其他字段 ...
    image_url = Column(String(255), nullable=True)  # 存的是OSS的object_key
    # ...

8. 前端:图片上传组件

admin.html 管理后台,管理员可以对每只宠物上传图片:

8.1 HTML结构

html 复制代码
<!-- 宠物管理表格中,每行有一个"上传图片"按钮 -->
<button class="btn btn-primary" 
        onclick="showUploadImageForm(${p.id})">
    上传图片
</button>

<!-- 上传表单(默认隐藏,点击按钮后展开) -->
<div id="uploadImageForm" style="display:none;">
    <h4>上传宠物图片</h4>
    <p>支持JPG/PNG/GIF/WebP格式,≤5MB,将自动压缩裁剪为正方形</p>
    <div class="form-group">
        <label>选择图片</label>
        <input type="file" id="imageFile" 
               accept=".jpg,.jpeg,.png,.gif,.webp" 
               onchange="previewImage(this)">
    </div>
    <div id="imagePreview" style="display:none;">
        <img id="previewImg" style="width:150px;height:150px;
             object-fit:cover;border-radius:8px;">
    </div>
    <button class="btn btn-success" onclick="uploadImage()" 
            id="uploadBtn" disabled>确认上传</button>
    <button class="btn" onclick="hideUploadImageForm()">取消</button>
</div>

8.2 JavaScript核心逻辑

javascript 复制代码
let currentUploadPetId = null;

// 展开上传表单
function showUploadImageForm(petId) {
    currentUploadPetId = petId;
    document.getElementById('uploadImageForm').style.display = 'block';
    document.getElementById('imageFile').value = '';
    document.getElementById('imagePreview').style.display = 'none';
    document.getElementById('uploadBtn').disabled = true;
    document.getElementById('uploadImageForm').scrollIntoView({ behavior: 'smooth' });
}

// 图片预览
function previewImage(input) {
    if (input.files && input.files[0]) {
        const file = input.files[0];
        // 前端预校验文件大小
        if (file.size > 5 * 1024 * 1024) {
            showMessage('petMessage', '文件大小不能超过5MB', 'error');
            input.value = '';
            return;
        }
        const reader = new FileReader();
        reader.onload = function(e) {
            document.getElementById('previewImg').src = e.target.result;
            document.getElementById('imagePreview').style.display = 'block';
            document.getElementById('uploadBtn').disabled = false;
        };
        reader.readAsDataURL(file);
    }
}

// 执行上传
async function uploadImage() {
    if (!currentUploadPetId) return;
    const fileInput = document.getElementById('imageFile');
    if (!fileInput.files[0]) {
        showMessage('petMessage', '请先选择图片', 'error');
        return;
    }

    const formData = new FormData();
    formData.append('file', fileInput.files[0]);

    const uploadBtn = document.getElementById('uploadBtn');
    uploadBtn.disabled = true;
    uploadBtn.textContent = '上传中...';

    try {
        const response = await fetch(
            `${API_BASE}/api/pets/${currentUploadPetId}/upload-image`, 
            {
                method: 'POST',
                headers: { 'Authorization': `Bearer ${getToken()}` },
                body: formData
            }
        );
        const result = await response.json();
        if (response.ok) {
            showMessage('petMessage', '图片上传成功', 'success');
            hideUploadImageForm();
            loadPetsForAdmin();  // 刷新列表显示新图片
        } else {
            showMessage('petMessage', result.detail || '上传失败', 'error');
        }
    } catch (error) {
        showMessage('petMessage', '上传失败: ' + error.message, 'error');
    } finally {
        uploadBtn.disabled = false;
        uploadBtn.textContent = '确认上传';
    }
}

⚠️ 注意 :上传图片使用的是原生 fetch 而非 axios,因为 FormData 需要直接传递,axios 的拦截器可能会修改 Content-Type 导致问题。


9. 前端:宠物卡片图片展示优化

在首页 index.html 中,宠物卡片使用 Flex 布局,图片在右侧,信息在左侧:

9.1 卡片的HTML渲染

python 复制代码
function renderPets(pets) {
    const container = document.getElementById('petList');
    if (pets.length === 0) {
        container.innerHTML = '<p style="text-align:center;">暂无宠物信息</p>';
        return;
    }

    container.innerHTML = pets.map(pet => `
        <div class="pet-card" onclick="viewPetDetail(${pet.id})">
            <div class="pet-info">
                <div class="pet-name">${pet.name}</div>
                <div class="pet-details">
                    ${getSpeciesText(pet.species)} · 
                    ${pet.age || '?'}岁 · 
                    ${pet.gender === 'male' ? '♂ 公' : '♀ 母'}
                </div>
                <span class="badge badge-${pet.status}">
                    ${getStatusText(pet.status)}
                </span>
            </div>
            <img class="pet-card-image" 
                 src="${pet.image_url || '/static/images/default-pet.png'}" 
                 alt="${pet.name}" 
                 onerror="this.src='/static/images/default-pet.png'">
        </div>
    `).join('');
}

9.2 CSS布局

css 复制代码
.pet-card {
    background: #fff;
    border-radius: 12px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.08);
    overflow: hidden;
    cursor: pointer;
    display: flex;
    flex-direction: row;      /* 水平排列:信息左,图片右 */
    min-height: 180px;
    transition: all 0.3s;
}

.pet-card:hover {
    transform: translateY(-8px) scale(1.02);
    box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}

.pet-card-image {
    width: 180px;
    min-width: 180px;         /* 固定宽度防止被压缩 */
    height: 180px;            /* 正方形显示 */
    object-fit: cover;        /* 保持比例裁剪溢出 */
    flex-shrink: 0;           /* 不允许缩小 */
}

.pet-card .pet-info {
    padding: 20px;
    flex: 1;                  /* 自动填充剩余空间 */
    display: flex;
    flex-direction: column;
    justify-content: center;
}

最终效果:

10. 完整的项目结构一览

css 复制代码
pet_adoption_system/
├── .env                          # OSS和数据库配置(不提交Git)
├── .env.example                  # 配置模板(提交Git)
├── main.py                       # FastAPI入口,启动时自动创建默认管理员
├── settings.py                   # 应用通用配置
├── database.py                   # MySQL数据库连接
├── requirements.txt              # 依赖清单
│
├── config/
│   └── oss_config.py             # 🔑 OSS配置(从.env读取)
│
├── services/
│   ├── oss_service.py            # 🔑 OSS核心服务(上传/签名URL/删除/图片处理)
│   ├── pet_service.py            # 宠物业务逻辑(调用OSS服务)
│   ├── auth_service.py           # 权限验证(require_admin)
│   └── ...
│
├── controllers/
│   ├── pet_controller.py         # 🔑 宠物API(含图片上传接口)
│   └── ...
│
├── models/
│   └── model.py                  # Pet模型(image_url字段存object_key)
│
├── crud/
│   └── pet_crud.py               # 数据库增删改查
│
├── common/
│   └── exceptions.py             # 自定义异常(UploadError等)
│
└── static/
    ├── index.html                # 首页(宠物卡片展示)
    ├── admin.html                # 🔑 管理后台(图片上传功能)
    ├── pet_detail.html           # 宠物详情
    ├── css/
    │   └── style.css             # 卡片布局 + 图片样式
    └── js/
        └── api.js                # Axios封装 + JWT鉴权

11. 踩坑与注意事项

11.1 AccessKey安全

⚠️ 千万不要把 AccessKey Secret 提交到 Git!

  • 项目中 .env 已加入 .gitignore

  • 提供 .env.example 模板供团队成员参考

  • 生产环境建议使用 RAM 子账号,只授予 OSS 必要的权限

11.2 Bucket权限选择

方案 优点 缺点 适用场景
公共读 URL永久有效,无需后端处理 任何人都能访问,有泄漏风险 公开的非敏感图片
私有 + 签名URL 安全性高,可控制有效期 URL有时效性,需后端生成 本项目采用的方案

11.3 图片处理策略

  • 为什么要处理图片:用户可能上传几MB的高清原图,直接存储浪费空间和流量

  • 本项目策略:Pillow + 中心裁剪为正方形 + 缩放至400×400 + JPEG品质85

  • 结果:一张5MB的原图处理后通常只有30-80KB,大幅节省存储和带宽成本

11.4 object_key vs 完整URL

数据库存的是 pets/5/1715432000.jpg(object_key),不是 https://xxx.oss-cn-hangzhou.aliyuncs.com/pets/5/...。原因:

  • 签名URL有过期时间,存完整URL会过期

  • 将来切换 CDN 域名,只需改配置,数据库无需迁移

  • 兼容混合数据源(外部链接、本地文件、OSS)

11.5 前后端双重校验

校验项 前端 后端
文件格式 accept 属性 + JS校验 扩展名白名单
文件大小 JS校验并阻止选择 读取后校验
权限 隐藏按钮 require_admin 依赖注入

11.6 上传接口使用fetch而非axios

上传 FormData 时,使用原生 fetch 而不通过 axios,避免 axios 拦截器自动设置 Content-Type: application/json 覆盖掉 multipart/form-data


12. 总结

本文完整展示了在一个 FastAPI 宠物收养系统中集成阿里云OSS的全过程:

  1. 配置层 :通过 .env 管理密钥,oss_config.py 统一读取

  2. 服务层oss_service.py 封装了图片处理、上传、签名URL生成、删除

  3. 接口层RESTful API 设计,格式/大小/权限三重校验

  4. 展示层:Flex布局实现卡片式图片展示,签名URL动态生成

整个流程的文件流转图:

css 复制代码
 用户选择图片 → 前端预览 → fetch上传FormData
     → 后端 pet_controller 校验格式/大小
     → oss_service.process_image() 裁剪压缩
     → oss_service.upload_image() 上传OSS
     → pet_crud.update_pet() 更新数据库
     → 返回签名URL给前端
     → 前端刷新列表显示新图片

如果你也在做类似的项目,希望这篇文章能帮你少走弯路,快速完成阿里云OSS的集成!🚀

相关文档

相关推荐
川冰ICE2 小时前
Python爬虫实战⑳|Pandas时间序列,趋势分析一网打尽
爬虫·python·pandas
金融大 k2 小时前
多市场行情时间戳对齐:UTC 存储的夏令时陷阱与数据库设计方案
python·websocket·行情数据
risc1234562 小时前
python 的字符串前缀
开发语言·python
如竟没有火炬2 小时前
字符串相乘——int数组转字符串
开发语言·数据结构·python·算法·leetcode·深度优先
Pkmer2 小时前
古法编程·新解:Python 类型注解的"一箭三雕"之术
python·ai编程
吃好睡好便好2 小时前
在Matlab中绘制三维等高线图
开发语言·python·学习·算法·matlab·信息可视化
keineahnung23452 小时前
PyTorch symbolic_shapes 模組的 is_contiguous 從哪來?── sizes_strides_user 安裝與實作解析
人工智能·pytorch·python·深度学习
C137的本贾尼3 小时前
别怕异步:`async` 和 `await` 的简单理解
开发语言·python
__log3 小时前
ComfyUI 集成技术方案分析报告
javascript·python·django