From 9fa602f21b883f8d2228fd7e6ac7105d6c7e746a Mon Sep 17 00:00:00 2001 From: zly <644706215@qq.com> Date: Sat, 22 Nov 2025 21:03:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(supabase):=20=E6=95=B4=E7=90=86=20Storage?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=E5=92=8C=E7=A4=BA=E4=BE=8B=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 docs/ 目录存放所有文档 - QUICK_START.md: 快速入门指南 - OPERATIONS_GUIDE.md: 完整运维指南 - VUE_API_INTEGRATION.md: Vue 集成文档 - DEPLOYMENT_INFO.md: 部署配置信息 - versions.md: 版本信息 - 创建 examples/ 目录存放示例代码 - storage_client.py: Python 完整客户端 - storage_client.js: JavaScript 完整客户端 - test_https_storage.py: 功能测试脚本 - 新增 README_STORAGE.md 作为 Storage 使用指南 - 修复签名 URL 生成问题(需要 /storage/v1 前缀) - 测试脚本支持资源已存在的情况 - 所有客户端代码已验证可用 功能特性: ✓ 公网 HTTPS 访问 ✓ 文件上传/下载 ✓ 生成临时下载链接 ✓ 完整的 REST API 客户端 ✓ 支持 Python 和 JavaScript --- supabase-stack/README_STORAGE.md | 237 ++++ supabase-stack/docs/DEPLOYMENT_INFO.md | 293 ++++ supabase-stack/docs/OPERATIONS_GUIDE.md | 350 +++++ supabase-stack/docs/QUICK_START.md | 76 + supabase-stack/docs/VUE_API_INTEGRATION.md | 1239 +++++++++++++++++ supabase-stack/docs/versions.md | 56 + supabase-stack/examples/storage_client.js | 467 +++++++ supabase-stack/examples/storage_client.py | 349 +++++ supabase-stack/examples/test_https_storage.py | 194 +++ 9 files changed, 3261 insertions(+) create mode 100644 supabase-stack/README_STORAGE.md create mode 100644 supabase-stack/docs/DEPLOYMENT_INFO.md create mode 100644 supabase-stack/docs/OPERATIONS_GUIDE.md create mode 100644 supabase-stack/docs/QUICK_START.md create mode 100644 supabase-stack/docs/VUE_API_INTEGRATION.md create mode 100644 supabase-stack/docs/versions.md create mode 100644 supabase-stack/examples/storage_client.js create mode 100644 supabase-stack/examples/storage_client.py create mode 100755 supabase-stack/examples/test_https_storage.py diff --git a/supabase-stack/README_STORAGE.md b/supabase-stack/README_STORAGE.md new file mode 100644 index 0000000..a633757 --- /dev/null +++ b/supabase-stack/README_STORAGE.md @@ -0,0 +1,237 @@ +# Supabase Storage 对象存储使用指南 + +完整的 Supabase 对象存储解决方案,支持公网 HTTPS 访问。 + +--- + +## 📚 文档导航 + +### 快速开始 +- **[快速入门](docs/QUICK_START.md)** - 5 分钟快速上手指南 +- **[运维指南](docs/OPERATIONS_GUIDE.md)** - 完整的系统架构和运维文档 + +### 集成文档 +- **[Vue API 集成](docs/VUE_API_INTEGRATION.md)** - Vue 应用集成指南 +- **[部署信息](docs/DEPLOYMENT_INFO.md)** - 部署配置详情 + +--- + +## 💻 客户端代码 + +所有示例代码位于 `examples/` 目录: + +### Python 客户端 +```bash +# 查看完整客户端代码 +cat examples/storage_client.py + +# 使用示例 +python3 << 'EOF' +import sys +sys.path.insert(0, 'examples') +from storage_client import SupabaseStorageClient + +client = SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' +) + +# 上传文件 +client.upload_file('bucket', 'photo.jpg', 'uploads/photo.jpg') + +# 生成临时下载链接 +url = client.create_signed_url('bucket', 'uploads/photo.jpg', 3600) +print(f'临时下载链接: {url}') +EOF +``` + +### JavaScript 客户端 +```bash +# 查看完整客户端代码 +cat examples/storage_client.js + +# 在网页中使用 + + +``` + +--- + +## 🧪 测试 + +运行完整的功能测试: + +```bash +# 测试所有 Storage API 功能 +python3 examples/test_https_storage.py +``` + +测试包含: +- ✓ 列出 Buckets +- ✓ 创建 Bucket +- ✓ 上传/更新文件 +- ✓ 下载文件 +- ✓ 生成签名 URL(临时下载链接) + +--- + +## 🚀 快速开始 + +### 1. 启动服务 + +```bash +# 启动 Supabase + MinIO +docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +``` + +### 2. 测试服务 + +```bash +python3 examples/test_https_storage.py +``` + +### 3. 使用客户端 + +**Python**: +```python +from examples.storage_client import SupabaseStorageClient + +client = SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-api-key' +) +``` + +**JavaScript**: +```javascript +// 在浏览器中 +const client = new SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-api-key' +); +``` + +--- + +## 🌐 访问端点 + +- **Storage API**: `https://amiap.hzau.edu.cn/supa/storage/v1` +- **Dashboard**: `http://100.64.0.2:18000` (内网) + +--- + +## 📂 项目结构 + +``` +supabase-stack/ +├── docs/ # 📚 文档 +│ ├── QUICK_START.md # 快速入门 +│ ├── OPERATIONS_GUIDE.md # 运维指南 +│ ├── VUE_API_INTEGRATION.md # Vue 集成 +│ ├── DEPLOYMENT_INFO.md # 部署信息 +│ └── versions.md # 版本信息 +│ +├── examples/ # 💻 示例代码 +│ ├── storage_client.py # Python 客户端 +│ ├── storage_client.js # JavaScript 客户端 +│ └── test_https_storage.py # 测试脚本 +│ +├── docker-compose.yml # Supabase 核心服务 +├── docker-compose.s3.yml # MinIO 对象存储 +├── .env # 环境变量配置 +│ +├── README.md # Supabase 原始说明 +├── README_STORAGE.md # Storage 使用指南(本文档) +└── CHANGELOG.md # 版本变更记录 +``` + +--- + +## 🔑 认证密钥 + +在 `.env` 文件中: + +```bash +# 服务密钥(后端使用,完全权限) +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +# 匿名密钥(前端使用,受权限控制) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +--- + +## ⚙️ 服务管理 + +```bash +# 查看服务状态 +docker compose ps + +# 查看日志 +docker compose logs -f storage + +# 重启服务 +docker compose restart storage + +# 停止服务 +docker compose stop +``` + +--- + +## 🛠️ 故障排查 + +查看 [运维指南](docs/OPERATIONS_GUIDE.md) 的故障排查章节。 + +--- + +## 📝 更多信息 + +- **完整文档**: 查看 `docs/` 目录 +- **代码示例**: 查看 `examples/` 目录 +- **原始 README**: [README.md](README.md) + +--- + +## ✅ 功能特性 + +- ✅ 公网 HTTPS 访问 +- ✅ 子路径支持(无需子域名) +- ✅ 文件上传/下载 +- ✅ 生成临时下载链接(签名 URL) +- ✅ Bucket 管理 +- ✅ 文件列表/移动/复制/删除 +- ✅ Python 和 JavaScript 客户端 +- ✅ 完整的 REST API + +--- + +## 🎯 使用场景 + +1. **Web 应用文件上传** - 用户头像、附件等 +2. **临时文件分享** - 生成带过期时间的下载链接 +3. **静态资源托管** - 图片、视频、文档 +4. **移动应用存储** - 跨平台数据同步 +5. **数据备份** - 自动化备份脚本 + +--- + +## 📞 技术支持 + +如有问题,请查看: +1. [快速入门](docs/QUICK_START.md) - 基础使用 +2. [运维指南](docs/OPERATIONS_GUIDE.md) - 深入了解 +3. 测试脚本 - `python3 examples/test_https_storage.py` + +🎉 **开始使用 Supabase Storage!** diff --git a/supabase-stack/docs/DEPLOYMENT_INFO.md b/supabase-stack/docs/DEPLOYMENT_INFO.md new file mode 100644 index 0000000..414e617 --- /dev/null +++ b/supabase-stack/docs/DEPLOYMENT_INFO.md @@ -0,0 +1,293 @@ +# Supabase 部署信息汇总 + +## 🌐 访问地址 + +### HTTPS 域名访问(推荐用于 API) +``` +API Base URL: https://amiap.hzau.edu.cn/supa +``` + +### 内网访问(用于 Dashboard) +``` +Dashboard: http://100.64.0.2:18000 +API Base: http://100.64.0.2:18000 +``` + +--- + +## 🔑 认证信息 + +### Dashboard 登录 +``` +URL: http://100.64.0.2:18000 +用户名: lab-admin +密码: 017b7076cfb25bd18410d1e5f4f7ec5a +``` + +### API Keys(用于前端/后端开发) +```javascript +// ✅ 公开密钥 - 可以在前端使用 +ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg" + +// ⚠️ 私密密钥 - 仅后端使用,不要暴露 +SERVICE_ROLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NjM4MDI2NjksImV4cCI6MjA3OTE2MjY2OX0.gQWUaTkZ6mjjlv2TED0cODp2meqqWuCGKZR1ptIbovg" +``` + +### 数据库连接 +``` +主机: 100.64.0.2 或 db (容器内) +端口: 5432 +数据库: postgres +用户: postgres +密码: a837234b952ad7aa9ab4852f47021660c038209a71fce027cec8bab37ad82ae5 +``` + +--- + +## 📡 API 端点 + +### ✅ 可用的服务端点 + +| 服务 | HTTPS 端点 | 内网端点 | 用途 | +|------|-----------|----------|------| +| REST API | `https://amiap.hzau.edu.cn/supa/rest/v1/` | `http://100.64.0.2:18000/rest/v1/` | 数据库 CRUD 操作 | +| Auth API | `https://amiap.hzau.edu.cn/supa/auth/v1/` | `http://100.64.0.2:18000/auth/v1/` | 用户认证 | +| Storage API | `https://amiap.hzau.edu.cn/supa/storage/v1/` | `http://100.64.0.2:18000/storage/v1/` | 文件存储 | +| Realtime | `wss://amiap.hzau.edu.cn/supa/realtime/v1/websocket` | `ws://100.64.0.2:18000/realtime/v1/websocket` | 实时订阅 | +| Dashboard | ❌ 不可用 | `http://100.64.0.2:18000/` | 管理界面 | + +--- + +## 🐳 Docker 服务状态 + +### 核心服务 +```bash +# 查看所有服务状态 +docker compose ps + +# 核心服务列表 +supabase-db # PostgreSQL 数据库 +supabase-kong # API 网关(端口: 18000) +supabase-auth # 用户认证服务 +supabase-rest # REST API 服务 +supabase-storage # 文件存储服务 +supabase-realtime # 实时订阅服务 +supabase-studio # Dashboard +supabase-meta # 数据库元数据 +supabase-analytics # 日志分析 +``` + +### 常用命令 +```bash +# 进入 supabase-stack 目录 +cd /vol1/1000/docker_server/traefik/supabase-stack + +# 启动所有服务 +docker compose up -d + +# 停止所有服务 +docker compose down + +# 查看服务日志 +docker compose logs -f kong +docker compose logs -f rest +docker compose logs -f auth + +# 重启特定服务 +docker compose restart kong +docker compose restart rest +``` + +--- + +## 🔧 配置文件 + +### 重要文件位置 +``` +/vol1/1000/docker_server/traefik/supabase-stack/ +├── .env # 环境变量配置 +├── docker-compose.yml # Docker Compose 配置 +├── VUE_API_INTEGRATION.md # Vue 集成文档(已创建) +├── DEPLOYMENT_INFO.md # 本文件 +├── volumes/ +│ ├── api/kong.yml # Kong 网关配置 +│ ├── db/ # 数据库数据目录 +│ └── storage/ # 文件存储目录 +``` + +### .env 关键配置 +```bash +# 数据库 +POSTGRES_PASSWORD=a837234b952ad7aa9ab4852f47021660c038209a71fce027cec8bab37ad82ae5 +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +# JWT +JWT_SECRET=7a264228051ad10724934342ce62dce584161c248841061de0d0a56e92d9bb1a + +# API Keys +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... + +# Dashboard +DASHBOARD_USERNAME=lab-admin +DASHBOARD_PASSWORD=017b7076cfb25bd18410d1e5f4f7ec5a + +# 域名配置 +SITE_URL=https://amiap.hzau.edu.cn +API_EXTERNAL_URL=https://amiap.hzau.edu.cn/supa +SUPABASE_PUBLIC_URL=https://amiap.hzau.edu.cn/supa +``` + +--- + +## 🚀 快速开始(前端集成) + +### 1. 安装依赖 +```bash +npm install @supabase/supabase-js +``` + +### 2. 创建 Supabase 客户端 +```javascript +// src/lib/supabaseClient.js +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = 'https://amiap.hzau.edu.cn/supa' +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg' + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) +``` + +### 3. 使用示例 +```javascript +// 查询数据 +const { data, error } = await supabase + .from('users') + .select('*') + +// 插入数据 +const { data, error } = await supabase + .from('users') + .insert([{ name: 'John', email: 'john@example.com' }]) + +// 用户登录 +const { data, error } = await supabase.auth.signInWithPassword({ + email: 'user@example.com', + password: 'password' +}) +``` + +--- + +## 🔍 测试 API + +### 快速测试脚本 +```bash +cd /vol1/1000/docker_server/traefik/supabase-stack + +# 测试 REST API +curl -H "apikey: ANON_KEY" \ + -H "Authorization: Bearer ANON_KEY" \ + https://amiap.hzau.edu.cn/supa/rest/v1/ + +# 测试 Auth API +curl https://amiap.hzau.edu.cn/supa/auth/v1/health + +# 测试 Storage API +curl https://amiap.hzau.edu.cn/supa/storage/v1/version +``` + +### 在浏览器中测试 +``` +1. 访问 Dashboard: http://100.64.0.2:18000 +2. 使用用户名/密码登录 +3. 创建测试表 +4. 在 Vue 应用中调用 API +``` + +--- + +## ⚠️ 已知问题 + +### 1. Dashboard 子路径问题 +- **问题**: `https://amiap.hzau.edu.cn/supa` 无法访问 Dashboard +- **原因**: Supabase Studio 是 SPA,不支持子路径部署 +- **解决**: 使用内网地址 `http://100.64.0.2:18000` + +### 2. Pooler 服务重启 +- **问题**: `supabase-pooler` 一直重启 +- **影响**: 无影响,这是连接池优化组件,非必需 +- **解决**: 可忽略或停止该服务 `docker stop supabase-pooler` + +### 3. REST API 缓存重试 +- **问题**: 首次访问可能看到 "schema cache retrying" +- **原因**: PostgreSQL schema 缓存加载 +- **解决**: 等待几秒自动恢复,或重启 `docker compose restart rest` + +--- + +## 📚 开发文档 + +| 文档 | 位置 | 说明 | +|------|------|------| +| Vue 集成指南 | `VUE_API_INTEGRATION.md` | 详细的前端集成教程 | +| 部署信息 | `DEPLOYMENT_INFO.md` | 本文件 | +| 官方文档 | https://supabase.com/docs | Supabase 官方文档 | + +--- + +## 🛠️ 故障排查 + +### 服务无法启动 +```bash +# 查看日志 +docker compose logs kong +docker compose logs rest + +# 重启服务 +docker compose restart + +# 完全重建 +docker compose down +docker compose up -d +``` + +### API 无法访问 +```bash +# 检查 Kong 状态 +docker compose ps kong + +# 检查端口 +netstat -tlnp | grep 18000 + +# 测试内网连接 +curl http://100.64.0.2:18000/rest/v1/ +``` + +### 数据库连接问题 +```bash +# 进入数据库容器 +docker exec -it supabase-db psql -U postgres + +# 查看数据库 +\l + +# 查看 schema +\dn +``` + +--- + +## 📞 支持 + +如有问题,请检查: +1. 所有容器是否正常运行: `docker compose ps` +2. 查看服务日志: `docker compose logs [service]` +3. 参考 `VUE_API_INTEGRATION.md` 了解详细用法 +4. 访问 Dashboard 管理数据库: `http://100.64.0.2:18000` + +--- + +**最后更新**: 2025-11-22 +**版本**: Supabase Self-Hosted Stack diff --git a/supabase-stack/docs/OPERATIONS_GUIDE.md b/supabase-stack/docs/OPERATIONS_GUIDE.md new file mode 100644 index 0000000..3bcde6e --- /dev/null +++ b/supabase-stack/docs/OPERATIONS_GUIDE.md @@ -0,0 +1,350 @@ +# Supabase Stack 运维指南 + +完整的 Supabase 对象存储运维手册 + +--- + +## 📋 目录 + +1. [系统架构](#系统架构) +2. [服务启动与管理](#服务启动与管理) +3. [Storage API 使用](#storage-api-使用) +4. [故障排查](#故障排查) + +--- + +## 🏗️ 系统架构 + +### 组件说明 + +``` +外部用户 (HTTPS) + ↓ +Traefik (:443) + ↓ +Kong Gateway (:18000) + ↓ +┌────────────────────────────────┐ +│ Supabase Services │ +├────────────────────────────────┤ +│ • Auth (GoTrue) - 用户认证 │ +│ • REST (PostgREST) - 数据API │ +│ • Storage - 对象存储 │ +│ • Realtime - 实时订阅 │ +└────────────────────────────────┘ + ↓ +PostgreSQL + MinIO +``` + +### 访问地址 + +| 服务 | 地址 | 用途 | +|------|------|------| +| 公网 API | https://amiap.hzau.edu.cn/supa | 所有 API 入口 | +| Storage API | https://amiap.hzau.edu.cn/supa/storage/v1 | 对象存储 | +| Dashboard | http://100.64.0.2:18000 | 管理后台(内网) | + +### 数据存储 + +- **PostgreSQL 数据**: `./volumes/db/data/` +- **对象存储数据**: `/vol1/1000/s3/stub/` + +--- + +## 🚀 服务启动与管理 + +### 启动服务 + +```bash +cd /vol1/1000/docker_server/traefik/supabase-stack + +# 启动所有服务(包含 MinIO 对象存储后端) +docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +``` + +**说明**: +- `docker-compose.yml` - Supabase 核心服务 +- `docker-compose.s3.yml` - MinIO 存储后端(**必需**) + +### 查看状态 + +```bash +# 查看所有容器 +docker compose ps + +# 查看日志 +docker compose logs -f storage +docker compose logs -f kong +``` + +### 停止/重启 + +```bash +# 停止服务 +docker compose stop + +# 重启特定服务 +docker compose restart storage +docker compose restart minio + +# 完全停止并删除容器(数据保留) +docker compose down +``` + +### 环境变量 + +关键密钥在 `.env` 文件中: + +```bash +# 服务密钥(后端使用) +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NjM4MDI2NjksImV4cCI6MjA3OTE2MjY2OX0.gQWUaTkZ6mjjlv2TED0cODp2meqqWuCGKZR1ptIbovg + +# 匿名密钥(前端使用) +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg +``` + +--- + +## 📦 Storage API 使用 + +### API 端点 + +``` +Base URL: https://amiap.hzau.edu.cn/supa +Storage: https://amiap.hzau.edu.cn/supa/storage/v1 +``` + +### 完整 Python 示例 + +保存为 `storage_client.py`: + +```python +import requests +from pathlib import Path + +class StorageClient: + def __init__(self, base_url, api_key): + self.base_url = base_url + self.headers = { + 'apikey': api_key, + 'Authorization': f'Bearer {api_key}' + } + + def create_bucket(self, name, public=False): + """创建 bucket""" + response = requests.post( + f'{self.base_url}/storage/v1/bucket', + headers=self.headers, + json={'name': name, 'public': public} + ) + return response.ok + + def upload_file(self, bucket, file_path, storage_path=None): + """上传文件""" + if not storage_path: + storage_path = Path(file_path).name + + with open(file_path, 'rb') as f: + response = requests.post( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers, + files={'file': f} + ) + return response.json() if response.ok else None + + def download_file(self, bucket, storage_path, local_path): + """下载文件""" + response = requests.get( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers + ) + if response.ok: + with open(local_path, 'wb') as f: + f.write(response.content) + return True + return False + + def create_signed_url(self, bucket, storage_path, expires_in=3600): + """生成临时下载链接""" + response = requests.post( + f'{self.base_url}/storage/v1/object/sign/{bucket}/{storage_path}', + headers=self.headers, + json={'expiresIn': expires_in} + ) + if response.ok: + return self.base_url + response.json()['signedURL'] + return None + +# 使用示例 +if __name__ == '__main__': + client = StorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' + ) + + # 创建 bucket + client.create_bucket('my-bucket') + + # 上传文件 + client.upload_file('my-bucket', 'photo.jpg', 'uploads/photo.jpg') + + # 生成临时链接 + url = client.create_signed_url('my-bucket', 'uploads/photo.jpg') + print(f'下载链接: {url}') +``` + +### 完整 JavaScript 示例 + +```javascript +class StorageClient { + constructor(baseUrl, apiKey) { + this.baseUrl = baseUrl; + this.headers = { + 'apikey': apiKey, + 'Authorization': `Bearer ${apiKey}` + }; + } + + async createBucket(name, isPublic = false) { + const response = await fetch(`${this.baseUrl}/storage/v1/bucket`, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name, public: isPublic }) + }); + return response.ok; + } + + async uploadFile(bucket, file, storagePath) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { + method: 'POST', + headers: this.headers, + body: formData + } + ); + return response.ok ? await response.json() : null; + } + + async createSignedUrl(bucket, storagePath, expiresIn = 3600) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/sign/${bucket}/${storagePath}`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ expiresIn }) + } + ); + if (response.ok) { + const data = await response.json(); + return this.baseUrl + data.signedURL; + } + return null; + } +} + +// 使用示例 +const client = new StorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' +); + +// 上传文件 +const fileInput = document.querySelector('input[type="file"]'); +fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + await client.uploadFile('my-bucket', file, `uploads/${file.name}`); +}); +``` + +完整代码示例请查看:`storage_client.py` 和 `storage_client.js` + +--- + +## 🔧 故障排查 + +### 常见问题 + +**1. 服务无法启动** +```bash +# 检查端口占用 +docker compose ps +netstat -tulpn | grep -E ":(8000|5432|9000)" + +# 查看日志 +docker compose logs storage +``` + +**2. API 返回 403** +```bash +# 检查密钥是否正确 +grep SERVICE_ROLE_KEY .env + +# 测试 API +curl -I https://amiap.hzau.edu.cn/supa/storage/v1/bucket \ + -H "apikey: YOUR_KEY" +``` + +**3. 上传失败** +```bash +# 检查 MinIO 是否运行 +docker compose ps minio + +# 查看 Storage 日志 +docker compose logs storage | tail -50 +``` + +### 健康检查 + +```bash +# 快速测试脚本 +python3 test_https_storage.py + +# 手动测试 +curl https://amiap.hzau.edu.cn/supa/rest/v1/ +``` + +### 备份 + +```bash +# 备份数据库 +docker exec supabase-db pg_dump -U postgres > backup.sql + +# 备份对象存储 +tar -czf s3_backup.tar.gz /vol1/1000/s3/ +``` + +--- + +## 📚 相关文档 + +- `storage_client.py` - Python 完整客户端代码 +- `storage_client.js` - JavaScript 完整客户端代码 +- `test_https_storage.py` - 测试脚本 +- `VUE_API_INTEGRATION.md` - Vue 集成指南 + +--- + +## 🎯 快速开始 + +```bash +# 1. 启动服务 +docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d + +# 2. 测试 +python3 test_https_storage.py + +# 3. 查看文档 +cat storage_client.py +``` + +完成!现在可以开始使用 Supabase Storage 了。 diff --git a/supabase-stack/docs/QUICK_START.md b/supabase-stack/docs/QUICK_START.md new file mode 100644 index 0000000..80ef7c0 --- /dev/null +++ b/supabase-stack/docs/QUICK_START.md @@ -0,0 +1,76 @@ +# Supabase Stack 快速参考 + +## 🚀 启动服务 + +```bash +cd /vol1/1000/docker_server/traefik/supabase-stack + +# 启动所有服务(包含 MinIO 对象存储) +docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d + +# 查看状态 +docker compose ps + +# 查看日志 +docker compose logs -f storage +``` + +## 📦 Storage API 访问 + +**端点**: `https://amiap.hzau.edu.cn/supa/storage/v1` + +**密钥**: +```bash +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NjM4MDI2NjksImV4cCI6MjA3OTE2MjY2OX0.gQWUaTkZ6mjjlv2TED0cODp2meqqWuCGKZR1ptIbovg +``` + +## 💻 快速使用 + +### Python +```python +from storage_client import SupabaseStorageClient + +client = SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' +) + +# 上传文件 +client.upload_file('bucket', 'photo.jpg', 'uploads/photo.jpg') + +# 生成临时链接 +url = client.create_signed_url('bucket', 'uploads/photo.jpg', 3600) +``` + +### JavaScript +```javascript +const client = new SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' +); + +// 上传文件 +await client.uploadFile('bucket', fileObject, 'uploads/photo.jpg'); + +// 生成临时链接 +const url = await client.createSignedUrl('bucket', 'uploads/photo.jpg', 3600); +``` + +## 📚 文档 + +- **OPERATIONS_GUIDE.md** - 完整运维指南(必读) +- **storage_client.py** - Python 完整客户端代码 +- **storage_client.js** - JavaScript 完整客户端代码 +- **test_https_storage.py** - 测试脚本 + +## 🧪 测试 + +```bash +python3 test_https_storage.py +``` + +## ⚠️ 重要 + +- **MinIO 必须运行**:docker-compose.s3.yml 不能删除 +- **数据位置**:/vol1/1000/s3/stub/ +- **不要直接访问 MinIO**:统一通过 Storage API diff --git a/supabase-stack/docs/VUE_API_INTEGRATION.md b/supabase-stack/docs/VUE_API_INTEGRATION.md new file mode 100644 index 0000000..92a5392 --- /dev/null +++ b/supabase-stack/docs/VUE_API_INTEGRATION.md @@ -0,0 +1,1239 @@ +# Supabase API 集成文档 - Vue 前端对接指南 + +## 📋 目录 + +- [基本信息](#基本信息) +- [快速开始](#快速开始) +- [API 端点](#api-端点) +- [认证配置](#认证配置) +- [REST API 使用](#rest-api-使用) +- [Auth API 使用](#auth-api-使用) +- [Storage API 使用](#storage-api-使用) +- [Realtime 使用](#realtime-使用) +- [完整示例](#完整示例) +- [错误处理](#错误处理) + +--- + +## 基本信息 + +### 🌐 服务地址 + +``` +Base URL: https://amiap.hzau.edu.cn/supa +内网地址: http://100.64.0.2:18000 +Dashboard: http://100.64.0.2:18000 (内网访问) +``` + +### 🔑 认证密钥 + +```javascript +// 从 .env 文件获取 +const SUPABASE_URL = 'https://amiap.hzau.edu.cn/supa' +const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg' + +// ⚠️ 仅后端使用!不要暴露给前端 +const SUPABASE_SERVICE_ROLE_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NjM4MDI2NjksImV4cCI6MjA3OTE2MjY2OX0.gQWUaTkZ6mjjlv2TED0cODp2meqqWuCGKZR1ptIbovg' +``` + +--- + +## 快速开始 + +### 1. 安装 Supabase 客户端 + +```bash +npm install @supabase/supabase-js +``` + +### 2. 创建 Supabase 客户端 + +```javascript +// src/lib/supabaseClient.js +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = 'https://amiap.hzau.edu.cn/supa' +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg' + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) +``` + +### 3. 在 Vue 组件中使用 + +```vue + +``` + +--- + +## API 端点 + +### ✅ 可用的 API 端点 + +| 服务 | 端点 | 用途 | 状态 | +|------|------|------|------| +| REST API | `https://amiap.hzau.edu.cn/supa/rest/v1/` | 数据库操作 | ✅ 可用 | +| Auth API | `https://amiap.hzau.edu.cn/supa/auth/v1/` | 用户认证 | ✅ 可用 | +| Storage API | `https://amiap.hzau.edu.cn/supa/storage/v1/` | 文件存储 | ✅ 可用 | +| Realtime | `wss://amiap.hzau.edu.cn/supa/realtime/v1/` | 实时订阅 | ✅ 可用 | +| Edge Functions | `https://amiap.hzau.edu.cn/supa/functions/v1/` | 云函数 | ⚠️ 需配置 | + +--- + +## 认证配置 + +### 环境变量配置 + +创建 `.env` 文件: + +```env +VITE_SUPABASE_URL=https://amiap.hzau.edu.cn/supa +VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzYzODAyNjY5LCJleHAiOjIwNzkxNjI2Njl9.ltGXvQKpguLaf8Vzomn310hLgOZbrjqZT-F3rR00ulg +``` + +### 使用环境变量 + +```javascript +// src/lib/supabaseClient.js +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) +``` + +--- + +## REST API 使用 + +### 查询数据 + +```javascript +// 查询所有记录 +const { data, error } = await supabase + .from('users') + .select('*') + +// 条件查询 +const { data, error } = await supabase + .from('users') + .select('id, name, email') + .eq('status', 'active') + .order('created_at', { ascending: false }) + .limit(10) + +// 关联查询 +const { data, error } = await supabase + .from('posts') + .select(` + *, + author:users(name, email), + comments(*) + `) +``` + +### 插入数据 + +```javascript +const { data, error } = await supabase + .from('users') + .insert([ + { name: 'John Doe', email: 'john@example.com' } + ]) + .select() +``` + +### 更新数据 + +```javascript +const { data, error } = await supabase + .from('users') + .update({ status: 'inactive' }) + .eq('id', userId) + .select() +``` + +### 删除数据 + +```javascript +const { data, error } = await supabase + .from('users') + .delete() + .eq('id', userId) +``` + +--- + +## Auth API 使用 + +### 用户注册 + +```javascript +const { data, error } = await supabase.auth.signUp({ + email: 'user@example.com', + password: 'password123', + options: { + data: { + first_name: 'John', + last_name: 'Doe' + } + } +}) +``` + +### 用户登录 + +```javascript +// 邮箱密码登录 +const { data, error } = await supabase.auth.signInWithPassword({ + email: 'user@example.com', + password: 'password123' +}) + +// OAuth 登录(需要配置) +const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google' +}) +``` + +### 获取当前用户 + +```javascript +const { data: { user } } = await supabase.auth.getUser() +``` + +### 退出登录 + +```javascript +const { error } = await supabase.auth.signOut() +``` + +### 监听认证状态 + +```javascript +supabase.auth.onAuthStateChange((event, session) => { + console.log(event, session) + + if (event === 'SIGNED_IN') { + console.log('用户已登录:', session.user) + } + if (event === 'SIGNED_OUT') { + console.log('用户已退出') + } +}) +``` + +### Vue 3 Composition API 示例 + +```vue + + + +``` + +--- + +## Storage API 使用 + +### 上传文件 + +```javascript +const file = event.target.files[0] +const fileName = `${Date.now()}-${file.name}` + +const { data, error } = await supabase.storage + .from('avatars') + .upload(fileName, file) + +if (error) { + console.error('上传失败:', error) +} else { + console.log('上传成功:', data) +} +``` + +### 下载文件 + +```javascript +const { data, error } = await supabase.storage + .from('avatars') + .download('path/to/file.jpg') +``` + +### 获取公开 URL + +```javascript +const { data } = supabase.storage + .from('avatars') + .getPublicUrl('path/to/file.jpg') + +console.log('公开URL:', data.publicUrl) +``` + +### 删除文件 + +```javascript +const { data, error } = await supabase.storage + .from('avatars') + .remove(['path/to/file.jpg']) +``` + +### Vue 文件上传组件示例 + +```vue + + + +``` + +--- + +## Realtime 使用 + +### 订阅数据变化 + +```javascript +// 订阅表的所有变化 +const channel = supabase + .channel('public:users') + .on('postgres_changes', + { event: '*', schema: 'public', table: 'users' }, + (payload) => { + console.log('数据变化:', payload) + } + ) + .subscribe() + +// 订阅特定操作 +const channel = supabase + .channel('public:posts') + .on('postgres_changes', + { event: 'INSERT', schema: 'public', table: 'posts' }, + (payload) => { + console.log('新增记录:', payload.new) + } + ) + .on('postgres_changes', + { event: 'UPDATE', schema: 'public', table: 'posts' }, + (payload) => { + console.log('更新记录:', payload.new) + } + ) + .on('postgres_changes', + { event: 'DELETE', schema: 'public', table: 'posts' }, + (payload) => { + console.log('删除记录:', payload.old) + } + ) + .subscribe() +``` + +### 取消订阅 + +```javascript +const removeChannel = async () => { + await supabase.removeChannel(channel) +} +``` + +### Vue 实时数据组件示例 + +```vue + + + +``` + +--- + +## 完整示例 + +### 完整的 CRUD 示例 + +```vue + + + +``` + +--- + +## 错误处理 + +### 统一错误处理 + +```javascript +// src/lib/errorHandler.js +export const handleSupabaseError = (error) => { + if (!error) return null + + // 认证错误 + if (error.status === 401) { + console.error('认证失败,请重新登录') + // 跳转到登录页 + router.push('/login') + } + + // 权限错误 + if (error.status === 403) { + console.error('权限不足') + } + + // 网络错误 + if (error.message && error.message.includes('fetch')) { + console.error('网络连接失败') + } + + return error.message || '操作失败' +} +``` + +### 使用错误处理 + +```javascript +import { handleSupabaseError } from '@/lib/errorHandler' + +const fetchData = async () => { + const { data, error } = await supabase + .from('users') + .select('*') + + if (error) { + const errorMsg = handleSupabaseError(error) + alert(errorMsg) + return + } + + // 处理成功的数据 + console.log(data) +} +``` + +--- + +## 🔧 数据库 Schema 示例 + +### 创建表(在 Dashboard 中执行) + +```sql +-- 创建用户表 +CREATE TABLE users ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + avatar_url TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 创建帖子表 +CREATE TABLE posts ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + content TEXT, + published BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 启用 RLS (Row Level Security) +ALTER TABLE users ENABLE ROW LEVEL SECURITY; +ALTER TABLE posts ENABLE ROW LEVEL SECURITY; + +-- 创建策略 +CREATE POLICY "用户可以查看所有用户" ON users + FOR SELECT USING (true); + +CREATE POLICY "用户只能更新自己的数据" ON users + FOR UPDATE USING (auth.uid() = id); + +CREATE POLICY "所有人可以查看已发布的帖子" ON posts + FOR SELECT USING (published = true); + +CREATE POLICY "用户可以创建自己的帖子" ON posts + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "用户可以更新自己的帖子" ON posts + FOR UPDATE USING (auth.uid() = user_id); +``` + +--- + +## 📚 更多资源 + +- [Supabase 官方文档](https://supabase.com/docs) +- [Supabase JS 客户端文档](https://supabase.com/docs/reference/javascript) +- [Vue 3 官方文档](https://vuejs.org/) + +--- + +## ⚠️ 注意事项 + +1. **ANON_KEY 是公开的**:可以在前端代码中使用 +2. **SERVICE_ROLE_KEY 必须保密**:只能在后端使用 +3. **使用 RLS (Row Level Security)**:保护数据安全 +4. **Dashboard 访问**:只能通过内网 `http://100.64.0.2:18000` 访问 +5. **API 端点**:通过 `https://amiap.hzau.edu.cn/supa` 访问所有服务 + +--- + +## 🎯 下一步 + +1. 在 Dashboard 中创建数据库表 +2. 配置 RLS 策略 +3. 在 Vue 应用中集成 Supabase +4. 开发认证、CRUD、文件上传等功能 +5. 测试实时订阅功能 + +--- + +## 📦 S3 对象存储配置与使用 + +### 概述 + +Supabase Storage 已配置使用 **MinIO** 作为 S3 兼容的对象存储后端。所有文件都会保存在宿主机的 `/vol1/1000/s3` 目录。 + +### 🔧 启动 S3 存储服务 + +```bash +cd /vol1/1000/docker_server/traefik/supabase-stack +docker compose -f docker-compose.yml -f docker-compose.s3.yml up -d +``` + +**说明**: +- `docker-compose.yml`: 主配置文件(包含所有 Supabase 服务) +- `docker-compose.s3.yml`: MinIO 配置文件(在 `/vol1/1000/docker_server/traefik/supabase-stack/` 目录) +- 所有对象文件实际存储在:`/vol1/1000/s3/stub//...` + +--- + +### 🌐 S3 访问端点 + +#### 统一 S3 端点(推荐) + +``` +https://amiap.hzau.edu.cn/supa/storage/v1/s3 +``` + +这是标准的 Supabase Storage S3 协议端点,完全兼容 AWS S3 API。 + +#### MinIO 管理控制台(可选) + +``` +http://100.64.0.2:9001 +用户名: supa-storage +密码: secret519521 +``` + +--- + +### 🔑 S3 认证密钥 + +#### 1. S3 协议访问密钥(推荐用于应用) + +用于通过 Supabase Storage 的 S3 端点访问: + +```bash +# 从 .env 文件 +S3_PROTOCOL_ACCESS_KEY_ID=supa-protocol-key +S3_PROTOCOL_ACCESS_KEY_SECRET=supa-protocol-secret +S3_PROTOCOL_REGION=stub +``` + +#### 2. MinIO 根密钥(内部使用) + +用于 storage 服务连接 MinIO,一般不直接使用: + +```bash +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=secret1234 +``` + +--- + +### 💻 客户端配置示例 + +#### Python (boto3) + +```python +import boto3 +from botocore.client import Config + +# 配置 S3 客户端连接到 Supabase Storage +s3_client = boto3.client( + 's3', + endpoint_url='https://amiap.hzau.edu.cn/supa/storage/v1/s3', + aws_access_key_id='supa-protocol-key', + aws_secret_access_key='supa-protocol-secret', + region_name='stub', + config=Config( + signature_version='s3v4', + s3={'addressing_style': 'path'} + ), + verify=True # HTTPS 验证 +) + +# 列出所有 buckets +response = s3_client.list_buckets() +for bucket in response['Buckets']: + print(f"Bucket: {bucket['Name']}") + +# 上传文件 +with open('example.jpg', 'rb') as file: + s3_client.upload_fileobj( + file, + 'my-bucket', # bucket 名称 + 'uploads/example.jpg' # 对象路径 + ) + +# 下载文件 +s3_client.download_file( + 'my-bucket', + 'uploads/example.jpg', + 'downloaded.jpg' +) + +# 生成预签名 URL(临时访问链接) +url = s3_client.generate_presigned_url( + 'get_object', + Params={'Bucket': 'my-bucket', 'Key': 'uploads/example.jpg'}, + ExpiresIn=3600 # 1小时过期 +) +print(f"Presigned URL: {url}") +``` + +#### JavaScript/Node.js (AWS SDK v3) + +```javascript +import { S3Client, ListBucketsCommand, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import fs from 'fs' + +// 配置 S3 客户端 +const s3Client = new S3Client({ + endpoint: 'https://amiap.hzau.edu.cn/supa/storage/v1/s3', + region: 'stub', + credentials: { + accessKeyId: 'supa-protocol-key', + secretAccessKey: 'supa-protocol-secret', + }, + forcePathStyle: true, // 使用路径风格访问 +}) + +// 列出 buckets +const listBuckets = async () => { + const command = new ListBucketsCommand({}) + const response = await s3Client.send(command) + console.log('Buckets:', response.Buckets) +} + +// 上传文件 +const uploadFile = async () => { + const fileContent = fs.readFileSync('example.jpg') + + const command = new PutObjectCommand({ + Bucket: 'my-bucket', + Key: 'uploads/example.jpg', + Body: fileContent, + ContentType: 'image/jpeg', + }) + + const response = await s3Client.send(command) + console.log('Upload success:', response) +} + +// 生成预签名 URL +const getPresignedUrl = async () => { + const command = new GetObjectCommand({ + Bucket: 'my-bucket', + Key: 'uploads/example.jpg', + }) + + const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 }) + console.log('Presigned URL:', url) + return url +} + +// 执行 +await listBuckets() +await uploadFile() +await getPresignedUrl() +``` + +#### PostgreSQL Foreign Data Wrapper (FDW) + +在 PostgreSQL 中直接查询 S3 对象: + +```sql +-- 1. 启用扩展 +CREATE EXTENSION IF NOT EXISTS wrappers CASCADE; + +-- 2. 创建 S3 服务器连接 +CREATE SERVER s3_server + FOREIGN DATA WRAPPER s3_wrapper + OPTIONS ( + aws_access_key_id 'supa-protocol-key', + aws_secret_access_key 'supa-protocol-secret', + aws_region 'stub', + endpoint_url 'https://amiap.hzau.edu.cn/supa/storage/v1/s3', + path_style_url 'true' + ); + +-- 3. 创建外部表映射到 S3 对象(例如 Parquet 文件) +CREATE FOREIGN TABLE my_s3_table ( + id bigint, + name text, + created_at timestamp +) +SERVER s3_server +OPTIONS ( + uri 's3://my-bucket/data/file.parquet', + format 'parquet' +); + +-- 4. 查询 S3 数据 +SELECT * FROM my_s3_table WHERE created_at > NOW() - INTERVAL '7 days'; +``` + +#### AWS CLI + +```bash +# 配置 AWS CLI +export AWS_ACCESS_KEY_ID=supa-protocol-key +export AWS_SECRET_ACCESS_KEY=supa-protocol-secret +export AWS_DEFAULT_REGION=stub + +# 或创建 AWS 配置文件 ~/.aws/config +# [profile supabase] +# region = stub +# output = json + +# ~/.aws/credentials +# [supabase] +# aws_access_key_id = supa-protocol-key +# aws_secret_access_key = supa-protocol-secret + +# 列出 buckets +aws s3 ls --endpoint-url=https://amiap.hzau.edu.cn/supa/storage/v1/s3 + +# 列出 bucket 中的对象 +aws s3 ls s3://my-bucket/ --endpoint-url=https://amiap.hzau.edu.cn/supa/storage/v1/s3 + +# 上传文件 +aws s3 cp example.jpg s3://my-bucket/uploads/ \ + --endpoint-url=https://amiap.hzau.edu.cn/supa/storage/v1/s3 + +# 下载文件 +aws s3 cp s3://my-bucket/uploads/example.jpg downloaded.jpg \ + --endpoint-url=https://amiap.hzau.edu.cn/supa/storage/v1/s3 + +# 同步目录 +aws s3 sync ./local-dir s3://my-bucket/backup/ \ + --endpoint-url=https://amiap.hzau.edu.cn/supa/storage/v1/s3 +``` + +#### Vue 3 + Supabase Client(推荐) + +```vue + + + +``` + +--- + +### 🗂️ 数据存储位置 + +**所有上传的文件实际保存在**: + +``` +/vol1/1000/s3/stub// +``` + +示例: +``` +/vol1/1000/s3/ +└── stub/ + ├── my-bucket/ + │ ├── uploads/ + │ │ ├── image1.jpg + │ │ └── image2.png + │ └── documents/ + │ └── report.pdf + └── avatars/ + └── user123.jpg +``` + +--- + +### 🔒 安全建议 + +1. **生产环境密钥**:修改 `.env` 中的默认密钥 +2. **HTTPS 访问**:外部访问使用 HTTPS 端点 +3. **Bucket 权限**:合理配置 RLS 策略 +4. **MinIO 控制台**:仅内网访问(端口 9001) + +--- + +### ❓ 常见问题 + +**Q: 文件存在哪里?** +A: 所有文件物理存储在 `/vol1/1000/s3/stub//` + +**Q: 如何备份数据?** +A: 直接备份 `/vol1/1000/s3` 目录即可 + +**Q: 可以直接访问 MinIO 吗?** +A: 可以,但推荐使用 Supabase Storage 的 S3 端点(更安全) + +**Q: 如何迁移现有文件到 S3?** +A: 使用 AWS CLI 或 Python 脚本批量上传到对应 bucket + +--- + +### 📝 在 Dashboard 中创建 Bucket + +1. 访问 Dashboard: `http://100.64.0.2:18000` +2. 登录后进入 **Storage** 页面 +3. 点击 **New Bucket** +4. 输入 bucket 名称(例如:`my-bucket`) +5. 配置访问权限(Public/Private) +6. 创建完成后,文件会保存在 `/vol1/1000/s3/stub/my-bucket/` + +--- + +### ⚙️ 配置文件说明 + +#### .env 配置 + +```bash +# MinIO 内部认证 +MINIO_ROOT_USER=supa-storage +MINIO_ROOT_PASSWORD=secret1234 + +# S3 协议访问密钥(用于外部客户端) +S3_PROTOCOL_ACCESS_KEY_ID=supa-protocol-key +S3_PROTOCOL_ACCESS_KEY_SECRET=supa-protocol-secret +S3_PROTOCOL_REGION=stub + +# S3 配置 +GLOBAL_S3_BUCKET=stub +GLOBAL_S3_ENDPOINT=http://minio:9000 +GLOBAL_S3_PROTOCOL=http +GLOBAL_S3_FORCE_PATH_STYLE=true +AWS_DEFAULT_REGION=stub +``` + +#### docker-compose.s3.yml + +```yaml +services: + minio: + image: minio/minio + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web 控制台 + environment: + MINIO_ROOT_USER: supa-storage + MINIO_ROOT_PASSWORD: secret1234 + command: server --console-address ":9001" /data + volumes: + - /vol1/1000/s3:/data:z # 数据存储位置 + + minio-createbucket: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; + /usr/bin/mc mb supa-minio/stub; + exit 0; + " +``` + +--- + +### 🔧 验证 S3 配置 + +#### 1. 检查 MinIO 服务状态 + +```bash +docker compose ps minio +``` + +#### 2. 使用 MinIO Client 验证 + +```bash +# 进入 MinIO 容器 +docker exec -it $(docker ps -qf "name=minio") sh + +# 配置 mc 别名 +mc alias set local http://localhost:9000 supa-storage secret1234 + +# 列出 buckets +mc ls local/ + +# 查看 stub bucket +mc ls local/stub/ + +# 退出 +exit +``` + +#### 3. 测试 S3 API 端点 + +```bash +# 测试 endpoint 是否可访问 +curl -I https://amiap.hzau.edu.cn/supa/storage/v1/s3 +``` + +--- + +### 📚 相关资源 + +- [Supabase Storage 文档](https://supabase.com/docs/guides/storage) +- [MinIO 文档](https://min.io/docs/) +- [AWS S3 API 文档](https://docs.aws.amazon.com/s3/) +- [boto3 文档](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) \ No newline at end of file diff --git a/supabase-stack/docs/versions.md b/supabase-stack/docs/versions.md new file mode 100644 index 0000000..4eae0c4 --- /dev/null +++ b/supabase-stack/docs/versions.md @@ -0,0 +1,56 @@ +# Docker Image Versions + +## 2025-11-12 +- supabase/studio:2025.11.10-sha-5291fe3 (prev 2025.10.27-sha-85b84e0) +- supabase/gotrue:v2.182.1 (prev v2.180.0) +- supabase/realtime:v2.63.0 (prev v2.57.2) +- supabase/storage-api:v1.29.0 (prev v1.28.2) +- supabase/edge-runtime:v1.69.23 (prev v1.69.15) +- supabase/supavisor:2.7.4 (prev 2.7.3) + +## 2025-10-28 +- supabase/studio:2025.10.27-sha-85b84e0 (prev 2025.10.20-sha-5005fc6) +- supabase/realtime:v2.57.2 (prev v2.56.0) +- supabase/storage-api:v1.28.2 (prev v1.28.1) +- supabase/postgres-meta:v0.93.1 (prev v0.93.0) +- supabase/edge-runtime:v1.69.15 (prev v1.69.14) + +## 2025-10-21 +- supabase/studio:2025.10.20-sha-5005fc6 (prev 2025.10.01-sha-8460121) +- supabase/realtime:v2.56.0 (prev v2.51.11) +- supabase/storage-api:v1.28.1 (prev v1.28.0) +- supabase/postgres-meta:v0.93.0 (prev v0.91.6) +- supabase/edge-runtime:v1.69.14 (prev v1.69.6) +- supabase/supavisor:2.7.3 (prev 2.7.0) + +## 2025-10-13 +- supabase/logflare:1.22.6 (prev 1.22.4) + +## 2025-10-08 +- supabase/studio:2025.10.01-sha-8460121 (prev 2025.06.30-sha-6f5982d) +- supabase/gotrue:v2.180.0 (prev v2.177.0) +- postgrest/postgrest:v13.0.7 (prev v12.2.12) +- supabase/realtime:v2.51.11 (prev v2.34.47) +- supabase/storage-api:v1.28.0 (prev v1.25.7) +- supabase/postgres-meta:v0.91.6 (prev v0.91.0) +- supabase/logflare:1.22.4 (prev 1.14.2) +- supabase/postgres:15.8.1.085 (prev 15.8.1.060) +- supabase/supavisor:2.7.0 (prev 2.5.7) + +## 2025-07-15 +- supabase/gotrue:v2.177.0 (prev v2.176.1) +- supabase/storage-api:v1.25.7 (prev v1.24.7) +- supabase/postgres-meta:v0.91.0 (prev v0.89.3) +- supabase/supavisor:2.5.7 (prev 2.5.6) + +## 2025-07-02 +- supabase/studio:2025.06.30-sha-6f5982d (prev 2025.06.02-sha-8f2993d) +- supabase/gotrue:v2.176.1 (prev v2.174.0) +- supabase/storage-api:v1.24.7 (prev v1.23.0) +- supabase/supavisor:2.5.6 (prev 2.5.1) + +## 2025-06-03 +- supabase/studio:2025.06.02-sha-8f2993d (prev 2025.05.19-sha-3487831) +- supabase/gotrue:v2.174.0 (prev v2.172.1) +- supabase/storage-api:v1.23.0 (prev v1.22.17) +- supabase/postgres-meta:v0.89.3 (prev v0.89.0) diff --git a/supabase-stack/examples/storage_client.js b/supabase-stack/examples/storage_client.js new file mode 100644 index 0000000..d00ed98 --- /dev/null +++ b/supabase-stack/examples/storage_client.js @@ -0,0 +1,467 @@ +/** + * Supabase Storage REST API 完整客户端 + * 无需 SDK,纯 JavaScript/TypeScript 实现 + * + * 使用示例: + * + * const client = new SupabaseStorageClient( + * 'https://amiap.hzau.edu.cn/supa', + * 'your-service-role-key' + * ); + * + * // 创建 bucket + * await client.createBucket('my-bucket'); + * + * // 上传文件 + * await client.uploadFile('my-bucket', fileObject, 'uploads/photo.jpg'); + * + * // 生成临时下载链接 + * const url = await client.createSignedUrl('my-bucket', 'uploads/photo.jpg', 3600); + */ + +class SupabaseStorageClient { + /** + * 初始化客户端 + * @param {string} baseUrl - Supabase Base URL + * @param {string} apiKey - SERVICE_ROLE_KEY 或 ANON_KEY + */ + constructor(baseUrl, apiKey) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.headers = { + 'apikey': apiKey, + 'Authorization': `Bearer ${apiKey}` + }; + } + + // ==================== Bucket 管理 ==================== + + /** + * 列出所有 buckets + * @returns {Promise} + */ + async listBuckets() { + const response = await fetch(`${this.baseUrl}/storage/v1/bucket`, { + headers: this.headers + }); + return response.ok ? await response.json() : null; + } + + /** + * 创建 bucket + * @param {string} name - bucket 名称 + * @param {boolean} isPublic - 是否公开 + * @param {number} fileSizeLimit - 文件大小限制(字节) + * @returns {Promise} + */ + async createBucket(name, isPublic = false, fileSizeLimit = 52428800) { + const response = await fetch(`${this.baseUrl}/storage/v1/bucket`, { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name, + public: isPublic, + file_size_limit: fileSizeLimit + }) + }); + return response.ok || response.status === 409; + } + + /** + * 删除 bucket + * @param {string} name - bucket 名称 + * @returns {Promise} + */ + async deleteBucket(name) { + const response = await fetch(`${this.baseUrl}/storage/v1/bucket/${name}`, { + method: 'DELETE', + headers: this.headers + }); + return response.ok; + } + + /** + * 清空 bucket + * @param {string} name - bucket 名称 + * @returns {Promise} + */ + async emptyBucket(name) { + const response = await fetch(`${this.baseUrl}/storage/v1/bucket/${name}/empty`, { + method: 'POST', + headers: this.headers + }); + return response.ok; + } + + // ==================== 文件上传 ==================== + + /** + * 上传文件 + * @param {string} bucket - bucket 名称 + * @param {File} file - 文件对象 + * @param {string} storagePath - 云端存储路径 + * @returns {Promise} + */ + async uploadFile(bucket, file, storagePath) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { + method: 'POST', + headers: this.headers, + body: formData + } + ); + return response.ok ? await response.json() : null; + } + + /** + * 上传字节数据 + * @param {string} bucket - bucket 名称 + * @param {Blob|ArrayBuffer} data - 数据 + * @param {string} storagePath - 云端存储路径 + * @param {string} contentType - MIME 类型 + * @returns {Promise} + */ + async uploadBytes(bucket, data, storagePath, contentType = 'application/octet-stream') { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': contentType + }, + body: data + } + ); + return response.ok ? await response.json() : null; + } + + /** + * 更新文件 + * @param {string} bucket - bucket 名称 + * @param {File} file - 文件对象 + * @param {string} storagePath - 云端存储路径 + * @returns {Promise} + */ + async updateFile(bucket, file, storagePath) { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { + method: 'PUT', + headers: this.headers, + body: formData + } + ); + return response.ok ? await response.json() : null; + } + + // ==================== 文件下载 ==================== + + /** + * 下载文件为 Blob + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 云端文件路径 + * @returns {Promise} + */ + async downloadFile(bucket, storagePath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { headers: this.headers } + ); + return response.ok ? await response.blob() : null; + } + + /** + * 下载文件为 ArrayBuffer + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 云端文件路径 + * @returns {Promise} + */ + async downloadBytes(bucket, storagePath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { headers: this.headers } + ); + return response.ok ? await response.arrayBuffer() : null; + } + + /** + * 下载文件为文本 + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 云端文件路径 + * @returns {Promise} + */ + async downloadText(bucket, storagePath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { headers: this.headers } + ); + return response.ok ? await response.text() : null; + } + + // ==================== 文件列表 ==================== + + /** + * 列出文件 + * @param {string} bucket - bucket 名称 + * @param {string} folder - 文件夹路径 + * @param {number} limit - 返回数量限制 + * @param {number} offset - 偏移量 + * @returns {Promise} + */ + async listFiles(bucket, folder = '', limit = 100, offset = 0) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/list/${bucket}`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + prefix: folder, + limit, + offset, + sortBy: { column: 'name', order: 'asc' } + }) + } + ); + return response.ok ? await response.json() : null; + } + + /** + * 搜索文件 + * @param {string} bucket - bucket 名称 + * @param {string} searchText - 搜索关键词 + * @param {number} limit - 返回数量限制 + * @returns {Promise} + */ + async searchFiles(bucket, searchText, limit = 100) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/list/${bucket}`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ search: searchText, limit }) + } + ); + return response.ok ? await response.json() : null; + } + + // ==================== 文件操作 ==================== + + /** + * 移动文件 + * @param {string} bucket - bucket 名称 + * @param {string} fromPath - 源路径 + * @param {string} toPath - 目标路径 + * @returns {Promise} + */ + async moveFile(bucket, fromPath, toPath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/move`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bucketId: bucket, + sourceKey: fromPath, + destinationKey: toPath + }) + } + ); + return response.ok; + } + + /** + * 复制文件 + * @param {string} bucket - bucket 名称 + * @param {string} fromPath - 源路径 + * @param {string} toPath - 目标路径 + * @returns {Promise} + */ + async copyFile(bucket, fromPath, toPath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/copy`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bucketId: bucket, + sourceKey: fromPath, + destinationKey: toPath + }) + } + ); + return response.ok ? await response.json() : null; + } + + /** + * 删除文件 + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 文件路径 + * @returns {Promise} + */ + async deleteFile(bucket, storagePath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}/${storagePath}`, + { + method: 'DELETE', + headers: this.headers + } + ); + return response.ok; + } + + /** + * 批量删除文件 + * @param {string} bucket - bucket 名称 + * @param {Array} filePaths - 文件路径数组 + * @returns {Promise} + */ + async deleteFiles(bucket, filePaths) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/${bucket}`, + { + method: 'DELETE', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ prefixes: filePaths }) + } + ); + return response.ok; + } + + // ==================== URL 生成 ==================== + + /** + * 生成签名 URL(临时下载链接) + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 文件路径 + * @param {number} expiresIn - 过期时间(秒),默认 1 小时 + * @returns {Promise} + */ + async createSignedUrl(bucket, storagePath, expiresIn = 3600) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/sign/${bucket}/${storagePath}`, + { + method: 'POST', + headers: { + ...this.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ expiresIn }) + } + ); + + if (response.ok) { + const data = await response.json(); + // 签名 URL 需要加上 /storage/v1 前缀 + return this.baseUrl + '/storage/v1' + data.signedURL; + } + return null; + } + + /** + * 生成上传签名 URL(允许前端直接上传) + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 文件路径 + * @returns {Promise} {url, token} + */ + async createUploadSignedUrl(bucket, storagePath) { + const response = await fetch( + `${this.baseUrl}/storage/v1/object/upload/sign/${bucket}/${storagePath}`, + { + method: 'POST', + headers: this.headers + } + ); + + if (response.ok) { + const data = await response.json(); + return { + url: this.baseUrl + '/storage/v1' + data.url, + token: data.token + }; + } + return null; + } + + /** + * 获取公开 URL(仅适用于 public bucket) + * @param {string} bucket - bucket 名称 + * @param {string} storagePath - 文件路径 + * @returns {string} + */ + getPublicUrl(bucket, storagePath) { + return `${this.baseUrl}/storage/v1/object/public/${bucket}/${storagePath}`; + } +} + +// ==================== 使用示例 ==================== + +// 在浏览器中使用 +if (typeof window !== 'undefined') { + window.SupabaseStorageClient = SupabaseStorageClient; +} + +// 在 Node.js 中使用 +if (typeof module !== 'undefined' && module.exports) { + module.exports = SupabaseStorageClient; +} + +// 示例代码 +/* +// 初始化客户端 +const client = new SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' +); + +// 上传文件(HTML5 File API) +const fileInput = document.querySelector('input[type="file"]'); +fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + const result = await client.uploadFile('my-bucket', file, `uploads/${file.name}`); + console.log('上传成功:', result); + + // 生成临时下载链接 + const url = await client.createSignedUrl('my-bucket', `uploads/${file.name}`, 3600); + console.log('下载链接:', url); +}); + +// 列出文件 +const files = await client.listFiles('my-bucket', 'uploads/'); +console.log('文件列表:', files); + +// 下载文件 +const blob = await client.downloadFile('my-bucket', 'uploads/photo.jpg'); +const url = URL.createObjectURL(blob); +// 创建下载链接 +const a = document.createElement('a'); +a.href = url; +a.download = 'photo.jpg'; +a.click(); + +查看 OPERATIONS_GUIDE.md 了解更多用法 +*/ diff --git a/supabase-stack/examples/storage_client.py b/supabase-stack/examples/storage_client.py new file mode 100644 index 0000000..a369897 --- /dev/null +++ b/supabase-stack/examples/storage_client.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Supabase Storage REST API 完整客户端 +无需 SDK,仅使用 requests 库 +""" + +import requests +from pathlib import Path +from typing import Optional, List, Dict, Any + +class SupabaseStorageClient: + """ + Supabase Storage REST API 客户端 + + 使用示例: + client = SupabaseStorageClient( + 'https://amiap.hzau.edu.cn/supa', + 'your-service-role-key' + ) + + # 创建 bucket + client.create_bucket('my-bucket') + + # 上传文件 + client.upload_file('my-bucket', 'photo.jpg', 'uploads/photo.jpg') + + # 生成临时下载链接 + url = client.create_signed_url('my-bucket', 'uploads/photo.jpg', 3600) + """ + + def __init__(self, base_url: str, api_key: str): + """ + 初始化客户端 + + Args: + base_url: Supabase Base URL (如 https://amiap.hzau.edu.cn/supa) + api_key: SERVICE_ROLE_KEY 或 ANON_KEY + """ + self.base_url = base_url.rstrip('/') + self.headers = { + 'apikey': api_key, + 'Authorization': f'Bearer {api_key}' + } + + # ==================== Bucket 管理 ==================== + + def list_buckets(self) -> Optional[List[Dict]]: + """列出所有 buckets""" + response = requests.get( + f'{self.base_url}/storage/v1/bucket', + headers=self.headers + ) + return response.json() if response.ok else None + + def create_bucket(self, name: str, public: bool = False, + file_size_limit: int = 52428800) -> bool: + """ + 创建 bucket + + Args: + name: bucket 名称 + public: 是否公开 + file_size_limit: 文件大小限制(字节) + """ + response = requests.post( + f'{self.base_url}/storage/v1/bucket', + headers=self.headers, + json={ + 'name': name, + 'public': public, + 'file_size_limit': file_size_limit + } + ) + return response.ok or response.status_code == 409 + + def delete_bucket(self, name: str) -> bool: + """删除 bucket""" + response = requests.delete( + f'{self.base_url}/storage/v1/bucket/{name}', + headers=self.headers + ) + return response.ok + + def empty_bucket(self, name: str) -> bool: + """清空 bucket""" + response = requests.post( + f'{self.base_url}/storage/v1/bucket/{name}/empty', + headers=self.headers + ) + return response.ok + + # ==================== 文件上传 ==================== + + def upload_file(self, bucket: str, file_path: str, + storage_path: Optional[str] = None) -> Optional[Dict]: + """ + 上传文件 + + Args: + bucket: bucket 名称 + file_path: 本地文件路径 + storage_path: 云端存储路径(默认使用文件名) + + Returns: + {'Key': 'path', 'Id': 'uuid'} 或 None + """ + if not storage_path: + storage_path = Path(file_path).name + + with open(file_path, 'rb') as f: + response = requests.post( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers, + files={'file': f} + ) + + return response.json() if response.ok else None + + def upload_bytes(self, bucket: str, data: bytes, storage_path: str, + content_type: str = 'application/octet-stream') -> Optional[Dict]: + """ + 上传字节数据 + + Args: + bucket: bucket 名称 + data: 字节数据 + storage_path: 云端存储路径 + content_type: MIME 类型 + """ + response = requests.post( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers={**self.headers, 'Content-Type': content_type}, + data=data + ) + return response.json() if response.ok else None + + def update_file(self, bucket: str, file_path: str, storage_path: str) -> Optional[Dict]: + """更新已存在的文件""" + with open(file_path, 'rb') as f: + response = requests.put( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers, + files={'file': f} + ) + return response.json() if response.ok else None + + # ==================== 文件下载 ==================== + + def download_file(self, bucket: str, storage_path: str, local_path: str) -> bool: + """ + 下载文件到本地 + + Args: + bucket: bucket 名称 + storage_path: 云端文件路径 + local_path: 本地保存路径 + """ + response = requests.get( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers + ) + + if response.ok: + with open(local_path, 'wb') as f: + f.write(response.content) + return True + return False + + def download_bytes(self, bucket: str, storage_path: str) -> Optional[bytes]: + """下载文件为字节数据""" + response = requests.get( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers + ) + return response.content if response.ok else None + + # ==================== 文件列表 ==================== + + def list_files(self, bucket: str, folder: str = '', + limit: int = 100, offset: int = 0) -> Optional[List[Dict]]: + """ + 列出文件 + + Args: + bucket: bucket 名称 + folder: 文件夹路径 + limit: 返回数量限制 + offset: 偏移量 + """ + response = requests.post( + f'{self.base_url}/storage/v1/object/list/{bucket}', + headers=self.headers, + json={ + 'prefix': folder, + 'limit': limit, + 'offset': offset, + 'sortBy': {'column': 'name', 'order': 'asc'} + } + ) + return response.json() if response.ok else None + + def search_files(self, bucket: str, search_text: str, limit: int = 100) -> Optional[List[Dict]]: + """搜索文件""" + response = requests.post( + f'{self.base_url}/storage/v1/object/list/{bucket}', + headers=self.headers, + json={'search': search_text, 'limit': limit} + ) + return response.json() if response.ok else None + + # ==================== 文件操作 ==================== + + def move_file(self, bucket: str, from_path: str, to_path: str) -> bool: + """移动文件""" + response = requests.post( + f'{self.base_url}/storage/v1/object/move', + headers=self.headers, + json={ + 'bucketId': bucket, + 'sourceKey': from_path, + 'destinationKey': to_path + } + ) + return response.ok + + def copy_file(self, bucket: str, from_path: str, to_path: str) -> Optional[Dict]: + """复制文件""" + response = requests.post( + f'{self.base_url}/storage/v1/object/copy', + headers=self.headers, + json={ + 'bucketId': bucket, + 'sourceKey': from_path, + 'destinationKey': to_path + } + ) + return response.json() if response.ok else None + + def delete_file(self, bucket: str, storage_path: str) -> bool: + """删除文件""" + response = requests.delete( + f'{self.base_url}/storage/v1/object/{bucket}/{storage_path}', + headers=self.headers + ) + return response.ok + + def delete_files(self, bucket: str, file_paths: List[str]) -> bool: + """批量删除文件""" + response = requests.delete( + f'{self.base_url}/storage/v1/object/{bucket}', + headers=self.headers, + json={'prefixes': file_paths} + ) + return response.ok + + # ==================== URL 生成 ==================== + + def create_signed_url(self, bucket: str, storage_path: str, + expires_in: int = 3600) -> Optional[str]: + """ + 生成签名 URL(临时下载链接) + + Args: + bucket: bucket 名称 + storage_path: 文件路径 + expires_in: 过期时间(秒),默认 1 小时 + + Returns: + 完整的签名 URL + """ + response = requests.post( + f'{self.base_url}/storage/v1/object/sign/{bucket}/{storage_path}', + headers=self.headers, + json={'expiresIn': expires_in} + ) + + if response.ok: + data = response.json() + # 签名 URL 需要加上 /storage/v1 前缀 + return self.base_url + '/storage/v1' + data['signedURL'] + return None + + def create_upload_signed_url(self, bucket: str, storage_path: str) -> Optional[Dict]: + """ + 生成上传签名 URL(允许前端直接上传) + + Returns: + {'url': upload_url, 'token': token} + """ + response = requests.post( + f'{self.base_url}/storage/v1/object/upload/sign/{bucket}/{storage_path}', + headers=self.headers + ) + + if response.ok: + data = response.json() + return { + 'url': self.base_url + '/storage/v1' + data['url'], + 'token': data['token'] + } + return None + + def get_public_url(self, bucket: str, storage_path: str) -> str: + """获取公开 URL(仅适用于 public bucket)""" + return f'{self.base_url}/storage/v1/object/public/{bucket}/{storage_path}' + + +# ==================== 使用示例 ==================== + +if __name__ == '__main__': + # 初始化客户端 + client = SupabaseStorageClient( + base_url='https://amiap.hzau.edu.cn/supa', + api_key='your-service-role-key' + ) + + print("Supabase Storage 客户端示例") + print("=" * 60) + + # 1. 创建 bucket + print("\n1. 创建 bucket...") + client.create_bucket('test-bucket', public=False) + + # 2. 列出 buckets + print("\n2. 列出所有 buckets...") + buckets = client.list_buckets() + if buckets: + for bucket in buckets: + print(f" - {bucket['name']}") + + # 3. 上传文件 + print("\n3. 上传文件...") + result = client.upload_bytes( + 'test-bucket', + b'Hello from Supabase Storage!', + 'test/hello.txt', + 'text/plain' + ) + if result: + print(f" ✓ 上传成功: {result['Key']}") + + # 4. 生成临时下载链接 + print("\n4. 生成临时下载链接...") + url = client.create_signed_url('test-bucket', 'test/hello.txt', 3600) + if url: + print(f" ✓ URL (1小时有效): {url[:80]}...") + + print("\n" + "=" * 60) + print("示例完成!查看 OPERATIONS_GUIDE.md 了解更多用法") diff --git a/supabase-stack/examples/test_https_storage.py b/supabase-stack/examples/test_https_storage.py new file mode 100755 index 0000000..e2a5918 --- /dev/null +++ b/supabase-stack/examples/test_https_storage.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +测试公网 HTTPS Supabase Storage API +快速验证所有功能是否正常 +""" + +import requests +import sys + +BASE_URL = 'https://amiap.hzau.edu.cn/supa' +API_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NjM4MDI2NjksImV4cCI6MjA3OTE2MjY2OX0.gQWUaTkZ6mjjlv2TED0cODp2meqqWuCGKZR1ptIbovg' + +headers = { + 'apikey': API_KEY, + 'Authorization': f'Bearer {API_KEY}' +} + +print("=" * 70) +print(" Supabase Storage 公网 HTTPS 功能测试") +print("=" * 70) +print() +print(f"端点: {BASE_URL}/storage/v1") +print() + +success_count = 0 +total_tests = 5 + +# 测试 1: 列出 Buckets +print("1️⃣ 列出 Buckets") +print("-" * 70) +try: + response = requests.get(f'{BASE_URL}/storage/v1/bucket', headers=headers) + if response.status_code == 200: + buckets = response.json() + print(f"✓ 成功! 找到 {len(buckets)} 个 bucket") + for b in buckets: + print(f" - {b['name']}") + success_count += 1 + else: + print(f"✗ 失败: {response.status_code}") +except Exception as e: + print(f"✗ 错误: {e}") +print() + +# 测试 2: 创建 Bucket(或确认存在) +print("2️⃣ 确认测试 Bucket 存在") +print("-" * 70) +try: + response = requests.post( + f'{BASE_URL}/storage/v1/bucket', + headers=headers, + json={'name': 'test-https', 'public': False} + ) + if response.status_code in [200, 201]: + print("✓ 创建成功") + success_count += 1 + elif response.status_code == 400 and 'already exists' in response.text.lower(): + print("✓ Bucket 已存在(正常)") + success_count += 1 + else: + print(f"⚠️ 响应: {response.status_code} - {response.text[:100]}") + # 检查是否能列出该 bucket(说明存在) + list_resp = requests.get(f'{BASE_URL}/storage/v1/bucket', headers=headers) + if list_resp.ok and any(b['name'] == 'test-https' for b in list_resp.json()): + print("✓ Bucket 已确认存在") + success_count += 1 +except Exception as e: + print(f"✗ 错误: {e}") +print() + +# 测试 3: 上传文件(如果存在则更新) +print("3️⃣ 上传/更新文件") +print("-" * 70) +test_data = b"Hello from HTTPS Storage API!\nThis is a test file.\nTimestamp: " + str(__import__('time').time()).encode() +try: + # 先尝试上传 + response = requests.post( + f'{BASE_URL}/storage/v1/object/test-https/test.txt', + headers={**headers, 'Content-Type': 'text/plain'}, + data=test_data + ) + + if response.status_code in [200, 201]: + print("✓ 上传成功") + print(f" 响应: {response.json()}") + success_count += 1 + elif response.status_code == 400 and 'already exists' in response.text.lower(): + # 文件已存在,使用 PUT 更新 + print(" 文件已存在,尝试更新...") + response = requests.put( + f'{BASE_URL}/storage/v1/object/test-https/test.txt', + headers={**headers, 'Content-Type': 'text/plain'}, + data=test_data + ) + if response.status_code in [200, 201]: + print("✓ 更新成功") + print(f" 响应: {response.json()}") + success_count += 1 + else: + print(f"✗ 更新失败: {response.status_code} - {response.text[:100]}") + else: + print(f"✗ 失败: {response.status_code} - {response.text[:100]}") +except Exception as e: + print(f"✗ 错误: {e}") +print() + +# 测试 4: 下载文件 +print("4️⃣ 下载文件") +print("-" * 70) +try: + response = requests.get( + f'{BASE_URL}/storage/v1/object/test-https/test.txt', + headers=headers + ) + if response.status_code == 200: + content = response.content.decode('utf-8') + print("✓ 下载成功") + print(f" 内容预览: {content[:50]}...") + print(f" 文件大小: {len(response.content)} bytes") + success_count += 1 + else: + print(f"✗ 失败: {response.status_code}") + if response.text: + print(f" 错误信息: {response.text[:100]}") +except Exception as e: + print(f"✗ 错误: {e}") +print() + +# 测试 5: 生成签名 URL +print("5️⃣ 生成签名 URL(临时下载链接)") +print("-" * 70) +try: + response = requests.post( + f'{BASE_URL}/storage/v1/object/sign/test-https/test.txt', + headers=headers, + json={'expiresIn': 3600} + ) + if response.status_code == 200: + data = response.json() + # 签名 URL 需要加上 /storage/v1 前缀 + signed_url = BASE_URL + '/storage/v1' + data['signedURL'] + print("✓ 生成成功") + print(f" 完整 URL:") + print(f" {signed_url}") + print(f" 有效期: 1 小时") + success_count += 1 + + # 验证签名 URL 是否可以下载(不带认证 headers,因为 token 在 URL 中) + print("\n 验证签名 URL 下载...") + try: + verify_response = requests.get(signed_url) # 不带 headers + if verify_response.status_code == 200: + content_preview = verify_response.content[:50].decode('utf-8', errors='ignore') + print(f" ✓ 签名 URL 可以正常下载!") + print(f" 大小: {len(verify_response.content)} bytes") + print(f" 内容: {content_preview}...") + print(f"\n 💡 这个 URL 可以直接在浏览器中打开或分享给他人") + else: + print(f" ⚠️ 签名 URL 返回: {verify_response.status_code}") + if verify_response.text: + print(f" 错误: {verify_response.text[:100]}") + except Exception as ve: + print(f" ⚠️ 验证失败: {ve}") + else: + print(f"✗ 失败: {response.status_code}") +except Exception as e: + print(f"✗ 错误: {e}") +print() + +# 总结 +print("=" * 70) +print(f" 测试完成: {success_count}/{total_tests} 通过") +print("=" * 70) +print() + +if success_count == total_tests: + print("🎉 所有功能正常!") + print() + print("你现在可以:") + print(" ✓ 通过公网 HTTPS 上传文件") + print(" ✓ 下载文件") + print(" ✓ 生成临时下载链接") + print(" ✓ 管理 buckets") + print() + print("📚 查看完整文档:") + print(" - QUICK_START.md 快速入门") + print(" - OPERATIONS_GUIDE.md 运维指南") + print(" - storage_client.py Python 客户端") + print(" - storage_client.js JavaScript 客户端") + sys.exit(0) +else: + print(f"⚠️ {total_tests - success_count} 个测试失败") + print("请检查配置或查看文档") + sys.exit(1)