Jelajahi Sumber

feat(custom-report): 实现巨量引擎自定义报表拉取及写入功能

- 新增 custom-report-config.json 进行报表配置管理,支持多种维度与指标定义
- 创建 CustomReportFieldConfig 表示报表配置项,包括数据主题、维度、指标、库表信息
- 实现 CustomReportConfigLoader 从classpath加载并缓存配置,支持动态添加报表类型
- 提供 CustomReportServiceImpl 服务,统一调用巨量引擎自定义报表接口
- 完成分页拉取逻辑,合并维度与指标数据,格式化日期字段,写入 Doris 库表
- 支持访问令牌传递及限流控制,确保接口调用稳定可靠
- 设计错误日志记录机制,失败时保存详细信息到数据库便于排查
- 提供接口 ICustomReportService 暴露报表拉取功能,方便业务调用与扩展
hpk 3 bulan lalu
induk
melakukan
c2383f0726

+ 25 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/data/service/ICustomReportService.java

@@ -0,0 +1,25 @@
+package cn.com.ctop.job.bytedance.data.service;
+
+/**
+ * 可复用的巨量引擎自定义报表服务
+ * <p>
+ * 通过 reportType 查找配置(维度/指标/Doris表),
+ * 统一调用 /open_api/v3.0/report/custom/get/ 接口并将结果写入 Doris。
+ */
+public interface ICustomReportService {
+
+    /**
+     * 获取自定义报表并写入 Doris
+     *
+     * @param reportType  报表类型,对应 custom-report-config.json 中的 key
+     *                    如 account_hour、account_daily、promoter_hour、promoter_daily、
+     *                    material_daily、product_daily
+     * @param advertiserId 广告主 ID
+     * @param accessToken  访问令牌
+     * @param startDate    开始日期(yyyy-MM-dd)
+     * @param endDate      结束日期(yyyy-MM-dd)
+     * @param isLog        是否记录错误日志(1=是)
+     */
+    void getCustomReport(String reportType, Long advertiserId, String accessToken,
+                         String startDate, String endDate, Integer isLog);
+}

+ 212 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/data/service/impl/CustomReportServiceImpl.java

@@ -0,0 +1,212 @@
+package cn.com.ctop.job.bytedance.data.service.impl;
+
+import cn.com.ctop.job.bytedance.data.entity.BytedanceErrorLog;
+import cn.com.ctop.job.bytedance.data.mapper.BytedancePromoterReportMapper;
+import cn.com.ctop.job.bytedance.data.mapper.BytedanceRefreshTokenMapper;
+import cn.com.ctop.job.bytedance.data.service.ICustomReportService;
+import cn.com.ctop.job.bytedance.data.utils.Check;
+import cn.com.ctop.job.bytedance.data.utils.CustomReportConfigLoader;
+import cn.com.ctop.job.bytedance.data.utils.CustomReportFieldConfig;
+import cn.com.ctop.job.bytedance.data.utils.HttpUtils;
+import cn.com.ctop.job.bytedance.utils.RateLimitUtil;
+import cn.com.ctop.job.bytedance.utils.doris.sink.DorisMultiTableStreamLoadManager;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 可复用的巨量引擎自定义报表服务实现
+ * <p>
+ * 核心流程:
+ * <ol>
+ *   <li>根据 reportType 从 {@link CustomReportConfigLoader} 读取维度/指标/表名配置</li>
+ *   <li>将维度和指标解析为 JSONArray,构建请求参数</li>
+ *   <li>调用 /open_api/v3.0/report/custom/get/ 接口(含限流)</li>
+ *   <li>解析返回的 rows,合并 dimensions + metrics 为 JSONObject</li>
+ *   <li>通过 {@link DorisMultiTableStreamLoadManager#add} 写入对应 Doris 表</li>
+ *   <li>自动分页拉取</li>
+ * </ol>
+ */
+@Service
+@Slf4j
+public class CustomReportServiceImpl implements ICustomReportService {
+
+    private static final String API_URL = "https://api.oceanengine.com/open_api/v3.0/report/custom/get/";
+    private static final int PAGE_SIZE = 100;
+
+    @Autowired
+    private CustomReportConfigLoader configLoader;
+
+    @Autowired
+    private DorisMultiTableStreamLoadManager dorisLoadManager;
+
+    @Autowired
+    private BytedanceRefreshTokenMapper tokenMapper;
+
+    @Autowired
+    private BytedancePromoterReportMapper promoterReportMapper;
+
+    @Autowired
+    private RateLimitUtil rateLimitUtil;
+
+    @Override
+    public void getCustomReport(String reportType, Long advertiserId, String accessToken,
+                                String startDate, String endDate, Integer isLog) {
+        getCustomReport(reportType, advertiserId, accessToken, startDate, endDate, isLog, 1);
+    }
+
+    /**
+     * 带分页的内部递归方法
+     *
+     * @param page 当前页码
+     */
+    private void getCustomReport(String reportType, Long advertiserId, String accessToken,
+                                String startDate, String endDate, Integer isLog, int page) {
+        // 1. 读取配置
+        CustomReportFieldConfig config = configLoader.getConfig(reportType);
+        if (config == null) {
+            log.error("报表类型 {} 未找到配置,请检查 custom-report-config.json", reportType);
+            return;
+        }
+
+        try {
+            // 2. 获取 devAppId(用于限流)
+            JSONObject authConfig = tokenMapper.getAuthConfig(advertiserId);
+            String devAppId = authConfig != null ? authConfig.getString("app_id") : null;
+
+            // 3. 构建请求参数
+            JSONArray dimensions = new JSONArray();
+            for (String dim : config.getDimensions()) {
+                dimensions.add(dim);
+            }
+            JSONArray metrics = new JSONArray();
+            for (String metric : config.getMetrics()) {
+                metrics.add(metric);
+            }
+
+            Map<String, Object> paramsMap = new HashMap<>();
+            paramsMap.put("advertiser_id", advertiserId);
+            paramsMap.put("start_time", startDate);
+            paramsMap.put("end_time", endDate);
+            paramsMap.put("data_topic", config.getDataTopic());
+            paramsMap.put("metrics", metrics);
+            paramsMap.put("dimensions", dimensions);
+            paramsMap.put("order_by", new JSONArray());
+            paramsMap.put("filters", new JSONArray());
+            paramsMap.put("page", page);
+            paramsMap.put("page_size", PAGE_SIZE);
+
+            Map<String, String> headers = new HashMap<>();
+            headers.put("Access-Token", accessToken);
+            headers.put("Content-Type", "application/json");
+
+            // 4. 调用 API(含限流)
+            String response = null;
+            if (rateLimitUtil.acquire(devAppId, API_URL)) {
+                response = HttpUtils.httpGet(API_URL, paramsMap, headers);
+            }
+
+            // 5. 解析返回结果
+            JSONObject result = JSONObject.parseObject(response);
+            if (Check.isNull(result)) {
+                log.info("账户id:{},报表类型:{},请求结果返回为空", advertiserId, reportType);
+                if (isLog != null && isLog == 1) {
+                    insertErrorLog(reportType, advertiserId, startDate, endDate, "请求结果返回为空");
+                }
+                return;
+            }
+
+            Integer code = result.getInteger("code");
+            int totalPage = 1;
+            int rowCount = 0;
+
+            if (code != null && code == 0) {
+                JSONObject data = result.getJSONObject("data");
+                if (!Check.isNull(data)) {
+                    // 获取总页数
+                    JSONObject pageInfo = data.getJSONObject("page_info");
+                    if (pageInfo != null) {
+                        totalPage = pageInfo.getIntValue("total_page");
+                    }
+
+                    JSONArray rows = data.getJSONArray("rows");
+                    if (!Check.isNull(rows)) {
+                        for (int i = 0; i < rows.size(); i++) {
+                            JSONObject rowJson = rows.getJSONObject(i);
+                            JSONObject metricsJson = rowJson.getJSONObject("metrics");
+                            JSONObject dimensionsJson = rowJson.getJSONObject("dimensions");
+
+                            // 6. 合并 dimensions + metrics 为一个 JSONObject
+                            JSONObject record = new JSONObject();
+                            if (dimensionsJson != null) {
+                                record.putAll(dimensionsJson);
+                            }
+                            if (metricsJson != null) {
+                                record.putAll(metricsJson);
+                            }
+
+                            // 7. 处理 stat_time_day 格式(去 dashes)
+                            String statTimeDay = record.getString("stat_time_day");
+                            if (statTimeDay != null) {
+                                record.put("stat_time_day", statTimeDay.replace("-", ""));
+                            }
+
+                            // 8. 添加 advertiser_id
+                            record.put("advertiser_id", advertiserId);
+
+                            // 9. 写入 Doris
+                            dorisLoadManager.add(config.getDorisDatabase(), config.getDorisTable(), record);
+                            rowCount++;
+                        }
+                    }
+                }
+            } else {
+                log.info("账户id:{},报表类型:{},请求失败,返回:{}", advertiserId, reportType, result);
+                if (isLog != null && isLog == 1) {
+                    insertErrorLog(reportType, advertiserId, startDate, endDate, result.getString("message"));
+                }
+                return;
+            }
+
+            log.info("账户ID:{},报表类型:{},第{}/{}页数据获取成功,本页{}条",
+                    advertiserId, reportType, page, totalPage, rowCount);
+
+            // 10. 分页递归
+            if (page < totalPage) {
+                try {
+                    Thread.sleep(200L);
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                getCustomReport(reportType, advertiserId, accessToken, startDate, endDate, isLog, page + 1);
+            } else {
+                log.info("账户ID:{},报表类型:{},所有页数据获取完成", advertiserId, reportType);
+            }
+        } catch (Exception e) {
+            log.error("账户ID:{},报表类型:{},获取数据异常", advertiserId, reportType, e);
+        }
+    }
+
+    /**
+     * 记录错误日志
+     */
+    private void insertErrorLog(String reportType, Long advertiserId, String startDate, String endDate, String msg) {
+        try {
+            BytedanceErrorLog errorInfo = new BytedanceErrorLog();
+            errorInfo.setAdvertiserId(advertiserId);
+            errorInfo.setStartDate(startDate);
+            errorInfo.setEndDate(endDate);
+            errorInfo.setDataDimension(reportType);
+            errorInfo.setDataType("custom");
+            errorInfo.setMsg(msg);
+            promoterReportMapper.replaceErrorInfo(errorInfo);
+        } catch (Exception e) {
+            log.error("记录错误日志失败", e);
+        }
+    }
+}

+ 62 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/data/utils/CustomReportConfigLoader.java

@@ -0,0 +1,62 @@
+package cn.com.ctop.job.bytedance.data.utils;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StreamUtils;
+
+import javax.annotation.PostConstruct;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 自定义报表配置加载器
+ * <p>
+ * 启动时从 classpath:custom-report-config.json 读取配置,
+ * 以 reportType 为 key 提供快速查询。
+ * <p>
+ * 新增报表类型时,只需在 custom-report-config.json 中追加配置项即可,
+ * 无需修改 Java 代码。
+ */
+@Slf4j
+@Component
+public class CustomReportConfigLoader {
+
+    private static final String CONFIG_FILE = "custom-report-config.json";
+
+    private Map<String, CustomReportFieldConfig> configMap = Collections.emptyMap();
+
+    @PostConstruct
+    public void init() {
+        try (InputStream is = new ClassPathResource(CONFIG_FILE).getInputStream()) {
+            String json = StreamUtils.copyToString(is, StandardCharsets.UTF_8);
+            configMap = JSON.parseObject(json, new TypeReference<Map<String, CustomReportFieldConfig>>() {});
+            log.info("自定义报表配置加载完成,共 {} 个报表类型: {}", configMap.size(), configMap.keySet());
+        } catch (Exception e) {
+            log.error("加载自定义报表配置失败: {}", CONFIG_FILE, e);
+            configMap = Collections.emptyMap();
+        }
+    }
+
+    /**
+     * 根据报表类型获取配置
+     *
+     * @param reportType 报表类型,如 account_hour、promoter_daily 等
+     * @return 配置项,不存在时返回 null
+     */
+    public CustomReportFieldConfig getConfig(String reportType) {
+        return configMap.get(reportType);
+    }
+
+    /**
+     * 获取所有已配置的报表类型
+     */
+    public Set<String> getReportTypes() {
+        return configMap.keySet();
+    }
+}

+ 75 - 0
job-bytedance/src/main/java/cn/com/ctop/job/bytedance/data/utils/CustomReportFieldConfig.java

@@ -0,0 +1,75 @@
+package cn.com.ctop.job.bytedance.data.utils;
+
+import java.util.List;
+
+/**
+ * 自定义报表配置项
+ * <p>
+ * 每个报表类型对应一份配置,包含:
+ * <ul>
+ *   <li>dataTopic: 巨量引擎 API 的 data_topic 参数(BASIC_DATA / PRODUCT_DATA 等)</li>
+ *   <li>dimensions: 维度字段列表</li>
+ *   <li>metrics: 指标字段列表</li>
+ *   <li>dorisDatabase: 写入 Doris/SelectDB 的库名</li>
+ *   <li>dorisTable: 写入 Doris/SelectDB 的表名</li>
+ * </ul>
+ */
+public class CustomReportFieldConfig {
+
+    private String dataTopic;
+    private List<String> dimensions;
+    private List<String> metrics;
+    private String dorisDatabase;
+    private String dorisTable;
+
+    public String getDataTopic() {
+        return dataTopic;
+    }
+
+    public void setDataTopic(String dataTopic) {
+        this.dataTopic = dataTopic;
+    }
+
+    public List<String> getDimensions() {
+        return dimensions;
+    }
+
+    public void setDimensions(List<String> dimensions) {
+        this.dimensions = dimensions;
+    }
+
+    public List<String> getMetrics() {
+        return metrics;
+    }
+
+    public void setMetrics(List<String> metrics) {
+        this.metrics = metrics;
+    }
+
+    public String getDorisDatabase() {
+        return dorisDatabase;
+    }
+
+    public void setDorisDatabase(String dorisDatabase) {
+        this.dorisDatabase = dorisDatabase;
+    }
+
+    public String getDorisTable() {
+        return dorisTable;
+    }
+
+    public void setDorisTable(String dorisTable) {
+        this.dorisTable = dorisTable;
+    }
+
+    @Override
+    public String toString() {
+        return "CustomReportFieldConfig{" +
+                "dataTopic='" + dataTopic + '\'' +
+                ", dimensions=" + dimensions +
+                ", metrics=" + metrics +
+                ", dorisDatabase='" + dorisDatabase + '\'' +
+                ", dorisTable='" + dorisTable + '\'' +
+                '}';
+    }
+}

+ 202 - 0
job-bytedance/src/main/resources/custom-report-config.json

@@ -0,0 +1,202 @@
+{
+  "account_hour": {
+    "dataTopic": "BASIC_DATA",
+    "dimensions": ["stat_time_day", "stat_time_hour"],
+    "metrics": [
+      "stat_pay_amount", "stat_cost", "active", "download_finish_cnt",
+      "attribution_next_day_open_cnt", "game_addiction",
+      "attribution_game_in_app_ltv_1day", "attribution_deep_convert_cnt",
+      "game_pay_count", "convert_cnt", "show_cnt", "active_pay",
+      "click_cnt", "click_start_cnt", "attribution_convert_cnt",
+      "deep_convert_cnt", "valid_play",
+      "attribution_billing_game_in_app_ltv_1day",
+      "attribution_micro_game_0d_ltv", "attribution_micro_game_3d_ltv",
+      "attribution_micro_game_7d_ltv", "stat_micro_game_0d_amount"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_advertiser_report_hour"
+  },
+  "account_daily": {
+    "dataTopic": "BASIC_DATA",
+    "dimensions": ["stat_time_day"],
+    "metrics": [
+      "stat_pay_amount", "stat_cost", "active", "download_finish_cnt",
+      "attribution_next_day_open_cnt", "game_addiction",
+      "attribution_game_in_app_ltv_1day", "attribution_deep_convert_cnt",
+      "game_pay_count", "convert_cnt", "show_cnt", "active_pay",
+      "click_cnt", "click_start_cnt", "attribution_convert_cnt",
+      "deep_convert_cnt", "valid_play",
+      "attribution_billing_game_in_app_ltv_1day",
+      "attribution_micro_game_0d_ltv", "attribution_micro_game_3d_ltv",
+      "attribution_micro_game_7d_ltv", "stat_micro_game_0d_amount"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_advertiser_report_daily"
+  },
+  "promoter_hour": {
+    "dataTopic": "BASIC_DATA",
+    "dimensions": [
+      "stat_time_day", "stat_time_hour",
+      "cdp_project_id", "cdp_project_name",
+      "cdp_promotion_id", "cdp_promotion_name",
+      "aweme_id", "aweme_nick_name"
+    ],
+    "metrics": [
+      "stat_pay_amount", "stat_cost", "active", "download_finish_cnt",
+      "attribution_next_day_open_cnt", "game_addiction",
+      "attribution_game_in_app_ltv_1day", "attribution_deep_convert_cnt",
+      "game_pay_count", "convert_cnt", "show_cnt", "active_pay",
+      "click_cnt", "click_start_cnt", "attribution_convert_cnt",
+      "deep_convert_cnt", "valid_play",
+      "attribution_billing_game_in_app_ltv_1day",
+      "attribution_micro_game_0d_ltv", "attribution_micro_game_3d_ltv",
+      "attribution_micro_game_7d_ltv", "stat_micro_game_0d_amount"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_promoter_report_hour"
+  },
+  "promoter_daily": {
+    "dataTopic": "BASIC_DATA",
+    "dimensions": [
+      "stat_time_day",
+      "cdp_project_id", "cdp_project_name",
+      "cdp_promotion_id", "cdp_promotion_name",
+      "aweme_id", "aweme_nick_name"
+    ],
+    "metrics": [
+      "stat_pay_amount", "stat_cost", "active", "download_finish_cnt",
+      "attribution_next_day_open_cnt", "game_addiction",
+      "attribution_game_in_app_ltv_1day", "attribution_deep_convert_cnt",
+      "game_pay_count", "convert_cnt", "show_cnt", "active_pay",
+      "click_cnt", "click_start_cnt", "attribution_convert_cnt",
+      "deep_convert_cnt", "valid_play",
+      "attribution_billing_game_in_app_ltv_1day",
+      "attribution_micro_game_0d_ltv", "attribution_micro_game_3d_ltv",
+      "attribution_micro_game_7d_ltv", "stat_micro_game_0d_amount"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_promoter_report_daily"
+  },
+  "material_daily": {
+    "dataTopic": "BASIC_DATA",
+    "dimensions": [
+      "stat_time_day",
+      "cdp_project_id", "cdp_project_name",
+      "cdp_promotion_id", "cdp_promotion_name",
+      "material_id", "image_mode",
+      "ad_platform_material_name", "ad_platform_material_content"
+    ],
+    "metrics": [
+      "stat_pay_amount", "stat_cost", "active", "download_finish_cnt",
+      "attribution_next_day_open_cnt", "game_addiction",
+      "attribution_game_in_app_ltv_1day", "attribution_deep_convert_cnt",
+      "game_pay_count", "convert_cnt", "show_cnt", "active_pay",
+      "click_cnt", "click_start_cnt", "attribution_convert_cnt",
+      "deep_convert_cnt", "valid_play",
+      "attribution_billing_game_in_app_ltv_1day",
+      "attribution_micro_game_0d_ltv", "attribution_micro_game_3d_ltv",
+      "attribution_micro_game_7d_ltv"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_material_report_daily"
+  },
+  "product_daily": {
+    "dataTopic": "PRODUCT_DATA",
+    "dimensions": [
+      "product_id", "ad_platform_product_name",
+      "stat_time_day",
+      "cdp_project_id", "cdp_promotion_id",
+      "product_platform_id",
+      "ad_platform_product_platform_name",
+      "ad_platform_product_first_category_name",
+      "ad_platform_product_sub_category_name",
+      "ad_platform_product_third_category_name"
+    ],
+    "metrics": [
+      "stat_cost", "show_cnt", "click_cnt",
+      "convert_cnt", "deep_convert_cnt"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_product_report_daily"
+  },
+  "uni_project_daily": {
+    "dataTopic": "UNI_PROJECT_DATA",
+    "dimensions": ["stat_time_day", "cdp_project_id", "cdp_project_name"],
+    "metrics": [
+      "all_stat_cost_trend", "all_show_cnt_trend", "all_click_cnt_trend",
+      "all_ctr_trend", "all_dy_like_trend", "all_dy_comment_trend",
+      "all_dy_share_trend", "all_ad_report_cnt_trend", "all_dislike_cnt_trend",
+      "all_total_play_trend", "all_valid_play_trend", "all_valid_play_rate_trend",
+      "all_valid_play_cost_of_mille_trend", "all_play_50_feed_break_trend",
+      "all_play_99_feed_break_trend", "all_play_over_rate_trend",
+      "all_play_duration_3s_trend", "all_valid_play_cost_trend",
+      "all_valid_play_of_mille_trend", "all_play_25_feed_break_trend",
+      "all_play_75_feed_break_trend", "all_average_play_time_per_play_trend",
+      "all_wifi_play_rate_trend", "all_dy_follow_trend", "all_gmv_trend",
+      "all_billing_gmv_24h_trend", "all_billing_gmv_0day_trend",
+      "all_billing_gmv_3days_trend", "all_billing_gmv_7days_trend",
+      "all_stat_total_cost_trend", "all_roi_trend", "all_roi_24h_trend",
+      "all_roi_0day_trend", "all_roi_3days_trend", "all_roi_7days_trend",
+      "all_micro_game_amount_trend", "all_billing_micro_game_24h_amount_trend",
+      "all_billing_micro_game_0d_amount_trend", "all_billing_micro_game_3d_amount_trend",
+      "all_billing_micro_game_7d_amount_trend", "all_pay_amount_fen_trend",
+      "all_billing_game_in_app_ltv_24h_trend", "all_billing_game_in_app_ltv_0day_trend",
+      "all_billing_game_in_app_ltv_3days_trend", "all_billing_game_in_app_ltv_7days_trend",
+      "all_income_trend", "all_income_24h_trend", "all_income_0day_trend",
+      "all_income_3days_trend", "all_income_7days_trend",
+      "all_pay_amount_income_trend", "all_pay_amount_income_24h_trend",
+      "all_pay_amount_income_0day_trend", "all_pay_amount_income_3days_trend",
+      "all_pay_amount_income_7days_trend", "all_micro_game_income_trend",
+      "all_micro_game_income_24h_trend", "all_micro_game_income_0day_trend",
+      "all_micro_game_income_3days_trend", "all_micro_game_income_7days_trend",
+      "all_micro_game_income_count_trend", "all_micro_game_income_count_24h_trend",
+      "all_micro_game_income_count_0day_trend", "all_micro_game_income_count_3days_trend",
+      "all_micro_game_income_count_7days_trend", "all_pay_amount_income_count_trend",
+      "all_pay_amount_income_count_24h_trend", "all_pay_amount_income_count_0day_trend",
+      "all_pay_amount_income_count_3days_trend", "all_pay_amount_income_count_7days_trend",
+      "all_gpm_trend", "all_gpmc_trend", "all_iap_refund_trend"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_uni_project_report_daily"
+  },
+  "uni_project_hour": {
+    "dataTopic": "UNI_PROJECT_DATA",
+    "dimensions": ["stat_time_day", "stat_time_hour", "cdp_project_id", "cdp_project_name"],
+    "metrics": [
+      "all_stat_cost_trend", "all_show_cnt_trend", "all_click_cnt_trend",
+      "all_ctr_trend", "all_dy_like_trend", "all_dy_comment_trend",
+      "all_dy_share_trend", "all_ad_report_cnt_trend", "all_dislike_cnt_trend",
+      "all_total_play_trend", "all_valid_play_trend", "all_valid_play_rate_trend",
+      "all_valid_play_cost_of_mille_trend", "all_play_50_feed_break_trend",
+      "all_play_99_feed_break_trend", "all_play_over_rate_trend",
+      "all_play_duration_3s_trend", "all_valid_play_cost_trend",
+      "all_valid_play_of_mille_trend", "all_play_25_feed_break_trend",
+      "all_play_75_feed_break_trend", "all_average_play_time_per_play_trend",
+      "all_wifi_play_rate_trend", "all_dy_follow_trend", "all_gmv_trend",
+      "all_billing_gmv_24h_trend", "all_billing_gmv_0day_trend",
+      "all_billing_gmv_3days_trend", "all_billing_gmv_7days_trend",
+      "all_stat_total_cost_trend", "all_roi_trend", "all_roi_24h_trend",
+      "all_roi_0day_trend", "all_roi_3days_trend", "all_roi_7days_trend",
+      "all_micro_game_amount_trend", "all_billing_micro_game_24h_amount_trend",
+      "all_billing_micro_game_0d_amount_trend", "all_billing_micro_game_3d_amount_trend",
+      "all_billing_micro_game_7d_amount_trend", "all_pay_amount_fen_trend",
+      "all_billing_game_in_app_ltv_24h_trend", "all_billing_game_in_app_ltv_0day_trend",
+      "all_billing_game_in_app_ltv_3days_trend", "all_billing_game_in_app_ltv_7days_trend",
+      "all_income_trend", "all_income_24h_trend", "all_income_0day_trend",
+      "all_income_3days_trend", "all_income_7days_trend",
+      "all_pay_amount_income_trend", "all_pay_amount_income_24h_trend",
+      "all_pay_amount_income_0day_trend", "all_pay_amount_income_3days_trend",
+      "all_pay_amount_income_7days_trend", "all_micro_game_income_trend",
+      "all_micro_game_income_24h_trend", "all_micro_game_income_0day_trend",
+      "all_micro_game_income_3days_trend", "all_micro_game_income_7days_trend",
+      "all_micro_game_income_count_trend", "all_micro_game_income_count_24h_trend",
+      "all_micro_game_income_count_0day_trend", "all_micro_game_income_count_3days_trend",
+      "all_micro_game_income_count_7days_trend", "all_pay_amount_income_count_trend",
+      "all_pay_amount_income_count_24h_trend", "all_pay_amount_income_count_0day_trend",
+      "all_pay_amount_income_count_3days_trend", "all_pay_amount_income_count_7days_trend",
+      "all_gpm_trend", "all_gpmc_trend", "all_iap_refund_trend"
+    ],
+    "dorisDatabase": "media_api",
+    "dorisTable": "bytedance_uni_project_report_hour"
+  }
+}