package upstreamimpl import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" sharedtoken "git.5iot.com/iotlcm-public/iotlcm-upstream-chan/internal/token" ) const ( yunzeTokenPath = "/openapi/v3/query/operator-token" yunzeTokenExpiryLayout = "2006-01-02 15:04:05" ) // YunzeTokenConfig contains the platform endpoint and the per-channel app id. // The Yunze operator-token API does not require app_secret. type YunzeTokenConfig struct { BaseURL string Path string AppID string Timeout time.Duration } // YunzeTokenProvider fetches the shared token from the Yunze platform. type YunzeTokenProvider struct { baseURL string path string appID string client *http.Client } type yunzeTokenResponse struct { Code json.RawMessage `json:"code"` Message string `json:"message"` Data struct { ChanAlias string `json:"chanAlias"` ChanKey string `json:"chanKey"` AccessToken string `json:"accessToken"` // The live endpoint returns the expiry as tokenExpire. The published // documentation incorrectly lists accessTokenExpire; it is being // corrected to match the implementation. TokenExpire string `json:"tokenExpire"` } `json:"data"` Error json.RawMessage `json:"error"` } // yunzeTokenRequest is the operator-token request body. type yunzeTokenRequest struct { AppID string `json:"appId"` ChanKey string `json:"chanKey"` } // yunzeFetcher adapts YunzeTokenProvider to the shared token TokenFetcher // contract, mirroring ziboFetcher and apikaFetcher. type yunzeFetcher struct{ provider *YunzeTokenProvider } func (f yunzeFetcher) FetchToken(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { return f.provider.Fetch(ctx, chanKey) } func NewYunzeTokenProvider(config YunzeTokenConfig) *YunzeTokenProvider { timeout := config.Timeout if timeout <= 0 { timeout = defaultUpstreamHTTPTimeout } return &YunzeTokenProvider{ baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), path: normalizeProviderPath(config.Path, yunzeTokenPath), appID: strings.TrimSpace(config.AppID), client: &http.Client{Timeout: timeout}, } } func (p *YunzeTokenProvider) Fetch(ctx context.Context, chanKey string) (sharedtoken.OneLinkToken, error) { if p == nil || p.client == nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token provider is not initialized") } if p.baseURL == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token base URL is required") } if p.appID == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze appId is required") } chanKey = strings.TrimSpace(chanKey) if chanKey == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze chanKey is required") } payload, err := json.Marshal(yunzeTokenRequest{AppID: p.appID, ChanKey: chanKey}) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("encode yunze token request: %w", err) } req, err := http.NewRequestWithContext( ctx, http.MethodPost, joinProviderURL(p.baseURL, p.path), bytes.NewReader(payload), ) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("create yunze token request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := p.client.Do(req) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("request yunze token: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024+1)) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("read yunze token response: %w", err) } if len(body) > 4*1024*1024 { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token response is too large") } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token HTTP status %d", resp.StatusCode) } var output yunzeTokenResponse if err := json.Unmarshal(body, &output); err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token response: %w", err) } code, err := decodeYunzeCode(output.Code) if err != nil { return sharedtoken.OneLinkToken{}, fmt.Errorf("decode yunze token code: %w", err) } if code != 0 { return sharedtoken.OneLinkToken{}, fmt.Errorf( "yunze token failed: code=%d message=%s", code, strings.TrimSpace(output.Message), ) } if len(output.Error) > 0 && string(output.Error) != "null" { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze token error: %s", string(output.Error)) } value := strings.TrimSpace(output.Data.AccessToken) if value == "" { return sharedtoken.OneLinkToken{}, fmt.Errorf("yunze accessToken is empty") } expiresAt, err := parseYunzeTokenExpiry(output.Data.TokenExpire) if err != nil { return sharedtoken.OneLinkToken{}, err } return sharedtoken.OneLinkToken{ Value: value, CreatedAt: time.Now(), ExpiresAt: expiresAt, }, nil } func decodeYunzeCode(raw json.RawMessage) (int, error) { var code int if err := json.Unmarshal(raw, &code); err == nil { return code, nil } var value string if err := json.Unmarshal(raw, &value); err != nil { return 0, err } var parsed int if _, err := fmt.Sscanf(strings.TrimSpace(value), "%d", &parsed); err != nil { return 0, err } return parsed, nil } // yunzeTokenLocation is the timezone used by the Yunze operator-token API. // It is resolved once so the hot refresh path does not re-read tzdata. var yunzeTokenLocation = func() *time.Location { if loc, err := time.LoadLocation("Asia/Shanghai"); err == nil { return loc } return time.Local }() func parseYunzeTokenExpiry(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, fmt.Errorf("yunze tokenExpire is empty") } expiresAt, err := time.ParseInLocation(yunzeTokenExpiryLayout, raw, yunzeTokenLocation) if err != nil { return time.Time{}, fmt.Errorf("parse yunze tokenExpire %q: %w", raw, err) } return expiresAt, nil }