Files

200 lines
5.9 KiB
Go

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
}