chore: 并入 iotlcm-upstream-chan 历史到 upstream-chan/(原提交 1e74497)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
192 changed files with 33119 additions and 0 deletions
@@ -0,0 +1,177 @@
package recharge
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const DefaultBaseURL = "http://www.sdtywl.cn/"
const requestInterval = time.Second
var requestGates sync.Map
// 上游业务码(综合充值平台API文档-通用反馈状态码)
const (
CodeSuccess = 200 // 成功
CodeMissingParam = 400 // 缺少参数
CodeSignError = 401 // sign错误
CodeForbidden = 403 // 无权访问
CodePwdKeyError = 404 // pwdkey错误
CodeNotWhiteList = 407 // 非白名单IP
CodeRateLimited = 429 // 访问超频(单接口60次/分)
CodeServerError = 500 // 其他错误
)
// BusinessError 上游业务错误(HTTP 200 但 code != 200)
type BusinessError struct {
Code int
Msg string
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("recharge upstream business error, code %d, msg %s", e.Code, e.Msg)
}
// Response 上游统一响应信封
type Response struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
type Config struct {
BaseURL string
PwdKey string
Pwdiv string
Timeout time.Duration
}
type Client struct {
baseURL string
pwdKey string
pwdiv string
http *http.Client
}
type requestGate struct {
mu sync.Mutex
next time.Time
}
func (gate *requestGate) wait(ctx context.Context) error {
for {
if err := ctx.Err(); err != nil {
return err
}
gate.mu.Lock()
delay := time.Until(gate.next)
if delay <= 0 {
gate.next = time.Now().Add(requestInterval)
gate.mu.Unlock()
return nil
}
gate.mu.Unlock()
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
func NewClient(cfg Config) *Client {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = DefaultBaseURL
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
pwdKey: cfg.PwdKey,
pwdiv: cfg.Pwdiv,
http: &http.Client{Timeout: timeout},
}
}
// DoPost 调用上游 POST 接口,返回业务信封;HTTP 层错误直接返回 error,
// 业务码(code != 200)由调用方判断
func (c *Client) DoPost(ctx context.Context, endpoint string, data any) (*Response, error) {
if err := c.requestGate(endpoint).wait(ctx); err != nil {
return nil, err
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
payload := map[string]any{
"pwdkey": c.pwdKey,
"timestamp": timestamp,
"sign": c.sign(timestamp),
}
if data != nil {
payload["data"] = data
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
return c.do(ctx, http.MethodPost, c.baseURL+"/prod-api/open/api"+endpoint, body)
}
func (c *Client) requestGate(endpoint string) *requestGate {
key := c.baseURL + "|" + c.pwdKey + "|" + strings.TrimSpace(endpoint)
gate, _ := requestGates.LoadOrStore(key, &requestGate{})
return gate.(*requestGate)
}
func (c *Client) sign(timestamp string) string {
sum := md5.Sum([]byte(c.pwdKey + c.pwdiv + timestamp))
return strings.ToLower(hex.EncodeToString(sum[:]))
}
func (c *Client) do(ctx context.Context, method, endpoint string, body []byte) (*Response, error) {
var reader io.Reader
if body != nil {
reader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json; charset=utf-8")
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("recharge upstream http status %d: %s", resp.StatusCode, string(respBody))
}
var output Response
if err := json.Unmarshal(respBody, &output); err != nil {
return nil, fmt.Errorf("decode recharge upstream response: %w, body=%s", err, string(respBody))
}
return &output, nil
}
@@ -0,0 +1,33 @@
package recharge
import (
"context"
"errors"
"testing"
"time"
)
func TestRequestGateHonorsCancellation(t *testing.T) {
gate := &requestGate{}
if err := gate.wait(context.Background()); err != nil {
t.Fatalf("first wait failed: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := gate.wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("wait error = %v, want deadline exceeded", err)
}
}
func TestClientSharesGatePerAccountAndEndpoint(t *testing.T) {
first := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
second := NewClient(Config{BaseURL: "http://example.com", PwdKey: "account"})
if first.requestGate("/findCard") != second.requestGate("/findCard") {
t.Fatal("same account and endpoint must share the rate limiter")
}
if first.requestGate("/findCard") == first.requestGate("/changeState") {
t.Fatal("different endpoints must not share the rate limiter")
}
}