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,521 @@
package upstreamimpl
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/service"
)
const henanXinjingDirectTemplateKey = "sim_henan_xinjing_direct_v1"
// henanXinjingConfig is the channel-level config for the direct Henan Xinjing
// template. The top-level userId/apiKey form is the canonical shape; the
// nested `credentials` block is kept for backward compatibility with earlier
// drafts of this template.
type henanXinjingConfig struct {
UserID string `json:"userId"`
APIKey string `json:"apiKey"`
APIKeyLegacy string `json:"apikey"`
API struct {
BaseURL string `json:"baseUrl"`
Timeout string `json:"timeout"`
} `json:"api"`
Credentials struct {
UserID string `json:"userId"`
APIKey string `json:"apiKey"`
APIKeyLegacy string `json:"apikey"`
} `json:"credentials"`
}
// henanXinjingParseConfig unmarshals one JSON config blob (channel or
// template) into henanXinjingConfig. Empty input yields a zero config.
func henanXinjingParseConfig(raw string) henanXinjingConfig {
var c henanXinjingConfig
raw = strings.TrimSpace(raw)
if raw == "" {
return c
}
_ = json.Unmarshal([]byte(raw), &c)
return c
}
// henanXinjingPickCreds resolves (userId, apiKey) with precedence:
// channel.chan_config > channel.app_key/app_secret > template.temp_config.
// This matches how the LCM admin stores credentials: per-channel in
// t_sim_channels, not in the shared template row.
func henanXinjingPickCreds(chanCfg, tplCfg henanXinjingConfig, appKey, appSecret string) (user, key string) {
firstNonEmptyStr := func(vals ...string) string {
for _, v := range vals {
if v = strings.TrimSpace(v); v != "" {
return v
}
}
return ""
}
user = firstNonEmptyStr(
chanCfg.UserID, chanCfg.Credentials.UserID,
appKey,
tplCfg.UserID, tplCfg.Credentials.UserID,
)
key = firstNonEmptyStr(
chanCfg.APIKey, chanCfg.APIKeyLegacy,
chanCfg.Credentials.APIKey, chanCfg.Credentials.APIKeyLegacy,
appSecret,
tplCfg.APIKey, tplCfg.APIKeyLegacy,
tplCfg.Credentials.APIKey, tplCfg.Credentials.APIKeyLegacy,
)
return
}
// henanXinjingFlexFloat accepts either a JSON number or a JSON string and
// normalizes to float64. The platform returns `used` as a number and
// `surplus`/`canused` as strings in some responses.
type henanXinjingFlexFloat float64
func (f *henanXinjingFlexFloat) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*f = 0
return nil
}
var n float64
if err := json.Unmarshal(data, &n); err == nil {
*f = henanXinjingFlexFloat(n)
return nil
}
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
s = strings.TrimSpace(s)
if s == "" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return fmt.Errorf("henan xinjing: parse float %q: %w", s, err)
}
*f = henanXinjingFlexFloat(v)
return nil
}
// henanXinjingCard mirrors `data` in `/api/v1/getChaxun`. Only fields we
// actually use are decoded; the platform returns many more.
//
// Documented field meanings (per upstream doc):
// - state : string card status code ("00" ok, "02" stopped, etc.)
// - status : int online/auth status (NOT the card lifecycle status)
// - used : KB (current month)
// - surplus : KB
// - expired_at / shouchong_time : yyyy-MM-dd HH:mm:ss strings
// - gprs_status : 0 unknown 1 on 2 off
// - power_status: 0 unknown 1 online 2 offline
// - display_status: 1 pending, 2 normal, 3 abnormal, 4 pre-close, 5 closed
type henanXinjingCard struct {
ICCID string `json:"iccid"`
CardNo string `json:"cardno"`
IMEI string `json:"imei"`
Used henanXinjingFlexFloat `json:"used"`
Surplus henanXinjingFlexFloat `json:"surplus"`
CanUsed henanXinjingFlexFloat `json:"canused"`
State string `json:"state"`
Status int `json:"status"`
OnlineState int `json:"onlinestate"`
GPRSStatus int `json:"gprs_status"`
PowerStatus int `json:"power_status"`
DisplayStatus int `json:"display_status"`
ExpiredAt string `json:"expired_at"`
FirstChargeAt string `json:"shouchong_time"`
Message string `json:"message"`
AutoName string `json:"autoname"`
MonthUseds []struct {
MonthDate string `json:"month_date"` // yyyyMM
UsedFlow string `json:"used_flow"` // MB per upstream doc
} `json:"month_useds"`
Package []struct {
OrderNo string `json:"orderno"`
PackageName string `json:"package_name"`
State int `json:"state"`
ExpiredAt string `json:"expired_at"`
CreatedAt string `json:"created_at"`
Flow string `json:"flow"`
MonthlyFlow henanXinjingFlexFloat `json:"monthly_flow"`
Surplus henanXinjingFlexFloat `json:"surplus"`
Used henanXinjingFlexFloat `json:"used"`
} `json:"package"`
}
type sSimHenanXinjingDirect struct {
channelConfig *model.SimChannelConfigItemCache
client *henanXinjingClient
}
func init() {
service.UpstreamAPI().RegisterTemp(henanXinjingDirectTemplateKey, NewSimHenanXinjingDirect())
}
func NewSimHenanXinjingDirect() *sSimHenanXinjingDirect { return &sSimHenanXinjingDirect{} }
func (s *sSimHenanXinjingDirect) NewTemp(ctx context.Context, cfg *model.SimChannelConfigItemCache) model.SimCardChannelI {
chanKey := ""
if cfg != nil && cfg.Channel != nil {
chanKey = cfg.Channel.ChanKey
}
if cfg == nil || cfg.Channel == nil || cfg.Template == nil {
g.Log().Warningf(ctx, "henan_xinjing_direct.NewTemp: nil cfg/channel/template, chanKey=%s", chanKey)
return nil
}
chanCfg := henanXinjingParseConfig(cfg.Channel.ChanConfig)
tplCfg := henanXinjingParseConfig(cfg.Template.TempConfig)
user, key := henanXinjingPickCreds(chanCfg, tplCfg, cfg.Channel.AppKey, cfg.Channel.AppSecret)
baseURL := strings.TrimSpace(chanCfg.API.BaseURL)
if baseURL == "" {
baseURL = strings.TrimSpace(tplCfg.API.BaseURL)
}
if user == "" || key == "" || baseURL == "" {
g.Log().Warningf(ctx,
"henan_xinjing_direct.NewTemp: missing required config, chanKey=%s userIdEmpty=%t apiKeyEmpty=%t baseURLEmpty=%t",
chanKey, user == "", key == "", baseURL == "")
return nil
}
timeout := defaultUpstreamHTTPTimeout
for _, raw := range []string{chanCfg.API.Timeout, tplCfg.API.Timeout} {
if raw == "" {
continue
}
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
timeout = d
break
}
}
return &sSimHenanXinjingDirect{
channelConfig: cfg,
client: newHenanXinjingClient(baseURL, user, key, timeout),
}
}
// simKeyCardNo picks the identifier Henan Xinjing accepts for `cardno`.
// The platform keys on its own card-number column, which corresponds to
// MSISDN for these cards; fall back to ICCID/IMSI when MSISDN is absent.
func henanXinjingCardNo(k *model.SimKey) string {
if k == nil {
return ""
}
if v := strings.TrimSpace(k.MSISDN); v != "" {
return v
}
if v := strings.TrimSpace(k.ICCID); v != "" {
return v
}
return strings.TrimSpace(k.IMSI)
}
// fetchCard calls /api/v1/getChaxun for a single SimKey.
func (s *sSimHenanXinjingDirect) fetchCard(ctx context.Context, k *model.SimKey) (henanXinjingCard, error) {
if k == nil {
return henanXinjingCard{}, fmt.Errorf("sim key is required")
}
no := henanXinjingCardNo(k)
if no == "" {
return henanXinjingCard{}, fmt.Errorf("msisdn/iccid/imsi is required")
}
var c henanXinjingCard
err := s.client.get(ctx, "/api/v1/getChaxun", map[string]string{"cardno": no}, &c)
return c, err
}
// henanXinjingKBToMB converts upstream KB to the MB unit SimDataUsageItem
// requires. Rounds to 4 decimal places to keep values readable.
func henanXinjingKBToMB(kb float64) float64 {
if kb <= 0 {
return 0
}
mb := kb / 1024
return float64(int(mb*10000+0.5)) / 10000
}
// henanXinjingMapCardStatus translates the platform's `state` string into the
// internal CardStatus code used across the gateway.
//
// Upstream doc:
//
// 0 unknown, 00 normal, 01 one-way stop, 02 stopped, 03 pre-close,
// 04 closed, 05 transfer, 06 dormant, 07 pending, 08 disabled,
// 09 inventory, 10 expired, 11 violation stop, 12 lost, 13 user-stop,
// 14 testing, 99 not-exists
//
// Internal consts (from existing adapters like tibiot):
//
// 1待激活 2已激活 4停机 6可测试 7库存 8预销户 9已销户
func henanXinjingMapCardStatus(state string) int {
switch strings.TrimSpace(state) {
case "00":
return 2 // 已激活/正常
case "01", "02", "08", "11", "12", "13":
return 4 // 停机
case "03":
return 8 // 预销户
case "04", "10":
return 9 // 已销户
case "06":
return 1 // 休眠 → 待激活
case "07":
return 1 // 待激活
case "09":
return 7 // 库存
case "14":
return 6 // 可测试
default:
return 0 // 未知
}
}
func henanXinjingStatusChangePath(operType string) (string, bool) {
switch operType {
case consts.CardStatusChangeOperTypeApplyStop:
return "/api/v1/stop", true
case consts.CardStatusChangeOperTypeApplyResume:
return "/api/v1/start", true
default:
return "", false
}
}
func henanXinjingMainOfferName(card henanXinjingCard) string {
if name := strings.TrimSpace(card.AutoName); name != "" {
return name
}
for _, item := range card.Package {
if name := strings.TrimSpace(item.PackageName); name != "" {
return name
}
}
return ""
}
func (s *sSimHenanXinjingDirect) QuerySimBasicInfo(ctx context.Context, in *model.SimBasicInfoInput) (out model.SimBasicInfoOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimBasicInfoItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData = model.SimBasicInfoItem{
ICCID: firstNonEmpty(c.ICCID, k.ICCID),
IMSI: k.IMSI,
MSISDN: firstNonEmpty(c.CardNo, k.MSISDN),
ActiveDate: c.FirstChargeAt,
OpenDate: c.FirstChargeAt, // use 首充时间 as the open/activate timestamp; expired_at goes to remark
Remark: c.Message,
SimStatus: strconv.Itoa(henanXinjingMapCardStatus(c.State)),
MainOfferName: henanXinjingMainOfferName(c),
}
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimImei(ctx context.Context, in *model.SimImeiInput) (out model.SimImeiOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimImeiItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.Imei = c.IMEI
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimCardStatus(ctx context.Context, in *model.SimCardStatusInput) (out model.SimCardStatusOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimCardStatusItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.CardStatus = henanXinjingMapCardStatus(c.State)
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsage(ctx context.Context, in *model.SimDataUsageInput) (out model.SimDataUsageOutput, err error) {
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDataUsageItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
r.ResData.DataUsage = henanXinjingKBToMB(float64(c.Used))
total := henanXinjingKBToMB(float64(c.Used) + float64(c.Surplus))
if total > 0 {
r.ResData.TotalFlow = &total
}
}
out = append(out, r)
}
return
}
func (s *sSimHenanXinjingDirect) ChangeSimStatus(ctx context.Context, in *model.SimCardStatusChangeInput) (out model.SimCardStatusChangeOutput, err error) {
operType, ok := normalizeCardStatusChangeOperType(ctx, in.ReqData.OperType)
if !ok {
return newInvalidCardStatusChangeOutput(in.SimKeys), nil
}
for _, k := range in.SimKeys {
r := &model.SimBaseRes[model.SimCardStatusChangeItem]{SimKey: k}
path, _ := henanXinjingStatusChangePath(operType)
no := henanXinjingCardNo(k)
var e error
if no == "" {
e = fmt.Errorf("msisdn/iccid/imsi is required")
} else {
e = s.client.get(ctx, path, map[string]string{"cardno": no}, nil)
}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
}
out = append(out, r)
}
return
}
func henanXinjingUnsupported[T any](keys []*model.SimKey, name string) (out []*model.SimBaseRes[T], err error) {
for _, k := range keys {
out = append(out, &model.SimBaseRes[T]{SimKey: k, Code: consts.ApiStatusError, Message: "henan xinjing direct does not support " + name})
}
return
}
func (s *sSimHenanXinjingDirect) QuerySimSession(ctx context.Context, in *model.SimSessionInput) (model.SimSessionOutput, error) {
return henanXinjingUnsupported[model.SimSessionItem](in.SimKeys, "session query")
}
func (s *sSimHenanXinjingDirect) QuerySimStopReason(ctx context.Context, in *model.SimStopReasonInput) (model.SimStopReasonOutput, error) {
return henanXinjingUnsupported[model.SimStopReasonItem](in.SimKeys, "stop reason query")
}
func (s *sSimHenanXinjingDirect) QuerySimFrozenState(ctx context.Context, in *model.SimFrozenStateInput) (model.SimFrozenStateOutput, error) {
return henanXinjingUnsupported[model.SimFrozenStateItem](in.SimKeys, "frozen state query")
}
func (s *sSimHenanXinjingDirect) QuerySimCardBindStatus(ctx context.Context, in *model.SimCardBindStatusInput) (model.SimCardBindStatusOutput, error) {
return henanXinjingUnsupported[model.SimCardBindStatusItem](in.SimKeys, "card binding query")
}
func (s *sSimHenanXinjingDirect) QuerySimDeviceStatus(ctx context.Context, in *model.SimDeviceStatusInput) (model.SimDeviceStatusOutput, error) {
out := make(model.SimDeviceStatusOutput, 0, len(in.SimKeys))
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDeviceStatusItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
r.Code = 0
// gprs_status: 0 unknown 1 power-on 2 power-off
// power_status: 0 unknown 1 online 2 offline
// DeviceStatus (compatible semantics): 0 abnormal 1 normal.
r.ResData.DeviceStatus = henanXinjingMapDeviceStatus(c.GPRSStatus, c.PowerStatus)
switch c.GPRSStatus {
case 1:
r.ResData.OperatingStatus = "1"
case 2:
r.ResData.OperatingStatus = "0"
}
}
out = append(out, r)
}
return out, nil
}
// henanXinjingMapDeviceStatus merges the platform's gprs_status (power on/off)
// and power_status (online/offline) into the gateway's binary device status.
// Only a card that is powered on AND online is "normal" (1); anything else,
// including unknown, maps to abnormal (0).
func henanXinjingMapDeviceStatus(gprsStatus, powerStatus int) string {
if gprsStatus == 1 && powerStatus == 1 {
return "1"
}
return "0"
}
func (s *sSimHenanXinjingDirect) QuerySimRegionLimitStatus(ctx context.Context, in *model.SimRegionLimitStatusInput) (model.SimRegionLimitStatusOutput, error) {
return henanXinjingUnsupported[model.SimRegionLimitStatusItem](in.SimKeys, "region limit query")
}
func (s *sSimHenanXinjingDirect) QuerySimDataDiagnosis(ctx context.Context, in *model.SimDataDiagnosisInput) (model.SimDataDiagnosisOutput, error) {
return henanXinjingUnsupported[any](in.SimKeys, "data diagnosis")
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsageMonthly(ctx context.Context, in *model.SimDataUsageMonthlyInput) (model.SimDataUsageMonthlyOutput, error) {
queryMonth := ""
if in != nil {
queryMonth = strings.TrimSpace(in.ReqData.QueryDate)
}
out := make(model.SimDataUsageMonthlyOutput, 0, len(in.SimKeys))
for _, k := range in.SimKeys {
c, e := s.fetchCard(ctx, k)
r := &model.SimBaseRes[model.SimDataUsageMonthlyOutputItem]{SimKey: k}
if e != nil {
r.Code = consts.ApiStatusError
r.Message = e.Error()
} else {
var selected *struct {
MonthDate string
UsedFlow string
}
for i := range c.MonthUseds {
m := strings.TrimSpace(c.MonthUseds[i].MonthDate)
if queryMonth == "" || strings.Contains(m, queryMonth) {
selected = &struct {
MonthDate string
UsedFlow string
}{c.MonthUseds[i].MonthDate, c.MonthUseds[i].UsedFlow}
break
}
}
if selected == nil {
r.Code = consts.ApiStatusError
r.Message = "monthly usage data not found"
} else {
r.Code = 0
r.ResData.DeadLine = strings.TrimSpace(selected.MonthDate)
// month_useds.used_flow is MB per the platform doc, unlike the
// KB used/surplus fields on the same response.
if v, perr := strconv.ParseFloat(strings.TrimSpace(selected.UsedFlow), 64); perr == nil {
r.ResData.DataAmount = v
}
}
}
out = append(out, r)
}
return out, nil
}
func (s *sSimHenanXinjingDirect) QuerySimDataUsageDaily(ctx context.Context, in *model.SimDataUsageDailyInput) (model.SimDataUsageDailyOutput, error) {
return henanXinjingUnsupported[model.SimDataUsageDailyOutputItem](in.SimKeys, "historical daily usage query")
}
func (s *sSimHenanXinjingDirect) QuerySimApnInfo(ctx context.Context, in *model.SimApnInfoInput) (model.SimApnInfoOutput, error) {
return henanXinjingUnsupported[model.SimApnInfoItemList](in.SimKeys, "APN query")
}
func (s *sSimHenanXinjingDirect) ChangeSimApnFunction(ctx context.Context, in *model.SimCardOperateChangeApnInput) (model.SimCardOperateChangeApnOutput, error) {
return henanXinjingUnsupported[model.SimCardOperateChangeApnItem](in.SimKeys, "APN change")
}