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") }