数据引擎重构

This commit is contained in:
lmk
2026-07-16 14:34:06 +08:00
parent 802139c349
commit b45c4a2316
19 changed files with 1332 additions and 307 deletions
+95 -22
View File
@@ -25,11 +25,27 @@ type ApiResult struct {
DurationMs int64
}
// TokenRefreshFunc token 刷新回调函数
// 由具体平台的 RefreshToken 函数实现,在检测到 401/token 过期时自动调用
type TokenRefreshFunc func(ctx context.Context, config *PlatformConfig) error
// ApiClient 通用 API 客户端
type ApiClient struct {
config *PlatformConfig
client *http.Client
rateLimiter *time.Ticker // 限流 ticker,可被 GC
config *PlatformConfig
client *http.Client
rateLimiter *time.Ticker // 限流 ticker,可被 GC
tokenRefreshFunc TokenRefreshFunc
bodyType string // 请求体序列化方式: ""=form-encoded, "json"=JSON
}
// SetBodyType 设置请求体序列化方式
func (c *ApiClient) SetBodyType(t string) {
c.bodyType = t
}
// SetTokenRefreshFunc 设置 token 刷新回调
func (c *ApiClient) SetTokenRefreshFunc(fn TokenRefreshFunc) {
c.tokenRefreshFunc = fn
}
// NewApiClient 创建客户端
@@ -91,11 +107,27 @@ func (c *ApiClient) doRequest(ctx context.Context, method, path string, body int
retryDelay = 1 * time.Second
}
refreshed := false // 标记是否已刷新过 token(每个 doRequest 最多刷新一次)
for attempt := 0; attempt <= maxRetries; attempt++ {
result, err = c.execute(ctx, method, path, body, paramsInQuery)
if err == nil {
return result, nil
}
// token 过期 → 刷新一次,立即重试(不占退避重试次数)
if isTokenExpiredError(err) && c.tokenRefreshFunc != nil && !refreshed {
logrus.Warnf("检测到 token 过期 [%s],尝试自动刷新...", path)
if refreshErr := c.tokenRefreshFunc(ctx, c.config); refreshErr != nil {
logrus.Errorf("Token 刷新失败: %v", refreshErr)
} else {
refreshed = true
logrus.Info("Token 刷新成功,立即重试请求")
// 不递减 attempt,直接用新 token 重试
continue
}
}
logrus.Warnf("请求失败 (attempt %d/%d): %v", attempt+1, maxRetries+1, err)
if attempt < maxRetries {
time.Sleep(retryDelay * time.Duration(attempt+1))
@@ -122,28 +154,50 @@ func (c *ApiClient) execute(ctx context.Context, method, path string, body inter
fullURL = c.applyAuthURL(fullURL)
// 将 URL 认证参数注入 body 并清除 URL(避免重复参数)
// 注意:token_in_query=true 的认证方式(如腾讯广告),token 必须留在 URL 中,不移入 body
var reqBody io.Reader
var reqBodyBytes []byte
if body != nil && !paramsInQuery {
if paramsMap, ok := body.(map[string]interface{}); ok {
// 从 URL 注入认证参数到 body
if parsed, _ := url.Parse(fullURL); parsed != nil {
q := parsed.Query()
for k, vs := range q {
if len(vs) > 0 {
if _, exists := paramsMap[k]; !exists {
paramsMap[k] = vs[0]
// token_in_query=true 时认证参数必须留在 URL 中,不注入 body
tokenInQuery := false
if c.config.AuthConfig != nil {
if tiq, _ := c.config.AuthConfig["token_in_query"].(bool); tiq {
tokenInQuery = true
}
}
if !tokenInQuery {
if paramsMap, ok := body.(map[string]interface{}); ok {
// 从 URL 注入认证参数到 body
if parsed, _ := url.Parse(fullURL); parsed != nil {
q := parsed.Query()
for k, vs := range q {
if len(vs) > 0 {
if _, exists := paramsMap[k]; !exists {
paramsMap[k] = vs[0]
}
q.Del(k)
}
q.Del(k)
}
parsed.RawQuery = q.Encode()
fullURL = parsed.String()
}
}
}
if paramsMap, ok := body.(map[string]interface{}); ok {
if c.bodyType == "json" {
// JSON body(如腾讯广告 POST 接口)
b, err := json.Marshal(paramsMap)
if err != nil {
return nil, fmt.Errorf("JSON序列化请求体失败: %w", err)
}
parsed.RawQuery = q.Encode()
fullURL = parsed.String()
reqBodyBytes = b
reqBody = bytes.NewBuffer(b)
} else {
// Form body
formStr := c.buildFormBody(paramsMap)
reqBodyBytes = []byte(formStr)
reqBody = strings.NewReader(formStr)
}
// Form body
formStr := c.buildFormBody(paramsMap)
reqBodyBytes = []byte(formStr)
reqBody = strings.NewReader(formStr)
} else {
b, err := json.Marshal(body)
if err != nil {
@@ -181,9 +235,13 @@ func (c *ApiClient) execute(ctx context.Context, method, path string, body inter
// 打印等效 curl
curlCmd := fmt.Sprintf("curl -X %s '%s'", method, fullURL)
if reqBodyBytes != nil && len(reqBodyBytes) > 0 {
for _, pair := range strings.Split(string(reqBodyBytes), "&") {
if pair != "" {
curlCmd += fmt.Sprintf(" --data-urlencode '%s'", pair)
if c.bodyType == "json" {
curlCmd += fmt.Sprintf(" -H 'Content-Type: application/json' -d '%s'", string(reqBodyBytes))
} else {
for _, pair := range strings.Split(string(reqBodyBytes), "&") {
if pair != "" {
curlCmd += fmt.Sprintf(" --data-urlencode '%s'", pair)
}
}
}
}
@@ -198,7 +256,11 @@ func (c *ApiClient) execute(ctx context.Context, method, path string, body inter
req.Header.Set("User-Agent", "data-engine/1.0")
if body != nil && !paramsInQuery {
if _, ok := body.(map[string]interface{}); ok {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if c.bodyType == "json" {
req.Header.Set("Content-Type", "application/json")
} else {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
} else {
req.Header.Set("Content-Type", "application/json")
}
@@ -219,6 +281,13 @@ func (c *ApiClient) execute(ctx context.Context, method, path string, body inter
if resp.StatusCode >= 400 {
return result, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
// 检查腾讯API业务级token错误(HTTP 200但code=11002access_token无效)
var apiResp struct {
Code int `json:"code"`
}
if err := json.Unmarshal(respBody, &apiResp); err == nil && apiResp.Code == 11002 {
return result, fmt.Errorf("token expired: code=11002, msg=%s", string(respBody))
}
return result, nil
}
@@ -283,6 +352,10 @@ func (c *ApiClient) buildFormBody(params map[string]interface{}) string {
}
case int, int8, int16, int32, int64:
q.Set(k, fmt.Sprintf("%d", val))
case []interface{}, map[string]interface{}:
// 数组或对象需要 JSON 序列化
b, _ := json.Marshal(v)
q.Set(k, string(b))
default:
q.Set(k, fmt.Sprintf("%v", v))
}