Files
ds2api/internal/admin/handler_vercel.go

327 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"bytes"
"context"
"crypto/md5"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"ds2api/internal/config"
)
func (h *Handler) syncVercel(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"})
return
}
opts, err := parseVercelSyncOptions(req)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
validated, failed := h.validateAccountsForVercelSync(r.Context(), opts.AutoValidate)
cfgJSON, cfgB64, err := h.exportSyncConfig(req)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
return
}
client := &http.Client{Timeout: 30 * time.Second}
params := buildVercelParams(opts.TeamID)
headers := map[string]string{"Authorization": "Bearer " + opts.VercelToken}
envResp, status, err := vercelRequest(r.Context(), client, http.MethodGet, "https://api.vercel.com/v9/projects/"+opts.ProjectID+"/env", params, headers, nil)
if err != nil || status != http.StatusOK {
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "获取环境变量失败"})
return
}
envs, _ := envResp["envs"].([]any)
status, err = upsertVercelEnv(r.Context(), client, opts.ProjectID, params, headers, envs, "DS2API_CONFIG_JSON", cfgB64)
if err != nil || (status != http.StatusOK && status != http.StatusCreated) {
writeJSON(w, statusOr(status, http.StatusInternalServerError), map[string]any{"detail": "更新环境变量失败"})
return
}
savedCreds := h.saveVercelProjectCredentials(r.Context(), client, opts, params, headers, envs)
manual, deployURL := triggerVercelDeployment(r.Context(), client, opts.ProjectID, params, headers)
_ = h.Store.SetVercelSync(syncHashForJSON(cfgJSON), time.Now().Unix())
result := map[string]any{"success": true, "validated_accounts": validated}
if manual {
result["message"] = "配置已同步到 Vercel请手动触发重新部署"
result["manual_deploy_required"] = true
} else {
result["message"] = "配置已同步,正在重新部署..."
result["deployment_url"] = deployURL
}
if len(failed) > 0 {
result["failed_accounts"] = failed
}
if len(savedCreds) > 0 {
result["saved_credentials"] = savedCreds
}
writeJSON(w, http.StatusOK, result)
}
type vercelSyncOptions struct {
VercelToken string
ProjectID string
TeamID string
AutoValidate bool
SaveCreds bool
UsePreconfig bool
}
func parseVercelSyncOptions(req map[string]any) (vercelSyncOptions, error) {
vercelToken, _ := req["vercel_token"].(string)
projectID, _ := req["project_id"].(string)
teamID, _ := req["team_id"].(string)
autoValidate := true
if v, ok := req["auto_validate"].(bool); ok {
autoValidate = v
}
saveCreds := true
if v, ok := req["save_credentials"].(bool); ok {
saveCreds = v
}
usePreconfig := vercelToken == "__USE_PRECONFIG__" || strings.TrimSpace(vercelToken) == ""
if usePreconfig {
vercelToken = strings.TrimSpace(os.Getenv("VERCEL_TOKEN"))
}
if strings.TrimSpace(projectID) == "" {
projectID = strings.TrimSpace(os.Getenv("VERCEL_PROJECT_ID"))
}
if strings.TrimSpace(teamID) == "" {
teamID = strings.TrimSpace(os.Getenv("VERCEL_TEAM_ID"))
}
vercelToken = strings.TrimSpace(vercelToken)
projectID = strings.TrimSpace(projectID)
teamID = strings.TrimSpace(teamID)
if vercelToken == "" || projectID == "" {
return vercelSyncOptions{}, fmt.Errorf("需要 Vercel Token 和 Project ID")
}
return vercelSyncOptions{
VercelToken: vercelToken,
ProjectID: projectID,
TeamID: teamID,
AutoValidate: autoValidate,
SaveCreds: saveCreds,
UsePreconfig: usePreconfig,
}, nil
}
func buildVercelParams(teamID string) url.Values {
params := url.Values{}
if strings.TrimSpace(teamID) != "" {
params.Set("teamId", strings.TrimSpace(teamID))
}
return params
}
func (h *Handler) validateAccountsForVercelSync(ctx context.Context, enabled bool) (int, []string) {
if !enabled {
return 0, nil
}
validated, failed := 0, []string{}
for _, acc := range h.Store.Snapshot().Accounts {
if strings.TrimSpace(acc.Token) != "" {
continue
}
token, err := h.DS.Login(ctx, acc)
if err != nil {
failed = append(failed, acc.Identifier())
} else {
validated++
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
}
time.Sleep(500 * time.Millisecond)
}
return validated, failed
}
func upsertVercelEnv(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string, envs []any, key, value string) (int, error) {
existingID := findEnvID(envs, key)
if existingID != "" {
_, status, err := vercelRequest(ctx, client, http.MethodPatch, "https://api.vercel.com/v9/projects/"+projectID+"/env/"+existingID, params, headers, map[string]any{"value": value})
return status, err
}
_, status, err := vercelRequest(ctx, client, http.MethodPost, "https://api.vercel.com/v10/projects/"+projectID+"/env", params, headers, map[string]any{
"key": key,
"value": value,
"type": "encrypted",
"target": []string{"production", "preview"},
})
return status, err
}
func (h *Handler) saveVercelProjectCredentials(ctx context.Context, client *http.Client, opts vercelSyncOptions, params url.Values, headers map[string]string, envs []any) []string {
if !opts.SaveCreds || opts.UsePreconfig {
return nil
}
saved := []string{}
creds := [][2]string{{"VERCEL_TOKEN", opts.VercelToken}, {"VERCEL_PROJECT_ID", opts.ProjectID}}
if opts.TeamID != "" {
creds = append(creds, [2]string{"VERCEL_TEAM_ID", opts.TeamID})
}
for _, kv := range creds {
status, _ := upsertVercelEnv(ctx, client, opts.ProjectID, params, headers, envs, kv[0], kv[1])
if status == http.StatusOK || status == http.StatusCreated {
saved = append(saved, kv[0])
}
}
return saved
}
func triggerVercelDeployment(ctx context.Context, client *http.Client, projectID string, params url.Values, headers map[string]string) (bool, string) {
projectResp, status, _ := vercelRequest(ctx, client, http.MethodGet, "https://api.vercel.com/v9/projects/"+projectID, params, headers, nil)
if status != http.StatusOK {
return true, ""
}
link, ok := projectResp["link"].(map[string]any)
if !ok {
return true, ""
}
linkType, _ := link["type"].(string)
if linkType != "github" {
return true, ""
}
repoID := intFrom(link["repoId"])
ref, _ := link["productionBranch"].(string)
if ref == "" {
ref = "main"
}
depResp, depStatus, _ := vercelRequest(ctx, client, http.MethodPost, "https://api.vercel.com/v13/deployments", params, headers, map[string]any{
"name": projectID,
"project": projectID,
"target": "production",
"gitSource": map[string]any{
"type": "github",
"repoId": repoID,
"ref": ref,
},
})
if depStatus != http.StatusOK && depStatus != http.StatusCreated {
return true, ""
}
deployURL, _ := depResp["url"].(string)
return false, deployURL
}
func (h *Handler) vercelStatus(w http.ResponseWriter, r *http.Request) {
snap := h.Store.Snapshot()
current := h.computeSyncHash()
synced := snap.VercelSyncHash != "" && snap.VercelSyncHash == current
draftHash := ""
draftDiffers := false
if r != nil && r.Method == http.MethodPost && r.Body != nil {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
if cfgJSON, _, err := h.exportSyncConfig(req); err == nil {
draftHash = syncHashForJSON(cfgJSON)
draftDiffers = draftHash != "" && draftHash != current
}
}
}
writeJSON(w, http.StatusOK, map[string]any{
"synced": synced,
"last_sync_time": nilIfZero(snap.VercelSyncTime),
"has_synced_before": snap.VercelSyncHash != "",
"env_backed": h.Store.IsEnvBacked(),
"config_hash": current,
"last_synced_hash": snap.VercelSyncHash,
"draft_hash": draftHash,
"draft_differs": draftDiffers,
})
}
func (h *Handler) exportSyncConfig(req map[string]any) (string, string, error) {
override, ok := req["config_override"]
if !ok || override == nil {
return h.Store.ExportJSONAndBase64()
}
raw, err := json.Marshal(override)
if err != nil {
return "", "", err
}
var cfg config.Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return "", "", err
}
cfg.DropInvalidAccounts()
cfg.ClearAccountTokens()
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
b, err := json.Marshal(cfg)
if err != nil {
return "", "", err
}
return string(b), base64.StdEncoding.EncodeToString(b), nil
}
func syncHashForJSON(s string) string {
var cfg config.Config
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
return ""
}
cfg.VercelSyncHash = ""
cfg.VercelSyncTime = 0
cfg.ClearAccountTokens()
b, err := json.Marshal(cfg)
if err != nil {
return ""
}
sum := md5.Sum(b)
return fmt.Sprintf("%x", sum)
}
func vercelRequest(ctx context.Context, client *http.Client, method, endpoint string, params url.Values, headers map[string]string, body any) (map[string]any, int, error) {
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, 0, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
parsed := map[string]any{}
_ = json.Unmarshal(b, &parsed)
if len(parsed) == 0 {
parsed["raw"] = string(b)
}
return parsed, resp.StatusCode, nil
}
func findEnvID(envs []any, key string) string {
for _, item := range envs {
m, ok := item.(map[string]any)
if !ok {
continue
}
if k, _ := m["key"].(string); k == key {
id, _ := m["id"].(string)
return id
}
}
return ""
}