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
+294
View File
@@ -0,0 +1,294 @@
package mingqian
import (
"context"
"encoding/json"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model/entity"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
"time"
)
func testAdapter(t *testing.T, handler http.HandlerFunc) *Adapter {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
client, err := NewClient(server.URL, "test-app", "test-secret", time.Second)
if err != nil {
t.Fatal(err)
}
return &Adapter{client: client}
}
func TestSign(t *testing.T) {
params := map[string]string{"appId": "test-app", "reqtime": "2026-09-17", "timestamp": "1789603260000", "transid": "20260917080000000abcdef", "version": "3.0", "sign": "ignored"}
if got := Sign(params, "test-secret"); got != "78e5d4f24b86159ad2f664ddbcc66282" {
t.Fatalf("wrong sign: %s", got)
}
}
func TestWireAndUsageMapping(t *testing.T) {
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || (request.URL.Path != usagePath && request.URL.Path != packageUsagePath) || request.Header.Get("Content-Type") != "application/json" {
t.Error("incorrect HTTP contract")
}
var payload struct {
Body map[string]string `json:"body"`
Data string `json:"data"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Error(err)
writer.WriteHeader(400)
return
}
if payload.Body["sign"] != Sign(payload.Body, "test-secret") {
t.Error("invalid signature")
}
stamp, err := strconv.ParseInt(payload.Body["timestamp"], 10, 64)
if err != nil || len(payload.Body["timestamp"]) != 13 || time.Until(time.UnixMilli(stamp)) < 55*time.Second || time.Until(time.UnixMilli(stamp)) > 65*time.Second {
t.Error("wrong millisecond timestamp offset")
}
if !regexp.MustCompile(`^\d{17}[0-9a-f]{6}$`).MatchString(payload.Body["transid"]) {
t.Error("invalid transaction ID")
}
var data struct {
Numbers []string `json:"numberArray"`
}
if err := json.Unmarshal([]byte(payload.Data), &data); err != nil || len(data.Numbers) != 3 {
t.Error("data must be escaped JSON containing requested cards")
}
if request.URL.Path == packageUsagePath {
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","totalpackage":10240,"targetpackageusage":2048},{"iccid":"A","totalpackage":"10240","targetpackageusage":"8192"}]}`)
return
}
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"B","use":2048},{"iccid":"A","use":"1024"}]}`)
})
result, err := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}, {ICCID: "missing"}}})
if err != nil || len(result) != 3 {
t.Fatalf("result: %v %v", result, err)
}
if result[0].Code != 0 || result[0].ResData.DataUsage != 1 || result[1].ResData.DataUsage != 2 || result[2].Code == 0 {
t.Fatalf("incorrect per-card mapping: %+v", result)
}
if result[0].ResData.TotalFlow != nil {
t.Fatal("monthly usage must not invent package total")
}
packageFlow := result[0].ResData.PackageFlow
if packageFlow == nil || packageFlow.TotalFlow != 10240 || packageFlow.DataUsage != 8192 || packageFlow.RemainingFlow != 2048 {
t.Fatalf("package cumulative flow must remain in MB and separate from monthly usage: %+v", packageFlow)
}
if result[1].ResData.PackageFlow == nil || result[1].ResData.PackageFlow.RemainingFlow != 8192 {
t.Fatal("package response must match the requested card, not array position")
}
}
func TestInvalidUsage(t *testing.T) {
for _, data := range []string{`{"iccid":"A"}`, `{"iccid":"A","use":null}`, `{"iccid":"A","use":-1}`, `{"iccid":"A","use":"NaN"}`, `{"iccid":"A","use":""}`} {
t.Run(data, func(t *testing.T) {
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
fmt.Fprintf(writer, `{"code":200,"data":[%s]}`, data)
})
result, _ := adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
if result[0].Code == 0 {
t.Fatal("unknown usage reported as zero success")
}
})
}
}
func TestCardStates(t *testing.T) {
for state, want := range map[int]int{1: 1, 2: 2, 3: 4, 4: 6, 5: 7, 6: 9} {
got, err := cardStatus(&state)
if err != nil || got != want {
t.Fatalf("state %d mapped to %d: %v", state, got, err)
}
}
unknown := 7
if _, err := cardStatus(&unknown); err == nil {
t.Fatal("expired state cannot be assumed to mean cancelled")
}
if _, err := cardStatus(nil); err == nil {
t.Fatal("missing state must fail")
}
}
func TestBatchAndBasicInfo(t *testing.T) {
calls := 0
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
calls++
var payload struct {
Data string `json:"data"`
}
json.NewDecoder(request.Body).Decode(&payload)
var data struct {
Numbers []string `json:"numberArray"`
}
json.Unmarshal([]byte(payload.Data), &data)
if len(data.Numbers) > 50 {
t.Error("query batch exceeds 50")
}
rows := []map[string]any{}
for _, value := range data.Numbers {
rows = append(rows, map[string]any{"iccid": value, "msisdn": "144000", "state": 2, "statename": "active", "activedate": "2026-01-01"})
}
json.NewEncoder(writer).Encode(map[string]any{"code": 200, "data": rows})
})
keys := []*model.SimKey{}
for index := 0; index < 51; index++ {
keys = append(keys, &model.SimKey{ICCID: strconv.Itoa(index)})
}
result, _ := adapter.QuerySimBasicInfo(context.Background(), &model.SimBasicInfoInput{SimKeys: keys})
if calls != 2 || len(result) != 51 {
t.Fatalf("calls=%d rows=%d", calls, len(result))
}
for _, item := range result {
if item.Code != 0 || item.ResData.ActiveDate != "" || item.ResData.IMSI != "" {
t.Fatal("incorrect or fabricated basic fields")
}
}
}
func TestOperationsAreSafe(t *testing.T) {
calls := 0
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
calls++
if request.URL.Path != "/simlist/test-resume" && request.URL.Path != "/simlist/get-sim-switchoff-by-number" {
t.Error("wrong operation route")
}
fmt.Fprint(writer, `{"code":200,"data":[{"iccid":"A","issuccess":1},{"iccid":"B","issuccess":0}]}`)
})
input := &model.SimCardStatusChangeInput{SimKeys: []*model.SimKey{{ICCID: "A"}, {ICCID: "B"}}, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}}
result, _ := adapter.ChangeSimStatus(context.Background(), input)
if calls != 1 || result[0].Code != 0 || result[1].Code == 0 {
t.Fatal("stop operation must reach the confirmed endpoint and inspect per-card success flags")
}
before := calls
input.ReqData.OperType = "1"
result, _ = adapter.ChangeSimStatus(context.Background(), input)
if calls != before || result[0].Code == 0 {
t.Fatal("unconfirmed resume path must not send any request")
}
adapter.resumePath = "/simlist/test-resume"
for _, operation := range []string{"0", "1"} {
input.ReqData.OperType = operation
result, _ = adapter.ChangeSimStatus(context.Background(), input)
if result[0].Code != 0 || result[1].Code == 0 {
t.Fatal("must inspect per-card success flags")
}
}
before = calls
input.ReqData.OperType = "invalid"
adapter.ChangeSimStatus(context.Background(), input)
if calls != before {
t.Fatal("invalid operation sent")
}
}
func TestResponseFailuresAndNoRetry(t *testing.T) {
for _, body := range []string{`{}`, `{"code":401,"msg":"test-secret"}`, `{"code":200}`, `{"code":200,"data":null}`, `{"code":200,"data":[{"iccid":"A","state":2},{"iccid":"A","state":3}]}`} {
t.Run(body, func(t *testing.T) {
calls := 0
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++; fmt.Fprint(writer, body) })
result, _ := adapter.QuerySimCardStatus(context.Background(), &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
if calls != 1 || result[0].Code == 0 || strings.Contains(result[0].Message, "test-secret") {
t.Fatal("invalid response accepted, retried or leaked secret")
}
})
}
}
func TestConfiguration(t *testing.T) {
cfg := &model.SimChannelConfigItemCache{Template: &entity.TSimChannelTemplate{TempConfig: `{"api":{"timeout":"1s"}}`}, Channel: &entity.TSimChannels{AppKey: "app", AppSecret: "secret"}}
adapter, err := New(cfg)
if err != nil || adapter.resumePath != "" {
t.Fatalf("invalid default configuration: %v", err)
}
cfg.Channel.AppSecret = ""
if _, err := New(cfg); err == nil {
t.Fatal("missing secret accepted")
}
cfg.Channel.AppSecret = "secret"
for _, raw := range []string{`{"api":`, `{"api":{"timeout":"0s"}}`, `{"api":{"resumePath":"/simlist/get-sim-switchoff-by-number"}}`} {
cfg.Template.TempConfig = raw
if _, err := New(cfg); err == nil {
t.Fatal("invalid config accepted")
}
}
}
func TestOperationBatchLimitAndHTTPFailure(t *testing.T) {
calls := 0
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) {
calls++
var payload struct {
Data string `json:"data"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Error(err)
}
var data struct {
Numbers []string `json:"numberArray"`
}
if err := json.Unmarshal([]byte(payload.Data), &data); err != nil {
t.Error(err)
}
if len(data.Numbers) > 20 {
t.Error("operation batch exceeds 20")
}
writer.WriteHeader(http.StatusTooManyRequests)
})
keys := make([]*model.SimKey, 21)
for index := range keys {
keys[index] = &model.SimKey{ICCID: strconv.Itoa(index)}
}
output, err := adapter.ChangeSimStatus(context.Background(), &model.SimCardStatusChangeInput{SimKeys: keys, ReqData: model.SimCardStatusChangeInputItem{OperType: "0"}})
if err != nil || calls != 2 || len(output) != 21 {
t.Fatalf("calls=%d len=%d err=%v", calls, len(output), err)
}
for _, item := range output {
if item.Code == 0 || !strings.Contains(item.Message, "429") {
t.Fatal("HTTP failure must not appear as successful operation")
}
}
}
func TestAccountGateSharing(t *testing.T) {
first, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second)
if err != nil {
t.Fatal(err)
}
second, err := NewClient("http://127.0.0.1", "same-account", "secret", time.Second)
if err != nil {
t.Fatal(err)
}
if first.gate != second.gate {
t.Fatal("same account must share rate limiter")
}
first.gate.mu.Lock()
first.gate.next = time.Now().Add(time.Second)
first.gate.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
if err := second.gate.wait(ctx); err == nil {
t.Fatal("rate limit wait must honor context timeout")
}
}
func TestCancelledAndUnsupportedDoNotCall(t *testing.T) {
calls := 0
adapter := testAdapter(t, func(writer http.ResponseWriter, request *http.Request) { calls++ })
ctx, cancel := context.WithCancel(context.Background())
cancel()
adapter.QuerySimCardStatus(ctx, &model.SimCardStatusInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
adapter.QuerySimDataUsage(context.Background(), &model.SimDataUsageInput{SimKeys: []*model.SimKey{nil, {IMSI: "only-imsi"}}})
adapter.QuerySimImei(context.Background(), &model.SimImeiInput{SimKeys: []*model.SimKey{{ICCID: "A"}}})
if calls != 0 {
t.Fatal("unexpected upstream request")
}
}