chore: 初始开源发布

iotlcm-upstream-chan:运营商上游通道适配服务(统一 /sim/* 协议)。

- 内联原私有依赖:yunze-public → internal/pub/(10 包 18 文件),
  yunze-pb/pb → internal/pb/(9 文件);Consul 注册改用官方
  gogf/gf/contrib/registry/consul/v2(API 兼容)
- Go module:yz-svr → git.5iot.com/iotlcm-public/iotlcm-upstream-chan
- 修复 mingqian 包测试:实现重构后 `adapter.operations` 字段已移除,
  测试断言按当前实现语义重写(不改产品行为)
- 凭据与内部标识脱敏:MySQL/Redis/Consul 凭据、私网与生产 IP、
  内网域名、真实手机号、注释里的真实 token/transid
- 移除内部资产:CI 触发器、生产通道种子 SQL、真实凭据测试资源文档
- Dockerfile 改用公共基础镜像,移除写死的 registry 凭据
- 补 Apache-2.0 LICENSE,重写 README,LF 行尾 + .gitattributes
This commit is contained in:
MiFaZhan
2026-10-22 15:42:29 +08:00
commit 96b1edff45
191 changed files with 30805 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")
}
}