pricing_service.go 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. package repository
  2. import (
  3. "context"
  4. "fmt"
  5. "io"
  6. "log/slog"
  7. "net/http"
  8. "strings"
  9. "time"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. )
  13. type pricingRemoteClient struct {
  14. httpClient *http.Client
  15. }
  16. // pricingRemoteClientError 代理初始化失败时的错误占位客户端
  17. // 所有请求直接返回初始化错误,禁止回退到直连
  18. type pricingRemoteClientError struct {
  19. err error
  20. }
  21. func (c *pricingRemoteClientError) FetchPricingJSON(_ context.Context, _ string) ([]byte, error) {
  22. return nil, c.err
  23. }
  24. func (c *pricingRemoteClientError) FetchHashText(_ context.Context, _ string) (string, error) {
  25. return "", c.err
  26. }
  27. // NewPricingRemoteClient 创建定价数据远程客户端
  28. // proxyURL 为空时直连,支持 http/https/socks5/socks5h 协议
  29. // 代理配置失败时行为由 allowDirectOnProxyError 控制:
  30. // - false(默认):返回错误占位客户端,禁止回退到直连
  31. // - true:回退到直连(仅限管理员显式开启)
  32. func NewPricingRemoteClient(proxyURL string, allowDirectOnProxyError bool) service.PricingRemoteClient {
  33. // 安全说明:httpclient.GetClient 的错误链(url.Parse / proxyutil)不含明文代理凭据,
  34. // 但仍通过 slog 仅在服务端日志记录,不会暴露给 HTTP 响应。
  35. sharedClient, err := httpclient.GetClient(httpclient.Options{
  36. Timeout: 30 * time.Second,
  37. ProxyURL: proxyURL,
  38. })
  39. if err != nil {
  40. if strings.TrimSpace(proxyURL) != "" && !allowDirectOnProxyError {
  41. slog.Warn("proxy client init failed, all requests will fail", "service", "pricing", "error", err)
  42. return &pricingRemoteClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)}
  43. }
  44. sharedClient = &http.Client{Timeout: 30 * time.Second}
  45. }
  46. return &pricingRemoteClient{
  47. httpClient: sharedClient,
  48. }
  49. }
  50. func (c *pricingRemoteClient) FetchPricingJSON(ctx context.Context, url string) ([]byte, error) {
  51. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  52. if err != nil {
  53. return nil, err
  54. }
  55. resp, err := c.httpClient.Do(req)
  56. if err != nil {
  57. return nil, err
  58. }
  59. defer func() { _ = resp.Body.Close() }()
  60. if resp.StatusCode != http.StatusOK {
  61. return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
  62. }
  63. return io.ReadAll(resp.Body)
  64. }
  65. func (c *pricingRemoteClient) FetchHashText(ctx context.Context, url string) (string, error) {
  66. req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
  67. if err != nil {
  68. return "", err
  69. }
  70. resp, err := c.httpClient.Do(req)
  71. if err != nil {
  72. return "", err
  73. }
  74. defer func() { _ = resp.Body.Close() }()
  75. if resp.StatusCode != http.StatusOK {
  76. return "", fmt.Errorf("HTTP %d", resp.StatusCode)
  77. }
  78. body, err := io.ReadAll(resp.Body)
  79. if err != nil {
  80. return "", err
  81. }
  82. // 哈希文件格式:hash filename 或者纯 hash
  83. hash := strings.TrimSpace(string(body))
  84. parts := strings.Fields(hash)
  85. if len(parts) > 0 {
  86. return parts[0], nil
  87. }
  88. return hash, nil
  89. }