package app import ( "context" "database/sql" "fmt" "net/http" "strconv" "strings" "sub2api-cn-relay-manager/internal/store/sqlite" ) type ListProviderAccountsRequest struct { HostID string ProviderID string LogicalGroupID string RouteID string ShadowGroupID string AccountStatus string BindingState string Query string Limit int } type UpdateProviderAccountStatusRequest struct { AccountID int64 `json:"-"` AccountStatus string `json:"-"` DisabledReason string `json:"reason,omitempty"` } type GetProviderAccountBindingCandidatesRequest struct { AccountID int64 } type UpdateProviderAccountBindingRequest struct { AccountID int64 `json:"-"` RouteID string `json:"route_id,omitempty"` Clear bool `json:"clear,omitempty"` } type ProviderAccountBindingCandidatesResult struct { ProviderAccount ProviderAccountInfo `json:"provider_account"` CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"` } type ProviderAccountInfo struct { ID int64 `json:"id"` HostID string `json:"host_id"` HostBaseURL string `json:"host_base_url"` ProviderID string `json:"provider_id"` ProviderName string `json:"provider_name"` RouteName string `json:"route_name,omitempty"` RouteID string `json:"route_id,omitempty"` LogicalGroupID string `json:"logical_group_id,omitempty"` ShadowGroupID string `json:"shadow_group_id,omitempty"` ShadowHostID string `json:"shadow_host_id,omitempty"` UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"` HostAccountID string `json:"host_account_id"` KeyFingerprint string `json:"key_fingerprint"` AccountName string `json:"account_name"` AccountStatus string `json:"account_status"` BindingState string `json:"binding_state,omitempty"` BindingCandidateCount int `json:"binding_candidate_count,omitempty"` LastProbeStatus string `json:"last_probe_status,omitempty"` LastProbeAt string `json:"last_probe_at,omitempty"` DisabledReason string `json:"disabled_reason,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-accounts action is not configured"}) return } accounts, err := fn(r.Context(), ListProviderAccountsRequest{ HostID: strings.TrimSpace(r.URL.Query().Get("host_id")), ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")), LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")), RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")), ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")), AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")), BindingState: strings.TrimSpace(r.URL.Query().Get("binding_state")), Query: strings.TrimSpace(r.URL.Query().Get("q")), Limit: parsePositiveInt(r.URL.Query().Get("limit")), }) if err != nil { writeHTTPError(w, classifyError(err)) return } if accounts == nil { accounts = []ProviderAccountInfo{} } writeJSON(w, http.StatusOK, map[string]any{"provider_accounts": accounts}) } func handleGetProviderAccountBindingCandidates(w http.ResponseWriter, r *http.Request, fn func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-account-binding-candidates action is not configured"}) return } accountID, parseErr := parseProviderAccountID(r.PathValue("accountID")) if parseErr != nil { writeHTTPError(w, parseErr) return } result, actionErr := fn(r.Context(), GetProviderAccountBindingCandidatesRequest{AccountID: accountID}) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, result) } func handleEnableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) { handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusActive) } func handleDisableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) { handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDisabled) } func handleRetireProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) { handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDeprecated) } func handleUpdateProviderAccountBinding(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error)) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-binding action is not configured"}) return } accountID, err := parseProviderAccountID(r.PathValue("accountID")) if err != nil { writeHTTPError(w, err) return } var req UpdateProviderAccountBindingRequest if r.ContentLength != 0 { if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } } req.AccountID = accountID account, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"provider_account": account}) } func handleUpdateProviderAccountStatus(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error), accountStatus string) { if fn == nil { writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-status action is not configured"}) return } accountID, err := parseProviderAccountID(r.PathValue("accountID")) if err != nil { writeHTTPError(w, err) return } req := UpdateProviderAccountStatusRequest{ AccountID: accountID, AccountStatus: accountStatus, } if r.ContentLength != 0 { if err := decodeJSON(r, &req); err != nil { writeHTTPError(w, err) return } req.AccountID = accountID req.AccountStatus = accountStatus } account, actionErr := fn(r.Context(), req) if actionErr != nil { writeHTTPError(w, classifyError(actionErr)) return } writeJSON(w, http.StatusOK, map[string]any{"provider_account": account}) } func parseProviderAccountID(rawID string) (int64, *httpError) { accountID, err := strconv.ParseInt(strings.TrimSpace(rawID), 10, 64) if err != nil || accountID <= 0 { return 0, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_request", Message: "account_id must be a positive integer"} } return accountID, nil } func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error) { return func(ctx context.Context, req ListProviderAccountsRequest) ([]ProviderAccountInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return nil, err } defer store.Close() if err := sqlite.SyncProviderAccountsFromLatestImportBatches(ctx, store); err != nil { return nil, err } rows, err := store.ProviderAccounts().List(ctx, sqlite.ProviderAccountListFilter{ HostID: req.HostID, ProviderID: req.ProviderID, LogicalGroupID: req.LogicalGroupID, RouteID: req.RouteID, ShadowGroupID: req.ShadowGroupID, AccountStatus: req.AccountStatus, BindingState: req.BindingState, Query: req.Query, Limit: req.Limit, }) if err != nil { return nil, err } result := make([]ProviderAccountInfo, 0, len(rows)) for _, row := range rows { result = append(result, providerAccountViewToInfo(row)) } return result, nil } } func buildGetProviderAccountBindingCandidatesAction(sqliteDSN string) func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) { return func(ctx context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return ProviderAccountBindingCandidatesResult{}, err } defer store.Close() account, err := store.ProviderAccounts().GetViewByID(ctx, req.AccountID) if err != nil { if err == sql.ErrNoRows { return ProviderAccountBindingCandidatesResult{}, fmt.Errorf("provider account %d not found", req.AccountID) } return ProviderAccountBindingCandidatesResult{}, err } candidates := make([]LogicalGroupRouteInfo, 0) if strings.TrimSpace(account.HostID) != "" && strings.TrimSpace(account.ShadowGroupID) != "" { routes, routeErr := store.LogicalGroupRoutes().ListByShadowBinding(ctx, account.HostID, account.ShadowGroupID) if routeErr != nil { return ProviderAccountBindingCandidatesResult{}, routeErr } for _, route := range routes { candidates = append(candidates, logicalGroupRouteRowToInfo(route, nil)) } } return ProviderAccountBindingCandidatesResult{ ProviderAccount: providerAccountViewToInfo(account), CandidateRoutes: candidates, }, nil } } func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) { return func(ctx context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return ProviderAccountInfo{}, err } defer store.Close() if err := store.ProviderAccounts().UpdateStatusByID(ctx, req.AccountID, accountStatus, strings.TrimSpace(req.DisabledReason)); err != nil { if err == sql.ErrNoRows { return ProviderAccountInfo{}, fmt.Errorf("provider account %d not found", req.AccountID) } return ProviderAccountInfo{}, err } updated, err := store.ProviderAccounts().GetViewByID(ctx, req.AccountID) if err != nil { return ProviderAccountInfo{}, err } return providerAccountViewToInfo(updated), nil } } func buildUpdateProviderAccountBindingAction(sqliteDSN string) func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) { return func(ctx context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) { store, err := sqlite.Open(ctx, sqliteDSN) if err != nil { return ProviderAccountInfo{}, err } defer store.Close() account, err := store.ProviderAccounts().GetByID(ctx, req.AccountID) if err != nil { if err == sql.ErrNoRows { return ProviderAccountInfo{}, fmt.Errorf("provider account %d not found", req.AccountID) } return ProviderAccountInfo{}, err } if req.Clear { if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, "", account.ShadowGroupID); err != nil { return ProviderAccountInfo{}, err } } else { routeID := strings.TrimSpace(req.RouteID) if routeID == "" { return ProviderAccountInfo{}, fmt.Errorf("route_id is required") } route, routeErr := store.LogicalGroupRoutes().GetByRouteID(ctx, routeID) if routeErr != nil { if routeErr == sql.ErrNoRows { return ProviderAccountInfo{}, fmt.Errorf("logical group route %q not found", routeID) } return ProviderAccountInfo{}, routeErr } if strings.TrimSpace(route.ShadowHostID) != strings.TrimSpace(accountHostIDForBinding(store, ctx, account)) { return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_host_id does not match provider account host", routeID) } if strings.TrimSpace(account.ShadowGroupID) != "" && strings.TrimSpace(route.ShadowGroupID) != strings.TrimSpace(account.ShadowGroupID) { return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_group_id does not match provider account shadow_group_id", routeID) } if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, route.RouteID, route.ShadowGroupID); err != nil { return ProviderAccountInfo{}, err } } updated, err := store.ProviderAccounts().GetViewByID(ctx, account.ID) if err != nil { return ProviderAccountInfo{}, err } return providerAccountViewToInfo(updated), nil } } func accountHostIDForBinding(store *sqlite.DB, ctx context.Context, account sqlite.ProviderAccount) string { if store == nil { return "" } hostRow, err := store.Hosts().GetByID(ctx, account.HostID) if err != nil { return "" } return strings.TrimSpace(hostRow.HostID) } func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo { return ProviderAccountInfo{ ID: row.ID, HostID: row.HostID, HostBaseURL: row.HostBaseURL, ProviderID: row.ProviderID, ProviderName: row.ProviderName, RouteName: row.RouteName, RouteID: row.RouteID, LogicalGroupID: row.LogicalGroupID, ShadowGroupID: row.ShadowGroupID, ShadowHostID: row.ShadowHostID, UpstreamBaseURLHint: row.UpstreamBaseURLHint, HostAccountID: row.HostAccountID, KeyFingerprint: row.KeyFingerprint, AccountName: row.AccountName, AccountStatus: row.AccountStatus, BindingState: row.BindingState, BindingCandidateCount: row.BindingCandidateCount, LastProbeStatus: row.LastProbeStatus, LastProbeAt: row.LastProbeAt, DisabledReason: row.DisabledReason, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } }