package withdrawalsource import ( "bytes" "context" "crypto/subtle" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "hyapp-admin-server/internal/config" ) const maxErrorBodyBytes = 2048 type DecisionRequest struct { SourceApplicationID string `json:"sourceApplicationId"` Decision string `json:"decision"` CredentialURLs []string `json:"credentialUrls"` Remark string `json:"remark"` ReviewerUserID uint `json:"reviewerUserId"` ReviewerName string `json:"reviewerName"` CommandID string `json:"commandId"` } type Registry struct { sources map[string]source } type source struct { config config.WithdrawalSourceConfig client *http.Client } func New(configs []config.WithdrawalSourceConfig) *Registry { registry := &Registry{sources: make(map[string]source)} for _, item := range configs { if !item.Enabled { continue } key := sourceKey(item.AppCode, item.SourceSystem) registry.sources[key] = source{ config: item, client: &http.Client{Timeout: item.RequestTimeout}, } } return registry } // AuthenticateSubmit 对 app_code + source_system 组合做独立令牌校验。 // 常量时间比较避免从鉴权耗时侧信道逐字符猜测资金接口令牌。 func (r *Registry) AuthenticateSubmit(appCode string, sourceSystem string, authorization string) bool { item, ok := r.lookup(appCode, sourceSystem) if !ok { return false } authorization = strings.TrimSpace(authorization) if !strings.HasPrefix(authorization, "Bearer ") { return false } provided := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) expected := strings.TrimSpace(item.config.SubmitToken) return provided != "" && len(provided) == len(expected) && subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 } func (r *Registry) HasSource(appCode string, sourceSystem string) bool { _, ok := r.lookup(appCode, sourceSystem) return ok } // ApplyDecision 只负责把终态回调来源钱包;来源端必须按 commandId 幂等。 // admin 本地状态只有在 2xx 后才会提交,因此任何网络错误都会保留可重试的审核状态。 func (r *Registry) ApplyDecision(ctx context.Context, appCode string, sourceSystem string, request DecisionRequest) (string, error) { item, ok := r.lookup(appCode, sourceSystem) if !ok { return "", errors.New("withdrawal source is not configured") } body, err := json.Marshal(request) if err != nil { return "", fmt.Errorf("encode withdrawal source decision: %w", err) } httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, item.config.CallbackURL, bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("build withdrawal source decision: %w", err) } httpRequest.Header.Set("Authorization", "Bearer "+item.config.CallbackToken) httpRequest.Header.Set("Content-Type", "application/json") response, err := item.client.Do(httpRequest) if err != nil { return "", fmt.Errorf("call withdrawal source decision: %w", err) } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { message, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorBodyBytes)) return "", fmt.Errorf("withdrawal source decision returned %d: %s", response.StatusCode, strings.TrimSpace(string(message))) } // 来源申请号已单独持久化;阶段 commandId 作为短事务号即可关联 admin 审核与来源幂等执行,且不会突破字段长度上限。 return "external:" + strings.TrimSpace(request.CommandID), nil } func (r *Registry) lookup(appCode string, sourceSystem string) (source, bool) { if r == nil { return source{}, false } item, ok := r.sources[sourceKey(appCode, sourceSystem)] return item, ok } func sourceKey(appCode string, sourceSystem string) string { return strings.ToLower(strings.TrimSpace(appCode)) + "\x00" + strings.ToLower(strings.TrimSpace(sourceSystem)) }