Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Deploy to Server

on:
push:
branches:
- main
workflow_dispatch: # 支持手动触发

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT || 22 }}
script: |
set -e

# 进入项目目录
cd ${{ secrets.PROJECT_DIR }}

# 拉取最新代码
git fetch origin main
git checkout main
git pull origin main

# 构建镜像
docker build -t sub2api:latest .

# 重启服务(只重启 sub2api,不动数据库和 Redis)
cd deploy
docker compose up -d sub2api

# 等待健康检查
echo "Waiting for health check..."
sleep 10
if docker compose ps sub2api | grep -q "healthy\|Up"; then
echo "Deploy successful!"
else
echo "Warning: service may not be healthy, check logs"
docker compose logs --tail=20 sub2api
fi
63 changes: 63 additions & 0 deletions .github/workflows/sync-upstream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Sync Upstream

on:
schedule:
- cron: '0 8 * * *' # 每天 UTC 8:00 (北京时间 16:00) 执行
workflow_dispatch: # 支持手动触发

jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Add upstream remote
run: git remote add upstream https://github.com/Wei-Shaw/sub2api.git

- name: Fetch upstream
run: git fetch upstream

- name: Check for new commits
id: check
run: |
COMMITS=$(git log main..upstream/main --oneline)
if [ -z "$COMMITS" ]; then
echo "has_updates=false" >> $GITHUB_OUTPUT
echo "No new commits from upstream"
else
echo "has_updates=true" >> $GITHUB_OUTPUT
echo "New commits found:"
echo "$COMMITS"
fi

- name: Create sync branch and PR
if: steps.check.outputs.has_updates == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="sync-upstream/$(date +%Y%m%d)"
git checkout -b "$BRANCH" upstream/main
git push origin "$BRANCH"

gh pr create \
--title "Sync upstream $(date +%Y-%m-%d)" \
--body "$(cat <<'EOF'
## 上游同步

自动从 [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api) 同步最新代码。

**新增提交:**
$(git log main..upstream/main --oneline)

请审核后决定是否合并。
EOF
)" \
--base main \
--head "$BRANCH"
15 changes: 13 additions & 2 deletions backend/internal/handler/openai_gateway_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,19 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareError(c *gin.Context, status
// Stream already started, send error as SSE event then close
flusher, ok := c.Writer.(http.Flusher)
if ok {
// Send error event in OpenAI SSE format
errorEvent := fmt.Sprintf(`event: error`+"\n"+`data: {"error": {"type": "%s", "message": "%s"}}`+"\n\n", errType, message)
// Send error event in OpenAI SSE format with proper JSON marshaling
errorData := map[string]any{
"error": map[string]string{
"type": errType,
"message": message,
},
}
jsonBytes, err := json.Marshal(errorData)
if err != nil {
_ = c.Error(err)
return
}
errorEvent := fmt.Sprintf("event: error\ndata: %s\n\n", string(jsonBytes))
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
_ = c.Error(err)
}
Expand Down
230 changes: 230 additions & 0 deletions deploy/docker-compose.bluegreen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# =============================================================================
# Sub2API Docker Compose - 蓝绿部署版本 (Zero-Downtime)
# =============================================================================
# 通过 Nginx 反向代理 + 蓝绿双实例实现零停机部署。
# 用户流量始终通过 Nginx 进入,部署时:
# 1. 启动新实例(绿)
# 2. 健康检查通过后,Nginx 切换流量到新实例
# 3. 旧实例(蓝)优雅关闭
# 用户全程无感知。
#
# 使用方法:不要直接操作此文件,使用 safe-deploy.sh 管理。
# 首次部署: ./safe-deploy.sh
# 查看状态: ./safe-deploy.sh --status
# 手动回滚: ./safe-deploy.sh --rollback
# =============================================================================

services:
# ===========================================================================
# Nginx 反向代理(用户入口)
# ===========================================================================
nginx:
image: nginx:alpine
container_name: sub2api-nginx
restart: unless-stopped
ports:
- "${BIND_HOST:-0.0.0.0}:${SERVER_PORT:-8080}:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/upstream.conf:/etc/nginx/conf.d/upstream.conf:ro
# 注意:不依赖任何 sub2api 实例。upstream.conf 决定流量指向。
# 如果 upstream 目标不可达,Nginx 返回 502 而非启动失败。
networks:
- sub2api-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/nginx-health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s

# ===========================================================================
# Sub2API Blue 实例
# ===========================================================================
sub2api-blue:
image: weishaw/sub2api:latest
container_name: sub2api-blue
restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
expose:
- "8080"
volumes:
- ./data:/app/data
environment:
- AUTO_SETUP=true
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
- SERVER_MODE=${SERVER_MODE:-release}
- RUN_MODE=${RUN_MODE:-standard}
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USER=${POSTGRES_USER:-sub2api}
- DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- DATABASE_DBNAME=${POSTGRES_DB:-sub2api}
- DATABASE_SSLMODE=disable
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=${REDIS_DB:-0}
- REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- JWT_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
- TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-}
- TZ=${TZ:-Asia/Shanghai}
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
- SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false}
- SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false}
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- sub2api-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s

# ===========================================================================
# Sub2API Green 实例
# ===========================================================================
sub2api-green:
image: weishaw/sub2api:latest
container_name: sub2api-green
restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
expose:
- "8080"
volumes:
- ./data:/app/data
environment:
- AUTO_SETUP=true
- SERVER_HOST=0.0.0.0
- SERVER_PORT=8080
- SERVER_MODE=${SERVER_MODE:-release}
- RUN_MODE=${RUN_MODE:-standard}
- DATABASE_HOST=postgres
- DATABASE_PORT=5432
- DATABASE_USER=${POSTGRES_USER:-sub2api}
- DATABASE_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- DATABASE_DBNAME=${POSTGRES_DB:-sub2api}
- DATABASE_SSLMODE=disable
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_PASSWORD=${REDIS_PASSWORD:-}
- REDIS_DB=${REDIS_DB:-0}
- REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-false}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@sub2api.local}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-}
- JWT_SECRET=${JWT_SECRET:-}
- JWT_EXPIRE_HOUR=${JWT_EXPIRE_HOUR:-24}
- TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-}
- TZ=${TZ:-Asia/Shanghai}
- GEMINI_OAUTH_CLIENT_ID=${GEMINI_OAUTH_CLIENT_ID:-}
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-}
- SECURITY_URL_ALLOWLIST_ENABLED=${SECURITY_URL_ALLOWLIST_ENABLED:-false}
- SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=${SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP:-false}
- SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=${SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS:-false}
- SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS=${SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS:-}
- UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- sub2api-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 20s
profiles:
- green

# ===========================================================================
# PostgreSQL Database
# ===========================================================================
postgres:
image: postgres:18-alpine
container_name: sub2api-postgres
restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
volumes:
- ./postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-sub2api}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
- POSTGRES_DB=${POSTGRES_DB:-sub2api}
- PGDATA=/var/lib/postgresql/data
- TZ=${TZ:-Asia/Shanghai}
networks:
- sub2api-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s

# ===========================================================================
# Redis Cache
# ===========================================================================
redis:
image: redis:8-alpine
container_name: sub2api-redis
restart: unless-stopped
ulimits:
nofile:
soft: 100000
hard: 100000
volumes:
- ./redis_data:/data
command: >
sh -c '
redis-server
--save 60 1
--appendonly yes
--appendfsync everysec
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
environment:
- TZ=${TZ:-Asia/Shanghai}
- REDISCLI_AUTH=${REDIS_PASSWORD:-}
networks:
- sub2api-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s

# =============================================================================
# Networks
# =============================================================================
networks:
sub2api-network:
driver: bridge
Loading