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 "" }