package upstreamimpl import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" ) const ( henanLelianLoginPath = "/api/v2/loginVerify" henanLelianSharePath = "/api/v2/getShareToken" henanLelianPlatformTTL = 23 * time.Hour henanLelianShareInterval = time.Minute ) // HenanLelianTokenConfig contains the public provider endpoints and the // per-channel credentials required to obtain a shared OneLink token. type HenanLelianTokenConfig struct { BaseURL string LoginPath string Path string UserName string Password string PasswayUID string Timeout time.Duration } // HenanLelianTokenProvider first obtains a platform access token and then // exchanges it for the carrier-issued OneLink token. type HenanLelianTokenProvider struct { baseURL string loginPath string path string userName string password string passwayUID string client *http.Client now func() time.Time platformMu sync.Mutex platformToken string platformTokenExp time.Time shareMu sync.Mutex nextShareAt time.Time shareInterval time.Duration } type henanLelianResponse struct { Code json.RawMessage `json:"code"` Message string `json:"message"` Payload struct { AccessToken string `json:"access_token"` Token string `json:"token"` } `json:"payload"` } func NewHenanLelianTokenProvider(config HenanLelianTokenConfig) *HenanLelianTokenProvider { timeout := config.Timeout if timeout <= 0 { timeout = defaultUpstreamHTTPTimeout } return &HenanLelianTokenProvider{ baseURL: strings.TrimRight(strings.TrimSpace(config.BaseURL), "/"), loginPath: normalizeProviderPath(config.LoginPath, henanLelianLoginPath), path: normalizeProviderPath(config.Path, henanLelianSharePath), userName: strings.TrimSpace(config.UserName), password: config.Password, passwayUID: strings.TrimSpace(config.PasswayUID), client: &http.Client{Timeout: timeout}, now: time.Now, shareInterval: henanLelianShareInterval, } } func (p *HenanLelianTokenProvider) Fetch(ctx context.Context) (OneLinkToken, error) { if p == nil || p.client == nil || p.now == nil { return OneLinkToken{}, fmt.Errorf("Henan Lelian token provider is not initialized") } if p.baseURL == "" { return OneLinkToken{}, fmt.Errorf("Henan Lelian token base URL is required") } if p.userName == "" || strings.TrimSpace(p.password) == "" { return OneLinkToken{}, fmt.Errorf("Henan Lelian username and password are required") } if p.passwayUID == "" { return OneLinkToken{}, fmt.Errorf("Henan Lelian passwayUid is required") } platformToken, err := p.getPlatformToken(ctx) if err != nil { return OneLinkToken{}, err } value, authFailed, err := p.fetchSharedToken(ctx, platformToken) if authFailed { p.clearPlatformToken(platformToken) } if err != nil { return OneLinkToken{}, err } now := p.now() return OneLinkToken{Value: value, CreatedAt: now}, nil } func (p *HenanLelianTokenProvider) getPlatformToken(ctx context.Context) (string, error) { p.platformMu.Lock() defer p.platformMu.Unlock() now := p.now() if p.platformToken != "" && now.Before(p.platformTokenExp) { return p.platformToken, nil } body, err := json.Marshal(map[string]string{ "userName": p.userName, "password": p.password, }) if err != nil { return "", fmt.Errorf("encode Henan Lelian login request: %w", err) } responseBody, status, err := p.postJSON(ctx, p.loginPath, body, "") if err != nil { return "", fmt.Errorf("request Henan Lelian login: %w", err) } if status < http.StatusOK || status >= http.StatusMultipleChoices { return "", fmt.Errorf("Henan Lelian login HTTP status %d: %s", status, strings.TrimSpace(string(responseBody))) } var output henanLelianResponse if err = json.Unmarshal(responseBody, &output); err != nil { return "", fmt.Errorf("decode Henan Lelian login response: %w", err) } code, err := parseHenanLelianCode(output.Code) if err != nil { return "", fmt.Errorf("decode Henan Lelian login code: %w", err) } if code != "0" { return "", fmt.Errorf("Henan Lelian login failed: code=%s, message=%s", code, output.Message) } token := strings.TrimSpace(output.Payload.AccessToken) if token == "" { return "", fmt.Errorf("Henan Lelian login access_token is empty") } p.platformToken = token p.platformTokenExp = now.Add(henanLelianPlatformTTL) return token, nil } func (p *HenanLelianTokenProvider) fetchSharedToken(ctx context.Context, platformToken string) (string, bool, error) { if err := p.reserveShareAttempt(); err != nil { return "", false, err } body, err := json.Marshal(map[string]string{"passwayUid": p.passwayUID}) if err != nil { return "", false, fmt.Errorf("encode Henan Lelian shared token request: %w", err) } responseBody, status, err := p.postJSON(ctx, p.path, body, platformToken) if err != nil { return "", false, fmt.Errorf("request Henan Lelian shared token: %w", err) } if status < http.StatusOK || status >= http.StatusMultipleChoices { authFailed := status == http.StatusUnauthorized || status == http.StatusForbidden return "", authFailed, fmt.Errorf("Henan Lelian shared token HTTP status %d: %s", status, strings.TrimSpace(string(responseBody))) } var output henanLelianResponse if err = json.Unmarshal(responseBody, &output); err != nil { return "", false, fmt.Errorf("decode Henan Lelian shared token response: %w", err) } code, err := parseHenanLelianCode(output.Code) if err != nil { return "", false, fmt.Errorf("decode Henan Lelian shared token code: %w", err) } if code != "0" { authFailed := isHenanLelianAuthFailure(code, output.Message) return "", authFailed, fmt.Errorf("Henan Lelian shared token failed: code=%s, message=%s", code, output.Message) } token := strings.TrimSpace(output.Payload.Token) if token == "" { return "", false, fmt.Errorf("Henan Lelian shared token is empty") } return token, false, nil } func (p *HenanLelianTokenProvider) reserveShareAttempt() error { p.shareMu.Lock() defer p.shareMu.Unlock() now := p.now() if now.Before(p.nextShareAt) { return fmt.Errorf("Henan Lelian shared token is rate limited until %s", p.nextShareAt.Format(time.RFC3339)) } p.nextShareAt = now.Add(p.shareInterval) return nil } func (p *HenanLelianTokenProvider) clearPlatformToken(rejected string) { p.platformMu.Lock() if p.platformToken == rejected { p.platformToken = "" p.platformTokenExp = time.Time{} } p.platformMu.Unlock() } func (p *HenanLelianTokenProvider) postJSON(ctx context.Context, path string, body []byte, platformToken string) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinProviderURL(p.baseURL, path), bytes.NewReader(body)) if err != nil { return nil, 0, err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") if platformToken != "" { req.Header.Set("access_token", platformToken) } resp, err := p.client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) return responseBody, resp.StatusCode, err } func parseHenanLelianCode(raw json.RawMessage) (string, error) { var value string if err := json.Unmarshal(raw, &value); err == nil { return strings.TrimSpace(value), nil } var number json.Number if err := json.Unmarshal(raw, &number); err != nil { return "", err } if _, err := strconv.ParseInt(number.String(), 10, 64); err != nil { return "", err } return number.String(), nil } func isHenanLelianAuthFailure(code, message string) bool { switch strings.TrimSpace(code) { case "401", "40101", "40102", "403": return true } message = strings.ToLower(strings.TrimSpace(message)) return strings.Contains(message, "access_token") || strings.Contains(message, "access token") || strings.Contains(message, "token expired") || strings.Contains(message, "token invalid") || strings.Contains(message, "鉴权") || strings.Contains(message, "认证失败") }