2026-05-29 14:43:34 +08:00
package app
import (
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type ListProviderAccountsRequest struct {
2026-05-29 15:50:28 +08:00
HostID string
ProviderID string
LogicalGroupID string
RouteID string
ShadowGroupID string
AccountStatus string
2026-05-29 19:07:01 +08:00
BindingState string
2026-05-29 15:50:28 +08:00
Query string
Limit int
2026-05-29 14:43:34 +08:00
}
type UpdateProviderAccountStatusRequest struct {
AccountID int64 ` json:"-" `
AccountStatus string ` json:"-" `
DisabledReason string ` json:"reason,omitempty" `
}
2026-05-29 19:07:01 +08:00
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" `
}
2026-05-29 14:43:34 +08:00
type ProviderAccountInfo struct {
2026-05-29 19:07:01 +08:00
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" `
2026-05-29 14:43:34 +08:00
}
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 {
2026-05-29 15:50:28 +08:00
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" ) ) ,
2026-05-29 19:07:01 +08:00
BindingState : strings . TrimSpace ( r . URL . Query ( ) . Get ( "binding_state" ) ) ,
2026-05-29 15:50:28 +08:00
Query : strings . TrimSpace ( r . URL . Query ( ) . Get ( "q" ) ) ,
Limit : parsePositiveInt ( r . URL . Query ( ) . Get ( "limit" ) ) ,
2026-05-29 14:43:34 +08:00
} )
if err != nil {
writeHTTPError ( w , classifyError ( err ) )
return
}
if accounts == nil {
accounts = [ ] ProviderAccountInfo { }
}
writeJSON ( w , http . StatusOK , map [ string ] any { "provider_accounts" : accounts } )
}
2026-05-29 19:07:01 +08:00
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 )
}
2026-05-29 14:43:34 +08:00
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 )
}
2026-05-29 19:07:01 +08:00
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 } )
}
2026-05-29 14:43:34 +08:00
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
}
2026-05-29 19:07:01 +08:00
accountID , err := parseProviderAccountID ( r . PathValue ( "accountID" ) )
if err != nil {
writeHTTPError ( w , err )
2026-05-29 14:43:34 +08:00
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
}
2026-05-29 19:07:01 +08:00
account , actionErr := fn ( r . Context ( ) , req )
if actionErr != nil {
writeHTTPError ( w , classifyError ( actionErr ) )
2026-05-29 14:43:34 +08:00
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "provider_account" : account } )
}
2026-05-29 19:07:01 +08:00
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
}
2026-05-29 14:43:34 +08:00
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 {
2026-05-29 15:50:28 +08:00
HostID : req . HostID ,
ProviderID : req . ProviderID ,
LogicalGroupID : req . LogicalGroupID ,
RouteID : req . RouteID ,
ShadowGroupID : req . ShadowGroupID ,
AccountStatus : req . AccountStatus ,
2026-05-29 19:07:01 +08:00
BindingState : req . BindingState ,
2026-05-29 15:50:28 +08:00
Query : req . Query ,
Limit : req . Limit ,
2026-05-29 14:43:34 +08:00
} )
if err != nil {
return nil , err
}
result := make ( [ ] ProviderAccountInfo , 0 , len ( rows ) )
for _ , row := range rows {
result = append ( result , providerAccountViewToInfo ( row ) )
}
return result , nil
}
}
2026-05-29 19:07:01 +08:00
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
}
}
2026-05-29 14:43:34 +08:00
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
}
}
2026-05-29 19:07:01 +08:00
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 )
}
2026-05-29 14:43:34 +08:00
func providerAccountViewToInfo ( row sqlite . ProviderAccountView ) ProviderAccountInfo {
return ProviderAccountInfo {
2026-05-29 19:07:01 +08:00
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 ,
2026-05-29 14:43:34 +08:00
}
}