docs: add DEVOPS guide and optimize Dockerfiles
- Add DEVOPS.md with complete deployment guide for apolo server - Add DATABASE.md (unified schema documentation) - Fix backend Dockerfile: Go 1.24 -> 1.23 (current stable) - Add marketplace Dockerfile with pnpm + static-web-server - Migrate marketplace from npm to pnpm - Remove duplicate database-schema.md and DATABASE_SCHEMA.md
This commit is contained in:
parent
d435bbe057
commit
5c59e7b5a1
7 changed files with 4091 additions and 5122 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ===== STAGE 1: Build =====
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Database Schema - SaveInMed
|
||||
|
||||
Documentação do esquema de banco de dados PostgreSQL do SaveInMed.
|
||||
Documentação completa do esquema de banco de dados PostgreSQL do SaveInMed.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
# Database Schema
|
||||
|
||||
This document visualizes the SaveInMed database schema using Mermaid.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
%% Entities
|
||||
COMPANY {
|
||||
int id pk
|
||||
string name
|
||||
enum status "ACTIVE | INACTIVE | SUSPENDED"
|
||||
datetime createdAt
|
||||
datetime updatedAt
|
||||
}
|
||||
|
||||
USER {
|
||||
int id pk
|
||||
string email uk
|
||||
string password
|
||||
string name
|
||||
enum role "USER | ADMIN"
|
||||
int companyId fk
|
||||
string refreshToken
|
||||
datetime createdAt
|
||||
datetime updatedAt
|
||||
}
|
||||
|
||||
PRODUCT {
|
||||
int id pk
|
||||
string name
|
||||
string sku uk
|
||||
decimal price "10,2"
|
||||
datetime createdAt
|
||||
datetime updatedAt
|
||||
}
|
||||
|
||||
INVENTORY_ITEM {
|
||||
int id pk
|
||||
int productId fk, uk
|
||||
int quantity
|
||||
datetime updatedAt
|
||||
}
|
||||
|
||||
ORDER {
|
||||
int id pk
|
||||
int buyerId fk
|
||||
int productId fk
|
||||
int quantity
|
||||
decimal total "12,2"
|
||||
datetime createdAt
|
||||
}
|
||||
|
||||
SYSTEM_SETTINGS {
|
||||
string key pk
|
||||
string value
|
||||
string category "default: GENERAL"
|
||||
boolean isSecure "default: false"
|
||||
datetime updatedAt
|
||||
}
|
||||
|
||||
%% Relationships
|
||||
COMPANY ||--|{ USER : "has"
|
||||
USER ||--|{ ORDER : "places"
|
||||
PRODUCT ||--|| INVENTORY_ITEM : "has stock"
|
||||
PRODUCT ||--|{ ORDER : "included in"
|
||||
```
|
||||
881
docs/DEVOPS.md
Normal file
881
docs/DEVOPS.md
Normal file
|
|
@ -0,0 +1,881 @@
|
|||
# DevOps Guide - SaveInMed
|
||||
|
||||
Guia de operações e infraestrutura do SaveInMed.
|
||||
|
||||
---
|
||||
|
||||
## Scaleway Container Registry
|
||||
|
||||
### 1. Login no Registry
|
||||
|
||||
```bash
|
||||
# Login no registry Scaleway (usar secret do CI/CD)
|
||||
echo "${SCW_SECRET_KEY}" | docker login rg.fr-par.scw.cloud -u nologin --password-stdin
|
||||
```
|
||||
|
||||
> [!CAUTION]
|
||||
> **NUNCA** commitar credenciais no código. Use variáveis de ambiente ou secrets do CI/CD.
|
||||
|
||||
### 2. Build da Imagem
|
||||
|
||||
```bash
|
||||
# Build com platform específico (importante para deploy em servidores AMD64)
|
||||
docker build --platform linux/amd64 --no-cache -t saveinmed-frontend:latest .
|
||||
|
||||
# Com BuildKit para cache otimizado
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--platform linux/amd64 \
|
||||
--cache-from rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest \
|
||||
-t saveinmed-frontend:latest .
|
||||
```
|
||||
|
||||
### 3. Tag e Push
|
||||
|
||||
```bash
|
||||
# Tag com versão e latest
|
||||
docker tag saveinmed-frontend:latest \
|
||||
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
|
||||
|
||||
docker tag saveinmed-frontend:latest \
|
||||
rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
|
||||
|
||||
# Push ambas as tags
|
||||
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:${VERSION}
|
||||
docker push rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-frontend:latest
|
||||
```
|
||||
|
||||
### 4. Variáveis de Ambiente (CI/CD)
|
||||
|
||||
```bash
|
||||
# Secrets do Forgejo/GitHub Actions
|
||||
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # IAM → API Keys
|
||||
SCW_REGISTRY_NAMESPACE=funcscwinfrastructure... # Container Registry → Namespace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Melhores Práticas de Versionamento
|
||||
|
||||
### Semantic Versioning (SemVer)
|
||||
|
||||
```
|
||||
MAJOR.MINOR.PATCH
|
||||
|
||||
Exemplo: 1.2.3
|
||||
- MAJOR (1): Breaking changes
|
||||
- MINOR (2): Novas features (backwards compatible)
|
||||
- PATCH (3): Bug fixes
|
||||
```
|
||||
|
||||
### Estratégias de Tag
|
||||
|
||||
| Tag | Uso | Quando |
|
||||
|-----|-----|--------|
|
||||
| `latest` | Desenvolvimento | Apenas em dev/staging |
|
||||
| `v1.2.3` | Produção | Release oficial |
|
||||
| `v1.2.3-rc.1` | Release Candidate | Pré-release para testes |
|
||||
| `sha-abc1234` | CI/CD | Cada commit (rastreabilidade) |
|
||||
| `main-20241229` | Branch + Data | Builds automáticos |
|
||||
|
||||
### Exemplo de Workflow CI/CD
|
||||
|
||||
```yaml
|
||||
# .forgejo/workflows/deploy.yaml
|
||||
env:
|
||||
REGISTRY: rg.fr-par.scw.cloud
|
||||
NAMESPACE: ${{ secrets.SCW_REGISTRY_NAMESPACE }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
steps:
|
||||
- name: Generate version
|
||||
run: |
|
||||
# Usar git tag se existir, senão usar SHA
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Scaleway Registry
|
||||
run: |
|
||||
echo "${{ secrets.SCW_SECRET_KEY }}" | \
|
||||
docker login $REGISTRY -u nologin --password-stdin
|
||||
|
||||
- name: Build and Push
|
||||
run: |
|
||||
docker build --platform linux/amd64 \
|
||||
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION \
|
||||
-t $REGISTRY/$NAMESPACE/saveinmed-frontend:latest .
|
||||
|
||||
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:$VERSION
|
||||
docker push $REGISTRY/$NAMESPACE/saveinmed-frontend:latest
|
||||
```
|
||||
|
||||
### ✅ Boas Práticas
|
||||
|
||||
- **Sempre usar tags específicas** em produção (nunca `latest`)
|
||||
- **Imagens imutáveis**: uma vez publicada, nunca sobrescrever
|
||||
- **Multi-stage builds** para imagens menores
|
||||
- **Scan de vulnerabilidades** antes do deploy
|
||||
- **Cleanup automático** de imagens antigas no registry
|
||||
|
||||
---
|
||||
|
||||
## Deploy no Servidor (apolo)
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
- Servidor: `apolo.rede5.com.br`
|
||||
- SO: AlmaLinux com Podman
|
||||
- Rede: `web_proxy` (para comunicação com Traefik)
|
||||
|
||||
### Estrutura de Diretórios
|
||||
|
||||
```
|
||||
/mnt/data/saveinmed/
|
||||
├── backend/.env # Variáveis de ambiente backend (Go)
|
||||
├── backoffice/.env # Variáveis de ambiente backoffice (NestJS)
|
||||
└── marketplace/.env # Variáveis de ambiente marketplace (Vite/React)
|
||||
|
||||
/etc/containers/systemd/
|
||||
├── saveinmed-backend.container
|
||||
├── saveinmed-backoffice.container
|
||||
└── saveinmed-marketplace.container
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Como as imagens vêm do Scaleway Registry, **não é necessário** clonar o repo no servidor.
|
||||
> O código está empacotado na imagem Docker.
|
||||
|
||||
---
|
||||
|
||||
### Passo 1: Criar Diretórios e Arquivos .env
|
||||
|
||||
```bash
|
||||
# Conectar no servidor
|
||||
ssh root@apolo
|
||||
|
||||
# Criar estrutura de diretórios
|
||||
mkdir -p /mnt/data/saveinmed/{backend,backoffice,marketplace}
|
||||
```
|
||||
|
||||
#### Backend .env (`/mnt/data/saveinmed/backend/.env`)
|
||||
|
||||
```bash
|
||||
# Geral
|
||||
ENV=production
|
||||
PORT=8080
|
||||
TZ=America/Sao_Paulo
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://USER:PASS@postgres-main:5432/saveinmed?sslmode=disable
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://:PASSWORD@redis-saveinmed:6379/0
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=GERAR_SECRET_32_CHARS
|
||||
JWT_EXPIRATION=7d
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=https://saveinmed.com.br,https://api.saveinmed.com.br
|
||||
|
||||
# Cookies
|
||||
COOKIE_SECRET=GERAR_SECRET
|
||||
COOKIE_DOMAIN=saveinmed.com.br
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# Scaleway Storage
|
||||
SCW_ACCESS_KEY=SCWxxx
|
||||
SCW_SECRET_KEY=xxx
|
||||
SCW_REGION=fr-par
|
||||
SCW_BUCKET_NAME=saveinmed-uploads
|
||||
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
|
||||
```
|
||||
|
||||
#### Marketplace .env (`/mnt/data/saveinmed/marketplace/.env`)
|
||||
|
||||
```bash
|
||||
# Vite/React SPA - variáveis são injetadas no build
|
||||
# Para produção, configure no Dockerfile ou CI/CD
|
||||
VITE_API_URL=https://api.saveinmed.com.br
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> O marketplace é uma SPA (Single Page Application) servida pelo nginx.
|
||||
> As variáveis `VITE_*` são injetadas em tempo de build, não de runtime.
|
||||
|
||||
---
|
||||
|
||||
### Passo 2: Criar Arquivos Quadlet
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Quadlet é o sistema nativo do Podman para gerenciar containers como serviços systemd.
|
||||
|
||||
#### Backend (`/etc/containers/systemd/saveinmed-backend.container`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SaveInMed Backend
|
||||
After=network-online.target postgres-main.service
|
||||
|
||||
[Container]
|
||||
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
|
||||
ContainerName=saveinmed-backend
|
||||
|
||||
# Variáveis de ambiente
|
||||
EnvironmentFile=/mnt/data/saveinmed/backend/.env
|
||||
|
||||
# Rede interna (Traefik)
|
||||
Network=web_proxy
|
||||
PublishPort=8080:8080
|
||||
|
||||
# Labels Traefik
|
||||
Label=traefik.enable=true
|
||||
Label=traefik.http.routers.saveinmed-backend.rule=Host(`api.saveinmed.com.br`)
|
||||
Label=traefik.http.routers.saveinmed-backend.entrypoints=websecure
|
||||
Label=traefik.http.routers.saveinmed-backend.tls.certresolver=myresolver
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### Marketplace (`/etc/containers/systemd/saveinmed-marketplace.container`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SaveInMed Marketplace (Vite/React SPA)
|
||||
After=network-online.target saveinmed-backend.service
|
||||
|
||||
[Container]
|
||||
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-marketplace:latest
|
||||
ContainerName=saveinmed-marketplace
|
||||
Pull=never
|
||||
|
||||
# serve roda na porta 3000
|
||||
Network=web_proxy
|
||||
PublishPort=3010:3000
|
||||
|
||||
# Labels Traefik
|
||||
Label=traefik.enable=true
|
||||
Label=traefik.http.routers.saveinmed-marketplace.rule=Host(`saveinmed.com.br`)
|
||||
Label=traefik.http.routers.saveinmed-marketplace.entrypoints=websecure
|
||||
Label=traefik.http.routers.saveinmed-marketplace.tls.certresolver=myresolver
|
||||
Label=traefik.http.services.saveinmed-marketplace.loadbalancer.server.port=3000
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### Backoffice (`/etc/containers/systemd/saveinmed-backoffice.container`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=SaveInMed Backoffice
|
||||
After=network-online.target saveinmed-backend.service
|
||||
|
||||
[Container]
|
||||
Image=rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backoffice:latest
|
||||
ContainerName=saveinmed-backoffice
|
||||
Pull=never
|
||||
|
||||
EnvironmentFile=/mnt/data/saveinmed/backoffice/.env
|
||||
|
||||
Network=web_proxy
|
||||
PublishPort=3011:3000
|
||||
|
||||
Label=traefik.enable=true
|
||||
Label=traefik.http.routers.saveinmed-backoffice.rule=Host(`backoffice.saveinmed.com.br`)
|
||||
Label=traefik.http.routers.saveinmed-backoffice.entrypoints=websecure
|
||||
Label=traefik.http.routers.saveinmed-backoffice.tls.certresolver=myresolver
|
||||
Label=traefik.http.services.saveinmed-backoffice.loadbalancer.server.port=3000
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Passo 3: Ativar e Iniciar Serviços
|
||||
|
||||
```bash
|
||||
# Recarregar configurações do systemd
|
||||
systemctl daemon-reload
|
||||
|
||||
# Iniciar serviços
|
||||
systemctl start saveinmed-backend
|
||||
systemctl start saveinmed-backoffice
|
||||
systemctl start saveinmed-marketplace
|
||||
|
||||
# Habilitar para iniciar no boot
|
||||
systemctl enable saveinmed-backend
|
||||
systemctl enable saveinmed-backoffice
|
||||
systemctl enable saveinmed-marketplace
|
||||
|
||||
# Verificar status
|
||||
systemctl status saveinmed-backend
|
||||
systemctl status saveinmed-marketplace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Passo 4: Atualizar Imagem (sem rebuild)
|
||||
|
||||
Como você não vai fazer build da imagem localmente, o processo de atualização é:
|
||||
|
||||
```bash
|
||||
# 1. Fazer pull da nova imagem
|
||||
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-backend:latest
|
||||
|
||||
# 2. Reiniciar o serviço (vai usar a nova imagem)
|
||||
systemctl restart saveinmed-backend
|
||||
```
|
||||
|
||||
Ou para todos:
|
||||
|
||||
```bash
|
||||
# Script de atualização
|
||||
for svc in backend backoffice marketplace; do
|
||||
podman pull rg.fr-par.scw.cloud/${SCW_REGISTRY_NAMESPACE}/saveinmed-${svc}:latest
|
||||
systemctl restart saveinmed-${svc}
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Configuração do Traefik
|
||||
|
||||
O Traefik já está configurado em `/etc/containers/systemd/traefik.container`:
|
||||
|
||||
```ini
|
||||
[Container]
|
||||
Image=docker.io/library/traefik:v3.0
|
||||
ContainerName=traefik
|
||||
Network=web_proxy
|
||||
|
||||
PublishPort=80:80
|
||||
PublishPort=443:443
|
||||
|
||||
Volume=/run/podman/podman.sock:/run/podman/podman.sock:ro
|
||||
Volume=/opt/traefik/letsencrypt:/letsecrypt
|
||||
|
||||
Exec=--providers.docker=true \
|
||||
--providers.docker.endpoint=unix:///run/podman/podman.sock \
|
||||
--providers.docker.exposedByDefault=false \
|
||||
--entryPoints.web.address=:80 \
|
||||
--entryPoints.websecure.address=:443 \
|
||||
--certificatesResolvers.myresolver.acme.tlschallenge=true \
|
||||
--certificatesResolvers.myresolver.acme.email=seu-email@dominio.com \
|
||||
--certificatesResolvers.myresolver.acme.storage=/letsecrypt/acme.json
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Os certificados SSL são gerados automaticamente pelo Let's Encrypt via TLS challenge.
|
||||
|
||||
---
|
||||
|
||||
### Comandos Úteis
|
||||
|
||||
```bash
|
||||
# Ver containers rodando
|
||||
podman ps
|
||||
|
||||
# Ver logs de um serviço
|
||||
journalctl -u saveinmed-backend -f
|
||||
|
||||
# Reiniciar todos os serviços SaveInMed
|
||||
systemctl restart saveinmed-*
|
||||
|
||||
# Verificar healthcheck
|
||||
curl -s https://api.saveinmed.com.br/health | jq
|
||||
|
||||
# Login no registry antes de pull
|
||||
echo "${SCW_SECRET_KEY}" | podman login rg.fr-par.scw.cloud -u nologin --password-stdin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Como Subir Imagem no Scaleway
|
||||
|
||||
### Passo a Passo Rápido
|
||||
|
||||
1. **Obter credenciais** no console Scaleway → IAM → API Keys
|
||||
2. **Configurar variáveis de ambiente** (ver abaixo)
|
||||
3. **Usar o endpoint de upload** da API
|
||||
|
||||
### Endpoint de Upload
|
||||
|
||||
```bash
|
||||
# Upload de imagem de produto
|
||||
curl -X POST https://api.saveinmed.com.br/v1/products/{productId}/images \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-F "image=@/path/to/image.jpg"
|
||||
|
||||
# Upload de documento KYC
|
||||
curl -X POST https://api.saveinmed.com.br/v1/companies/documents \
|
||||
-H "Authorization: Bearer {token}" \
|
||||
-F "document=@/path/to/license.pdf" \
|
||||
-F "type=license"
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://s3.fr-par.scw.cloud/saveinmed-uploads/products/abc123/main.webp"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scaleway Object Storage
|
||||
|
||||
O Scaleway Object Storage é um serviço de armazenamento de objetos compatível com a API S3 da AWS. Usamos este serviço para:
|
||||
- Imagens de produtos
|
||||
- Documentos KYC (licenças, CNPJs)
|
||||
- Fotos de perfil
|
||||
- Comprovantes de entrega
|
||||
|
||||
---
|
||||
|
||||
## Configuração
|
||||
|
||||
### 1. Variáveis de Ambiente
|
||||
|
||||
```bash
|
||||
# .env
|
||||
SCW_ACCESS_KEY=SCWxxxxxxxxxxxxxxxxx
|
||||
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
SCW_REGION=fr-par # ou nl-ams, pl-waw
|
||||
SCW_BUCKET_NAME=saveinmed-uploads
|
||||
SCW_ENDPOINT=https://s3.fr-par.scw.cloud
|
||||
```
|
||||
|
||||
### 2. Estrutura de Buckets
|
||||
|
||||
```
|
||||
saveinmed-uploads/
|
||||
├── products/ # Imagens de produtos
|
||||
│ └── {product_id}/
|
||||
│ ├── main.webp
|
||||
│ └── thumb.webp
|
||||
├── documents/ # Documentos KYC
|
||||
│ └── {company_id}/
|
||||
│ ├── cnpj.pdf
|
||||
│ └── license.pdf
|
||||
├── profiles/ # Fotos de perfil
|
||||
│ └── {user_id}.webp
|
||||
└── shipments/ # Comprovantes de entrega
|
||||
└── {shipment_id}/
|
||||
└── proof.webp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementação Backend (Go)
|
||||
|
||||
### 1. Dependências
|
||||
|
||||
```bash
|
||||
go get github.com/aws/aws-sdk-go-v2/config
|
||||
go get github.com/aws/aws-sdk-go-v2/service/s3
|
||||
go get github.com/aws/aws-sdk-go-v2/credentials
|
||||
```
|
||||
|
||||
### 2. Cliente S3 (internal/storage/s3.go)
|
||||
|
||||
```go
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
type S3Client struct {
|
||||
client *s3.Client
|
||||
bucketName string
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func NewS3Client() (*S3Client, error) {
|
||||
region := os.Getenv("SCW_REGION")
|
||||
endpoint := os.Getenv("SCW_ENDPOINT")
|
||||
accessKey := os.Getenv("SCW_ACCESS_KEY")
|
||||
secretKey := os.Getenv("SCW_SECRET_KEY")
|
||||
bucketName := os.Getenv("SCW_BUCKET_NAME")
|
||||
|
||||
resolver := aws.EndpointResolverWithOptionsFunc(
|
||||
func(service, region string, options ...interface{}) (aws.Endpoint, error) {
|
||||
return aws.Endpoint{URL: endpoint}, nil
|
||||
},
|
||||
)
|
||||
|
||||
cfg, err := config.LoadDefaultConfig(context.TODO(),
|
||||
config.WithRegion(region),
|
||||
config.WithEndpointResolverWithOptions(resolver),
|
||||
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = true // Required for Scaleway
|
||||
})
|
||||
|
||||
return &S3Client{
|
||||
client: client,
|
||||
bucketName: bucketName,
|
||||
endpoint: endpoint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload envia um arquivo para o bucket
|
||||
func (c *S3Client) Upload(ctx context.Context, key string, reader io.Reader, contentType string) (string, error) {
|
||||
_, err := c.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(c.bucketName),
|
||||
Key: aws.String(key),
|
||||
Body: reader,
|
||||
ContentType: aws.String(contentType),
|
||||
ACL: "public-read", // ou "private" para documentos sensíveis
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s/%s", c.endpoint, c.bucketName, key)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// GetPresignedURL gera URL temporária para download
|
||||
func (c *S3Client) GetPresignedURL(ctx context.Context, key string, expiration time.Duration) (string, error) {
|
||||
presigner := s3.NewPresignClient(c.client)
|
||||
|
||||
req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucketName),
|
||||
Key: aws.String(key),
|
||||
}, s3.WithPresignExpires(expiration))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return req.URL, nil
|
||||
}
|
||||
|
||||
// Delete remove um arquivo do bucket
|
||||
func (c *S3Client) Delete(ctx context.Context, key string) error {
|
||||
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(c.bucketName),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handler de Upload (internal/http/handler/upload_handler.go)
|
||||
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const maxUploadSize = 10 << 20 // 10MB
|
||||
|
||||
var allowedMimeTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
"image/webp": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request) {
|
||||
// Limitar tamanho
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
||||
|
||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
||||
http.Error(w, "Arquivo muito grande (max 10MB)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, "Arquivo não encontrado", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Validar MIME type
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !allowedMimeTypes[contentType] {
|
||||
http.Error(w, "Tipo de arquivo não permitido", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Gerar key única
|
||||
productID := r.PathValue("productId")
|
||||
ext := filepath.Ext(header.Filename)
|
||||
key := fmt.Sprintf("products/%s/%s%s", productID, uuid.New().String(), ext)
|
||||
|
||||
// Upload
|
||||
url, err := h.storage.Upload(r.Context(), key, file, contentType)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"url": url})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementação Frontend (TypeScript)
|
||||
|
||||
### 1. Serviço de Upload (src/services/storageService.ts)
|
||||
|
||||
```typescript
|
||||
import { apiClient } from './apiClient'
|
||||
|
||||
interface UploadResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export const storageService = {
|
||||
// Upload de imagem de produto
|
||||
uploadProductImage: async (productId: string, file: File): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await apiClient.post<UploadResponse>(
|
||||
`/v1/products/${productId}/images`,
|
||||
formData
|
||||
)
|
||||
return response.url
|
||||
},
|
||||
|
||||
// Upload de documento KYC
|
||||
uploadDocument: async (file: File, type: 'cnpj' | 'license' | 'other'): Promise<string> => {
|
||||
const formData = new FormData()
|
||||
formData.append('document', file)
|
||||
formData.append('type', type)
|
||||
|
||||
const response = await apiClient.post<UploadResponse>(
|
||||
'/v1/companies/documents',
|
||||
formData
|
||||
)
|
||||
return response.url
|
||||
},
|
||||
|
||||
// Upload com preview e progresso
|
||||
uploadWithProgress: async (
|
||||
endpoint: string,
|
||||
file: File,
|
||||
onProgress: (percent: number) => void
|
||||
): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText)
|
||||
resolve(response.url)
|
||||
} else {
|
||||
reject(new Error('Upload failed'))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')))
|
||||
|
||||
xhr.open('POST', `${import.meta.env.VITE_API_URL}${endpoint}`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${localStorage.getItem('token')}`)
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Componente de Upload (src/components/ImageUpload.tsx)
|
||||
|
||||
```tsx
|
||||
import { useState, useRef } from 'react'
|
||||
import { storageService } from '../services/storageService'
|
||||
|
||||
interface ImageUploadProps {
|
||||
productId: string
|
||||
onUploadComplete: (url: string) => void
|
||||
}
|
||||
|
||||
export function ImageUpload({ productId, onUploadComplete }: ImageUploadProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validações
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
alert('Apenas JPEG, PNG ou WebP são permitidos')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('Máximo 10MB')
|
||||
return
|
||||
}
|
||||
|
||||
// Preview local
|
||||
setPreview(URL.createObjectURL(file))
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
const url = await storageService.uploadWithProgress(
|
||||
`/v1/products/${productId}/images`,
|
||||
file,
|
||||
setProgress
|
||||
)
|
||||
onUploadComplete(url)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
alert('Falha no upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="upload-container">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? `Enviando ${progress}%` : '📤 Enviar Imagem'}
|
||||
</button>
|
||||
|
||||
{preview && (
|
||||
<img src={preview} alt="Preview" className="upload-preview" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Melhores Práticas
|
||||
|
||||
### 1. Segurança
|
||||
|
||||
- ✅ **Validar MIME type** no backend (não confiar no frontend)
|
||||
- ✅ **Limitar tamanho** máximo de upload (10MB recomendado)
|
||||
- ✅ **Usar URLs presignadas** para documentos sensíveis
|
||||
- ✅ **Verificar autenticação** antes de permitir upload
|
||||
- ✅ **Sanitizar nomes** de arquivos (usar UUID)
|
||||
|
||||
### 2. Performance
|
||||
|
||||
- ✅ **Compressão de imagens** antes do upload (client-side)
|
||||
- ✅ **Gerar thumbnails** automaticamente
|
||||
- ✅ **Usar WebP** como formato preferencial
|
||||
- ✅ **CDN** em produção para distribuição global
|
||||
|
||||
### 3. Organização
|
||||
|
||||
```bash
|
||||
# Estrutura de keys recomendada
|
||||
{bucket}/{tipo}/{entity_id}/{arquivo}
|
||||
|
||||
# Exemplos:
|
||||
products/abc123/main.webp
|
||||
documents/company456/license.pdf
|
||||
profiles/user789.webp
|
||||
```
|
||||
|
||||
### 4. Custo
|
||||
|
||||
- ✅ **Lifecycle rules** para limpar arquivos antigos
|
||||
- ✅ **Storage class** adequado (Standard vs Glacier-like)
|
||||
- ✅ **Monitorar uso** para evitar surpresas
|
||||
|
||||
---
|
||||
|
||||
## Configuração do Bucket (Console Scaleway)
|
||||
|
||||
1. Acesse **Object Storage** no console Scaleway
|
||||
2. Clique em **Create a bucket**
|
||||
3. Nome: `saveinmed-uploads`
|
||||
4. Região: `fr-par` (ou mais próxima)
|
||||
5. Visibility: **Private** (acesso via API)
|
||||
6. Configure **CORS**:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://saveinmed.com.br", "http://localhost:5173"],
|
||||
"AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Erro: "Access Denied"
|
||||
- Verifique as credenciais `SCW_ACCESS_KEY` e `SCW_SECRET_KEY`
|
||||
- Confirme que as permissões do bucket estão corretas
|
||||
|
||||
### Erro: "CORS Policy"
|
||||
- Configure CORS no bucket (ver acima)
|
||||
- Adicione o domínio do frontend na lista de origens permitidas
|
||||
|
||||
### Upload lento
|
||||
- Use upload multipart para arquivos > 5MB
|
||||
- Considere compressão client-side para imagens
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- [Scaleway Object Storage Documentation](https://www.scaleway.com/en/docs/object-storage-feature/)
|
||||
- [AWS SDK for Go v2](https://aws.github.io/aws-sdk-go-v2/docs/)
|
||||
- [S3-Compatible API Reference](https://www.scaleway.com/en/docs/object-storage-s3-api/)
|
||||
26
marketplace/Dockerfile
Normal file
26
marketplace/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ===== STAGE 1: Build =====
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm-marketplace,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
# ===== STAGE 2: Production (static-web-server ~3MB binary) =====
|
||||
FROM joseluisq/static-web-server:2-alpine
|
||||
|
||||
COPY --from=builder /app/dist /public
|
||||
|
||||
# SPA mode: fallback para index.html
|
||||
ENV SERVER_FALLBACK_PAGE=/public/index.html
|
||||
ENV SERVER_ROOT=/public
|
||||
ENV SERVER_PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
5054
marketplace/package-lock.json
generated
5054
marketplace/package-lock.json
generated
File diff suppressed because it is too large
Load diff
3182
marketplace/pnpm-lock.yaml
Normal file
3182
marketplace/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue