115 lines
3.9 KiB
Go
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))
|
|
}
|