115 lines
3.9 KiB
Go

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