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
95 changes: 95 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
name: Build & Deploy

on:
push:
branches: [main]
workflow_dispatch:

jobs:
docker:
name: Build and push Docker image to GHCR
runs-on: ubuntu-latest
permissions:
packages: write
contents: read

steps:
- name: Check out
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
name: Build & Deploy to VPS
runs-on: ubuntu-latest
needs: docker
if: ${{ vars.VPS_HOST != '' }}

steps:
- name: Check out
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Build frontend
run: |
cd web/default
bun install --frozen-lockfile
bun run build
cd ../..

- name: Build Go binary
run: |
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o new-api .

- name: Package artifact
run: tar -czf deploy.tar.gz new-api web/default/dist

- name: Copy to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ vars.VPS_HOST }}
username: ${{ vars.VPS_USER || 'root' }}
password: ${{ secrets.VPS_PASSWORD }}
source: deploy.tar.gz
target: /tmp

- name: Deploy and restart
uses: appleboy/ssh-action@v1.2.2
with:
host: ${{ vars.VPS_HOST }}
username: ${{ vars.VPS_USER || 'root' }}
password: ${{ secrets.VPS_PASSWORD }}
script: |
set -e
cd /root/new-api
git pull origin main || true
tar -xzf /tmp/deploy.tar.gz
systemctl restart new-api
rm /tmp/deploy.tar.gz
echo "Deploy completed at $(date)"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ upload
build
*.db-journal
logs
*.log
web/dist
web/node_modules
.env
Expand All @@ -31,6 +32,7 @@ electron/dist
.gocache-temp
.gopath
.test
vendor/
token_estimator_test.go
skills-lock.json
.playwright-mcp
Expand Down
4 changes: 4 additions & 0 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,7 @@ func TestChannel(c *gin.Context) {
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
if result.newAPIError != nil {
recordChannelStatusProbe(channel, false, milliseconds, result.newAPIError.Error())
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": result.newAPIError.Error(),
Expand All @@ -911,6 +912,7 @@ func TestChannel(c *gin.Context) {
})
return
}
recordChannelStatusProbe(channel, true, milliseconds, "")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -979,8 +981,10 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse

if newAPIError == nil {
summary.Succeeded++
recordChannelStatusProbe(channel, true, milliseconds, "")
} else {
summary.Failed++
recordChannelStatusProbe(channel, false, milliseconds, newAPIError.Error())
}

// disable channel
Expand Down
120 changes: 120 additions & 0 deletions controller/channel_status_probe.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package controller

import (
"net"
"net/url"
"strings"
"time"

"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting"
)

// recordChannelStatusProbe 在渠道测试完成后追加一条 probe log。
//
// 该函数保持异步、幂等:任何错误不影响原有测试流程。仅当该渠道所属分组在
// status_page.groups 且 enabled=true 时写入,避免无谓 IO。
func recordChannelStatusProbe(channel *model.Channel, success bool, latencyMs int64, errMessage string) {
if channel == nil {
return
}
groups := channel.GetGroups()
if !anyStatusPageGroupEnabled(groups) {
return
}

setting := operation_setting.GetStatusPageSetting()
level := model.ChannelStatusProbeLevelFail
if success {
if setting.DegradedLatencyMs > 0 && latencyMs >= int64(setting.DegradedLatencyMs) {
level = model.ChannelStatusProbeLevelDegraded
} else {
level = model.ChannelStatusProbeLevelOK
}
}

pingMs := 0
if setting.EnablePingProbe {
if ms, ok := probeBaseURLPing(channel.GetBaseURL(), setting.PingProbeTimeoutMs); ok {
pingMs = ms
}
}

// 明确不写入密钥、上游错误详情等敏感数据;仅保留一个短提示
safeMessage := sanitizeProbeMessage(errMessage)

go model.AppendChannelStatusProbeLog(
channel.Id,
success,
level,
int(latencyMs),
pingMs,
safeMessage,
)
}

func anyStatusPageGroupEnabled(groups []string) bool {
for _, g := range groups {
if operation_setting.IsStatusPageGroupEnabled(g) {
return true
}
}
return false
}

// probeBaseURLPing 对 base URL 的 host:port 做一次短超时 TCP 拨号,作为连通延迟。
//
// 不发起 HEAD 请求以避免命中鉴权/CDN 逻辑;仅测试 TCP 连接握手时间。
// 返回值单位为毫秒,最小 1(避免与「无数据」的 0 语义冲突)。
func probeBaseURLPing(baseURL string, timeoutMs int) (int, bool) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return 0, false
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" {
return 0, false
}
host := parsed.Host
if !strings.Contains(host, ":") {
if parsed.Scheme == "http" {
host = host + ":80"
} else {
host = host + ":443"
}
}
if timeoutMs <= 0 {
timeoutMs = operation_setting.StatusPageDefaultPingProbeTimeoutMs
}
dialer := net.Dialer{Timeout: time.Duration(timeoutMs) * time.Millisecond}
start := time.Now()
conn, err := dialer.Dial("tcp", host)
if err != nil {
return 0, false
}
_ = conn.Close()
elapsed := int(time.Since(start).Milliseconds())
if elapsed <= 0 {
elapsed = 1
}
return elapsed, true
}

// sanitizeProbeMessage 只保留错误类型的短描述,禁止携带密钥/URL 参数
func sanitizeProbeMessage(msg string) string {
msg = strings.TrimSpace(msg)
if msg == "" {
return ""
}
// 单行化,去掉可能包含的敏感 header/URL
msg = strings.ReplaceAll(msg, "\n", " ")
msg = strings.ReplaceAll(msg, "\r", " ")
if len(msg) > 200 {
runes := []rune(msg)
if len(runes) > 200 {
msg = string(runes[:200])
}
}
return msg
}

Loading
Loading