package upstreamimpl import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" ) // HenanXinjingTokenConfig carries the credentials needed to call the // platform's `/api/v1/searchToken` endpoint and convert the response into a // sharedtoken.OneLinkToken for the sharedtoken manager. // // The channel card-no is optional at construction time because the // underlying platform scopes tokens to (channel_id, channel_pname), not to // a specific card. When CardNo is empty the fetcher falls back to the // chanKey passed by the token manager (matching how zibo/tengyu providers // treat the per-channel seed value). type HenanXinjingTokenConfig struct { BaseURL string Path string // defaults to /api/v1/searchToken UserID string APIKey string ChannelID string // e.g. "80", "97", "194" ChannelPName string // e.g. "cmiotonelinkqx" CardNo string // optional seed card number; may be overridden by manager Timeout time.Duration } // HenanXinjingTokenProvider fetches a shared token for a given // (userId, channel_id, channel_pname) triple from the Henan Xinjing // platform. type HenanXinjingTokenProvider struct { baseURL, path, userID, apiKey, channelID, channelPName, cardNo string client *http.Client } // henanXinjingSearchTokenResponse matches the upstream response shape: // // { // "code": 0, // "msg": "查询成功", // "data": { // "id": 80, // "chl_username": "C5010371A16640006582238", // "accesstoken": "v1b74e73d741cea620f0ceecaf7a733fa6964aa53fffcbf8d3a5a4c8715aabe3", // "accesstoken_at": "2026-09-16 13:19:29" // } // } // // `code` may be either a JSON number or string; decode via henanXinjingDecodeCode. type henanXinjingSearchTokenResponse struct { Code json.RawMessage `json:"code"` Msg string `json:"msg"` Data struct { ID json.RawMessage `json:"id"` ChlUsername string `json:"chl_username"` AccessToken string `json:"accesstoken"` AccessTokenAt string `json:"accesstoken_at"` AccessTokenSB string `json:"accesstokensb"` AccessTokenAlias string `json:"access_token"` } `json:"data"` } const ( henanXinjingSearchTokenPath = "/api/v1/searchToken" henanXinjingTokenExpiryLayout = "2006-01-02 15:04:05" henanXinjingTokenRefreshBackoff = 5 * time.Minute ) func NewHenanXinjingTokenProvider(config HenanXinjingTokenConfig) *HenanXinjingTokenProvider { timeout := config.Timeout if timeout <= 0 { timeout = defaultUpstreamHTTPTimeout } return &HenanXinjingTokenProvider{ baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), path: normalizeProviderPath(config.Path, henanXinjingSearchTokenPath), userID: strings.TrimSpace(config.UserID), apiKey: strings.TrimSpace(config.APIKey), channelID: strings.TrimSpace(config.ChannelID), channelPName: strings.TrimSpace(config.ChannelPName), cardNo: strings.TrimSpace(config.CardNo), client: &http.Client{Timeout: timeout}, } } // parseExpiry interprets the platform's `accesstoken_at` field. The // platform uses local China time without an explicit zone, so parse in // Asia/Shanghai. func henanXinjingParseExpiry(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, fmt.Errorf("empty accesstoken_at") } loc, err := time.LoadLocation("Asia/Shanghai") if err != nil { loc = time.Local } t, err := time.ParseInLocation(henanXinjingTokenExpiryLayout, raw, loc) if err != nil { return time.Time{}, fmt.Errorf("parse accesstoken_at %q: %w", raw, err) } return t, nil } // firstToken returns the first non-empty token field. The platform has been // observed returning the token under `accesstoken`, but earlier docs // referenced `accesstokensb` / `access_token`; accept all three. func henanXinjingFirstToken(resp *henanXinjingSearchTokenResponse) string { if v := strings.TrimSpace(resp.Data.AccessToken); v != "" { return v } if v := strings.TrimSpace(resp.Data.AccessTokenSB); v != "" { return v } return strings.TrimSpace(resp.Data.AccessTokenAlias) } // fetch performs the signed GET against /api/v1/searchToken for the given // card number. Card number may come from the manager's chanKey seed. func (p *HenanXinjingTokenProvider) fetch(ctx context.Context, cardNo string) (sharedtoken.OneLinkToken, error) { if p == nil || p.client == nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing token provider is not initialized") } if p.baseURL == "" || p.userID == "" || p.apiKey == "" || p.channelID == "" || p.channelPName == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: baseURL, userID, apiKey, channelID and channelPName are required") } cardNo = strings.TrimSpace(cardNo) if cardNo == "" { cardNo = p.cardNo } // cardno is optional on the real platform (verified live): the token is // scoped to (userId, channel_id, channel_pname), so omit the param when // no seed card number is configured instead of failing the fetch. times := strconv.FormatInt(time.Now().Unix(), 10) values := url.Values{} values.Set("userId", p.userID) if cardNo != "" { values.Set("cardno", cardNo) } values.Set("channel_id", p.channelID) values.Set("channel_pname", p.channelPName) values.Set("times", times) values.Set("sign", henanXinjingSign(p.userID, p.apiKey, times)) endpoint := p.baseURL + p.path + "?" + values.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: build searchToken request: %w", err) } resp, err := p.client.Do(req) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: request searchToken: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: read searchToken body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken HTTP %d: %s", resp.StatusCode, string(body)) } var output henanXinjingSearchTokenResponse if err := json.Unmarshal(body, &output); err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: decode searchToken response: %w (body=%s)", err, string(body)) } code, err := henanXinjingDecodeCode(output.Code) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken: %w (body=%s)", err, string(body)) } if code != 0 { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken failed: code=%d msg=%s", code, output.Msg) } tokenValue := henanXinjingFirstToken(&output) if tokenValue == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("henan xinjing: searchToken returned empty accesstoken") } expiresAt, err := henanXinjingParseExpiry(output.Data.AccessTokenAt) if err != nil { return sharedtoken.OneLinkToken{}, err } // Refresh slightly ahead of the declared expiry to absorb clock skew. if until := time.Until(expiresAt); until > henanXinjingTokenRefreshBackoff { expiresAt = expiresAt.Add(-henanXinjingTokenRefreshBackoff) } return sharedtoken.OneLinkToken{ Value: tokenValue, CreatedAt: time.Now(), ExpiresAt: expiresAt, }, nil } // Fetch satisfies the sharedtoken.Fetcher-style interface used by the // token manager. The chanKey argument is unused here because Henan Xinjing // scopes tokens to the (channel_id, channel_pname) pair rather than to a // specific chan_key; we always fetch with the configured cardNo. func (p *HenanXinjingTokenProvider) Fetch(ctx context.Context) (sharedtoken.OneLinkToken, error) { return p.fetch(ctx, "") } // FetchToken matches the sharedtoken.TokenFetcher interface used by // sharedtoken.NewTokenManager. chanKey is the token manager's cache key // (typically the channel's chan_key); it does not influence the upstream // request but lets the manager cache one token per channel. func (p *HenanXinjingTokenProvider) FetchToken(ctx context.Context, _ string) (sharedtoken.OneLinkToken, error) { return p.fetch(ctx, "") } // henanXinjingFetcher adapts HenanXinjingTokenProvider to the sharedtoken // fetcher shape used by RegisterTokenProviderFactory. type henanXinjingFetcher struct{ provider *HenanXinjingTokenProvider } func (f henanXinjingFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { return f.provider.fetch(ctx, "") }