数据引擎重构

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))
}
+45 -4
View File
@@ -13,6 +13,22 @@ import (
"github.com/sirupsen/logrus"
)
// CompensateResult 单次补偿扫描结果
type CompensateResult struct {
TotalFailed int `json:"totalFailed"`
Retried int `json:"retried"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
MaxRetryReached int `json:"maxRetryReached"`
}
// TriggerCompensation 手动触发一次补偿扫描(等价于 runCompensation 的一次执行)
// 由 HTTP 端点调用,用于 PPGo_Job 调度
func TriggerCompensation(ctx context.Context) *CompensateResult {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: 1})
return runCompensation(ctx)
}
// StartCompensation 启动补偿调度器(在后台循环执行)
func StartCompensation(ctx context.Context) {
sec := g.Cfg().MustGet(ctx, "sync.compensation_interval_seconds", 300).Int()
@@ -38,7 +54,7 @@ func StartCompensation(ctx context.Context) {
}
}
func runCompensation(ctx context.Context) {
func runCompensation(ctx context.Context) *CompensateResult {
logrus.Info("=== 开始补偿扫描 ===")
tasks, err := dao.SyncTaskLog.QueryFailedTasks(ctx, &taskDto.QueryFailedTasksReq{
@@ -47,11 +63,16 @@ func runCompensation(ctx context.Context) {
})
if err != nil {
logrus.Errorf("查询失败任务异常: %v", err)
return
return &CompensateResult{}
}
result := &CompensateResult{
TotalFailed: len(tasks),
}
if len(tasks) == 0 {
logrus.Info("当前没有需要补偿的任务")
return
return result
}
logrus.Infof("发现 %d 个失败任务", len(tasks))
@@ -64,6 +85,7 @@ func runCompensation(ctx context.Context) {
Status: "manual_review",
ErrorMessage: fmt.Sprintf("已达最大重试次数 %d", task.MaxRetry),
})
result.MaxRetryReached++
continue
}
@@ -83,9 +105,25 @@ func runCompensation(ctx context.Context) {
RetryCount: &retryCount,
})
_, err := SyncByConfig(ctx, platformCode, interfaceCode, false)
result.Retried++
_, err := SyncByConfig(ctx, platformCode, interfaceCode, false) // 补偿用增量,数据已 upsert 落库,不需要全量重拉
if err != nil {
logrus.Errorf("补偿失败: %v", err)
// token 过期是平台级别问题,重试无意义,直接标记为 manual_review 等待人工处理
if isTokenExpiredError(err) {
logrus.Warnf("平台 [%s] token 过期,标记为 manual_review 等待人工处理", platformCode)
dao.SyncTaskLog.Update(ctx, &taskDto.UpdateSyncTaskLogReq{
ID: task.Id,
Status: "manual_review",
ErrorMessage: err.Error(),
ErrorCode: "TOKEN_EXPIRED",
})
result.Failed++
continue
}
backoff := []int{5, 15, 30, 60, 120}
waitMin := 5
if retryCount <= len(backoff) {
@@ -101,6 +139,7 @@ func runCompensation(ctx context.Context) {
ErrorCode: "COMPENSATION_FAILED",
NextRetryTime: nextRetry,
})
result.Failed++
} else {
logrus.Infof("补偿成功: %s/%s", platformCode, interfaceCode)
now := time.Now()
@@ -109,8 +148,10 @@ func runCompensation(ctx context.Context) {
Status: "success",
CompletedAt: now,
})
result.Succeeded++
}
}
logrus.Info("=== 补偿扫描完成 ===")
return result
}
+4
View File
@@ -4,6 +4,8 @@ import (
"context"
"time"
"dataengine/utils"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/sirupsen/logrus"
)
@@ -15,12 +17,14 @@ func InsertRows(ctx context.Context, tableName string, conflictKeys []string, ro
}
now := time.Now()
tenantId := utils.GetCurrentTenantId(ctx)
for i := range rows {
if rows[i] == nil {
rows[i] = make(map[string]interface{})
}
// 始终覆盖 updated_at;不设置 created_at 让数据库维护首次值(upsert 时不会覆盖)
rows[i]["updated_at"] = now
rows[i]["tenant_id"] = tenantId
}
batchSize := 100
+274 -92
View File
@@ -13,8 +13,10 @@ import (
dao "dataengine/dao/copydata"
taskDto "dataengine/model/dto/copydata"
entity "dataengine/model/entity/dict"
"dataengine/utils"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/os/grpool"
"github.com/sirupsen/logrus"
)
@@ -46,6 +48,45 @@ type RecursiveConfig struct {
TargetParam string `json:"target_param"`
}
// isInterfaceDueForFullSync 检查单个接口是否该做全量同步了
// 即使调用方要求增量,如果距上次全量超过阈值,自动升级为全量
func isInterfaceDueForFullSync(ctx context.Context, platformCode, interfaceCode string) bool {
interval := GetFullSyncIntervalHours(ctx)
if interval <= 0 {
return false
}
var lastFullSync int64
// 使用 gfdb.Raw 确保 COALESCE 表达式不被框架改写
record, err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Where("platform_code", platformCode).
Where("interface_code", interfaceCode).
Value("COALESCE(last_full_sync_time, 0)")
if err != nil {
logrus.Warnf("[%s/%s] 查询 last_full_sync_time 失败: %v,回退增量", platformCode, interfaceCode, err)
return false
}
if record != nil {
lastFullSync = record.Int64()
}
logrus.Infof("[%s/%s] 读取到 last_full_sync_time=%d", platformCode, interfaceCode, lastFullSync)
if lastFullSync == 0 {
logrus.Infof("[%s/%s] 从未全量,自动升级为全量同步", platformCode, interfaceCode)
return true
}
elapsed := time.Now().Unix() - lastFullSync
if elapsed > int64(interval)*3600 {
logrus.Infof("[%s/%s] 距上次全量 %d 小时(阈值 %d 小时),自动升级为全量同步",
platformCode, interfaceCode, elapsed/3600, interval)
return true
}
logrus.Debugf("[%s/%s] 距上次全量 %d 小时,未到阈值 %d 小时,执行增量", platformCode, interfaceCode, elapsed/3600, interval)
return false
}
// SyncByConfig 执行同步
func SyncByConfig(ctx context.Context, platformCode, interfaceCode string, isFullSync bool) (*SyncResult, error) {
// 创建超时 context 防止单次同步卡死
@@ -92,21 +133,38 @@ func SyncByConfig(ctx context.Context, platformCode, interfaceCode string, isFul
return nil, fmt.Errorf("建表失败: %w", err)
}
// 先读取已有的 last_sync_time,供 markSyncRunning 保留(防止全量覆盖为0)
existingLastSync := getLastSyncTime(ctx, platformCode, interfaceCode)
// 即使调用方要求增量,如果距上次全量超过阈值,自动升级为全量
if !isFullSync {
isFullSync = isInterfaceDueForFullSync(ctx, platformCode, interfaceCode)
}
// 检查上次同步状态(在标记 running 之前检查)
prevStatus := getSyncStatus(ctx, platformCode, interfaceCode)
lastSyncTime := int64(0)
if !isFullSync {
lastSyncTime = getLastSyncTime(ctx, platformCode, interfaceCode)
lastSyncTime = existingLastSync
}
if prevStatus == "running" {
logrus.Warnf("检测到上次同步异常中断 [%s/%s],将重新全量同步", platformCode, interfaceCode)
lastSyncTime = 0
if lastSyncTime <= 0 {
logrus.Warnf("检测到上次同步异常中断 [%s/%s],无有效同步点,执行全量", platformCode, interfaceCode)
lastSyncTime = 0
} else {
logrus.Infof("检测到上次同步异常中断 [%s/%s],数据已落库,从同步点 %d 恢复增量", platformCode, interfaceCode, lastSyncTime)
}
}
// 标记同步开始(保留 last_sync_time 不变,状态设为 running
markSyncRunning(ctx, platformCode, interfaceCode, lastSyncTime)
// 标记同步开始(不写入 last_sync_time,由 updateSyncTime 独家管理
markSyncRunning(ctx, platformCode, interfaceCode)
api := NewApiClient(platform)
api.SetTokenRefreshFunc(RefreshTencentToken)
// 支持 body_type: "json" 配置(如腾讯广告 POST 接口需要 JSON body 而非 form-encoded
if bt, ok := iface.RequestConfig["body_type"].(string); ok {
api.SetBodyType(bt)
}
defer api.Close()
prefetch := parsePrefetchConfig(iface.RequestConfig)
@@ -202,10 +260,10 @@ func syncSingleAPI(ctx context.Context, api *ApiClient, platform *PlatformConfig
inQuery := paramsInQuery(iface)
method := string(iface.Method)
// 游标参数名
// 游标参数名(与 page_param 分开,腾讯游标模式使用独立的 cursor 参数)
cursorParam := "cursor"
if p, ok := iface.RequestConfig["page_param"].(string); ok && p != "" {
cursorParam = p
if cp, ok := iface.RequestConfig["cursor_param"].(string); ok && cp != "" {
cursorParam = cp
}
cursorMode := isCursorPagination(iface)
@@ -314,11 +372,14 @@ func syncSingleAPI(ctx context.Context, api *ApiClient, platform *PlatformConfig
}
}
if globalMaxTime <= 0 {
globalMaxTime = time.Now().Unix()
if maxTime > globalMaxTime {
globalMaxTime = maxTime
}
}
updateSyncTime(ctx, platform.PlatformCode, iface.Code, globalMaxTime)
if globalMaxTime <= 0 {
globalMaxTime = time.Now().Unix()
}
updateSyncTime(ctx, platform.PlatformCode, iface.Code, globalMaxTime, taskType)
result.Duration = fmt.Sprintf("%.1fs", time.Since(start).Seconds())
logrus.Infof("同步完成 - 表:%s, %d条, 写入%d条, 耗时%s", td.TableName, result.TotalRows, result.InsertedRows, result.Duration)
@@ -401,7 +462,14 @@ func syncWithPrefetch(ctx context.Context, api *ApiClient, platform *PlatformCon
if cp, ok := prefetchIface.RequestConfig["cursor_pagination"].(bool); ok {
prefetchIsCursor = cp
}
if p, ok := prefetchIface.RequestConfig["page_param"].(string); ok && p != "" {
// 游标模式使用独立的 cursor_param,不与 page_param 共用
if prefetchIsCursor {
if cp, ok := prefetchIface.RequestConfig["cursor_param"].(string); ok && cp != "" {
prefetchPageParam = cp
} else {
prefetchPageParam = "cursor"
}
} else if p, ok := prefetchIface.RequestConfig["page_param"].(string); ok && p != "" {
prefetchPageParam = p
}
}
@@ -498,68 +566,94 @@ func syncWithPrefetch(ctx context.Context, api *ApiClient, platform *PlatformCon
time.Sleep(100 * time.Millisecond)
}
} else {
// ----- 常规分页预取 -----
firstExtra := make(map[string]interface{})
if prefetchIsCursor {
// 支持 initial_cursor 配置,如果没有则使用空字符串
if icv, ok := prefetchReqIface.RequestConfig["initial_cursor"]; ok {
firstExtra[prefetchPageParam] = icv
localLoaded := false
// 优先从本地表读取预取数据,避免每次都调用 API
localTable := ""
if prefetchIface != nil && prefetchIface.TableDefinition != nil {
if td, err := ParseTableDefinition(prefetchIface.TableDefinition); err == nil && td.TableName != "" {
localTable = td.TableName
}
}
if localTable != "" && prefetch.ValueField != "" {
result, err := gfdb.DB(ctx).Model(ctx, localTable).
Fields(prefetch.ValueField).
All()
if err != nil {
logrus.Warnf("从本地表 %s 读取预取数据失败: %v", localTable, err)
} else if result.Len() > 0 {
logrus.Infof("从本地表 %s 读取到 %d 个预取实体", localTable, result.Len())
for _, row := range result {
if v := row[prefetch.ValueField]; v != nil {
allEntities = append(allEntities, v.Val())
}
}
localLoaded = true
} else {
firstExtra[prefetchPageParam] = ""
logrus.Infof("本地表 %s 无数据,回退 API 预取", localTable)
}
}
body := buildReqBody(ctx, prefetchReqIface, 1, prefetchPageSize, lastSyncTime, firstExtra)
logrus.Debugf("预取请求 URL: %s, Method: %s, Body: %+v", prefetch.URL, prefetchMethod, body)
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
recordFailure(ctx, platform.PlatformCode, iface.Code, taskType, fmt.Sprintf("预取第一页请求失败: %v", err))
return nil, fmt.Errorf("预取第一页失败: %w", err)
}
rows, prefetchTotalPages, _, nextCursor, err := parseRespExt(resp.Body, prefetchRespCfg)
if err != nil {
recordFailure(ctx, platform.PlatformCode, iface.Code, taskType, fmt.Sprintf("解析预取响应失败: %v", err))
return nil, fmt.Errorf("解析预取响应失败: %w", err)
}
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
if prefetchIsCursor {
for nextCursor != "" && nextCursor != "nomore" {
body := buildReqBody(ctx, prefetchReqIface, 1, prefetchPageSize, lastSyncTime, map[string]interface{}{
prefetchPageParam: nextCursor,
})
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
logrus.Errorf("预取游标 %s 请求失败: %v", nextCursor, err)
break
if !localLoaded {
// ----- 常规分页预取 -----
firstExtra := make(map[string]interface{})
if prefetchIsCursor {
if icv, ok := prefetchReqIface.RequestConfig["initial_cursor"]; ok {
firstExtra[prefetchPageParam] = icv
}
rows, _, _, nc, pe := parseRespExt(resp.Body, prefetchRespCfg)
if pe != nil {
logrus.Errorf("预取游标 %s 解析失败: %v", nextCursor, pe)
break
}
if len(rows) == 0 {
break
}
nextCursor = nc
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
time.Sleep(100 * time.Millisecond)
// 没有 initial_cursor 时不覆盖 page 参数,保持默认 page=1
}
} else {
for page := 2; page <= prefetchTotalPages; page++ {
body := buildReqBody(ctx, prefetchReqIface, page, prefetchPageSize, lastSyncTime, nil)
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
logrus.Errorf("预取第 %d 页请求失败: %v", page, err)
continue
body := buildReqBody(ctx, prefetchReqIface, 1, prefetchPageSize, lastSyncTime, firstExtra)
logrus.Debugf("预取请求 URL: %s, Method: %s, Body: %+v", prefetch.URL, prefetchMethod, body)
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
recordFailure(ctx, platform.PlatformCode, iface.Code, taskType, fmt.Sprintf("预取第一页请求失败: %v", err))
return nil, fmt.Errorf("预取第一页失败: %w", err)
}
rows, prefetchTotalPages, _, nextCursor, err := parseRespExt(resp.Body, prefetchRespCfg)
if err != nil {
recordFailure(ctx, platform.PlatformCode, iface.Code, taskType, fmt.Sprintf("解析预取响应失败: %v", err))
return nil, fmt.Errorf("解析预取响应失败: %w", err)
}
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
if prefetchIsCursor {
for nextCursor != "" && nextCursor != "nomore" {
body := buildReqBody(ctx, prefetchReqIface, 1, prefetchPageSize, lastSyncTime, map[string]interface{}{
prefetchPageParam: nextCursor,
})
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
logrus.Errorf("预取游标 %s 请求失败: %v", nextCursor, err)
break
}
rows, _, _, nc, pe := parseRespExt(resp.Body, prefetchRespCfg)
if pe != nil {
logrus.Errorf("预取游标 %s 解析失败: %v", nextCursor, pe)
break
}
if len(rows) == 0 {
break
}
nextCursor = nc
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
time.Sleep(100 * time.Millisecond)
}
rows, _, _, _, pe := parseRespExt(resp.Body, prefetchRespCfg)
if pe != nil {
logrus.Errorf("预取第 %d 页解析失败: %v", page, pe)
continue
} else {
for page := 2; page <= prefetchTotalPages; page++ {
body := buildReqBody(ctx, prefetchReqIface, page, prefetchPageSize, lastSyncTime, nil)
resp, err := api.Request(ctx, prefetchMethod, prefetch.URL, body, prefetchInQuery)
if err != nil {
logrus.Errorf("预取第 %d 页请求失败: %v", page, err)
continue
}
rows, _, _, _, pe := parseRespExt(resp.Body, prefetchRespCfg)
if pe != nil {
logrus.Errorf("预取第 %d 页解析失败: %v", page, pe)
continue
}
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
time.Sleep(100 * time.Millisecond)
}
collectPrefetchEntities(rows, prefetch, &allEntities, &allRows)
time.Sleep(100 * time.Millisecond)
}
}
}
@@ -735,7 +829,7 @@ func syncWithPrefetch(ctx context.Context, api *ApiClient, platform *PlatformCon
if globalMaxTime <= 0 {
globalMaxTime = time.Now().Unix()
}
updateSyncTime(ctx, platform.PlatformCode, iface.Code, globalMaxTime)
updateSyncTime(ctx, platform.PlatformCode, iface.Code, globalMaxTime, taskType)
result.Duration = fmt.Sprintf("%.1fs", time.Since(start).Seconds())
logrus.Infof("同步完成 - 表:%s, %d条, 写入%d条, 耗时%s", td.TableName, result.TotalRows, result.InsertedRows, result.Duration)
@@ -814,7 +908,7 @@ func syncRecursive(ctx context.Context, api *ApiClient, platform *PlatformConfig
}
inserted, _ := savePage(ctx, td, allRows)
updateSyncTime(ctx, platform.PlatformCode, iface.Code, time.Now().Unix())
updateSyncTime(ctx, platform.PlatformCode, iface.Code, time.Now().Unix(), "full")
result := &SyncResult{
TableName: td.TableName,
@@ -910,7 +1004,7 @@ func buildPrefetchParams(iface *entity.ApiInterface) map[string]interface{} {
k == "cursor_pagination" || k == "time_field_mode" ||
k == "recursive" || k == "max_recursive_depth" ||
k == "initial_cursor" || k == "pagination_mode" ||
k == "full_sync_start_time" || k == "row_inject" {
k == "body_type" || k == "full_sync_start_time" || k == "row_inject" {
continue
}
if k == pageParam || k == psParam {
@@ -1058,7 +1152,7 @@ func buildReqBody(ctx context.Context, iface *entity.ApiInterface, page, pageSiz
k == "body_wrapper_field" || k == "exclude_from_wrapper" ||
k == "top_level_params" || k == "recursive" ||
k == "max_recursive_depth" || k == "initial_cursor" ||
k == "pagination_mode" || k == "full_sync_start_time" ||
k == "body_type" || k == "full_sync_start_time" ||
k == "row_inject" {
continue
}
@@ -1109,6 +1203,9 @@ func buildReqBody(ctx context.Context, iface *entity.ApiInterface, page, pageSiz
} else {
timeMs = time.Now().Add(-time.Duration(GetDefaultLookbackDays(ctx)) * 24 * time.Hour).UnixMilli()
}
} else if timeMs < 1000000000000 {
// lastSyncTime 存的是秒(已归一化),快手 API 需要毫秒
timeMs = timeMs * 1000
}
// 仅在配置未指定 queryType 时设默认值,尊重配置
if _, exists := body["queryType"]; !exists {
@@ -1145,6 +1242,21 @@ func buildReqBody(ctx context.Context, iface *entity.ApiInterface, page, pageSiz
} else {
body["filtering"] = []interface{}{timeFilter}
}
} else {
// 全量且没有 full_sync_start_time:用 default_lookback_days 兜底
lookbackDays := GetDefaultLookbackDays(ctx)
defaultStart := time.Now().Add(-time.Duration(lookbackDays) * 24 * time.Hour).Unix()
logrus.Infof("全量同步使用默认回溯 %d 天,过滤时间戳: %d", lookbackDays, defaultStart)
timeFilter := map[string]interface{}{
"field": tf,
"operator": "GREATER_EQUALS",
"values": []interface{}{fmt.Sprintf("%d", defaultStart)},
}
if existing, ok := body["filtering"].([]interface{}); ok {
body["filtering"] = append(existing, timeFilter)
} else {
body["filtering"] = []interface{}{timeFilter}
}
}
}
}
@@ -1386,8 +1498,11 @@ func savePage(ctx context.Context, td *TableDefinition, rows []map[string]interf
return 0, nil
}
colSet := make(map[string]bool)
for _, c := range td.Columns {
colDefs := make(map[string]*ColumnDef)
for i := range td.Columns {
c := &td.Columns[i]
colSet[c.Name] = true
colDefs[c.Name] = c
}
var clean []map[string]interface{}
for _, row := range rows {
@@ -1397,6 +1512,14 @@ func savePage(ctx context.Context, td *TableDefinition, rows []map[string]interf
c[k] = v
}
}
// 填充有默认值的字段(API 响应中缺失时使用)
for name, col := range colDefs {
if col.DefaultValue != "" {
if _, exists := c[name]; !exists {
c[name] = col.DefaultValue
}
}
}
if r, ok := row["raw_data"]; ok {
c["raw_data"] = r
}
@@ -1406,46 +1529,85 @@ func savePage(ctx context.Context, td *TableDefinition, rows []map[string]interf
}
func getLastSyncTime(ctx context.Context, platformCode, interfaceCode string) int64 {
var t int64
gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Fields("last_sync_time").
v, err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Where("platform_code", platformCode).
Where("interface_code", interfaceCode).
Scan(&t)
return t
Value("last_sync_time")
if err != nil || v == nil {
return 0
}
return v.Int64()
}
func getSyncStatus(ctx context.Context, platformCode, interfaceCode string) string {
var s string
gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Fields("sync_status").
v, err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Where("platform_code", platformCode).
Where("interface_code", interfaceCode).
Scan(&s)
return s
Value("sync_status")
if err != nil || v == nil {
return ""
}
return v.String()
}
func markSyncRunning(ctx context.Context, platformCode, interfaceCode string, lastSyncTime int64) {
func getSyncCount(ctx context.Context, platformCode, interfaceCode string) int64 {
v, err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Where("platform_code", platformCode).
Where("interface_code", interfaceCode).
Value("COALESCE(sync_count, 0)")
if err != nil || v == nil {
return 0
}
return v.Int64()
}
func markSyncRunning(ctx context.Context, platformCode, interfaceCode string) {
tenantId := utils.GetCurrentTenantId(ctx)
gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Data(map[string]interface{}{
"platform_code": platformCode,
"interface_code": interfaceCode,
"last_sync_time": lastSyncTime,
"sync_status": "running",
"tenant_id": tenantId,
}).
OnConflict("platform_code", "interface_code").
Save()
}
func updateSyncTime(ctx context.Context, platformCode, interfaceCode string, t int64) {
// normalizeSyncTimestamp 将同步时间戳统一归一化到秒
// 外部 API 响应中的时间戳可能是秒或毫秒,统一存为秒
func normalizeSyncTimestamp(ts int64) int64 {
if ts > 1000000000000 { // > 1e12 显然是毫秒
return ts / 1000
}
return ts
}
// updateSyncTime 更新同步跟踪记录
// syncType: "full" / "incremental"
func updateSyncTime(ctx context.Context, platformCode, interfaceCode string, t int64, syncType string) {
t = normalizeSyncTimestamp(t)
tenantId := utils.GetCurrentTenantId(ctx)
// 先读取当前的 sync_count
currentCount := getSyncCount(ctx, platformCode, interfaceCode)
data := gdb.Map{
"platform_code": platformCode,
"interface_code": interfaceCode,
"last_sync_time": t,
"last_sync_at": time.Now(),
"sync_status": "success",
"last_sync_type": syncType,
"sync_count": currentCount + 1,
"tenant_id": tenantId,
}
if syncType == "full" {
data["last_full_sync_time"] = time.Now().Unix() // 用当前时间,不是数据时间戳
}
gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Data(map[string]interface{}{
"platform_code": platformCode,
"interface_code": interfaceCode,
"last_sync_time": t,
"last_sync_at": time.Now(),
"sync_status": "success",
}).
Data(data).
OnConflict("platform_code", "interface_code").
Save()
}
@@ -1465,6 +1627,26 @@ func recordFailure(ctx context.Context, platformCode, interfaceCode, taskType, e
})
}
// isTokenExpiredError 判断错误是否因 token 过期导致
func isTokenExpiredError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
// 常见 token 过期标识(兼容各平台)
tokenExpiredKeywords := []string{
"TOKEN过期", "token过期", "token_expired", "Token过期",
"result=28", // 快手
"access_token", "token已失效", "token无效",
}
for _, kw := range tokenExpiredKeywords {
if strings.Contains(msg, kw) {
return true
}
}
return false
}
// findInterfaceByURL 在所有接口中查找匹配 URL 的接口
func findInterfaceByURL(ifaces []entity.ApiInterface, url string) *entity.ApiInterface {
for i := range ifaces {
+9
View File
@@ -51,6 +51,15 @@ func GetSyncTimeout(ctx context.Context) int {
return t
}
// GetFullSyncIntervalHours 获取全量同步间隔(小时;0=禁用自动全量,首次全量后只走增量)
func GetFullSyncIntervalHours(ctx context.Context) int {
h := g.Cfg().MustGet(ctx, "sync.full_sync_interval_hours", 0).Int()
if h < 0 {
return 0
}
return h
}
// GetDefaultLookbackDays 获取全量同步默认回溯天数(默认90)
func GetDefaultLookbackDays(ctx context.Context) int {
d := g.Cfg().MustGet(ctx, "sync.default_lookback_days", 90).Int()
+133
View File
@@ -0,0 +1,133 @@
package sync
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
consts "dataengine/consts/public"
"dataengine/utils"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/sirupsen/logrus"
)
// refreshTokenMu 防止并发刷新 token(多个接口同时检测到过期时串行化)
var refreshTokenMu sync.Mutex
// tencentTokenResponse 腾讯广告 OAuth 刷新 token 响应
type tencentTokenResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data *struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"access_token_expires_in"`
RefreshExpiresIn int `json:"refresh_token_expires_in"`
} `json:"data"`
}
// RefreshTencentToken 刷新腾讯广告 OAuth2 token
// 由 ApiClient 在检测到 401/token 过期时自动调用
func RefreshTencentToken(ctx context.Context, platform *PlatformConfig) error {
refreshTokenMu.Lock()
defer refreshTokenMu.Unlock()
if platform.AuthConfig == nil {
return fmt.Errorf("平台 [%s] 未配置 auth_config", platform.PlatformCode)
}
clientID := platform.ClientId
clientSecret := platform.ClientSecret
refreshToken, _ := platform.AuthConfig["refresh_token"].(string)
if clientID == "" || clientSecret == "" || refreshToken == "" {
return fmt.Errorf("平台 [%s] OAuth2 配置不完整: client_id / client_secret / refresh_token 缺失",
platform.PlatformCode)
}
logrus.Infof("正在刷新腾讯广告 token [platform=%s]", platform.PlatformCode)
// 调用腾讯 OAuth 刷新端点(使用短超时防止阻塞)
refreshCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 腾讯 OAuth refresh_token 接口使用 GET 方法,参数在查询字符串中
refreshURL := fmt.Sprintf("https://api.e.qq.com/oauth/refresh_token?client_id=%s&client_secret=%s&refresh_token=%s",
url.QueryEscape(clientID), url.QueryEscape(clientSecret), url.QueryEscape(refreshToken))
req, err := http.NewRequestWithContext(refreshCtx, "GET", refreshURL, nil)
if err != nil {
return fmt.Errorf("创建刷新请求失败: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("请求刷新 token 失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取刷新响应失败: %w", err)
}
var tokenResp tencentTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return fmt.Errorf("解析刷新响应失败: %w", err)
}
if tokenResp.Code != 0 {
return fmt.Errorf("腾讯 OAuth 刷新失败: code=%d, msg=%s", tokenResp.Code, tokenResp.Message)
}
if tokenResp.Data == nil {
return fmt.Errorf("腾讯 OAuth 刷新响应缺少 data 字段")
}
newAccessToken := tokenResp.Data.AccessToken
newRefreshToken := tokenResp.Data.RefreshToken
if newAccessToken == "" {
return fmt.Errorf("腾讯 OAuth 刷新返回的 access_token 为空")
}
// --- 更新内存中的配置 ---
// token 字段(存明文 token
platform.Token = newAccessToken
// AccessToken 字段(运行时使用的 token)
platform.AccessToken = newAccessToken
// auth_config 中的 refresh_token
platform.AuthConfig["refresh_token"] = newRefreshToken
// --- 更新数据库 ---
tenantID := utils.GetCurrentTenantId(ctx)
if tenantID == 0 {
tenantID = 1 // 保底默认租户
}
// 使用 gfdb 直接更新 token 和 auth_config(避免 DAO 层的 OmitEmpty 吞掉空值)
_, err = gfdb.DB(ctx).Model(ctx, consts.DatasourcePlatformTable).
Data(g.Map{
"token": newAccessToken,
"auth_config": platform.AuthConfig,
"updated_at": gtime.Now(),
}).
Where("platform_code", platform.PlatformCode).
Where("tenant_id", tenantID).
Update()
if err != nil {
return fmt.Errorf("更新数据库 token 失败: %w", err)
}
logrus.Infof("腾讯广告 Token 刷新成功 [platform=%s] (access_token有效期:%ds, refresh_token有效期:%ds)",
platform.PlatformCode, tokenResp.Data.ExpiresIn, tokenResp.Data.RefreshExpiresIn)
return nil
}
+122 -6
View File
@@ -4,29 +4,78 @@ import (
"context"
"time"
consts "dataengine/consts/public"
dao "dataengine/dao/dict"
dto "dataengine/model/dto/dict"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/sirupsen/logrus"
)
// SyncRunItemResult 单次同步执行结果
type SyncRunItemResult struct {
PlatformCode string `json:"platformCode"`
InterfaceCode string `json:"interfaceCode"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// StartAutoSync 启动自动同步(独立 goroutine,每次完成后等待 interval 再执行下一次)
func StartAutoSync(ctx context.Context) {
interval := GetSyncInterval(ctx)
logrus.Infof("自动同步调度器启动,间隔: %d 分钟(完成一次后开始计时)", interval)
for {
runAutoSync(ctx)
runAutoSync(ctx, false) // 后台调度不关心返回结果,错误已内部打日志
logrus.Infof("自动同步完成,等待 %d 分钟后执行下一次", interval)
time.Sleep(time.Duration(interval) * time.Minute)
}
}
func runAutoSync(ctx context.Context) {
// determineSyncMode 判断本次同步应该增量还是全量
// 返回 true=全量, false=增量
func determineSyncMode(ctx context.Context) bool {
interval := GetFullSyncIntervalHours(ctx)
if interval <= 0 {
return false // 自动全量关闭,永远增量
}
// 查询 sync_tracker 中最早的全量同步时间
v, err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Value("COALESCE(MIN(last_full_sync_time), 0)")
if err != nil {
logrus.Warnf("查询全量同步时间失败,回退增量模式: %v", err)
return false
}
minFullSync := int64(0)
if v != nil {
minFullSync = v.Int64()
}
if minFullSync == 0 {
logrus.Info("检测到从未全量同步的接口,本次执行全量同步")
return true
}
elapsed := time.Now().Unix() - minFullSync
if elapsed > int64(interval)*3600 {
logrus.Infof("距上次全量已过 %d 小时(阈值 %d 小时),本次执行全量同步", elapsed/3600, interval)
return true
}
logrus.Debugf("距上次全量 %d 小时,未到阈值 %d 小时,执行增量同步", elapsed/3600, interval)
return false
}
func runAutoSync(ctx context.Context, forceFull bool) []SyncRunItemResult {
logrus.Info("=== 开始自动同步 ===")
// 判断本次同步模式(增量 / 全量)
isFullSync := forceFull || determineSyncMode(ctx)
logrus.Infof("本次同步模式: %s", map[bool]string{true: "全量", false: "增量"}[isFullSync])
// 从配置读取同步租户 ID(运维部署时配置)
tenantId := g.Cfg().MustGet(ctx, "sync.default_tenant_id", 1).Uint64()
@@ -39,9 +88,11 @@ func runAutoSync(ctx context.Context) {
}, tenantId)
if err != nil {
logrus.Errorf("查询平台列表失败: %v", err)
return
return nil
}
var results []SyncRunItemResult
for _, p := range platforms {
// 查询该平台下有 table_definition 的接口
interfaces, _, err := dao.ApiInterface.List(ctx, &dto.ListApiInterfaceReq{
@@ -59,20 +110,85 @@ func runAutoSync(ctx context.Context) {
}
logrus.Infof("自动同步: %s / %s", p.PlatformCode, iface.Code)
// isFullSync=false 表示去查 sync_tracker
// 有记录 → 增量,无记录 → lastSyncTime=0 → 全量
_, err := SyncByConfig(ctx, p.PlatformCode, iface.Code, false)
_, err := SyncByConfig(ctx, p.PlatformCode, iface.Code, isFullSync)
item := SyncRunItemResult{
PlatformCode: p.PlatformCode,
InterfaceCode: iface.Code,
}
if err != nil {
logrus.Errorf("自动同步失败 [%s/%s]: %v", p.PlatformCode, iface.Code, err)
item.Success = false
item.Error = err.Error()
results = append(results, item)
// token 过期是平台级别问题,该平台剩余接口继续请求只会重复失败,直接跳过
if isTokenExpiredError(err) {
logrus.Warnf("平台 [%s] token 已过期,跳过该平台剩余接口", p.PlatformCode)
break
}
} else {
item.Success = true
results = append(results, item)
}
}
}
logrus.Info("=== 自动同步完成 ===")
return results
}
// TriggerAllSync 手动触发全量同步(等价于 runAutoSync 的一次执行)
// 由 HTTP 端点调用,用于 PPGo_Job 调度。
// forceFull=true 强制全量,false 则由自动策略判断。
// 返回每个平台接口的执行结果列表。
func TriggerAllSync(ctx context.Context, forceFull bool) []SyncRunItemResult {
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: g.Cfg().MustGet(ctx, "sync.default_tenant_id", 1).Uint64()})
return runAutoSync(ctx, forceFull)
}
// RecoverInterruptedSyncs 服务启动时恢复异常中断的同步
// 扫描 sync_tracker 中 sync_status="running" 的接口,自动重新全量同步
// 适用于服务进程崩溃后重启的场景,不等 PPGo_Job 下轮调度
func RecoverInterruptedSyncs(ctx context.Context) {
type trackerItem struct {
PlatformCode string
InterfaceCode string
}
var items []trackerItem
err := gfdb.DB(ctx).Model(ctx, consts.SyncTrackerTable).
Fields("platform_code", "interface_code").
Where("sync_status", "running").
Scan(&items)
if err != nil {
logrus.Warnf("查询中断同步任务失败: %v", err)
return
}
if len(items) == 0 {
logrus.Info("启动恢复扫描:没有发现异常中断的同步任务")
return
}
tenantId := g.Cfg().MustGet(ctx, "sync.default_tenant_id", 1).Uint64()
ctx = context.WithValue(ctx, "user", &beans.User{UserName: "admin", TenantId: tenantId})
logrus.Infof("启动恢复扫描:发现 %d 个异常中断的同步任务,开始恢复...", len(items))
for _, item := range items {
logrus.Infof("正在恢复中断的同步 [%s/%s]...", item.PlatformCode, item.InterfaceCode)
_, err := SyncByConfig(ctx, item.PlatformCode, item.InterfaceCode, true)
if err != nil {
logrus.Errorf("恢复同步失败 [%s/%s]: %v", item.PlatformCode, item.InterfaceCode, err)
} else {
logrus.Infof("恢复同步成功 [%s/%s]", item.PlatformCode, item.InterfaceCode)
}
}
}
// InitAndStartAutoSync 在 main 中调用:初始化配置后启动自动同步和补偿
func InitAndStartAutoSync(ctx context.Context) {
// 服务启动时恢复异常中断的同步(不等 PPGo_Job 调度)
RecoverInterruptedSyncs(ctx)
// 读取配置中的同步开关
enabled := g.Cfg().MustGet(ctx, "sync.auto_sync_enabled", false).Bool()
if enabled {
+11 -5
View File
@@ -11,9 +11,10 @@ import (
// ColumnDef 列定义
type ColumnDef struct {
Name string `json:"name"`
Type string `json:"type"`
Comment string `json:"comment,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Comment string `json:"comment,omitempty"`
DefaultValue string `json:"default_value,omitempty"`
}
// TableDefinition 表结构定义
@@ -41,10 +42,11 @@ func ParseTableDefinition(raw map[string]interface{}) (*TableDefinition, error)
n, _ := cm["name"].(string)
t, _ := cm["type"].(string)
comment, _ := cm["comment"].(string)
defaultVal, _ := cm["default_value"].(string)
if n == "" || t == "" {
continue
}
td.Columns = append(td.Columns, ColumnDef{Name: n, Type: t, Comment: comment})
td.Columns = append(td.Columns, ColumnDef{Name: n, Type: t, Comment: comment, DefaultValue: defaultVal})
}
if keys, _ := raw["conflict_keys"].([]interface{}); keys != nil {
for _, k := range keys {
@@ -82,7 +84,11 @@ func buildCreateSQL(td *TableDefinition) string {
"deleted_at TIMESTAMP WITH TIME ZONE",
}
for _, c := range td.Columns {
cols = append(cols, fmt.Sprintf("%s %s", c.Name, c.Type))
colSQL := fmt.Sprintf("%s %s", c.Name, c.Type)
if c.DefaultValue != "" {
colSQL += fmt.Sprintf(" DEFAULT '%s'", strings.ReplaceAll(c.DefaultValue, "'", "''"))
}
cols = append(cols, colSQL)
}
cols = append(cols, "raw_data JSONB DEFAULT '{}'")