mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-16 06:05:07 +08:00
refactor backend API structure
This commit is contained in:
24
internal/httpapi/admin/vercel/deps.go
Normal file
24
internal/httpapi/admin/vercel/deps.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package vercel
|
||||
|
||||
import (
|
||||
"ds2api/internal/chathistory"
|
||||
adminshared "ds2api/internal/httpapi/admin/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Store adminshared.ConfigStore
|
||||
Pool adminshared.PoolController
|
||||
DS adminshared.DeepSeekCaller
|
||||
OpenAI adminshared.OpenAIChatCaller
|
||||
ChatHistory *chathistory.Store
|
||||
}
|
||||
|
||||
var writeJSON = adminshared.WriteJSON
|
||||
var intFrom = adminshared.IntFrom
|
||||
|
||||
func nilIfZero(v int64) any { return adminshared.NilIfZero(v) }
|
||||
func statusOr(v int, d int) int { return adminshared.StatusOr(v, d) }
|
||||
|
||||
func (h *Handler) computeSyncHash() string {
|
||||
return adminshared.ComputeSyncHash(h.Store)
|
||||
}
|
||||
326
internal/httpapi/admin/vercel/handler_vercel.go
Normal file
326
internal/httpapi/admin/vercel/handler_vercel.go
Normal file
@@ -0,0 +1,326 @@
|
||||
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)
|
||||
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 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 ""
|
||||
}
|
||||
9
internal/httpapi/admin/vercel/routes.go
Normal file
9
internal/httpapi/admin/vercel/routes.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package vercel
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
|
||||
func RegisterRoutes(r chi.Router, h *Handler) {
|
||||
r.Post("/vercel/sync", h.syncVercel)
|
||||
r.Get("/vercel/status", h.vercelStatus)
|
||||
r.Post("/vercel/status", h.vercelStatus)
|
||||
}
|
||||
Reference in New Issue
Block a user