Spaces:
Sleeping
Sleeping
| package vercel | |
| 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, h.Store.Snapshot().Vercel) | |
| 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) | |
| credentialsWarning := "" | |
| if saved, err := h.saveLocalVercelCredentials(opts); err == nil && saved { | |
| savedCreds = append(savedCreds, "config.vercel") | |
| } else if err != nil { | |
| credentialsWarning = "保存 Vercel 凭据到本地配置失败: " + err.Error() | |
| } | |
| 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 | |
| } | |
| if credentialsWarning != "" { | |
| result["credentials_warning"] = credentialsWarning | |
| } | |
| 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, saved config.VercelConfig) (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 = firstNonEmpty(os.Getenv("VERCEL_TOKEN"), saved.Token) | |
| } | |
| if strings.TrimSpace(projectID) == "" { | |
| projectID = firstNonEmpty(os.Getenv("VERCEL_PROJECT_ID"), saved.ProjectID) | |
| } | |
| if strings.TrimSpace(teamID) == "" { | |
| teamID = firstNonEmpty(os.Getenv("VERCEL_TEAM_ID"), saved.TeamID) | |
| } | |
| 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 firstNonEmpty(values ...string) string { | |
| for _, value := range values { | |
| if trimmed := strings.TrimSpace(value); trimmed != "" { | |
| return trimmed | |
| } | |
| } | |
| return "" | |
| } | |
| 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 (h *Handler) saveLocalVercelCredentials(opts vercelSyncOptions) (bool, error) { | |
| if !opts.SaveCreds { | |
| return false, nil | |
| } | |
| err := h.Store.Update(func(c *config.Config) error { | |
| token := opts.VercelToken | |
| if opts.UsePreconfig { | |
| token = c.Vercel.Token | |
| } | |
| c.Vercel = config.NormalizeVercelConfig(config.VercelConfig{ | |
| Token: token, | |
| ProjectID: opts.ProjectID, | |
| TeamID: opts.TeamID, | |
| }) | |
| return nil | |
| }) | |
| return err == nil, err | |
| } | |
| 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 encodeVercelSyncConfig(h.Store.Snapshot()) | |
| } | |
| raw, err := json.Marshal(override) | |
| if err != nil { | |
| return "", "", err | |
| } | |
| var cfg config.Config | |
| if err := json.Unmarshal(raw, &cfg); err != nil { | |
| return "", "", err | |
| } | |
| return encodeVercelSyncConfig(cfg) | |
| } | |
| func encodeVercelSyncConfig(cfg config.Config) (string, string, error) { | |
| cfg.DropInvalidAccounts() | |
| cfg.ClearAccountTokens() | |
| cfg.ClearVercelCredentials() | |
| 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() | |
| cfg.ClearVercelCredentials() | |
| 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 func() { _ = 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 "" | |
| } | |